Compare commits
3 Commits
1cc0a156a1
...
90b4ed7300
Author | SHA1 | Date | |
---|---|---|---|
90b4ed7300 | |||
32e0aa593b | |||
|
802d8f2db4 |
52
src/tt/db.py
52
src/tt/db.py
@ -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()
|
||||||
|
@ -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"
|
||||||
|
468
src/tt/tui.py
468
src/tt/tui.py
@ -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
370
src/tt/tui_editor.py
Normal 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
46
src/tt/tui_keybindings.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user