Compare commits
	
		
			No commits in common. "92cc3c5b40900c97851dd0b7d804687bbe9eb5d2" and "2c5310fe0d36f3eec9e9151ed8c1ed047d585357" have entirely different histories.
		
	
	
		
			92cc3c5b40
			...
			2c5310fe0d
		
	
		
					 13 changed files with 107 additions and 345 deletions
				
			
		|  | @ -9,7 +9,6 @@ dependencies = [ | ||||||
|   "lxml>=5.3.0", |   "lxml>=5.3.0", | ||||||
|   "peewee>=3.17.8", |   "peewee>=3.17.8", | ||||||
|   "textual>=1.0.0", |   "textual>=1.0.0", | ||||||
|   "tomlkit>=0.13.2", |  | ||||||
|   "typer>=0.15.1", |   "typer>=0.15.1", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,14 +0,0 @@ | ||||||
| import tomlkit |  | ||||||
| 
 |  | ||||||
| def get_enum(name): |  | ||||||
|     with open("tt.toml", "r") as f: |  | ||||||
|         config = tomlkit.load(f) |  | ||||||
|      |  | ||||||
|     for enum in config.get("enums", []): |  | ||||||
|         if enum["name"] == name: |  | ||||||
|             return {v["value"]: v for v in enum["values"]} |  | ||||||
| 
 |  | ||||||
|     raise ValueError(f"no such enum! {name}") |  | ||||||
| 
 |  | ||||||
| STATUSES = get_enum("status") |  | ||||||
| PROJECTS = get_enum("projects") |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| from datetime import datetime, timedelta | from datetime import datetime, timedelta | ||||||
| from peewee import fn, JOIN | from peewee import fn, JOIN | ||||||
| from ..db import Task, Category | from ..db import Task, Category, TaskStatus | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_category_summary(num: int = 5) -> list[dict]: | def get_category_summary(num: int = 5) -> list[dict]: | ||||||
|  | @ -20,7 +20,7 @@ def get_category_summary(num: int = 5) -> list[dict]: | ||||||
|     overdue_count = ( |     overdue_count = ( | ||||||
|         Task.select(Task.category, fn.COUNT(Task.id).alias("overdue")) |         Task.select(Task.category, fn.COUNT(Task.id).alias("overdue")) | ||||||
|         .where( |         .where( | ||||||
|             (~Task.deleted) & (Task.due < now) & (Task.status != "done") |             (~Task.deleted) & (Task.due < now) & (Task.status != TaskStatus.DONE.value) | ||||||
|         ) |         ) | ||||||
|         .group_by(Task.category) |         .group_by(Task.category) | ||||||
|     ) |     ) | ||||||
|  | @ -32,7 +32,7 @@ def get_category_summary(num: int = 5) -> list[dict]: | ||||||
|             (~Task.deleted) |             (~Task.deleted) | ||||||
|             & (Task.due >= now) |             & (Task.due >= now) | ||||||
|             & (Task.due <= week_from_now) |             & (Task.due <= week_from_now) | ||||||
|             & (Task.status != "done") |             & (Task.status != TaskStatus.DONE.value) | ||||||
|         ) |         ) | ||||||
|         .group_by(Task.category) |         .group_by(Task.category) | ||||||
|     ) |     ) | ||||||
|  | @ -41,16 +41,16 @@ def get_category_summary(num: int = 5) -> list[dict]: | ||||||
|     query = ( |     query = ( | ||||||
|         Category.select( |         Category.select( | ||||||
|             Category.name, |             Category.name, | ||||||
|             fn.COALESCE(fn.SUM(Task.status == "zero"), 0).alias( |             fn.COALESCE(fn.SUM(Task.status == TaskStatus.ZERO.value), 0).alias( | ||||||
|                 "zero_count" |                 "zero_count" | ||||||
|             ), |             ), | ||||||
|             fn.COALESCE(fn.SUM(Task.status == "wip"), 0).alias( |             fn.COALESCE(fn.SUM(Task.status == TaskStatus.WIP.value), 0).alias( | ||||||
|                 "wip_count" |                 "wip_count" | ||||||
|             ), |             ), | ||||||
|             fn.COALESCE(fn.SUM(Task.status == "blocked"), 0).alias( |             fn.COALESCE(fn.SUM(Task.status == TaskStatus.BLOCKED.value), 0).alias( | ||||||
|                 "blocked_count" |                 "blocked_count" | ||||||
|             ), |             ), | ||||||
|             fn.COALESCE(fn.SUM(Task.status == "done"), 0).alias( |             fn.COALESCE(fn.SUM(Task.status == TaskStatus.DONE.value), 0).alias( | ||||||
|                 "done_count" |                 "done_count" | ||||||
|             ), |             ), | ||||||
|             fn.COALESCE(overdue_count.c.overdue, 0).alias("overdue"), |             fn.COALESCE(overdue_count.c.overdue, 0).alias("overdue"), | ||||||
|  | @ -148,7 +148,7 @@ def get_due_soon( | ||||||
|             (~Task.deleted) |             (~Task.deleted) | ||||||
|             & (Task.due.is_null(False)) |             & (Task.due.is_null(False)) | ||||||
|             & (Task.due != "") |             & (Task.due != "") | ||||||
|             & (Task.status != "done") |             & (Task.status != TaskStatus.DONE.value) | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,8 +2,7 @@ import json | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from peewee import fn | from peewee import fn | ||||||
| from peewee import Case, Value | from peewee import Case, Value | ||||||
| from ..db import db, Task, Category, SavedSearch | from ..db import db, Task, Category, TaskStatus, SavedSearch | ||||||
| from .. import config |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def category_lookup(category): | def category_lookup(category): | ||||||
|  | @ -19,7 +18,7 @@ def get_task(item_id: int) -> Task: | ||||||
| def add_task( | def add_task( | ||||||
|     text: str, |     text: str, | ||||||
|     category: str, |     category: str, | ||||||
|     status: str, |     status: str = TaskStatus.ZERO.value, | ||||||
|     due: datetime | None = None, |     due: datetime | None = None, | ||||||
|     type: str = "", |     type: str = "", | ||||||
| ) -> Task: | ) -> Task: | ||||||
|  | @ -63,7 +62,7 @@ def _parse_sort_string(sort_string, status_order): | ||||||
| 
 | 
 | ||||||
|         if field == "status": |         if field == "status": | ||||||
|             if not status_order: |             if not status_order: | ||||||
|                 status_order = list(config.STATUSES.keys()) |                 status_order = [s.value for s in TaskStatus] | ||||||
|             # CASE statement that maps each status to its position in the order |             # CASE statement that maps each status to its position in the order | ||||||
|             order_case = Case( |             order_case = Case( | ||||||
|                 Task.status, |                 Task.status, | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								src/tt/db.py
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								src/tt/db.py
									
									
									
									
									
								
							|  | @ -20,6 +20,15 @@ db = SqliteDatabase( | ||||||
|     }, |     }, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | class TaskStatus(Enum): | ||||||
|  |     # order is used for progression in toggle | ||||||
|  |     ZERO = "zero" | ||||||
|  |     WIP = "wip" | ||||||
|  |     BLOCKED = "blocked" | ||||||
|  |     DONE = "done" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class GeneratorType(Enum): | class GeneratorType(Enum): | ||||||
|     DAYS_BETWEEN = "days-btwn" |     DAYS_BETWEEN = "days-btwn" | ||||||
|     MONTHLY = "monthly" |     MONTHLY = "monthly" | ||||||
|  | @ -39,7 +48,10 @@ class Category(BaseModel): | ||||||
| 
 | 
 | ||||||
| class Task(BaseModel): | class Task(BaseModel): | ||||||
|     text = TextField() |     text = TextField() | ||||||
|     status = CharField() |     status = CharField( | ||||||
|  |         choices=[(status.value, status.name) for status in TaskStatus], | ||||||
|  |         default=TaskStatus.ZERO.value, | ||||||
|  |     ) | ||||||
|     due = DateTimeField(null=True) |     due = DateTimeField(null=True) | ||||||
|     category = ForeignKeyField(Category, backref="tasks", null=True) |     category = ForeignKeyField(Category, backref="tasks", null=True) | ||||||
|     type = CharField() |     type = CharField() | ||||||
|  |  | ||||||
|  | @ -1,7 +1,6 @@ | ||||||
| import csv | import csv | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from tt.db import initialize_db, Task, Category | from tt.db import initialize_db, Task, Category, TaskStatus | ||||||
| from tt.config import STATUSES |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def import_tasks_from_csv(filename: str): | def import_tasks_from_csv(filename: str): | ||||||
|  | @ -21,7 +20,9 @@ def import_tasks_from_csv(filename: str): | ||||||
| 
 | 
 | ||||||
|             # Validate status |             # Validate status | ||||||
|             status = row["status"].lower() if row["status"] else "zero" |             status = row["status"].lower() if row["status"] else "zero" | ||||||
|             if status not in STATUSES: |             try: | ||||||
|  |                 TaskStatus(status) | ||||||
|  |             except ValueError: | ||||||
|                 print(f"Warning: Invalid status '{status}', defaulting to 'zero'") |                 print(f"Warning: Invalid status '{status}', defaulting to 'zero'") | ||||||
|                 status = "zero" |                 status = "zero" | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,4 +1,3 @@ | ||||||
| import datetime |  | ||||||
| from textual.app import App | from textual.app import App | ||||||
| from textual.widgets import ( | from textual.widgets import ( | ||||||
|     DataTable, |     DataTable, | ||||||
|  | @ -11,15 +10,30 @@ from textual.containers import Container | ||||||
| from ..utils import ( | from ..utils import ( | ||||||
|     remove_rich_tag, |     remove_rich_tag, | ||||||
|     filter_to_string, |     filter_to_string, | ||||||
|  |     advance_enum_val, | ||||||
|     get_text_from_editor, |     get_text_from_editor, | ||||||
| ) | ) | ||||||
| from .keymodal import KeyModal | from .keymodal import KeyModal | ||||||
| from .modals import ChoiceModal, DateModal, ConfirmModal | 
 | ||||||
|  | ELLIPSIS = "…" | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class NotifyValidationError(Exception): | class NotifyValidationError(Exception): | ||||||
|     """will notify and continue if raised""" |     """will notify and continue if raised""" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def _enum_preprocessor(enumCls): | ||||||
|  |     """generate a default preprocessor to enforce enums""" | ||||||
|  | 
 | ||||||
|  |     def preprocessor(val): | ||||||
|  |         try: | ||||||
|  |             enumCls(val) | ||||||
|  |             return val | ||||||
|  |         except ValueError: | ||||||
|  |             raise NotifyValidationError( | ||||||
|  |                 f"Invalid value. Use: {[s.value for s in enumCls]}" | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class TableColumnConfig: | class TableColumnConfig: | ||||||
|     def __init__( |     def __init__( | ||||||
|  | @ -28,6 +42,8 @@ class TableColumnConfig: | ||||||
|         display_name: str, |         display_name: str, | ||||||
|         *, |         *, | ||||||
|         default=None, |         default=None, | ||||||
|  |         enum=None, | ||||||
|  |         preprocessor=None, | ||||||
|         enable_editor=False, |         enable_editor=False, | ||||||
|         filterable=True, |         filterable=True, | ||||||
|         read_only=False, |         read_only=False, | ||||||
|  | @ -35,57 +51,16 @@ class TableColumnConfig: | ||||||
|         self.field = field |         self.field = field | ||||||
|         self.display_name = display_name |         self.display_name = display_name | ||||||
|         self.default = default |         self.default = default | ||||||
|  |         self.enum = enum | ||||||
|         self.enable_editor = enable_editor |         self.enable_editor = enable_editor | ||||||
|         self.filterable = filterable |         self.filterable = filterable | ||||||
|         self.read_only = read_only |         self.read_only = read_only | ||||||
| 
 |         if preprocessor: | ||||||
|     def preprocess(self, val): |             self.preprocessor = preprocessor | ||||||
|         return val  # no-op |         elif self.enum: | ||||||
| 
 |             self.preprocessor = _enum_preprocessor(self.enum) | ||||||
|     def start_change(self, app, current_value):     |  | ||||||
|         if current_value.endswith(ELLIPSIS): |  | ||||||
|             app.action_start_edit() |  | ||||||
|         else: |         else: | ||||||
|             # default edit mode |             self.preprocessor = lambda x: x | ||||||
|             app._show_input("edit", current_value) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class EnumColumnConfig(TableColumnConfig): |  | ||||||
|     def __init__( |  | ||||||
|         self, |  | ||||||
|         field: str, |  | ||||||
|         display_name: str, |  | ||||||
|         enum, |  | ||||||
|         **kwargs |  | ||||||
|     ): |  | ||||||
|         super().__init__(field, display_name, **kwargs) |  | ||||||
|         self.enum = enum |  | ||||||
| 
 |  | ||||||
|     def preprocess(self, val): |  | ||||||
|         if val in self.enum: |  | ||||||
|             return val |  | ||||||
|         else: |  | ||||||
|             raise NotifyValidationError( |  | ||||||
|                 f"Invalid value {val}. Use: {list(self.enum)}" |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|     def start_change(self, app, current_value): |  | ||||||
|         # a weird hack? pass app here and correct modal gets pushed |  | ||||||
|         app.push_screen(ChoiceModal(self.enum, current_value), app.apply_change) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class DateColumnConfig(TableColumnConfig): |  | ||||||
|     def preprocess(self, val): |  | ||||||
|         try: |  | ||||||
|             return datetime.strptime(val, "%Y-%m-%d") |  | ||||||
|         except ValueError: |  | ||||||
|             raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD") |  | ||||||
| 
 |  | ||||||
|     def start_change(self, app, current_value): |  | ||||||
|         app.push_screen(DateModal(current_value), app.apply_change) |  | ||||||
| ELLIPSIS = "…" |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TableEditor(App): | class TableEditor(App): | ||||||
|  | @ -159,7 +134,7 @@ class TableEditor(App): | ||||||
|         ("?", "show_keys", "show keybindings"), |         ("?", "show_keys", "show keybindings"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     def __init__(self): |     def __init__(self, default_view="default"): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.filters = {} |         self.filters = {} | ||||||
|         self.sort_string = ""  # TODO: default sort |         self.sort_string = ""  # TODO: default sort | ||||||
|  | @ -237,10 +212,7 @@ class TableEditor(App): | ||||||
|             self.table.move_cursor(row=0, column=1) |             self.table.move_cursor(row=0, column=1) | ||||||
| 
 | 
 | ||||||
|     def action_delete_item(self): |     def action_delete_item(self): | ||||||
|         self.push_screen(ConfirmModal(f"delete ?"), self._delete_item_callback) |         if self.table.cursor_column == 0: | ||||||
| 
 |  | ||||||
|     def _delete_item_callback(self, confirm): |  | ||||||
|         if confirm and self.table.cursor_column == 0: |  | ||||||
|             cur_row = self.table.cursor_row |             cur_row = self.table.cursor_row | ||||||
|             item_id = int(self.table.get_cell_at((cur_row, 0))) |             item_id = int(self.table.get_cell_at((cur_row, 0))) | ||||||
|             # deletable items need a delete |             # deletable items need a delete | ||||||
|  | @ -257,9 +229,11 @@ class TableEditor(App): | ||||||
|                 continue |                 continue | ||||||
|             val = self.filters.get(fc.field, fc.default) |             val = self.filters.get(fc.field, fc.default) | ||||||
|             if val is not None: |             if val is not None: | ||||||
|                 if "," in val: |                 # enums use comma separated filters | ||||||
|                     val = val.split(",")[0] # TODO: fix hack for enums |                 if fc.enum: | ||||||
|                 prepopulated[fc.field] = val |                     prepopulated[fc.field] = val.split(",")[0] | ||||||
|  |                 else: | ||||||
|  |                     prepopulated[fc.field] = val | ||||||
| 
 | 
 | ||||||
|         new_item = self.add_item_callback(**prepopulated) |         new_item = self.add_item_callback(**prepopulated) | ||||||
|         self.refresh_data(restore_cursor=False) |         self.refresh_data(restore_cursor=False) | ||||||
|  | @ -273,21 +247,21 @@ class TableEditor(App): | ||||||
|     def _active_item_id(self): |     def _active_item_id(self): | ||||||
|         return int(self.table.get_cell_at((self.table.cursor_row, 0))) |         return int(self.table.get_cell_at((self.table.cursor_row, 0))) | ||||||
| 
 | 
 | ||||||
|     # def action_toggle_cell(self): |     def action_toggle_cell(self): | ||||||
|     #     cur_row = self.table.cursor_row |         cur_row = self.table.cursor_row | ||||||
|     #     cur_col = self.table.cursor_column |         cur_col = self.table.cursor_column | ||||||
|     #     cconf = self._active_column_config() |         cconf = self._active_column_config() | ||||||
| 
 | 
 | ||||||
|     #     if cconf.enum: |         if cconf.enum: | ||||||
|     #         item_id = self._active_item_id() |             item_id = self._active_item_id() | ||||||
|     #         current_val = self.table.get_cell_at((cur_row, cur_col)) |             current_val = self.table.get_cell_at((cur_row, cur_col)) | ||||||
|     #         next_val = advance_enum_val(cconf.enum, current_val) |             next_val = advance_enum_val(cconf.enum, current_val) | ||||||
|     #         self.table.update_cell_at((cur_row, cur_col), next_val) |             self.table.update_cell_at((cur_row, cur_col), next_val) | ||||||
|     #         # trigger item_id to be saved on the next cursor move |             # trigger item_id to be saved on the next cursor move | ||||||
|     #         # this avoids filtered columns disappearing right away |             # this avoids filtered columns disappearing right away | ||||||
|     #         # and tons of DB writes |             # and tons of DB writes | ||||||
|     #         update = {cconf.field: next_val} |             update = {cconf.field: next_val} | ||||||
|     #         self._register_save_on_move(item_id, **update) |             self._register_save_on_move(item_id, **update) | ||||||
| 
 | 
 | ||||||
|     def _register_save_on_move(self, item_id, **kwargs): |     def _register_save_on_move(self, item_id, **kwargs): | ||||||
|         if self.save_on_move and self.save_on_move["item_id"] != item_id: |         if self.save_on_move and self.save_on_move["item_id"] != item_id: | ||||||
|  | @ -359,15 +333,15 @@ class TableEditor(App): | ||||||
|         if cconf.read_only: |         if cconf.read_only: | ||||||
|             return |             return | ||||||
| 
 | 
 | ||||||
|         # save cursor before callback, so correct position updates |  | ||||||
|         self._save_cursor() |         self._save_cursor() | ||||||
|         current_value = self.table.get_cell_at( |         current_value = self.table.get_cell_at( | ||||||
|             (self.table.cursor_row, self.table.cursor_column) |             (self.table.cursor_row, self.table.cursor_column) | ||||||
|         ) |         ) | ||||||
|  |         if current_value.endswith(ELLIPSIS): | ||||||
|  |             self.notify("multi-line text, use (e)dit") | ||||||
|  |             return | ||||||
|         current_value = remove_rich_tag(current_value) |         current_value = remove_rich_tag(current_value) | ||||||
| 
 |         self._show_input("edit", current_value) | ||||||
|         # delegate to start_change, which will call back to self |  | ||||||
|         cconf.start_change(self, current_value) |  | ||||||
| 
 | 
 | ||||||
|     def action_start_edit(self): |     def action_start_edit(self): | ||||||
|         cconf = self._active_column_config() |         cconf = self._active_column_config() | ||||||
|  | @ -418,7 +392,7 @@ class TableEditor(App): | ||||||
| 
 | 
 | ||||||
|         # preprocess/validate the field being saved |         # preprocess/validate the field being saved | ||||||
|         try: |         try: | ||||||
|             update_data[field] = cconf.preprocess(new_value) |             update_data[field] = cconf.preprocessor(new_value) | ||||||
|             self.update_item_callback(item_id, **update_data) |             self.update_item_callback(item_id, **update_data) | ||||||
|             self.refresh_data() |             self.refresh_data() | ||||||
|         except NotifyValidationError as e: |         except NotifyValidationError as e: | ||||||
|  |  | ||||||
|  | @ -1,186 +0,0 @@ | ||||||
| from textual.screen import ModalScreen |  | ||||||
| from textual.binding import Binding |  | ||||||
| from textual.widgets import RadioSet, RadioButton, Label |  | ||||||
| from .. import config |  | ||||||
| from ..utils import get_color_enum |  | ||||||
| 
 |  | ||||||
| class ConfirmModal(ModalScreen): |  | ||||||
|     CSS = """ |  | ||||||
|     ConfirmModal { |  | ||||||
|         align: center middle; |  | ||||||
|         background: $primary 30%; |  | ||||||
|     } |  | ||||||
|     """ |  | ||||||
| 
 |  | ||||||
|     BINDINGS = [ |  | ||||||
|         ("y", "confirm", "Down"), |  | ||||||
|         ("n,escape", "cancel", "cancel"), |  | ||||||
|     ] |  | ||||||
| 
 |  | ||||||
|     def __init__(self, message): |  | ||||||
|         self.message = message |  | ||||||
|         super().__init__() |  | ||||||
| 
 |  | ||||||
|     def compose(self): |  | ||||||
|         yield Label(self.message) |  | ||||||
|         yield Label("(y)es") |  | ||||||
|         yield Label("(n)o") |  | ||||||
| 
 |  | ||||||
|     def action_confirm(self): |  | ||||||
|         self.dismiss(True) |  | ||||||
| 
 |  | ||||||
|     def action_cancel(self): |  | ||||||
|         self.app.pop_screen() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ChoiceModal(ModalScreen): |  | ||||||
|     CSS = """ |  | ||||||
|     ChoiceModal { |  | ||||||
|         align: center middle; |  | ||||||
|         background: $primary 30%; |  | ||||||
|     } |  | ||||||
|     ChoiceModal Label { |  | ||||||
|         height: 1; |  | ||||||
|     } |  | ||||||
|     """ |  | ||||||
| 
 |  | ||||||
|     BINDINGS = [ |  | ||||||
|         ("j", "cursor_down", "Down"), |  | ||||||
|         ("k", "cursor_up", "Up"), |  | ||||||
|         Binding("enter", "select", "Select", priority=True), |  | ||||||
|         ("c", "select", "Select"), |  | ||||||
|         ("escape", "cancel", "cancel"), |  | ||||||
|     ] |  | ||||||
| 
 |  | ||||||
|     def __init__(self, enum, selected): |  | ||||||
|         self._enum = enum |  | ||||||
|         self.selected = selected |  | ||||||
|         super().__init__() |  | ||||||
| 
 |  | ||||||
|     def compose(self): |  | ||||||
|         yield RadioSet( |  | ||||||
|             *[ |  | ||||||
|                 RadioButton( |  | ||||||
|                     get_color_enum(e.value, config.STATUSES, "red"), value=self.selected == str(e.value) |  | ||||||
|                 ) |  | ||||||
|                 for e in self._enum |  | ||||||
|             ] |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     def action_cursor_down(self): |  | ||||||
|         self.query_one(RadioSet).action_next_button() |  | ||||||
| 
 |  | ||||||
|     def action_cursor_up(self): |  | ||||||
|         self.query_one(RadioSet).action_previous_button() |  | ||||||
| 
 |  | ||||||
|     def action_select(self): |  | ||||||
|         rs = self.query_one(RadioSet) |  | ||||||
|         rs.action_toggle_button() |  | ||||||
|         pressed = rs.pressed_button |  | ||||||
|         self.dismiss(str(pressed.label)) |  | ||||||
| 
 |  | ||||||
|     def action_cancel(self): |  | ||||||
|         self.app.pop_screen() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class DateModal(ModalScreen): |  | ||||||
|     CSS = """ |  | ||||||
|     DateModal { |  | ||||||
|         layout: horizontal; |  | ||||||
|         align: center middle; |  | ||||||
|         background: $primary 30%; |  | ||||||
|     } |  | ||||||
|     DateModal Label { |  | ||||||
|         border: solid grey; |  | ||||||
|     } |  | ||||||
|     DateModal Label.selected-date { |  | ||||||
|         border: solid green; |  | ||||||
|     } |  | ||||||
|     """ |  | ||||||
| 
 |  | ||||||
|     BINDINGS = [ |  | ||||||
|         ("j", "cursor_down", "Down"), |  | ||||||
|         ("k", "cursor_up", "Up"), |  | ||||||
|         ("h", "cursor_left", "Left"), |  | ||||||
|         ("l", "cursor_right", "Right"), |  | ||||||
|         #        ("0,1,2,3,4,5,6,7,8,9", "num_entry", "#"), |  | ||||||
|         Binding("enter", "select", "Select", priority=True), |  | ||||||
|         ("escape", "cancel", "cancel"), |  | ||||||
|     ] |  | ||||||
| 
 |  | ||||||
|     def __init__(self, date): |  | ||||||
|         self.pieces = [int(p) for p in date.split("-")] |  | ||||||
|         self.selected = 1  # start on month |  | ||||||
|         super().__init__() |  | ||||||
| 
 |  | ||||||
|     def compose(self): |  | ||||||
|         for idx, piece in enumerate(self.pieces): |  | ||||||
|             yield Label( |  | ||||||
|                 str(piece), classes="selected-date" if idx == self.selected else "" |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|     def action_cursor_left(self): |  | ||||||
|         # cycle Y/M/D |  | ||||||
|         self.selected = (self.selected - 1) % 3 |  | ||||||
|         self._update_highlight() |  | ||||||
| 
 |  | ||||||
|     def action_cursor_right(self): |  | ||||||
|         self.selected = (self.selected + 1) % 3 |  | ||||||
|         self._update_highlight() |  | ||||||
| 
 |  | ||||||
|     def _update_highlight(self): |  | ||||||
|         for idx, lbl in enumerate(self.query("Label")): |  | ||||||
|             if idx == self.selected: |  | ||||||
|                 lbl.add_class("selected-date") |  | ||||||
|             else: |  | ||||||
|                 lbl.remove_class("selected-date") |  | ||||||
| 
 |  | ||||||
|     def max_for(self, piece): |  | ||||||
|         days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] |  | ||||||
|         if piece == 0: |  | ||||||
|             return 3000 |  | ||||||
|         elif piece == 1: |  | ||||||
|             return 12 |  | ||||||
|         else: |  | ||||||
|             # -1 for offset array |  | ||||||
|             return days_in_month[self.pieces[1] - 1] |  | ||||||
| 
 |  | ||||||
|     def _move_piece(self, by): |  | ||||||
|         cur_value = self.pieces[self.selected] |  | ||||||
|         cur_value += by |  | ||||||
|         if cur_value == 0: |  | ||||||
|             cur_value = self.max_for(self.selected) |  | ||||||
|         if cur_value > self.max_for(self.selected): |  | ||||||
|             cur_value = 1 |  | ||||||
|         self.pieces[self.selected] = cur_value |  | ||||||
|         cur_label = self.query("Label")[self.selected] |  | ||||||
|         cur_label.update(str(cur_value)) |  | ||||||
| 
 |  | ||||||
|     def action_cursor_down(self): |  | ||||||
|         self._move_piece(-1) |  | ||||||
| 
 |  | ||||||
|     def action_cursor_up(self): |  | ||||||
|         self._move_piece(1) |  | ||||||
| 
 |  | ||||||
|     def on_key(self, event) -> None: |  | ||||||
|         key = event.key |  | ||||||
|         if key in "0123456789": |  | ||||||
|             cur_value = self.pieces[self.selected] |  | ||||||
|             # If we can append the values like 1+2 = 12 |  | ||||||
|             # then do that, otherwise reset the value |  | ||||||
|             # so (for months) 1+3 = 3 |  | ||||||
|             appended = int(str(cur_value) + key) |  | ||||||
|             if appended <= self.max_for(self.selected): |  | ||||||
|                 cur_value = appended |  | ||||||
|             else: |  | ||||||
|                 cur_value = int(key) |  | ||||||
|             self.pieces[self.selected] = cur_value |  | ||||||
|             self._move_piece(0) |  | ||||||
|             event.prevent_default() |  | ||||||
| 
 |  | ||||||
|     def action_select(self): |  | ||||||
|         date = "-".join(str(p) for p in self.pieces) |  | ||||||
|         self.dismiss(date) |  | ||||||
| 
 |  | ||||||
|     def action_cancel(self): |  | ||||||
|         self.app.pop_screen() |  | ||||||
|  | @ -11,7 +11,6 @@ from ..db import GeneratorType | ||||||
| from .editor import ( | from .editor import ( | ||||||
|     TableEditor, |     TableEditor, | ||||||
|     TableColumnConfig, |     TableColumnConfig, | ||||||
|     EnumColumnConfig, |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -19,14 +18,14 @@ class TaskGenEditor(TableEditor): | ||||||
|     TABLE_CONFIG = ( |     TABLE_CONFIG = ( | ||||||
|         TableColumnConfig("id", "ID"), |         TableColumnConfig("id", "ID"), | ||||||
|         TableColumnConfig("template", "Template", default="recur {val}"), |         TableColumnConfig("template", "Template", default="recur {val}"), | ||||||
|         EnumColumnConfig( |         TableColumnConfig( | ||||||
|             "type", |             "type", | ||||||
|             "Type", |             "Type", | ||||||
|             default=GeneratorType.DAYS_BETWEEN.value, |             default=GeneratorType.DAYS_BETWEEN.value, | ||||||
|             enum=GeneratorType, |             enum=GeneratorType, | ||||||
|         ), |         ), | ||||||
|         TableColumnConfig("val", "Value", default="1"), |         TableColumnConfig("val", "Value", default="1"), | ||||||
|         TableColumnConfig("next_at", "Next @", read_only=True), |         TableColumnConfig("next_at", "Next @", default="", read_only=True), | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|  |  | ||||||
|  | @ -2,40 +2,47 @@ import json | ||||||
| from textual.widgets import Input | from textual.widgets import Input | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| 
 | 
 | ||||||
| from .. import config |  | ||||||
| from ..controller.tasks import ( | from ..controller.tasks import ( | ||||||
|     get_task, |     get_task, | ||||||
|     get_tasks, |     get_tasks, | ||||||
|     add_task, |     add_task, | ||||||
|     update_task, |     update_task, | ||||||
|  |     TaskStatus, | ||||||
|     save_view, |     save_view, | ||||||
|     get_saved_view, |     get_saved_view, | ||||||
| ) | ) | ||||||
| from ..utils import ( | from ..utils import ( | ||||||
|     get_color_enum, |     get_colored_category, | ||||||
|  |     get_colored_status, | ||||||
|     get_colored_date, |     get_colored_date, | ||||||
| ) | ) | ||||||
| from .editor import ( | from .editor import ( | ||||||
|     TableEditor, |     TableEditor, | ||||||
|     TableColumnConfig, |     TableColumnConfig, | ||||||
|     EnumColumnConfig, |     NotifyValidationError, | ||||||
|     DateColumnConfig, |  | ||||||
|     ELLIPSIS, |     ELLIPSIS, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | 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(TableEditor): | class TT(TableEditor): | ||||||
|     TABLE_CONFIG = ( |     TABLE_CONFIG = ( | ||||||
|         TableColumnConfig("id", "ID"), |         TableColumnConfig("id", "ID"), | ||||||
|         TableColumnConfig("text", "Task", default="new task", enable_editor=True), |         TableColumnConfig("text", "Task", default="new task", enable_editor=True), | ||||||
|         EnumColumnConfig( |         TableColumnConfig( | ||||||
|             "status", |             "status", | ||||||
|             "Status", |             "Status", | ||||||
|             enum=config.STATUSES, |  | ||||||
|             default="zero", |             default="zero", | ||||||
|  |             enum=TaskStatus, | ||||||
|         ), |         ), | ||||||
|         TableColumnConfig("type", "Type", default=""), |         TableColumnConfig("type", "Type", default=""), | ||||||
|         DateColumnConfig("due", "Due", default=""), |         TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor), | ||||||
|         TableColumnConfig("category", "Category", default="main"), |         TableColumnConfig("category", "Category", default="main"), | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  | @ -89,12 +96,10 @@ class TT(TableEditor): | ||||||
|             sort=self.sort_string, |             sort=self.sort_string, | ||||||
|         ) |         ) | ||||||
|         for item in items: |         for item in items: | ||||||
|             category = get_color_enum( |             category = get_colored_category( | ||||||
|                 item.category.name if item.category else " - ", |                 item.category.name if item.category else " - " | ||||||
|                 config.PROJECTS, |  | ||||||
|                 "grey" |  | ||||||
|             ) |             ) | ||||||
|             status = get_color_enum(item.status, config.STATUSES, "red") |             status = get_colored_status(item.status) | ||||||
|             due = get_colored_date(item.due) |             due = get_colored_date(item.due) | ||||||
| 
 | 
 | ||||||
|             if "\n" in item.text: |             if "\n" in item.text: | ||||||
|  |  | ||||||
|  | @ -32,9 +32,15 @@ def advance_enum_val(enum_type, cur_val): | ||||||
|     return members[next_idx] |     return members[next_idx] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_color_enum(value: str, enum: dict[str, dict], default: str) -> str: | def get_colored_status(status: str) -> str: | ||||||
|     color = enum.get(value, {"color": default})["color"] |     colors = { | ||||||
|     return f"[{color}]{value}[/]" |         "zero": "#666666", | ||||||
|  |         "wip": "#33aa99", | ||||||
|  |         "blocked": "#cc9900", | ||||||
|  |         "done": "#009900", | ||||||
|  |     } | ||||||
|  |     color = colors.get(status, "#666666") | ||||||
|  |     return f"[{color}]{status}[/]" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_colored_category(category: str) -> str: | def get_colored_category(category: str) -> str: | ||||||
|  |  | ||||||
							
								
								
									
										21
									
								
								tt.toml
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								tt.toml
									
									
									
									
									
								
							|  | @ -1,21 +0,0 @@ | ||||||
| [[enums]] |  | ||||||
| name = "status" |  | ||||||
| values = [ |  | ||||||
|   { value = "zero", color = "#666666" }, |  | ||||||
|   { value = "blocked", color = "#33a99" }, |  | ||||||
|   { value = "wip", color = "#cc9900" }, |  | ||||||
|   { value = "done", color = "#009900" }, |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[enums]] |  | ||||||
| name = "projects" |  | ||||||
| values = [ |  | ||||||
|   { value = "SECT", color = "purple" }, |  | ||||||
|   { value = "life", color = "#00cc00" }, |  | ||||||
|   { value = "CAPP", color = "#cc0000" }, |  | ||||||
|   { value = "ilikethis", color = "#cccc00" }, |  | ||||||
|   { value = "krang", color = "#ff00ff"}, |  | ||||||
|   { value = "artworld", color = "#0000cc"}, |  | ||||||
|   { value = "TT", color = "#00ff00"}, |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
							
								
								
									
										14
									
								
								uv.lock
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								uv.lock
									
									
									
									
									
								
							|  | @ -1,5 +1,4 @@ | ||||||
| version = 1 | version = 1 | ||||||
| revision = 1 |  | ||||||
| requires-python = ">=3.10" | requires-python = ">=3.10" | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -161,7 +160,7 @@ name = "click" | ||||||
| version = "8.1.8" | version = "8.1.8" | ||||||
| source = { registry = "https://pypi.org/simple" } | source = { registry = "https://pypi.org/simple" } | ||||||
| dependencies = [ | dependencies = [ | ||||||
|     { name = "colorama", marker = "sys_platform == 'win32'" }, |     { name = "colorama", marker = "platform_system == 'Windows'" }, | ||||||
| ] | ] | ||||||
| sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } | ||||||
| wheels = [ | wheels = [ | ||||||
|  | @ -902,15 +901,6 @@ wheels = [ | ||||||
|     { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, |     { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "tomlkit" |  | ||||||
| version = "0.13.2" |  | ||||||
| source = { registry = "https://pypi.org/simple" } |  | ||||||
| sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } |  | ||||||
| wheels = [ |  | ||||||
|     { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "tt" | name = "tt" | ||||||
| version = "0.1.0" | version = "0.1.0" | ||||||
|  | @ -920,7 +910,6 @@ dependencies = [ | ||||||
|     { name = "lxml" }, |     { name = "lxml" }, | ||||||
|     { name = "peewee" }, |     { name = "peewee" }, | ||||||
|     { name = "textual" }, |     { name = "textual" }, | ||||||
|     { name = "tomlkit" }, |  | ||||||
|     { name = "typer" }, |     { name = "typer" }, | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | @ -937,7 +926,6 @@ requires-dist = [ | ||||||
|     { name = "lxml", specifier = ">=5.3.0" }, |     { name = "lxml", specifier = ">=5.3.0" }, | ||||||
|     { name = "peewee", specifier = ">=3.17.8" }, |     { name = "peewee", specifier = ">=3.17.8" }, | ||||||
|     { name = "textual", specifier = ">=1.0.0" }, |     { name = "textual", specifier = ">=1.0.0" }, | ||||||
|     { name = "tomlkit", specifier = ">=0.13.2" }, |  | ||||||
|     { name = "typer", specifier = ">=0.15.1" }, |     { name = "typer", specifier = ">=0.15.1" }, | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue