Compare commits
	
		
			2 commits
		
	
	
		
			85fa719b96
			...
			55623a0781
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 55623a0781 | |||
| 5984c28d09 | 
					 3 changed files with 153 additions and 38 deletions
				
			
		|  | @ -10,7 +10,14 @@ from peewee import ( | ||||||
|     TextField, |     TextField, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| db = SqliteDatabase("tasks.db") | db = SqliteDatabase( | ||||||
|  |     "tasks.db", | ||||||
|  |     pragmas={ | ||||||
|  |         "journal_mode": "wal", | ||||||
|  |         "synchronous": "normal", | ||||||
|  |         "foreign_keys": 1, | ||||||
|  |     }, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TaskStatus(Enum): | class TaskStatus(Enum): | ||||||
|  |  | ||||||
							
								
								
									
										150
									
								
								src/tt/tui.py
									
									
									
									
									
								
							
							
						
						
									
										150
									
								
								src/tt/tui.py
									
									
									
									
									
								
							|  | @ -1,20 +1,35 @@ | ||||||
| import re |  | ||||||
| from textual.app import App | 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 textual.containers import Container | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| 
 | 
 | ||||||
| from .controller import get_tasks, add_task, update_task, TaskStatus | from .controller import get_tasks, add_task, update_task, TaskStatus | ||||||
| from .db import initialize_db | from .db import initialize_db | ||||||
| 
 | from .utils import ( | ||||||
| # TODO: toggle status with 't' |     remove_rich_tag, | ||||||
| # TODO: add way to filter on other columns |     advance_enum_val, | ||||||
| # TODO: safe DB mode |     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 | # Nice to Have | ||||||
| # TODO: CLI? | # TODO: CLI | ||||||
| # TODO: config- default category, types, colors | # TODO: config- default category, types, colors | ||||||
| # TODO: dropdowns for status, type, category | # TODO: dropdowns for status, type, category | ||||||
|  | # Eventual Goals | ||||||
|  | # TODO: abstraction | ||||||
| 
 | 
 | ||||||
|  | COLUMNS = ("ID", "Task", "Status", "Type", "Due", "Category") | ||||||
| column_to_field = { | column_to_field = { | ||||||
|     1: "text", |     1: "text", | ||||||
|     2: "status", |     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): | class TT(App): | ||||||
|     CSS = """ |     CSS = """ | ||||||
|     #footer { |     #footer { | ||||||
|  | @ -102,14 +95,17 @@ class TT(App): | ||||||
|         ("g", "cursor_top", "Top"), |         ("g", "cursor_top", "Top"), | ||||||
|         ("G", "cursor_bottom", "Bottom"), |         ("G", "cursor_bottom", "Bottom"), | ||||||
|         # filtering & editing |         # filtering & editing | ||||||
|         ("/", "start_search", "Search"), |         ("/", "start_search", "search tasks by name"), | ||||||
|         ("f", "start_filter", "Filter"), |         ("f", "start_filter", "filter tasks by category"), | ||||||
|         ("c", "start_edit", "Edit Cell"), |  | ||||||
|         ("escape", "cancel_edit", "Cancel Edit"), |         ("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 |         # other | ||||||
|         ("a", "add_task", "Add"), |         ("q", "quit", "quit"), | ||||||
|         ("d", "delete_task", "Delete"), |         ("?", "show_keys", "show keybindings"), | ||||||
|         ("q", "quit", "Quit"), |  | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|  | @ -117,6 +113,7 @@ class TT(App): | ||||||
|         self.search_query = "" |         self.search_query = "" | ||||||
|         self.search_category = "" |         self.search_category = "" | ||||||
|         self.saved_cursor_pos = (0, 0) |         self.saved_cursor_pos = (0, 0) | ||||||
|  |         self.save_on_move = None | ||||||
| 
 | 
 | ||||||
|     def compose(self): |     def compose(self): | ||||||
|         self.header = Header() |         self.header = Header() | ||||||
|  | @ -138,30 +135,36 @@ class TT(App): | ||||||
|                 yield self.right_status |                 yield self.right_status | ||||||
| 
 | 
 | ||||||
|     def on_mount(self): |     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) |         self.refresh_tasks(restore_cursor=False) | ||||||
| 
 | 
 | ||||||
|     def action_cursor_left(self): |     def action_cursor_left(self): | ||||||
|         self.table.move_cursor(column=self.table.cursor_column - 1) |         self.table.move_cursor(column=self.table.cursor_column - 1) | ||||||
|         if self.table.cursor_column == 0: |         if self.table.cursor_column == 0: | ||||||
|             self.table.cursor_type = "row" |             self.table.cursor_type = "row" | ||||||
|  |         self._perform_save_on_move() | ||||||
| 
 | 
 | ||||||
|     def action_cursor_right(self): |     def action_cursor_right(self): | ||||||
|         self.table.move_cursor(column=self.table.cursor_column + 1) |         self.table.move_cursor(column=self.table.cursor_column + 1) | ||||||
|         if self.table.cursor_column != 0: |         if self.table.cursor_column != 0: | ||||||
|             self.table.cursor_type = "cell" |             self.table.cursor_type = "cell" | ||||||
|  |         self._perform_save_on_move() | ||||||
| 
 | 
 | ||||||
|     def action_cursor_up(self): |     def action_cursor_up(self): | ||||||
|         self.table.move_cursor(row=self.table.cursor_row - 1) |         self.table.move_cursor(row=self.table.cursor_row - 1) | ||||||
|  |         self._perform_save_on_move() | ||||||
| 
 | 
 | ||||||
|     def action_cursor_down(self): |     def action_cursor_down(self): | ||||||
|         self.table.move_cursor(row=self.table.cursor_row + 1) |         self.table.move_cursor(row=self.table.cursor_row + 1) | ||||||
|  |         self._perform_save_on_move() | ||||||
| 
 | 
 | ||||||
|     def action_cursor_top(self): |     def action_cursor_top(self): | ||||||
|         self.table.move_cursor(row=0) |         self.table.move_cursor(row=0) | ||||||
|  |         self._perform_save_on_move() | ||||||
| 
 | 
 | ||||||
|     def action_cursor_bottom(self): |     def action_cursor_bottom(self): | ||||||
|         self.table.move_cursor(row=self.table.row_count - 1) |         self.table.move_cursor(row=self.table.row_count - 1) | ||||||
|  |         self._perform_save_on_move() | ||||||
| 
 | 
 | ||||||
|     def refresh_tasks(self, *, restore_cursor=True): |     def refresh_tasks(self, *, restore_cursor=True): | ||||||
|         # show table |         # show table | ||||||
|  | @ -210,7 +213,6 @@ class TT(App): | ||||||
|             self.refresh_tasks() |             self.refresh_tasks() | ||||||
| 
 | 
 | ||||||
|     def action_add_task(self): |     def action_add_task(self): | ||||||
|         """Add a new task with default values.""" |  | ||||||
|         new_task = add_task( |         new_task = add_task( | ||||||
|             text="New Task", |             text="New Task", | ||||||
|             type="", |             type="", | ||||||
|  | @ -221,6 +223,34 @@ class TT(App): | ||||||
|         self.move_cursor_to_task(new_task.id) |         self.move_cursor_to_task(new_task.id) | ||||||
|         self.action_start_edit() |         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): |     def move_cursor_to_task(self, task_id): | ||||||
|         # ick, but only way to search table? |         # ick, but only way to search table? | ||||||
|         for row in range(self.table.row_count): |         for row in range(self.table.row_count): | ||||||
|  | @ -314,6 +344,52 @@ class TT(App): | ||||||
|         finally: |         finally: | ||||||
|             self.action_cancel_edit() |             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(): | def run(): | ||||||
|     initialize_db() |     initialize_db() | ||||||
|  |  | ||||||
							
								
								
									
										32
									
								
								src/tt/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/tt/utils.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -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}[/]" | ||||||
		Loading…
	
		Reference in a new issue