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 peewee import (
|
||||
BooleanField,
|
||||
@ -68,9 +68,57 @@ class SavedSearch(BaseModel):
|
||||
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():
|
||||
db.connect()
|
||||
db.create_tables([Category, Task, SavedSearch])
|
||||
db.create_tables(
|
||||
[
|
||||
Category,
|
||||
Task,
|
||||
SavedSearch,
|
||||
# TaskGenerator
|
||||
]
|
||||
)
|
||||
if not Category.select().exists():
|
||||
Category.create(name="default")
|
||||
db.close()
|
||||
|
@ -1,4 +1,3 @@
|
||||
import sys
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from tt.db import initialize_db, Task, Category, TaskStatus
|
||||
@ -17,9 +16,7 @@ def import_tasks_from_csv(filename: str):
|
||||
try:
|
||||
due_date = datetime.strptime(row["due"].strip(), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
print(
|
||||
f"Warning: Couldn't parse date '{row['due']}', skipping due date"
|
||||
)
|
||||
print(f"Warning: Couldn't parse '{row['due']}', skipping due date")
|
||||
|
||||
# Validate status
|
||||
status = row["status"].lower() if row["status"] else "zero"
|
||||
|
468
src/tt/tui.py
468
src/tt/tui.py
@ -1,14 +1,5 @@
|
||||
import json
|
||||
from textual.app import App
|
||||
from textual.screen import ModalScreen
|
||||
from rich.table import Table
|
||||
from textual.widgets import (
|
||||
DataTable,
|
||||
Header,
|
||||
Input,
|
||||
Static,
|
||||
)
|
||||
from textual.containers import Container
|
||||
from textual.widgets import Input
|
||||
from datetime import datetime
|
||||
|
||||
from .controller import (
|
||||
@ -22,109 +13,62 @@ from .controller import (
|
||||
)
|
||||
from .db import initialize_db
|
||||
from .utils import (
|
||||
remove_rich_tag,
|
||||
filter_to_string,
|
||||
advance_enum_val,
|
||||
get_colored_category,
|
||||
get_colored_status,
|
||||
get_colored_date,
|
||||
get_text_from_editor,
|
||||
)
|
||||
from .tui_editor import (
|
||||
TableEditor,
|
||||
TableColumnConfig,
|
||||
NotifyValidationError,
|
||||
)
|
||||
|
||||
DEFAULT_CATEGORY = "main"
|
||||
COLUMNS = ("ID", "Task", "Status", "Type", "Due", "Category")
|
||||
column_to_field = {
|
||||
0: "ID",
|
||||
1: "text",
|
||||
2: "status",
|
||||
3: "type",
|
||||
4: "due",
|
||||
5: "category",
|
||||
}
|
||||
|
||||
def due_preprocessor(val):
|
||||
try:
|
||||
return datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
|
||||
class TT(App):
|
||||
CSS = """
|
||||
#footer {
|
||||
dock: bottom;
|
||||
max-height: 2;
|
||||
}
|
||||
#input_bar {
|
||||
height: 2;
|
||||
border-top: solid green;
|
||||
layout: grid;
|
||||
grid-size: 2;
|
||||
grid-columns: 10 1fr;
|
||||
display: none;
|
||||
}
|
||||
def status_preprocessor(val):
|
||||
try:
|
||||
TaskStatus(val)
|
||||
return val
|
||||
except ValueError:
|
||||
raise NotifyValidationError(
|
||||
f"Invalid status. Use: {[s.value for s in TaskStatus]}"
|
||||
)
|
||||
|
||||
#prompt_label {
|
||||
width: 10;
|
||||
content-align: left middle;
|
||||
}
|
||||
|
||||
#input_widget {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#status_bar {
|
||||
border-top: solid white;
|
||||
height: 2;
|
||||
margin: 0;
|
||||
layout: grid;
|
||||
grid-size: 2;
|
||||
}
|
||||
|
||||
#left_status {
|
||||
height: 1;
|
||||
margin: 0;
|
||||
padding-left: 1;
|
||||
}
|
||||
#right_status {
|
||||
height: 1;
|
||||
margin: 0;
|
||||
padding-right: 1;
|
||||
text-align: right;
|
||||
}
|
||||
"""
|
||||
class TT(TableEditor):
|
||||
TABLE_CONFIG = (
|
||||
TableColumnConfig("id", "ID"),
|
||||
TableColumnConfig("text", "Task", default="new task", enable_editor=True),
|
||||
TableColumnConfig(
|
||||
"status",
|
||||
"Status",
|
||||
default="zero",
|
||||
enum=TaskStatus,
|
||||
preprocessor=status_preprocessor,
|
||||
),
|
||||
TableColumnConfig("type", "Type", default=""),
|
||||
TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor),
|
||||
TableColumnConfig("category", "Category", default="main"),
|
||||
)
|
||||
update_item_callback = update_task
|
||||
update_item_callback = add_task
|
||||
get_item_callback = get_task
|
||||
|
||||
BINDINGS = [
|
||||
# movement
|
||||
("h", "cursor_left", "Left"),
|
||||
("j", "cursor_down", "Down"),
|
||||
("k", "cursor_up", "Up"),
|
||||
("l", "cursor_right", "Right"),
|
||||
("g", "cursor_top", "Top"),
|
||||
("G", "cursor_bottom", "Bottom"),
|
||||
# filtering & editing
|
||||
("/", "start_search", "search tasks by name"),
|
||||
("f", "start_filter", "filter tasks by category"),
|
||||
("s", "start_sort", "sort tasks"),
|
||||
("escape", "cancel_edit", "Cancel Edit"),
|
||||
# edits
|
||||
("c", "start_change", "change current cell"),
|
||||
("e", "start_edit", "open cell in editor"),
|
||||
("a", "add_task", "add task"),
|
||||
("t", "toggle_cell", "toggle status"),
|
||||
("d", "delete_task", "delete (must be on row mode)"),
|
||||
# saved views
|
||||
("ctrl+s", "save_view", "save current view"),
|
||||
("ctrl+o", "load_view", "load saved view"),
|
||||
# other
|
||||
("q", "quit", "quit"),
|
||||
("?", "show_keys", "show keybindings"),
|
||||
]
|
||||
|
||||
def __init__(self, default_view="default"):
|
||||
super().__init__()
|
||||
self.filters = {}
|
||||
self.sort_string = "due,status"
|
||||
self._load_view(default_view)
|
||||
self.search_query = ""
|
||||
self.saved_cursor_pos = (1, 0)
|
||||
self.save_on_move = None
|
||||
|
||||
def _load_view(self, name):
|
||||
try:
|
||||
@ -134,62 +78,25 @@ class TT(App):
|
||||
except Exception:
|
||||
self.notify(f"Could not load {name}")
|
||||
|
||||
def compose(self):
|
||||
self.header = Header()
|
||||
self.table = DataTable()
|
||||
self.input_widget = Input(id="input_widget")
|
||||
self.input_label = Static("label", id="prompt_label")
|
||||
self.input_bar = Container(id="input_bar")
|
||||
self.status_bar = Container(id="status_bar")
|
||||
self.left_status = Static("LEFT", id="left_status")
|
||||
self.right_status = Static(self.sort_string, id="right_status")
|
||||
yield self.header
|
||||
yield Container(self.table)
|
||||
with Container(id="footer"):
|
||||
with self.input_bar:
|
||||
yield self.input_label
|
||||
yield self.input_widget
|
||||
with self.status_bar:
|
||||
yield self.left_status
|
||||
yield self.right_status
|
||||
def action_save_view(self):
|
||||
self._show_input("save-view", "default")
|
||||
|
||||
def on_mount(self):
|
||||
self.table.add_columns(*COLUMNS)
|
||||
self.refresh_tasks(restore_cursor=False)
|
||||
def action_load_view(self):
|
||||
self._show_input("load-view", "")
|
||||
|
||||
def action_cursor_left(self):
|
||||
self.table.move_cursor(column=self.table.cursor_column - 1)
|
||||
if self.table.cursor_column == 0:
|
||||
self.table.cursor_type = "row"
|
||||
self._perform_save_on_move()
|
||||
def on_input_submitted(self, event: Input.Submitted):
|
||||
# Override to add save/load view
|
||||
if self.mode == "save-view":
|
||||
save_view(event.value, filters=self.filters, sort_string=self.sort_string)
|
||||
elif self.mode == "load-view":
|
||||
self._load_view(event.value)
|
||||
self.refresh_tasks(restore_cursor=False)
|
||||
else:
|
||||
super().on_input_submitted(event)
|
||||
return
|
||||
|
||||
def action_cursor_right(self):
|
||||
self.table.move_cursor(column=self.table.cursor_column + 1)
|
||||
if self.table.cursor_column != 0:
|
||||
self.table.cursor_type = "cell"
|
||||
self._perform_save_on_move()
|
||||
|
||||
def action_cursor_up(self):
|
||||
self.table.move_cursor(row=self.table.cursor_row - 1)
|
||||
self._perform_save_on_move()
|
||||
|
||||
def action_cursor_down(self):
|
||||
self.table.move_cursor(row=self.table.cursor_row + 1)
|
||||
self._perform_save_on_move()
|
||||
|
||||
def action_cursor_top(self):
|
||||
self.table.move_cursor(row=0)
|
||||
self._perform_save_on_move()
|
||||
|
||||
def action_cursor_bottom(self):
|
||||
self.table.move_cursor(row=self.table.row_count - 1)
|
||||
self._perform_save_on_move()
|
||||
|
||||
def refresh_tasks(self, *, restore_cursor=True):
|
||||
# show table
|
||||
self.table.clear()
|
||||
|
||||
tasks = get_tasks(
|
||||
def refresh_items(self):
|
||||
items = get_tasks(
|
||||
self.search_query,
|
||||
category=self.filters.get("category"),
|
||||
statuses=self.filters.get("status", "").split(",")
|
||||
@ -197,274 +104,23 @@ class TT(App):
|
||||
else None,
|
||||
sort=self.sort_string,
|
||||
)
|
||||
for task in tasks:
|
||||
for item in items:
|
||||
category = get_colored_category(
|
||||
task.category.name if task.category else " - "
|
||||
item.category.name if item.category else " - "
|
||||
)
|
||||
status = get_colored_status(task.status)
|
||||
due = get_colored_date(task.due)
|
||||
status = get_colored_status(item.status)
|
||||
due = get_colored_date(item.due)
|
||||
|
||||
self.table.add_row(
|
||||
str(task.id),
|
||||
task.text.split("\n")[0], # first line
|
||||
str(item.id),
|
||||
item.text.split("\n")[0], # first line
|
||||
status,
|
||||
task.type,
|
||||
item.type,
|
||||
due,
|
||||
category,
|
||||
key=str(task.id),
|
||||
key=str(item.id),
|
||||
)
|
||||
|
||||
# update footer
|
||||
filter_display = filter_to_string(self.filters, self.search_query)
|
||||
self.left_status.update(f"{len(tasks)} |{filter_display}")
|
||||
|
||||
# restore cursor
|
||||
if restore_cursor:
|
||||
self.table.move_cursor(
|
||||
row=self.saved_cursor_pos[0], column=self.saved_cursor_pos[1]
|
||||
)
|
||||
else:
|
||||
self.table.move_cursor(row=0, column=1)
|
||||
|
||||
def action_delete_task(self):
|
||||
if self.table.cursor_column == 0:
|
||||
cur_row = self.table.cursor_row
|
||||
task_id = int(self.table.get_cell_at((cur_row, 0)))
|
||||
update_task(task_id, deleted=True)
|
||||
self._save_cursor()
|
||||
self.refresh_tasks()
|
||||
|
||||
def action_add_task(self):
|
||||
# if filtering on type, status, or category
|
||||
# the new task should use the selected value
|
||||
category = self.filters.get("category", DEFAULT_CATEGORY)
|
||||
status = self.filters.get("status", TaskStatus.ZERO.value).split(",")[0]
|
||||
type_ = self.filters.get("type", "").split(",")[0]
|
||||
new_task = add_task(
|
||||
text="New Task",
|
||||
type=type_,
|
||||
status=status,
|
||||
category=category,
|
||||
)
|
||||
self.refresh_tasks(restore_cursor=False)
|
||||
self.move_cursor_to_task(new_task.id)
|
||||
self.action_start_change()
|
||||
|
||||
def action_toggle_cell(self):
|
||||
cur_row = self.table.cursor_row
|
||||
cur_col = self.table.cursor_column
|
||||
active_col_name = column_to_field[cur_col]
|
||||
if active_col_name == "status":
|
||||
task_id = int(self.table.get_cell_at((cur_row, 0)))
|
||||
current_val = self.table.get_cell_at((cur_row, cur_col))
|
||||
next_val = advance_enum_val(TaskStatus, current_val)
|
||||
self.table.update_cell_at((cur_row, cur_col), next_val)
|
||||
# trigger task_id to be saved on the next cursor move
|
||||
# this avoids filtered columns disappearing right away
|
||||
# and tons of DB writes
|
||||
self._register_save_on_move(task_id, status=next_val)
|
||||
|
||||
def _register_save_on_move(self, task_id, **kwargs):
|
||||
if self.save_on_move and self.save_on_move["task_id"] != task_id:
|
||||
# we should only ever overwrite the same item
|
||||
raise Exception("invalid save_on_move state")
|
||||
self.save_on_move = {"task_id": task_id, **kwargs}
|
||||
|
||||
def _perform_save_on_move(self):
|
||||
if self.save_on_move:
|
||||
update_task(**self.save_on_move)
|
||||
self._save_cursor()
|
||||
self.refresh_tasks()
|
||||
# reset status
|
||||
self.save_on_move = None
|
||||
|
||||
def move_cursor_to_task(self, task_id):
|
||||
# ick, but only way to search table?
|
||||
for row in range(self.table.row_count):
|
||||
data = self.table.get_row_at(row)
|
||||
if data[0] == str(task_id):
|
||||
self.table.move_cursor(row=row, column=1)
|
||||
break
|
||||
|
||||
# Control of edit bar ####################
|
||||
|
||||
def _show_input(self, mode, start_value):
|
||||
self.mode = mode
|
||||
self.input_bar.display = True
|
||||
self.input_widget.value = start_value
|
||||
if mode.endswith("-view"):
|
||||
mode_input_label = "view name: "
|
||||
else:
|
||||
mode_input_label = f"{mode}: "
|
||||
self.input_label.update(mode_input_label)
|
||||
self.set_focus(self.input_widget)
|
||||
|
||||
def _hide_input(self):
|
||||
self.input_bar.display = False
|
||||
self.set_focus(self.table)
|
||||
|
||||
def action_cancel_edit(self):
|
||||
self._hide_input()
|
||||
self.table.cursor_type = "cell"
|
||||
|
||||
def action_start_search(self):
|
||||
self._show_input("search", "")
|
||||
|
||||
def action_save_view(self):
|
||||
self._show_input("save-view", "default")
|
||||
|
||||
def action_load_view(self):
|
||||
self._show_input("load-view", "")
|
||||
|
||||
def action_start_filter(self):
|
||||
# filter the currently selected column
|
||||
cur_col = self.table.cursor_column
|
||||
active_col_name = column_to_field[cur_col]
|
||||
# # only allow filtering these columns
|
||||
if active_col_name not in ("status", "type", "due", "category"):
|
||||
return
|
||||
cur_filter_val = self.filters.get(active_col_name) or ""
|
||||
self._show_input("filter", cur_filter_val)
|
||||
self.table.cursor_type = "column"
|
||||
|
||||
def action_start_sort(self):
|
||||
self._show_input("sort", self.sort_string)
|
||||
|
||||
def _save_cursor(self):
|
||||
self.saved_cursor_pos = (self.table.cursor_row, self.table.cursor_column)
|
||||
|
||||
def action_start_change(self):
|
||||
if self.table.cursor_row is None or self.table.cursor_column == 0:
|
||||
return
|
||||
|
||||
self._save_cursor()
|
||||
current_value = self.table.get_cell_at(
|
||||
(self.table.cursor_row, self.table.cursor_column)
|
||||
)
|
||||
current_value = remove_rich_tag(current_value)
|
||||
self._show_input("edit", current_value)
|
||||
|
||||
def action_start_edit(self):
|
||||
cur_col = self.table.cursor_column
|
||||
|
||||
if self.table.cursor_row is None or cur_col != 1:
|
||||
return
|
||||
|
||||
self._save_cursor()
|
||||
task_id = self.table.get_cell_at((self.table.cursor_row, 0))
|
||||
# get task from db so we can see 100% of the text
|
||||
task = get_task(task_id)
|
||||
|
||||
# found at https://github.com/Textualize/textual/discussions/1654
|
||||
self._driver.stop_application_mode()
|
||||
new_text = get_text_from_editor(task.text)
|
||||
self.refresh()
|
||||
self._driver.start_application_mode()
|
||||
if new_text is not None:
|
||||
update_task(task_id, text=new_text)
|
||||
self.refresh_tasks()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted):
|
||||
if self.mode == "search":
|
||||
self.search_query = event.value
|
||||
self.refresh_tasks(restore_cursor=False)
|
||||
elif self.mode == "filter":
|
||||
cur_col = self.table.cursor_column
|
||||
active_col_name = column_to_field[cur_col]
|
||||
self.filters[active_col_name] = event.value
|
||||
self.refresh_tasks(restore_cursor=False)
|
||||
self.table.cursor_type = "cel"
|
||||
elif self.mode == "sort":
|
||||
self.sort_string = event.value
|
||||
self.refresh_tasks(restore_cursor=False)
|
||||
self.right_status.update(self.sort_string)
|
||||
elif self.mode == "edit":
|
||||
self.apply_change(event.value)
|
||||
elif self.mode == "save-view":
|
||||
save_view(event.value, filters=self.filters, sort_string=self.sort_string)
|
||||
elif self.mode == "load-view":
|
||||
self._load_view(event.value)
|
||||
self.refresh_tasks(restore_cursor=False)
|
||||
else:
|
||||
raise ValueError(f"unknown mode: {self.mode}")
|
||||
self._hide_input()
|
||||
|
||||
def apply_change(self, new_value):
|
||||
row, col = self.saved_cursor_pos
|
||||
task_id = int(self.table.get_cell_at((row, 0)))
|
||||
|
||||
field = column_to_field[col]
|
||||
update_data = {}
|
||||
|
||||
if field == "due":
|
||||
try:
|
||||
update_data["due"] = datetime.strptime(new_value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
self.notify("Invalid date format. Use YYYY-MM-DD")
|
||||
elif field == "status":
|
||||
try:
|
||||
TaskStatus(new_value) # validate status
|
||||
update_data["status"] = new_value
|
||||
except ValueError:
|
||||
self.notify(f"Invalid status. Use: {[s.value for s in TaskStatus]}")
|
||||
else:
|
||||
update_data[field] = new_value
|
||||
|
||||
try:
|
||||
update_task(task_id, **update_data)
|
||||
self.refresh_tasks()
|
||||
except Exception as e:
|
||||
self.notify(f"Error updating task: {str(e)}")
|
||||
finally:
|
||||
# no longer in edit mode
|
||||
self.action_cancel_edit()
|
||||
|
||||
def action_show_keys(self):
|
||||
self.push_screen(KeyBindingsScreen())
|
||||
|
||||
|
||||
class KeyBindingsScreen(ModalScreen):
|
||||
CSS = """
|
||||
KeyBindingsScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
Vertical {
|
||||
height: auto;
|
||||
border: tall $primary;
|
||||
}
|
||||
|
||||
Static.title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Static.footer {
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
table = Table(expand=True, show_header=True)
|
||||
table.add_column("Key")
|
||||
table.add_column("Description")
|
||||
|
||||
table.add_row("j/k/h/l", "up/down/left/right")
|
||||
table.add_row("g/G", "top/bottom")
|
||||
table.add_row("esc", "dismiss modal or search bar")
|
||||
|
||||
for binding in self.app.BINDINGS:
|
||||
if binding[0] not in ["h", "j", "k", "l", "g", "G", "escape"]:
|
||||
table.add_row(binding[0], binding[2])
|
||||
|
||||
yield Static("tt keybindings", classes="title")
|
||||
yield Static(table)
|
||||
yield Static("Press any key to dismiss", classes="footer")
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
def run(default_view):
|
||||
initialize_db()
|
||||
|
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