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 *.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 peewee import Case, Value
from ..db import db, Task, SavedSearch from ..db import db, Task, SavedSearch
from .. import config from .. import config
from ..constants import SPECIAL_DATES_PIECES
def get_task(item_id: int) -> Task: def get_task(item_id: int) -> Task:
@ -14,7 +14,7 @@ def get_task(item_id: int) -> Task:
def add_task( def add_task(
text: str, text: str,
status: str, status: str,
due: datetime | None = None, due: datetime | str = SPECIAL_DATES_PIECES["unclassified"],
type: str = "", type: str = "",
project: str = "", project: str = "",
) -> Task: ) -> Task:
@ -84,8 +84,8 @@ def get_tasks(
query = query.where(fn.Lower(Task.text).contains(search_text.lower())) query = query.where(fn.Lower(Task.text).contains(search_text.lower()))
if statuses: if statuses:
query = query.where(Task.status.in_(statuses)) query = query.where(Task.status.in_(statuses))
#if projects: if projects:
# query = query.where(Task.project.in_(projects)) query = query.where(Task.project.in_(projects))
sort_expressions = _parse_sort_string(sort, statuses) sort_expressions = _parse_sort_string(sort, statuses)
query = query.order_by(*sort_expressions) 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.app import App
from textual.widgets import ( from textual.widgets import (
DataTable, DataTable,
@ -13,86 +12,10 @@ from ..utils import (
remove_rich_tag, remove_rich_tag,
filter_to_string, filter_to_string,
get_text_from_editor, get_text_from_editor,
get_color_enum,
get_colored_date,
) )
from .keymodal import KeyModal from .keymodal import KeyModal
from .modals import ChoiceModal, DateModal, ConfirmModal from .modals import ConfirmModal
from .columns import get_col_cls
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)
@ -187,11 +110,7 @@ class TableEditor(App):
# set up columns # set up columns
for col in view["columns"]: for col in view["columns"]:
field_type = col.get("field_type", "text") field_type = col.get("field_type", "text")
field_cls = { field_cls = get_col_cls(field_type)
"text": TableColumnConfig,
"enum": EnumColumnConfig,
"date": DateColumnConfig,
}[field_type]
field_name = col["field_name"] field_name = col["field_name"]
display_name = col.get("display_name", field_name.title()) display_name = col.get("display_name", field_name.title())
default = col.get("default") default = col.get("default")

View File

@ -37,6 +37,7 @@ class KeyModal(ModalScreen):
for binding in self.app.BINDINGS: for binding in self.app.BINDINGS:
if binding[0] not in ["h", "j", "k", "l", "g", "G", "escape"]: if binding[0] not in ["h", "j", "k", "l", "g", "G", "escape"]:
table.add_row(binding[0], binding[2]) table.add_row(binding[0], binding[2])
# TODO: MRO?
yield Static("tt keybindings", classes="title") yield Static("tt keybindings", classes="title")
yield Static(table) yield Static(table)

View File

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

View File

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

View File

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

View File

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