From 55623a078115aecbe3e506d2fd24c18568ff149b Mon Sep 17 00:00:00 2001 From: James Turk Date: Sat, 4 Jan 2025 01:08:36 -0600 Subject: [PATCH] toggling and help --- src/tt/tui.py | 150 ++++++++++++++++++++++++++++++++++++------------ src/tt/utils.py | 32 +++++++++++ 2 files changed, 145 insertions(+), 37 deletions(-) create mode 100644 src/tt/utils.py diff --git a/src/tt/tui.py b/src/tt/tui.py index 0a706cd..eeae98a 100644 --- a/src/tt/tui.py +++ b/src/tt/tui.py @@ -1,20 +1,35 @@ -import re from textual.app import App -from textual.widgets import DataTable, Header, Input, Static +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 .controller import get_tasks, add_task, update_task, TaskStatus from .db import initialize_db - -# TODO: toggle status with 't' -# TODO: add way to filter on other columns -# TODO: safe DB mode +from .utils import ( + remove_rich_tag, + advance_enum_val, + get_colored_category, + get_colored_status, +) +# TODO: add filtering on status +# TODO: sorting +# TODO: saved searches +# TODO: date coloring rules (days away, days of week, week of year) # Nice to Have -# TODO: CLI? +# TODO: CLI # TODO: config- default category, types, colors # TODO: dropdowns for status, type, category +# Eventual Goals +# TODO: abstraction +COLUMNS = ("ID", "Task", "Status", "Type", "Due", "Category") column_to_field = { 1: "text", 2: "status", @@ -24,28 +39,6 @@ column_to_field = { } -def remove_rich_tag(text): - pattern = r"\[[^\]]*\](.*?)\[/\]" - return re.sub(pattern, r"\1", text) - - -def get_colored_status(status: str) -> str: - colors = { - "zero": "#666666", - "wip": "#33aa99", - "blocked": "#cc9900", - "done": "#009900", - } - return f"[{colors[status]}]{status}[/]" - - -def get_colored_category(category: str) -> str: - hash_val = sum(ord(c) for c in category) - hue = hash_val % 360 - color = f"rgb({hue},200,200) on default" - return f"[{color}]{category}[/]" - - class TT(App): CSS = """ #footer { @@ -102,14 +95,17 @@ class TT(App): ("g", "cursor_top", "Top"), ("G", "cursor_bottom", "Bottom"), # filtering & editing - ("/", "start_search", "Search"), - ("f", "start_filter", "Filter"), - ("c", "start_edit", "Edit Cell"), + ("/", "start_search", "search tasks by name"), + ("f", "start_filter", "filter tasks by category"), ("escape", "cancel_edit", "Cancel Edit"), + # edits + ("c", "start_edit", "edit current cell"), + ("a", "add_task", "add task"), + ("t", "toggle_cell", "toggle status"), + ("d", "delete_task", "delete (must be on row mode)"), # other - ("a", "add_task", "Add"), - ("d", "delete_task", "Delete"), - ("q", "quit", "Quit"), + ("q", "quit", "quit"), + ("?", "show_keys", "show keybindings"), ] def __init__(self): @@ -117,6 +113,7 @@ class TT(App): self.search_query = "" self.search_category = "" self.saved_cursor_pos = (0, 0) + self.save_on_move = None def compose(self): self.header = Header() @@ -138,30 +135,36 @@ class TT(App): yield self.right_status def on_mount(self): - self.table.add_columns("ID", "Task", "Status", "Type", "Due", "Category") + self.table.add_columns(*COLUMNS) self.refresh_tasks(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_tasks(self, *, restore_cursor=True): # show table @@ -210,7 +213,6 @@ class TT(App): self.refresh_tasks() def action_add_task(self): - """Add a new task with default values.""" new_task = add_task( text="New Task", type="", @@ -221,6 +223,34 @@ class TT(App): self.move_cursor_to_task(new_task.id) self.action_start_edit() + 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): @@ -314,6 +344,52 @@ class TT(App): finally: 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("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(): initialize_db() diff --git a/src/tt/utils.py b/src/tt/utils.py new file mode 100644 index 0000000..7f108c8 --- /dev/null +++ b/src/tt/utils.py @@ -0,0 +1,32 @@ +import re + + +def remove_rich_tag(text): + """remove rich styling from a string""" + pattern = r"\[[^\]]*\](.*?)\[/\]" + return re.sub(pattern, r"\1", text) + + +def advance_enum_val(enum_type, cur_val): + """advance a value in an enum by one, wrapping around""" + members = [str(e.value) for e in enum_type] + cur_idx = members.index(remove_rich_tag(cur_val)) + next_idx = (cur_idx + 1) % len(members) + return members[next_idx] + + +def get_colored_status(status: str) -> str: + colors = { + "zero": "#666666", + "wip": "#33aa99", + "blocked": "#cc9900", + "done": "#009900", + } + return f"[{colors[status]}]{status}[/]" + + +def get_colored_category(category: str) -> str: + hash_val = sum(ord(c) for c in category) + hue = hash_val % 360 + color = f"rgb({hue},200,200) on default" + return f"[{color}]{category}[/]"