From 90b4ed7300b5ae57b6d58a8d807ed1a59ce91ba8 Mon Sep 17 00:00:00 2001 From: jpt Date: Sun, 5 Jan 2025 04:03:25 -0600 Subject: [PATCH] split table so it can be used to edit generators too --- src/tt/db.py | 12 +- src/tt/import_csv.py | 5 +- src/tt/tui.py | 468 +++++--------------------------------- src/tt/tui_editor.py | 370 ++++++++++++++++++++++++++++++ src/tt/tui_keybindings.py | 46 ++++ 5 files changed, 488 insertions(+), 413 deletions(-) create mode 100644 src/tt/tui_editor.py create mode 100644 src/tt/tui_keybindings.py diff --git a/src/tt/db.py b/src/tt/db.py index 00801de..79eddbe 100644 --- a/src/tt/db.py +++ b/src/tt/db.py @@ -8,7 +8,6 @@ from peewee import ( Model, SqliteDatabase, TextField, - JSONField, ) db = SqliteDatabase( @@ -72,7 +71,7 @@ class SavedSearch(BaseModel): class TaskGenerator(Model): template = CharField() type = CharField() - config = JSONField() + config = TextField() deleted = BooleanField(default=False) last_generated_at = DateTimeField(null=True) created_at = DateTimeField(default=datetime.now) @@ -112,7 +111,14 @@ class TaskGenerator(Model): def initialize_db(): db.connect() - db.create_tables([Category, Task, SavedSearch, TaskGenerator]) + db.create_tables( + [ + Category, + Task, + SavedSearch, + # TaskGenerator + ] + ) if not Category.select().exists(): Category.create(name="default") db.close() diff --git a/src/tt/import_csv.py b/src/tt/import_csv.py index 991acc9..0acd8ca 100644 --- a/src/tt/import_csv.py +++ b/src/tt/import_csv.py @@ -1,4 +1,3 @@ -import sys import csv from datetime import datetime from tt.db import initialize_db, Task, Category, TaskStatus @@ -17,9 +16,7 @@ def import_tasks_from_csv(filename: str): try: due_date = datetime.strptime(row["due"].strip(), "%Y-%m-%d") except ValueError: - print( - f"Warning: Couldn't parse date '{row['due']}', skipping due date" - ) + print(f"Warning: Couldn't parse '{row['due']}', skipping due date") # Validate status status = row["status"].lower() if row["status"] else "zero" diff --git a/src/tt/tui.py b/src/tt/tui.py index 6eba192..8cd8e64 100644 --- a/src/tt/tui.py +++ b/src/tt/tui.py @@ -1,14 +1,5 @@ import json -from textual.app import App -from textual.screen import ModalScreen -from rich.table import Table -from textual.widgets import ( - DataTable, - Header, - Input, - Static, -) -from textual.containers import Container +from textual.widgets import Input from datetime import datetime from .controller import ( @@ -22,109 +13,62 @@ from .controller import ( ) from .db import initialize_db from .utils import ( - remove_rich_tag, - filter_to_string, - advance_enum_val, get_colored_category, get_colored_status, get_colored_date, - get_text_from_editor, +) +from .tui_editor import ( + TableEditor, + TableColumnConfig, + NotifyValidationError, ) -DEFAULT_CATEGORY = "main" -COLUMNS = ("ID", "Task", "Status", "Type", "Due", "Category") -column_to_field = { - 0: "ID", - 1: "text", - 2: "status", - 3: "type", - 4: "due", - 5: "category", -} + +def due_preprocessor(val): + try: + return datetime.strptime(val, "%Y-%m-%d") + except ValueError: + raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD") -class TT(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; - } +def status_preprocessor(val): + try: + TaskStatus(val) + return val + except ValueError: + raise NotifyValidationError( + f"Invalid status. Use: {[s.value for s in TaskStatus]}" + ) - #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; - } - """ +class TT(TableEditor): + TABLE_CONFIG = ( + TableColumnConfig("id", "ID"), + TableColumnConfig("text", "Task", default="new task", enable_editor=True), + TableColumnConfig( + "status", + "Status", + default="zero", + enum=TaskStatus, + preprocessor=status_preprocessor, + ), + TableColumnConfig("type", "Type", default=""), + TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor), + TableColumnConfig("category", "Category", default="main"), + ) + update_item_callback = update_task + update_item_callback = add_task + get_item_callback = get_task 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 ("ctrl+s", "save_view", "save current view"), ("ctrl+o", "load_view", "load saved view"), - # 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 _load_view(self, name): try: @@ -134,62 +78,25 @@ class TT(App): except Exception: self.notify(f"Could not load {name}") - 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 action_save_view(self): + self._show_input("save-view", "default") - def on_mount(self): - self.table.add_columns(*COLUMNS) - self.refresh_tasks(restore_cursor=False) + def action_load_view(self): + self._show_input("load-view", "") - 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 on_input_submitted(self, event: Input.Submitted): + # Override to add save/load view + if 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: + super().on_input_submitted(event) + return - 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_tasks(self, *, restore_cursor=True): - # show table - self.table.clear() - - tasks = get_tasks( + def refresh_items(self): + items = get_tasks( self.search_query, category=self.filters.get("category"), statuses=self.filters.get("status", "").split(",") @@ -197,274 +104,23 @@ class TT(App): else None, sort=self.sort_string, ) - for task in tasks: + for item in items: category = get_colored_category( - task.category.name if task.category else " - " + item.category.name if item.category else " - " ) - status = get_colored_status(task.status) - due = get_colored_date(task.due) + status = get_colored_status(item.status) + due = get_colored_date(item.due) self.table.add_row( - str(task.id), - task.text.split("\n")[0], # first line + str(item.id), + item.text.split("\n")[0], # first line status, - task.type, + item.type, due, 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): initialize_db() diff --git a/src/tt/tui_editor.py b/src/tt/tui_editor.py new file mode 100644 index 0000000..c8e39c1 --- /dev/null +++ b/src/tt/tui_editor.py @@ -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()) diff --git a/src/tt/tui_keybindings.py b/src/tt/tui_keybindings.py new file mode 100644 index 0000000..66418da --- /dev/null +++ b/src/tt/tui_keybindings.py @@ -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()