Compare commits
5 Commits
74ed6516b4
...
fea33eb62e
Author | SHA1 | Date | |
---|---|---|---|
fea33eb62e | |||
be682f4754 | |||
83ae35006b | |||
1bc94bfd0a | |||
547a65e19f |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,2 @@
|
||||
*.pyc
|
||||
*.db
|
||||
*.db*
|
||||
|
10
src/tt/constants.py
Normal file
10
src/tt/constants.py
Normal 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[/]",
|
||||
}
|
||||
|
@ -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
89
src/tt/tui/columns.py
Normal 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]
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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}[/]"
|
||||
|
6
tt.toml
6
tt.toml
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user