Compare commits

...

3 Commits

Author SHA1 Message Date
jpt
90b4ed7300 split table so it can be used to edit generators too 2025-01-05 06:06:49 -06:00
jpt
32e0aa593b models for recurring tasks 2025-01-05 04:03:12 -06:00
JJ_EMPTY_STRING
802d8f2db4 2025-01-05 03:44:31 -06:00
5 changed files with 529 additions and 412 deletions

View File

@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from peewee import ( from peewee import (
BooleanField, BooleanField,
@ -68,9 +68,57 @@ class SavedSearch(BaseModel):
return self.name return self.name
class TaskGenerator(Model):
template = CharField()
type = CharField()
config = TextField()
deleted = BooleanField(default=False)
last_generated_at = DateTimeField(null=True)
created_at = DateTimeField(default=datetime.now)
def should_generate(self) -> bool:
"""
generator types: config keys
recurring: days_between
monthly: day_of_month
"""
if self.deleted:
return False
if not self.last_generated_at:
return True
now = datetime.now()
if self.type == "recurring":
days_between = self.config["days_between"]
days_since = (now - self.last_generated_at).days
return days_since >= days_between
elif self.type == "monthly":
day_of_month = self.generator_config["day_of_month"]
# check each day until now to see if target day occurred
one_day = timedelta(days=1)
check_date = self.last_generated_at + one_day
while check_date <= now:
if check_date.day == day_of_month:
return True
check_date += one_day
return False
return False
def initialize_db(): def initialize_db():
db.connect() db.connect()
db.create_tables([Category, Task, SavedSearch]) db.create_tables(
[
Category,
Task,
SavedSearch,
# TaskGenerator
]
)
if not Category.select().exists(): if not Category.select().exists():
Category.create(name="default") Category.create(name="default")
db.close() db.close()

View File

@ -1,4 +1,3 @@
import sys
import csv import csv
from datetime import datetime from datetime import datetime
from tt.db import initialize_db, Task, Category, TaskStatus from tt.db import initialize_db, Task, Category, TaskStatus
@ -17,9 +16,7 @@ def import_tasks_from_csv(filename: str):
try: try:
due_date = datetime.strptime(row["due"].strip(), "%Y-%m-%d") due_date = datetime.strptime(row["due"].strip(), "%Y-%m-%d")
except ValueError: except ValueError:
print( print(f"Warning: Couldn't parse '{row['due']}', skipping due date")
f"Warning: Couldn't parse date '{row['due']}', skipping due date"
)
# Validate status # Validate status
status = row["status"].lower() if row["status"] else "zero" status = row["status"].lower() if row["status"] else "zero"

View File

@ -1,14 +1,5 @@
import json import json
from textual.app import App from textual.widgets import Input
from textual.screen import ModalScreen
from rich.table import Table
from textual.widgets import (
DataTable,
Header,
Input,
Static,
)
from textual.containers import Container
from datetime import datetime from datetime import datetime
from .controller import ( from .controller import (
@ -22,109 +13,62 @@ from .controller import (
) )
from .db import initialize_db from .db import initialize_db
from .utils import ( from .utils import (
remove_rich_tag,
filter_to_string,
advance_enum_val,
get_colored_category, get_colored_category,
get_colored_status, get_colored_status,
get_colored_date, get_colored_date,
get_text_from_editor, )
from .tui_editor import (
TableEditor,
TableColumnConfig,
NotifyValidationError,
) )
DEFAULT_CATEGORY = "main"
COLUMNS = ("ID", "Task", "Status", "Type", "Due", "Category") def due_preprocessor(val):
column_to_field = { try:
0: "ID", return datetime.strptime(val, "%Y-%m-%d")
1: "text", except ValueError:
2: "status", raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
3: "type",
4: "due",
5: "category",
}
class TT(App): def status_preprocessor(val):
CSS = """ try:
#footer { TaskStatus(val)
dock: bottom; return val
max-height: 2; except ValueError:
} raise NotifyValidationError(
#input_bar { f"Invalid status. Use: {[s.value for s in TaskStatus]}"
height: 2; )
border-top: solid green;
layout: grid;
grid-size: 2;
grid-columns: 10 1fr;
display: none;
}
#prompt_label {
width: 10;
content-align: left middle;
}
#input_widget {
width: 100%;
margin: 0;
border: none;
}
#status_bar { class TT(TableEditor):
border-top: solid white; TABLE_CONFIG = (
height: 2; TableColumnConfig("id", "ID"),
margin: 0; TableColumnConfig("text", "Task", default="new task", enable_editor=True),
layout: grid; TableColumnConfig(
grid-size: 2; "status",
} "Status",
default="zero",
#left_status { enum=TaskStatus,
height: 1; preprocessor=status_preprocessor,
margin: 0; ),
padding-left: 1; TableColumnConfig("type", "Type", default=""),
} TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor),
#right_status { TableColumnConfig("category", "Category", default="main"),
height: 1; )
margin: 0; update_item_callback = update_task
padding-right: 1; update_item_callback = add_task
text-align: right; get_item_callback = get_task
}
"""
BINDINGS = [ BINDINGS = [
# movement
("h", "cursor_left", "Left"),
("j", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
("l", "cursor_right", "Right"),
("g", "cursor_top", "Top"),
("G", "cursor_bottom", "Bottom"),
# filtering & editing
("/", "start_search", "search tasks by name"),
("f", "start_filter", "filter tasks by category"),
("s", "start_sort", "sort tasks"),
("escape", "cancel_edit", "Cancel Edit"),
# edits
("c", "start_change", "change current cell"),
("e", "start_edit", "open cell in editor"),
("a", "add_task", "add task"),
("t", "toggle_cell", "toggle status"),
("d", "delete_task", "delete (must be on row mode)"),
# saved views # saved views
("ctrl+s", "save_view", "save current view"), ("ctrl+s", "save_view", "save current view"),
("ctrl+o", "load_view", "load saved view"), ("ctrl+o", "load_view", "load saved view"),
# other
("q", "quit", "quit"),
("?", "show_keys", "show keybindings"),
] ]
def __init__(self, default_view="default"): def __init__(self, default_view="default"):
super().__init__() super().__init__()
self.filters = {}
self.sort_string = "due,status"
self._load_view(default_view) self._load_view(default_view)
self.search_query = ""
self.saved_cursor_pos = (1, 0)
self.save_on_move = None
def _load_view(self, name): def _load_view(self, name):
try: try:
@ -134,62 +78,25 @@ class TT(App):
except Exception: except Exception:
self.notify(f"Could not load {name}") self.notify(f"Could not load {name}")
def compose(self): def action_save_view(self):
self.header = Header() self._show_input("save-view", "default")
self.table = DataTable()
self.input_widget = Input(id="input_widget")
self.input_label = Static("label", id="prompt_label")
self.input_bar = Container(id="input_bar")
self.status_bar = Container(id="status_bar")
self.left_status = Static("LEFT", id="left_status")
self.right_status = Static(self.sort_string, id="right_status")
yield self.header
yield Container(self.table)
with Container(id="footer"):
with self.input_bar:
yield self.input_label
yield self.input_widget
with self.status_bar:
yield self.left_status
yield self.right_status
def on_mount(self): def action_load_view(self):
self.table.add_columns(*COLUMNS) self._show_input("load-view", "")
self.refresh_tasks(restore_cursor=False)
def action_cursor_left(self): def on_input_submitted(self, event: Input.Submitted):
self.table.move_cursor(column=self.table.cursor_column - 1) # Override to add save/load view
if self.table.cursor_column == 0: if self.mode == "save-view":
self.table.cursor_type = "row" save_view(event.value, filters=self.filters, sort_string=self.sort_string)
self._perform_save_on_move() elif self.mode == "load-view":
self._load_view(event.value)
self.refresh_tasks(restore_cursor=False)
else:
super().on_input_submitted(event)
return
def action_cursor_right(self): def refresh_items(self):
self.table.move_cursor(column=self.table.cursor_column + 1) items = get_tasks(
if self.table.cursor_column != 0:
self.table.cursor_type = "cell"
self._perform_save_on_move()
def action_cursor_up(self):
self.table.move_cursor(row=self.table.cursor_row - 1)
self._perform_save_on_move()
def action_cursor_down(self):
self.table.move_cursor(row=self.table.cursor_row + 1)
self._perform_save_on_move()
def action_cursor_top(self):
self.table.move_cursor(row=0)
self._perform_save_on_move()
def action_cursor_bottom(self):
self.table.move_cursor(row=self.table.row_count - 1)
self._perform_save_on_move()
def refresh_tasks(self, *, restore_cursor=True):
# show table
self.table.clear()
tasks = get_tasks(
self.search_query, self.search_query,
category=self.filters.get("category"), category=self.filters.get("category"),
statuses=self.filters.get("status", "").split(",") statuses=self.filters.get("status", "").split(",")
@ -197,274 +104,23 @@ class TT(App):
else None, else None,
sort=self.sort_string, sort=self.sort_string,
) )
for task in tasks: for item in items:
category = get_colored_category( category = get_colored_category(
task.category.name if task.category else " - " item.category.name if item.category else " - "
) )
status = get_colored_status(task.status) status = get_colored_status(item.status)
due = get_colored_date(task.due) due = get_colored_date(item.due)
self.table.add_row( self.table.add_row(
str(task.id), str(item.id),
task.text.split("\n")[0], # first line item.text.split("\n")[0], # first line
status, status,
task.type, item.type,
due, due,
category, category,
key=str(task.id), key=str(item.id),
) )
# update footer
filter_display = filter_to_string(self.filters, self.search_query)
self.left_status.update(f"{len(tasks)} |{filter_display}")
# restore cursor
if restore_cursor:
self.table.move_cursor(
row=self.saved_cursor_pos[0], column=self.saved_cursor_pos[1]
)
else:
self.table.move_cursor(row=0, column=1)
def action_delete_task(self):
if self.table.cursor_column == 0:
cur_row = self.table.cursor_row
task_id = int(self.table.get_cell_at((cur_row, 0)))
update_task(task_id, deleted=True)
self._save_cursor()
self.refresh_tasks()
def action_add_task(self):
# if filtering on type, status, or category
# the new task should use the selected value
category = self.filters.get("category", DEFAULT_CATEGORY)
status = self.filters.get("status", TaskStatus.ZERO.value).split(",")[0]
type_ = self.filters.get("type", "").split(",")[0]
new_task = add_task(
text="New Task",
type=type_,
status=status,
category=category,
)
self.refresh_tasks(restore_cursor=False)
self.move_cursor_to_task(new_task.id)
self.action_start_change()
def action_toggle_cell(self):
cur_row = self.table.cursor_row
cur_col = self.table.cursor_column
active_col_name = column_to_field[cur_col]
if active_col_name == "status":
task_id = int(self.table.get_cell_at((cur_row, 0)))
current_val = self.table.get_cell_at((cur_row, cur_col))
next_val = advance_enum_val(TaskStatus, current_val)
self.table.update_cell_at((cur_row, cur_col), next_val)
# trigger task_id to be saved on the next cursor move
# this avoids filtered columns disappearing right away
# and tons of DB writes
self._register_save_on_move(task_id, status=next_val)
def _register_save_on_move(self, task_id, **kwargs):
if self.save_on_move and self.save_on_move["task_id"] != task_id:
# we should only ever overwrite the same item
raise Exception("invalid save_on_move state")
self.save_on_move = {"task_id": task_id, **kwargs}
def _perform_save_on_move(self):
if self.save_on_move:
update_task(**self.save_on_move)
self._save_cursor()
self.refresh_tasks()
# reset status
self.save_on_move = None
def move_cursor_to_task(self, task_id):
# ick, but only way to search table?
for row in range(self.table.row_count):
data = self.table.get_row_at(row)
if data[0] == str(task_id):
self.table.move_cursor(row=row, column=1)
break
# Control of edit bar ####################
def _show_input(self, mode, start_value):
self.mode = mode
self.input_bar.display = True
self.input_widget.value = start_value
if mode.endswith("-view"):
mode_input_label = "view name: "
else:
mode_input_label = f"{mode}: "
self.input_label.update(mode_input_label)
self.set_focus(self.input_widget)
def _hide_input(self):
self.input_bar.display = False
self.set_focus(self.table)
def action_cancel_edit(self):
self._hide_input()
self.table.cursor_type = "cell"
def action_start_search(self):
self._show_input("search", "")
def action_save_view(self):
self._show_input("save-view", "default")
def action_load_view(self):
self._show_input("load-view", "")
def action_start_filter(self):
# filter the currently selected column
cur_col = self.table.cursor_column
active_col_name = column_to_field[cur_col]
# # only allow filtering these columns
if active_col_name not in ("status", "type", "due", "category"):
return
cur_filter_val = self.filters.get(active_col_name) or ""
self._show_input("filter", cur_filter_val)
self.table.cursor_type = "column"
def action_start_sort(self):
self._show_input("sort", self.sort_string)
def _save_cursor(self):
self.saved_cursor_pos = (self.table.cursor_row, self.table.cursor_column)
def action_start_change(self):
if self.table.cursor_row is None or self.table.cursor_column == 0:
return
self._save_cursor()
current_value = self.table.get_cell_at(
(self.table.cursor_row, self.table.cursor_column)
)
current_value = remove_rich_tag(current_value)
self._show_input("edit", current_value)
def action_start_edit(self):
cur_col = self.table.cursor_column
if self.table.cursor_row is None or cur_col != 1:
return
self._save_cursor()
task_id = self.table.get_cell_at((self.table.cursor_row, 0))
# get task from db so we can see 100% of the text
task = get_task(task_id)
# found at https://github.com/Textualize/textual/discussions/1654
self._driver.stop_application_mode()
new_text = get_text_from_editor(task.text)
self.refresh()
self._driver.start_application_mode()
if new_text is not None:
update_task(task_id, text=new_text)
self.refresh_tasks()
def on_input_submitted(self, event: Input.Submitted):
if self.mode == "search":
self.search_query = event.value
self.refresh_tasks(restore_cursor=False)
elif self.mode == "filter":
cur_col = self.table.cursor_column
active_col_name = column_to_field[cur_col]
self.filters[active_col_name] = event.value
self.refresh_tasks(restore_cursor=False)
self.table.cursor_type = "cel"
elif self.mode == "sort":
self.sort_string = event.value
self.refresh_tasks(restore_cursor=False)
self.right_status.update(self.sort_string)
elif self.mode == "edit":
self.apply_change(event.value)
elif self.mode == "save-view":
save_view(event.value, filters=self.filters, sort_string=self.sort_string)
elif self.mode == "load-view":
self._load_view(event.value)
self.refresh_tasks(restore_cursor=False)
else:
raise ValueError(f"unknown mode: {self.mode}")
self._hide_input()
def apply_change(self, new_value):
row, col = self.saved_cursor_pos
task_id = int(self.table.get_cell_at((row, 0)))
field = column_to_field[col]
update_data = {}
if field == "due":
try:
update_data["due"] = datetime.strptime(new_value, "%Y-%m-%d")
except ValueError:
self.notify("Invalid date format. Use YYYY-MM-DD")
elif field == "status":
try:
TaskStatus(new_value) # validate status
update_data["status"] = new_value
except ValueError:
self.notify(f"Invalid status. Use: {[s.value for s in TaskStatus]}")
else:
update_data[field] = new_value
try:
update_task(task_id, **update_data)
self.refresh_tasks()
except Exception as e:
self.notify(f"Error updating task: {str(e)}")
finally:
# no longer in edit mode
self.action_cancel_edit()
def action_show_keys(self):
self.push_screen(KeyBindingsScreen())
class KeyBindingsScreen(ModalScreen):
CSS = """
KeyBindingsScreen {
align: center middle;
}
Vertical {
height: auto;
border: tall $primary;
}
Static.title {
text-align: center;
text-style: bold;
}
Static.footer {
text-align: center;
color: $text-muted;
}
"""
def compose(self):
table = Table(expand=True, show_header=True)
table.add_column("Key")
table.add_column("Description")
table.add_row("j/k/h/l", "up/down/left/right")
table.add_row("g/G", "top/bottom")
table.add_row("esc", "dismiss modal or search bar")
for binding in self.app.BINDINGS:
if binding[0] not in ["h", "j", "k", "l", "g", "G", "escape"]:
table.add_row(binding[0], binding[2])
yield Static("tt keybindings", classes="title")
yield Static(table)
yield Static("Press any key to dismiss", classes="footer")
def on_key(self, event) -> None:
self.app.pop_screen()
def run(default_view): def run(default_view):
initialize_db() initialize_db()

370
src/tt/tui_editor.py Normal file
View File

@ -0,0 +1,370 @@
from textual.app import App
from textual.widgets import (
DataTable,
Header,
Input,
Static,
)
from textual.containers import Container
from .utils import (
remove_rich_tag,
filter_to_string,
advance_enum_val,
get_text_from_editor,
)
from .tui_keybindings import KeyBindingsScreen
class NotifyValidationError(Exception):
"""will notify and continue if raised"""
class TableColumnConfig:
def __init__(
self,
field: str,
display_name: str,
*,
default=None,
enum=None,
enable_editor=False,
preprocessor=None,
):
self.field = field
self.display_name = display_name
self.default = default
self.enum = enum
self.preprocessor = preprocessor or (lambda x: x)
self.enable_editor = enable_editor
class TableEditor(App):
CSS = """
#footer {
dock: bottom;
max-height: 2;
}
#input_bar {
height: 2;
border-top: solid green;
layout: grid;
grid-size: 2;
grid-columns: 10 1fr;
display: none;
}
#prompt_label {
width: 10;
content-align: left middle;
}
#input_widget {
width: 100%;
margin: 0;
border: none;
}
#status_bar {
border-top: solid white;
height: 2;
margin: 0;
layout: grid;
grid-size: 2;
}
#left_status {
height: 1;
margin: 0;
padding-left: 1;
}
#right_status {
height: 1;
margin: 0;
padding-right: 1;
text-align: right;
}
"""
BINDINGS = [
# movement
("h", "cursor_left", "Left"),
("j", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
("l", "cursor_right", "Right"),
("g", "cursor_top", "Top"),
("G", "cursor_bottom", "Bottom"),
# filtering & editing
("/", "start_search", "search by name"),
("f", "start_filter", "filter current column"),
("s", "start_sort", "sort"),
("escape", "cancel_edit", "cancel edit"),
# edits
("c", "start_change", "change current cell"),
("e", "start_edit", "open cell in editor"),
("a", "add_item", "add item"),
("t", "toggle_cell", "toggle enum"),
("d", "delete_item", "delete (must be on row mode)"),
# other
("q", "quit", "quit"),
("?", "show_keys", "show keybindings"),
]
def __init__(self, default_view="default"):
super().__init__()
self.filters = {}
self.sort_string = "due,status"
self._load_view(default_view)
self.search_query = ""
self.saved_cursor_pos = (1, 0)
self.save_on_move = None
def compose(self):
self.header = Header()
self.table = DataTable()
self.input_widget = Input(id="input_widget")
self.input_label = Static("label", id="prompt_label")
self.input_bar = Container(id="input_bar")
self.status_bar = Container(id="status_bar")
self.left_status = Static("LEFT", id="left_status")
self.right_status = Static(self.sort_string, id="right_status")
yield self.header
yield Container(self.table)
with Container(id="footer"):
with self.input_bar:
yield self.input_label
yield self.input_widget
with self.status_bar:
yield self.left_status
yield self.right_status
def on_mount(self):
column_names = [c.display_name for c in self.TABLE_CONFIG]
self.table.add_columns(*column_names)
self.refresh_data(restore_cursor=False)
def action_cursor_left(self):
self.table.move_cursor(column=self.table.cursor_column - 1)
if self.table.cursor_column == 0:
self.table.cursor_type = "row"
self._perform_save_on_move()
def action_cursor_right(self):
self.table.move_cursor(column=self.table.cursor_column + 1)
if self.table.cursor_column != 0:
self.table.cursor_type = "cell"
self._perform_save_on_move()
def action_cursor_up(self):
self.table.move_cursor(row=self.table.cursor_row - 1)
self._perform_save_on_move()
def action_cursor_down(self):
self.table.move_cursor(row=self.table.cursor_row + 1)
self._perform_save_on_move()
def action_cursor_top(self):
self.table.move_cursor(row=0)
self._perform_save_on_move()
def action_cursor_bottom(self):
self.table.move_cursor(row=self.table.row_count - 1)
self._perform_save_on_move()
def refresh_data(self, *, restore_cursor=True):
# reset table
self.table.clear()
self.refresh_items()
# update footer
filter_display = filter_to_string(self.filters, self.search_query)
self.left_status.update(f"{self.table.row_count} |{filter_display}")
# restore cursor
if restore_cursor:
self.table.move_cursor(
row=self.saved_cursor_pos[0], column=self.saved_cursor_pos[1]
)
else:
self.table.move_cursor(row=0, column=1)
def action_delete_item(self):
if self.table.cursor_column == 0:
cur_row = self.table.cursor_row
item_id = int(self.table.get_cell_at((cur_row, 0)))
self.update_item_callback(item_id, deleted=True)
self._save_cursor()
self.refresh_data()
def action_add_item(self):
# if filtering on type, status, or category
# the new item should use the selected value
# prepopulate with either the field default or the current filter
prepopulated = {
field.name: self.filters.get(field.name, field.default)
for field in self.TABLE_CONFIG
}
# TODO: need to split type_ and status
new_item = self.add_item_callback(**prepopulated)
self.refresh_data(restore_cursor=False)
self.move_cursor_to_item(new_item.id)
self.action_start_change()
def _active_column_config(self):
cur_col = self.table.cursor_column
return self.TABLE_CONFIG[cur_col]
def _active_item_id(self):
return int(self.table.get_cell_at((self.table.cursor_row, 0)))
def action_toggle_cell(self):
cur_row = self.table.cursor_row
cur_col = self.table.cursor_column
cconf = self._active_column_config()
if cconf.enum:
item_id = self._active_item_id()
current_val = self.table.get_cell_at((cur_row, cur_col))
next_val = advance_enum_val(cconf.enum, current_val)
self.table.update_cell_at((cur_row, cur_col), next_val)
# trigger item_id to be saved on the next cursor move
# this avoids filtered columns disappearing right away
# and tons of DB writes
self._register_save_on_move(item_id, status=next_val)
def _register_save_on_move(self, item_id, **kwargs):
if self.save_on_move and self.save_on_move["item_id"] != item_id:
# we should only ever overwrite the same item
raise Exception("invalid save_on_move state")
self.save_on_move = {"item_id": item_id, **kwargs}
def _perform_save_on_move(self):
if self.save_on_move:
self.update_item_callback(**self.save_on_move)
self._save_cursor()
self.refresh_data()
# reset status
self.save_on_move = None
def move_cursor_to_item(self, item_id):
# ick, but only way to search table?
for row in range(self.table.row_count):
data = self.table.get_row_at(row)
if data[0] == str(item_id):
self.table.move_cursor(row=row, column=1)
break
# Control of edit bar ####################
def _show_input(self, mode, start_value):
self.mode = mode
self.input_bar.display = True
self.input_widget.value = start_value
# TODO: a better solution here, violating Liskov
if mode.endswith("-view"):
mode_input_label = "view name: "
else:
mode_input_label = f"{mode}: "
self.input_label.update(mode_input_label)
self.set_focus(self.input_widget)
def _hide_input(self):
self.input_bar.display = False
self.set_focus(self.table)
def action_cancel_edit(self):
self._hide_input()
self.table.cursor_type = "cell"
def action_start_search(self):
self._show_input("search", "")
def action_start_filter(self):
cconf = self._active_column_config()
if cconf.filterable:
return
cur_filter_val = self.filters.get(cconf.name) or ""
self._show_input("filter", cur_filter_val)
self.table.cursor_type = "column"
def action_start_sort(self):
self._show_input("sort", self.sort_string)
def _save_cursor(self):
self.saved_cursor_pos = (self.table.cursor_row, self.table.cursor_column)
def action_start_change(self):
if self.table.cursor_row is None or self.table.cursor_column == 0:
return
self._save_crefresh_data()
current_value = self.table.get_cell_at(
(self.table.cursor_row, self.table.cursor_column)
)
current_value = remove_rich_tag(current_value)
self._show_input("edit", current_value)
def action_start_edit(self):
cconf = self._active_column_config()
if not cconf.enable_editor:
return
self._save_cursor()
# get item from db to ensure we have the original text
# since the table might have an edited/truncated version
item_id = self._active_item_id()
item = self.get_item_callback(item_id)
# found at https://github.com/Textualize/textual/discussions/1654
self._drirefresh_data.stop_application_mode()
new_text = get_text_from_editor(item.text)
self.refresh()
self._driver.start_application_mode()
if new_text is not None:
self.update_item_callback(item_id, text=new_text)
self.refresh_data()
def on_input_submitted(self, event: Input.Submitted):
self._hide_input()
if self.mode == "search":
self.refresh_data = event.value
self.refresh_data(restore_cursor=False)
elif self.mode == "filter":
cconf = self._active_column_config()
self.filters[cconf.name] = event.value
self.refresh_data(restore_cursor=False)
self.table.cursor_type = "cel"
elif self.mode == "sort":
self.sort_string = event.value
self.refresh_data(restore_cursor=False)
self.right_status.update(self.sort_string)
elif self.mode == "edit":
self.apply_change(event.value)
else:
raise ValueError(f"unknown mode: {self.mode}")
def apply_change(self, new_value):
row, col = self.saved_cursor_pos
item_id = int(self.table.get_cell_at((row, 0)))
cconf = self.TABLE_CONFIG[col]
field = cconf.name
update_data = {}
# preprocess/validate the field being saved
try:
update_data[field] = cconf.preprocess(new_value)
self.update_item_callback(item_id, **update_data)
self.refresh_data()
except NotifyValidationError as e:
self.notify(e.message)
except Exception as e:
self.notify(f"Error updating item: {str(e)}")
finally:
# break out of edit mode
self.action_cancel_edit()
def action_show_keys(self):
self.push_screen(KeyBindingsScreen())

46
src/tt/tui_keybindings.py Normal file
View File

@ -0,0 +1,46 @@
from textual.screen import ModalScreen
from rich.table import Table
from textual.widgets import Static
class KeyBindingsScreen(ModalScreen):
CSS = """
KeyBindingsScreen {
align: center middle;
}
Vertical {
height: auto;
border: tall $primary;
}
Static.title {
text-align: center;
text-style: bold;
}
Static.footer {
text-align: center;
color: $text-muted;
}
"""
def compose(self):
table = Table(expand=True, show_header=True)
table.add_column("Key")
table.add_column("Description")
table.add_row("j/k/h/l", "up/down/left/right")
table.add_row("g/G", "top/bottom")
table.add_row("esc", "dismiss modal or search bar")
for binding in self.app.BINDINGS:
if binding[0] not in ["h", "j", "k", "l", "g", "G", "escape"]:
table.add_row(binding[0], binding[2])
yield Static("tt keybindings", classes="title")
yield Static(table)
yield Static("Press any key to dismiss", classes="footer")
def on_key(self, event) -> None:
self.app.pop_screen()