Compare commits

...

5 Commits

Author SHA1 Message Date
jpt
fea33eb62e nice QoL improvements, let's call it v0.3 2025-04-13 12:36:44 -05:00
jpt
be682f4754 unclassified dates 2025-04-13 12:25:25 -05:00
jpt
83ae35006b special dates support 2025-04-13 12:13:01 -05:00
jpt
1bc94bfd0a split out columns 2025-04-13 11:14:24 -05:00
jpt
547a65e19f fix date modal 2025-04-13 11:05:25 -05:00
10 changed files with 207 additions and 131 deletions

2
.gitignore vendored
View File

@ -1,2 +1,2 @@
*.pyc
*.db
*.db*

10
src/tt/constants.py Normal file
View File

@ -0,0 +1,10 @@
SPECIAL_DATES_PIECES = {
"future": (3000,1,1),
"unclassified": (1999,1,1),
}
SPECIAL_DATES_DISPLAY = {
"3000-01-01": "[#333333]future[/]",
"1999-01-01": "[#cccccc]unclassified[/]",
}

View File

@ -4,7 +4,7 @@ from peewee import fn
from peewee import Case, Value
from ..db import db, Task, SavedSearch
from .. import config
from ..constants import SPECIAL_DATES_PIECES
def get_task(item_id: int) -> Task:
@ -14,7 +14,7 @@ def get_task(item_id: int) -> Task:
def add_task(
text: str,
status: str,
due: datetime | None = None,
due: datetime | str = SPECIAL_DATES_PIECES["unclassified"],
type: str = "",
project: str = "",
) -> Task:
@ -84,8 +84,8 @@ def get_tasks(
query = query.where(fn.Lower(Task.text).contains(search_text.lower()))
if statuses:
query = query.where(Task.status.in_(statuses))
#if projects:
# query = query.where(Task.project.in_(projects))
if projects:
query = query.where(Task.project.in_(projects))
sort_expressions = _parse_sort_string(sort, statuses)
query = query.order_by(*sort_expressions)

89
src/tt/tui/columns.py Normal file
View File

@ -0,0 +1,89 @@
import datetime
from ..utils import (
get_color_enum,
get_colored_date,
)
from .modals import ChoiceModal, DateModal
class NotifyValidationError(Exception):
"""will notify and continue if raised"""
ELLIPSIS = ""
class TableColumnConfig:
def __init__(
self,
field: str,
display_name: str,
*,
default=None,
enable_editor=False,
filterable=True,
read_only=False,
):
self.field = field
self.display_name = display_name
self.default = default
self.enable_editor = enable_editor
self.filterable = filterable
self.read_only = read_only
def preprocess(self, val):
return val # no-op
def start_change(self, app, current_value):
if current_value.endswith(ELLIPSIS):
app.action_start_edit()
else:
# default edit mode
app._show_input("edit", current_value)
def format_for_display(self, val):
val = str(val)
if "\n" in val:
val = val.split("\n")[0] + ELLIPSIS
return val
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 format_for_display(self, val):
return get_color_enum(val, 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.datetime.strptime(val, "%Y-%m-%d")
except ValueError:
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
def format_for_display(self, val):
return get_colored_date(val)
def start_change(self, app, current_value):
app.push_screen(DateModal(current_value), app.apply_change)
def get_col_cls(field_type):
return {
"text": TableColumnConfig,
"enum": EnumColumnConfig,
"date": DateColumnConfig,
}[field_type]

View File

@ -1,4 +1,3 @@
import datetime
from textual.app import App
from textual.widgets import (
DataTable,
@ -13,86 +12,10 @@ from ..utils import (
remove_rich_tag,
filter_to_string,
get_text_from_editor,
get_color_enum,
get_colored_date,
)
from .keymodal import KeyModal
from .modals import ChoiceModal, DateModal, ConfirmModal
class NotifyValidationError(Exception):
"""will notify and continue if raised"""
ELLIPSIS = ""
class TableColumnConfig:
def __init__(
self,
field: str,
display_name: str,
*,
default=None,
enable_editor=False,
filterable=True,
read_only=False,
):
self.field = field
self.display_name = display_name
self.default = default
self.enable_editor = enable_editor
self.filterable = filterable
self.read_only = read_only
def preprocess(self, val):
return val # no-op
def start_change(self, app, current_value):
if current_value.endswith(ELLIPSIS):
app.action_start_edit()
else:
# default edit mode
app._show_input("edit", current_value)
def format_for_display(self, val):
val = str(val)
if "\n" in val:
val = val.split("\n")[0] + ELLIPSIS
return val
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 format_for_display(self, val):
return get_color_enum(val, 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 format_for_display(self, val):
return get_colored_date(val)
def start_change(self, app, current_value):
app.push_screen(DateModal(current_value), app.apply_change)
from .modals import ConfirmModal
from .columns import get_col_cls
@ -187,11 +110,7 @@ class TableEditor(App):
# set up columns
for col in view["columns"]:
field_type = col.get("field_type", "text")
field_cls = {
"text": TableColumnConfig,
"enum": EnumColumnConfig,
"date": DateColumnConfig,
}[field_type]
field_cls = get_col_cls(field_type)
field_name = col["field_name"]
display_name = col.get("display_name", field_name.title())
default = col.get("default")

View File

@ -37,6 +37,7 @@ class KeyModal(ModalScreen):
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])
# TODO: MRO?
yield Static("tt keybindings", classes="title")
yield Static(table)

View File

@ -1,8 +1,12 @@
import datetime
from textual.screen import ModalScreen
from textual.binding import Binding
from textual.widgets import RadioSet, RadioButton, Label
from .. import config
from textual.widgets import Label
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from ..utils import get_color_enum
from ..constants import SPECIAL_DATES_PIECES
class ConfirmModal(ModalScreen):
CSS = """
@ -42,42 +46,56 @@ class ChoiceModal(ModalScreen):
ChoiceModal Label {
height: 1;
}
ChoiceModal Label#selected {
background: white;
}
"""
BINDINGS = [
("j,tab", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
Binding("enter", "select", "Select", priority=False),
("k,shift+tab", "cursor_up", "Up"),
Binding("enter", "select", "Select", priority=True),
("escape", "cancel", "cancel"),
]
def __init__(self, enum, selected):
self._enum = enum
self.selected = selected
self.enum_by_idx = list(self._enum)
# selection index
self.sel_idx = 0
# convert value back to index for initial selection
for idx, e in enumerate(self._enum):
if e.value == selected:
self.sel_idx = idx
break
super().__init__()
def compose(self):
yield RadioSet(
*[
RadioButton(
get_color_enum(e.value, config.STATUSES), value=self.selected == str(e.value)
)
for e in self._enum
]
for idx, e in enumerate(self._enum):
yield Label(
("> " if idx == self.sel_idx else " ") +
get_color_enum(e.value, self._enum),
classes="selected" if idx == self.sel_idx else "",
)
def _move_cursor(self, dir):
labels = self.query(Label)
# reset old
labels[self.sel_idx].update(" " + get_color_enum(self.enum_by_idx[self.sel_idx], self._enum))
# move cursor
self.sel_idx = (self.sel_idx + dir) % len(self._enum)
# reset new
labels[self.sel_idx].update("> " + get_color_enum(self.enum_by_idx[self.sel_idx], self._enum))
def action_cursor_down(self):
self.query_one(RadioSet).action_next_button()
self._move_cursor(1)
def action_cursor_up(self):
self.query_one(RadioSet).action_previous_button()
self._move_cursor(-1)
def action_select(self):
rs = self.query_one(RadioSet)
# TODO: this doesn't work
#rs.action_toggle_button()
pressed = rs.pressed_button
self.dismiss(str(pressed.label))
async def action_select(self):
self.dismiss(self.enum_by_idx[self.sel_idx])
def action_cancel(self):
self.app.pop_screen()
@ -86,37 +104,71 @@ class ChoiceModal(ModalScreen):
class DateModal(ModalScreen):
CSS = """
DateModal {
layout: horizontal;
align: center middle;
background: $primary 30%;
}
DateModal Vertical {
border: double teal;
height: 10;
width: 50;
}
DateModal Horizonal {
}
DateModal Label {
border: solid grey;
border: solid white;
align: center middle;
}
DateModal Label.selected-date {
border: solid green;
}
DateModal Label.hints {
border: solid grey;
height: 4;
}
"""
BINDINGS = [
("j", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
("h,tab", "cursor_left", "Left"),
("l", "cursor_right", "Right"),
("h,shift+tab", "cursor_left", "Left"),
("l,tab", "cursor_right", "Right"),
("f", "future", "Future"),
("t", "today", "Today"),
("u", "unclassified", "Unclassified"),
Binding("enter", "select", "Select", priority=True),
("escape", "cancel", "cancel"),
]
pieces = reactive([0, 0, 0], recompose=True)
def __init__(self, date):
self.pieces = [int(p) for p in date.split("-")]
self.selected = 1 # start on month
super().__init__()
if date in SPECIAL_DATES_PIECES:
self.pieces = list(SPECIAL_DATES_PIECES[date])
elif date:
self.pieces = [int(p) for p in date.split("-")]
else:
self.action_today()
self.selected = 1 # start on month
def compose(self):
for idx, piece in enumerate(self.pieces):
yield Label(
str(piece), classes="selected-date" if idx == self.selected else ""
)
with Vertical():
with Horizontal():
yield Label(f"{self.pieces[0]}")
yield Label(f"{self.pieces[1]}", classes="selected-date")
yield Label(f"{self.pieces[2]}")
yield Label("""(h/j/k/l) move (enter) confirm (esc) quit
(p)ast (t)oday (f)uture""", classes="hints")
def action_future(self):
self.pieces = list(SPECIAL_DATES_PIECES["future"])
def action_unclassified(self):
self.pieces = list(SPECIAL_DATES_PIECES["unclassified"])
def action_today(self):
today = datetime.date.today()
self.pieces = [today.year, today.month, today.day]
def action_cursor_left(self):
# cycle Y/M/D
@ -152,8 +204,7 @@ class DateModal(ModalScreen):
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))
self.mutate_reactive(DateModal.pieces)
def action_cursor_down(self):
self._move_piece(-1)
@ -178,8 +229,7 @@ class DateModal(ModalScreen):
event.prevent_default()
def action_select(self):
date = "-".join(str(p) for p in self.pieces)
self.dismiss(date)
self.dismiss("-".join(str(p) for p in self.pieces))
def action_cancel(self):
self.app.pop_screen()

View File

@ -56,10 +56,8 @@ class TT(TableEditor):
def refresh_items(self):
items = get_tasks(
self.search_query,
projects=self.filters.get("project", "").split(","),
statuses=self.filters.get("status", "").split(",")
if "status" in self.filters
else None,
projects=self._filters_to_list("project"),
statuses=self._filters_to_list("status"),
sort=self.sort_string,
)
for item in items:
@ -68,6 +66,12 @@ class TT(TableEditor):
key=str(item.id),
)
def _filters_to_list(self, key):
filters = self.filters.get(key)
if filters:
return filters.split(",")
else:
return None
def run(default_view):
app = TT(default_view)

View File

@ -3,6 +3,7 @@ import os
import datetime
import tempfile
import subprocess
from .constants import SPECIAL_DATES_DISPLAY
def filter_to_string(filters, search_query):
@ -41,6 +42,8 @@ def get_colored_date(date: datetime.date) -> str:
if not isinstance(date, datetime.date):
return ""
as_str = date.strftime("%Y-%m-%d")
if as_str in SPECIAL_DATES_DISPLAY:
return SPECIAL_DATES_DISPLAY[as_str]
today = datetime.date.today()
if date.date() < today:
return f"[#eeeeee on #dd1111]{as_str}[/]"

View File

@ -28,7 +28,7 @@ values = [
[[views]]
name = "tasks"
sort = "due"
sort = "due,status"
[views.filters]
status = "wip,blocked,zero"
@ -40,7 +40,7 @@ read_only = true
[[views.columns]]
field_name = "text"
display_name = "Task"
default = "new taskz"
default = "new task"
editor = true
[[views.columns]]
@ -56,7 +56,7 @@ default = ""
[[views.columns]]
field_name = "due"
field_type = "date"
default = ""
default = "1999-01-01"
[[views.columns]]
field_name = "project"