Compare commits
5 Commits
2c5310fe0d
...
92cc3c5b40
Author | SHA1 | Date | |
---|---|---|---|
92cc3c5b40 | |||
8323323dea | |||
77ac5a5cd2 | |||
0d6c20cff6 | |||
9c55adb88e |
@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"lxml>=5.3.0",
|
"lxml>=5.3.0",
|
||||||
"peewee>=3.17.8",
|
"peewee>=3.17.8",
|
||||||
"textual>=1.0.0",
|
"textual>=1.0.0",
|
||||||
|
"tomlkit>=0.13.2",
|
||||||
"typer>=0.15.1",
|
"typer>=0.15.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
14
src/tt/config.py
Normal file
14
src/tt/config.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import tomlkit
|
||||||
|
|
||||||
|
def get_enum(name):
|
||||||
|
with open("tt.toml", "r") as f:
|
||||||
|
config = tomlkit.load(f)
|
||||||
|
|
||||||
|
for enum in config.get("enums", []):
|
||||||
|
if enum["name"] == name:
|
||||||
|
return {v["value"]: v for v in enum["values"]}
|
||||||
|
|
||||||
|
raise ValueError(f"no such enum! {name}")
|
||||||
|
|
||||||
|
STATUSES = get_enum("status")
|
||||||
|
PROJECTS = get_enum("projects")
|
@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from peewee import fn, JOIN
|
from peewee import fn, JOIN
|
||||||
from ..db import Task, Category, TaskStatus
|
from ..db import Task, Category
|
||||||
|
|
||||||
|
|
||||||
def get_category_summary(num: int = 5) -> list[dict]:
|
def get_category_summary(num: int = 5) -> list[dict]:
|
||||||
@ -20,7 +20,7 @@ def get_category_summary(num: int = 5) -> list[dict]:
|
|||||||
overdue_count = (
|
overdue_count = (
|
||||||
Task.select(Task.category, fn.COUNT(Task.id).alias("overdue"))
|
Task.select(Task.category, fn.COUNT(Task.id).alias("overdue"))
|
||||||
.where(
|
.where(
|
||||||
(~Task.deleted) & (Task.due < now) & (Task.status != TaskStatus.DONE.value)
|
(~Task.deleted) & (Task.due < now) & (Task.status != "done")
|
||||||
)
|
)
|
||||||
.group_by(Task.category)
|
.group_by(Task.category)
|
||||||
)
|
)
|
||||||
@ -32,7 +32,7 @@ def get_category_summary(num: int = 5) -> list[dict]:
|
|||||||
(~Task.deleted)
|
(~Task.deleted)
|
||||||
& (Task.due >= now)
|
& (Task.due >= now)
|
||||||
& (Task.due <= week_from_now)
|
& (Task.due <= week_from_now)
|
||||||
& (Task.status != TaskStatus.DONE.value)
|
& (Task.status != "done")
|
||||||
)
|
)
|
||||||
.group_by(Task.category)
|
.group_by(Task.category)
|
||||||
)
|
)
|
||||||
@ -41,16 +41,16 @@ def get_category_summary(num: int = 5) -> list[dict]:
|
|||||||
query = (
|
query = (
|
||||||
Category.select(
|
Category.select(
|
||||||
Category.name,
|
Category.name,
|
||||||
fn.COALESCE(fn.SUM(Task.status == TaskStatus.ZERO.value), 0).alias(
|
fn.COALESCE(fn.SUM(Task.status == "zero"), 0).alias(
|
||||||
"zero_count"
|
"zero_count"
|
||||||
),
|
),
|
||||||
fn.COALESCE(fn.SUM(Task.status == TaskStatus.WIP.value), 0).alias(
|
fn.COALESCE(fn.SUM(Task.status == "wip"), 0).alias(
|
||||||
"wip_count"
|
"wip_count"
|
||||||
),
|
),
|
||||||
fn.COALESCE(fn.SUM(Task.status == TaskStatus.BLOCKED.value), 0).alias(
|
fn.COALESCE(fn.SUM(Task.status == "blocked"), 0).alias(
|
||||||
"blocked_count"
|
"blocked_count"
|
||||||
),
|
),
|
||||||
fn.COALESCE(fn.SUM(Task.status == TaskStatus.DONE.value), 0).alias(
|
fn.COALESCE(fn.SUM(Task.status == "done"), 0).alias(
|
||||||
"done_count"
|
"done_count"
|
||||||
),
|
),
|
||||||
fn.COALESCE(overdue_count.c.overdue, 0).alias("overdue"),
|
fn.COALESCE(overdue_count.c.overdue, 0).alias("overdue"),
|
||||||
@ -148,7 +148,7 @@ def get_due_soon(
|
|||||||
(~Task.deleted)
|
(~Task.deleted)
|
||||||
& (Task.due.is_null(False))
|
& (Task.due.is_null(False))
|
||||||
& (Task.due != "")
|
& (Task.due != "")
|
||||||
& (Task.status != TaskStatus.DONE.value)
|
& (Task.status != "done")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -2,7 +2,8 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from peewee import fn
|
from peewee import fn
|
||||||
from peewee import Case, Value
|
from peewee import Case, Value
|
||||||
from ..db import db, Task, Category, TaskStatus, SavedSearch
|
from ..db import db, Task, Category, SavedSearch
|
||||||
|
from .. import config
|
||||||
|
|
||||||
|
|
||||||
def category_lookup(category):
|
def category_lookup(category):
|
||||||
@ -18,7 +19,7 @@ def get_task(item_id: int) -> Task:
|
|||||||
def add_task(
|
def add_task(
|
||||||
text: str,
|
text: str,
|
||||||
category: str,
|
category: str,
|
||||||
status: str = TaskStatus.ZERO.value,
|
status: str,
|
||||||
due: datetime | None = None,
|
due: datetime | None = None,
|
||||||
type: str = "",
|
type: str = "",
|
||||||
) -> Task:
|
) -> Task:
|
||||||
@ -62,7 +63,7 @@ def _parse_sort_string(sort_string, status_order):
|
|||||||
|
|
||||||
if field == "status":
|
if field == "status":
|
||||||
if not status_order:
|
if not status_order:
|
||||||
status_order = [s.value for s in TaskStatus]
|
status_order = list(config.STATUSES.keys())
|
||||||
# CASE statement that maps each status to its position in the order
|
# CASE statement that maps each status to its position in the order
|
||||||
order_case = Case(
|
order_case = Case(
|
||||||
Task.status,
|
Task.status,
|
||||||
|
14
src/tt/db.py
14
src/tt/db.py
@ -20,15 +20,6 @@ db = SqliteDatabase(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(Enum):
|
|
||||||
# order is used for progression in toggle
|
|
||||||
ZERO = "zero"
|
|
||||||
WIP = "wip"
|
|
||||||
BLOCKED = "blocked"
|
|
||||||
DONE = "done"
|
|
||||||
|
|
||||||
|
|
||||||
class GeneratorType(Enum):
|
class GeneratorType(Enum):
|
||||||
DAYS_BETWEEN = "days-btwn"
|
DAYS_BETWEEN = "days-btwn"
|
||||||
MONTHLY = "monthly"
|
MONTHLY = "monthly"
|
||||||
@ -48,10 +39,7 @@ class Category(BaseModel):
|
|||||||
|
|
||||||
class Task(BaseModel):
|
class Task(BaseModel):
|
||||||
text = TextField()
|
text = TextField()
|
||||||
status = CharField(
|
status = CharField()
|
||||||
choices=[(status.value, status.name) for status in TaskStatus],
|
|
||||||
default=TaskStatus.ZERO.value,
|
|
||||||
)
|
|
||||||
due = DateTimeField(null=True)
|
due = DateTimeField(null=True)
|
||||||
category = ForeignKeyField(Category, backref="tasks", null=True)
|
category = ForeignKeyField(Category, backref="tasks", null=True)
|
||||||
type = CharField()
|
type = CharField()
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
|
from tt.config import STATUSES
|
||||||
|
|
||||||
|
|
||||||
def import_tasks_from_csv(filename: str):
|
def import_tasks_from_csv(filename: str):
|
||||||
@ -20,9 +21,7 @@ def import_tasks_from_csv(filename: str):
|
|||||||
|
|
||||||
# Validate status
|
# Validate status
|
||||||
status = row["status"].lower() if row["status"] else "zero"
|
status = row["status"].lower() if row["status"] else "zero"
|
||||||
try:
|
if status not in STATUSES:
|
||||||
TaskStatus(status)
|
|
||||||
except ValueError:
|
|
||||||
print(f"Warning: Invalid status '{status}', defaulting to 'zero'")
|
print(f"Warning: Invalid status '{status}', defaulting to 'zero'")
|
||||||
status = "zero"
|
status = "zero"
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
DataTable,
|
DataTable,
|
||||||
@ -10,30 +11,15 @@ from textual.containers import Container
|
|||||||
from ..utils import (
|
from ..utils import (
|
||||||
remove_rich_tag,
|
remove_rich_tag,
|
||||||
filter_to_string,
|
filter_to_string,
|
||||||
advance_enum_val,
|
|
||||||
get_text_from_editor,
|
get_text_from_editor,
|
||||||
)
|
)
|
||||||
from .keymodal import KeyModal
|
from .keymodal import KeyModal
|
||||||
|
from .modals import ChoiceModal, DateModal, ConfirmModal
|
||||||
ELLIPSIS = "…"
|
|
||||||
|
|
||||||
|
|
||||||
class NotifyValidationError(Exception):
|
class NotifyValidationError(Exception):
|
||||||
"""will notify and continue if raised"""
|
"""will notify and continue if raised"""
|
||||||
|
|
||||||
|
|
||||||
def _enum_preprocessor(enumCls):
|
|
||||||
"""generate a default preprocessor to enforce enums"""
|
|
||||||
|
|
||||||
def preprocessor(val):
|
|
||||||
try:
|
|
||||||
enumCls(val)
|
|
||||||
return val
|
|
||||||
except ValueError:
|
|
||||||
raise NotifyValidationError(
|
|
||||||
f"Invalid value. Use: {[s.value for s in enumCls]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TableColumnConfig:
|
class TableColumnConfig:
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -42,8 +28,6 @@ class TableColumnConfig:
|
|||||||
display_name: str,
|
display_name: str,
|
||||||
*,
|
*,
|
||||||
default=None,
|
default=None,
|
||||||
enum=None,
|
|
||||||
preprocessor=None,
|
|
||||||
enable_editor=False,
|
enable_editor=False,
|
||||||
filterable=True,
|
filterable=True,
|
||||||
read_only=False,
|
read_only=False,
|
||||||
@ -51,16 +35,57 @@ class TableColumnConfig:
|
|||||||
self.field = field
|
self.field = field
|
||||||
self.display_name = display_name
|
self.display_name = display_name
|
||||||
self.default = default
|
self.default = default
|
||||||
self.enum = enum
|
|
||||||
self.enable_editor = enable_editor
|
self.enable_editor = enable_editor
|
||||||
self.filterable = filterable
|
self.filterable = filterable
|
||||||
self.read_only = read_only
|
self.read_only = read_only
|
||||||
if preprocessor:
|
|
||||||
self.preprocessor = preprocessor
|
def preprocess(self, val):
|
||||||
elif self.enum:
|
return val # no-op
|
||||||
self.preprocessor = _enum_preprocessor(self.enum)
|
|
||||||
|
def start_change(self, app, current_value):
|
||||||
|
if current_value.endswith(ELLIPSIS):
|
||||||
|
app.action_start_edit()
|
||||||
else:
|
else:
|
||||||
self.preprocessor = lambda x: x
|
# default edit mode
|
||||||
|
app._show_input("edit", current_value)
|
||||||
|
|
||||||
|
|
||||||
|
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 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 start_change(self, app, current_value):
|
||||||
|
app.push_screen(DateModal(current_value), app.apply_change)
|
||||||
|
ELLIPSIS = "…"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TableEditor(App):
|
class TableEditor(App):
|
||||||
@ -134,7 +159,7 @@ class TableEditor(App):
|
|||||||
("?", "show_keys", "show keybindings"),
|
("?", "show_keys", "show keybindings"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, default_view="default"):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.filters = {}
|
self.filters = {}
|
||||||
self.sort_string = "" # TODO: default sort
|
self.sort_string = "" # TODO: default sort
|
||||||
@ -212,7 +237,10 @@ class TableEditor(App):
|
|||||||
self.table.move_cursor(row=0, column=1)
|
self.table.move_cursor(row=0, column=1)
|
||||||
|
|
||||||
def action_delete_item(self):
|
def action_delete_item(self):
|
||||||
if self.table.cursor_column == 0:
|
self.push_screen(ConfirmModal(f"delete ?"), self._delete_item_callback)
|
||||||
|
|
||||||
|
def _delete_item_callback(self, confirm):
|
||||||
|
if confirm and self.table.cursor_column == 0:
|
||||||
cur_row = self.table.cursor_row
|
cur_row = self.table.cursor_row
|
||||||
item_id = int(self.table.get_cell_at((cur_row, 0)))
|
item_id = int(self.table.get_cell_at((cur_row, 0)))
|
||||||
# deletable items need a delete
|
# deletable items need a delete
|
||||||
@ -229,10 +257,8 @@ class TableEditor(App):
|
|||||||
continue
|
continue
|
||||||
val = self.filters.get(fc.field, fc.default)
|
val = self.filters.get(fc.field, fc.default)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
# enums use comma separated filters
|
if "," in val:
|
||||||
if fc.enum:
|
val = val.split(",")[0] # TODO: fix hack for enums
|
||||||
prepopulated[fc.field] = val.split(",")[0]
|
|
||||||
else:
|
|
||||||
prepopulated[fc.field] = val
|
prepopulated[fc.field] = val
|
||||||
|
|
||||||
new_item = self.add_item_callback(**prepopulated)
|
new_item = self.add_item_callback(**prepopulated)
|
||||||
@ -247,21 +273,21 @@ class TableEditor(App):
|
|||||||
def _active_item_id(self):
|
def _active_item_id(self):
|
||||||
return int(self.table.get_cell_at((self.table.cursor_row, 0)))
|
return int(self.table.get_cell_at((self.table.cursor_row, 0)))
|
||||||
|
|
||||||
def action_toggle_cell(self):
|
# def action_toggle_cell(self):
|
||||||
cur_row = self.table.cursor_row
|
# cur_row = self.table.cursor_row
|
||||||
cur_col = self.table.cursor_column
|
# cur_col = self.table.cursor_column
|
||||||
cconf = self._active_column_config()
|
# cconf = self._active_column_config()
|
||||||
|
|
||||||
if cconf.enum:
|
# if cconf.enum:
|
||||||
item_id = self._active_item_id()
|
# item_id = self._active_item_id()
|
||||||
current_val = self.table.get_cell_at((cur_row, cur_col))
|
# current_val = self.table.get_cell_at((cur_row, cur_col))
|
||||||
next_val = advance_enum_val(cconf.enum, current_val)
|
# next_val = advance_enum_val(cconf.enum, current_val)
|
||||||
self.table.update_cell_at((cur_row, cur_col), next_val)
|
# self.table.update_cell_at((cur_row, cur_col), next_val)
|
||||||
# trigger item_id to be saved on the next cursor move
|
# # trigger item_id to be saved on the next cursor move
|
||||||
# this avoids filtered columns disappearing right away
|
# # this avoids filtered columns disappearing right away
|
||||||
# and tons of DB writes
|
# # and tons of DB writes
|
||||||
update = {cconf.field: next_val}
|
# update = {cconf.field: next_val}
|
||||||
self._register_save_on_move(item_id, **update)
|
# self._register_save_on_move(item_id, **update)
|
||||||
|
|
||||||
def _register_save_on_move(self, item_id, **kwargs):
|
def _register_save_on_move(self, item_id, **kwargs):
|
||||||
if self.save_on_move and self.save_on_move["item_id"] != item_id:
|
if self.save_on_move and self.save_on_move["item_id"] != item_id:
|
||||||
@ -333,15 +359,15 @@ class TableEditor(App):
|
|||||||
if cconf.read_only:
|
if cconf.read_only:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# save cursor before callback, so correct position updates
|
||||||
self._save_cursor()
|
self._save_cursor()
|
||||||
current_value = self.table.get_cell_at(
|
current_value = self.table.get_cell_at(
|
||||||
(self.table.cursor_row, self.table.cursor_column)
|
(self.table.cursor_row, self.table.cursor_column)
|
||||||
)
|
)
|
||||||
if current_value.endswith(ELLIPSIS):
|
|
||||||
self.notify("multi-line text, use (e)dit")
|
|
||||||
return
|
|
||||||
current_value = remove_rich_tag(current_value)
|
current_value = remove_rich_tag(current_value)
|
||||||
self._show_input("edit", current_value)
|
|
||||||
|
# delegate to start_change, which will call back to self
|
||||||
|
cconf.start_change(self, current_value)
|
||||||
|
|
||||||
def action_start_edit(self):
|
def action_start_edit(self):
|
||||||
cconf = self._active_column_config()
|
cconf = self._active_column_config()
|
||||||
@ -392,7 +418,7 @@ class TableEditor(App):
|
|||||||
|
|
||||||
# preprocess/validate the field being saved
|
# preprocess/validate the field being saved
|
||||||
try:
|
try:
|
||||||
update_data[field] = cconf.preprocessor(new_value)
|
update_data[field] = cconf.preprocess(new_value)
|
||||||
self.update_item_callback(item_id, **update_data)
|
self.update_item_callback(item_id, **update_data)
|
||||||
self.refresh_data()
|
self.refresh_data()
|
||||||
except NotifyValidationError as e:
|
except NotifyValidationError as e:
|
||||||
|
186
src/tt/tui/modals.py
Normal file
186
src/tt/tui/modals.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.widgets import RadioSet, RadioButton, Label
|
||||||
|
from .. import config
|
||||||
|
from ..utils import get_color_enum
|
||||||
|
|
||||||
|
class ConfirmModal(ModalScreen):
|
||||||
|
CSS = """
|
||||||
|
ConfirmModal {
|
||||||
|
align: center middle;
|
||||||
|
background: $primary 30%;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("y", "confirm", "Down"),
|
||||||
|
("n,escape", "cancel", "cancel"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, message):
|
||||||
|
self.message = message
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
yield Label(self.message)
|
||||||
|
yield Label("(y)es")
|
||||||
|
yield Label("(n)o")
|
||||||
|
|
||||||
|
def action_confirm(self):
|
||||||
|
self.dismiss(True)
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
|
||||||
|
class ChoiceModal(ModalScreen):
|
||||||
|
CSS = """
|
||||||
|
ChoiceModal {
|
||||||
|
align: center middle;
|
||||||
|
background: $primary 30%;
|
||||||
|
}
|
||||||
|
ChoiceModal Label {
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("j", "cursor_down", "Down"),
|
||||||
|
("k", "cursor_up", "Up"),
|
||||||
|
Binding("enter", "select", "Select", priority=True),
|
||||||
|
("c", "select", "Select"),
|
||||||
|
("escape", "cancel", "cancel"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, enum, selected):
|
||||||
|
self._enum = enum
|
||||||
|
self.selected = selected
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
yield RadioSet(
|
||||||
|
*[
|
||||||
|
RadioButton(
|
||||||
|
get_color_enum(e.value, config.STATUSES, "red"), value=self.selected == str(e.value)
|
||||||
|
)
|
||||||
|
for e in self._enum
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_cursor_down(self):
|
||||||
|
self.query_one(RadioSet).action_next_button()
|
||||||
|
|
||||||
|
def action_cursor_up(self):
|
||||||
|
self.query_one(RadioSet).action_previous_button()
|
||||||
|
|
||||||
|
def action_select(self):
|
||||||
|
rs = self.query_one(RadioSet)
|
||||||
|
rs.action_toggle_button()
|
||||||
|
pressed = rs.pressed_button
|
||||||
|
self.dismiss(str(pressed.label))
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
|
||||||
|
class DateModal(ModalScreen):
|
||||||
|
CSS = """
|
||||||
|
DateModal {
|
||||||
|
layout: horizontal;
|
||||||
|
align: center middle;
|
||||||
|
background: $primary 30%;
|
||||||
|
}
|
||||||
|
DateModal Label {
|
||||||
|
border: solid grey;
|
||||||
|
}
|
||||||
|
DateModal Label.selected-date {
|
||||||
|
border: solid green;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("j", "cursor_down", "Down"),
|
||||||
|
("k", "cursor_up", "Up"),
|
||||||
|
("h", "cursor_left", "Left"),
|
||||||
|
("l", "cursor_right", "Right"),
|
||||||
|
# ("0,1,2,3,4,5,6,7,8,9", "num_entry", "#"),
|
||||||
|
Binding("enter", "select", "Select", priority=True),
|
||||||
|
("escape", "cancel", "cancel"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, date):
|
||||||
|
self.pieces = [int(p) for p in date.split("-")]
|
||||||
|
self.selected = 1 # start on month
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
for idx, piece in enumerate(self.pieces):
|
||||||
|
yield Label(
|
||||||
|
str(piece), classes="selected-date" if idx == self.selected else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_cursor_left(self):
|
||||||
|
# cycle Y/M/D
|
||||||
|
self.selected = (self.selected - 1) % 3
|
||||||
|
self._update_highlight()
|
||||||
|
|
||||||
|
def action_cursor_right(self):
|
||||||
|
self.selected = (self.selected + 1) % 3
|
||||||
|
self._update_highlight()
|
||||||
|
|
||||||
|
def _update_highlight(self):
|
||||||
|
for idx, lbl in enumerate(self.query("Label")):
|
||||||
|
if idx == self.selected:
|
||||||
|
lbl.add_class("selected-date")
|
||||||
|
else:
|
||||||
|
lbl.remove_class("selected-date")
|
||||||
|
|
||||||
|
def max_for(self, piece):
|
||||||
|
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
if piece == 0:
|
||||||
|
return 3000
|
||||||
|
elif piece == 1:
|
||||||
|
return 12
|
||||||
|
else:
|
||||||
|
# -1 for offset array
|
||||||
|
return days_in_month[self.pieces[1] - 1]
|
||||||
|
|
||||||
|
def _move_piece(self, by):
|
||||||
|
cur_value = self.pieces[self.selected]
|
||||||
|
cur_value += by
|
||||||
|
if cur_value == 0:
|
||||||
|
cur_value = self.max_for(self.selected)
|
||||||
|
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))
|
||||||
|
|
||||||
|
def action_cursor_down(self):
|
||||||
|
self._move_piece(-1)
|
||||||
|
|
||||||
|
def action_cursor_up(self):
|
||||||
|
self._move_piece(1)
|
||||||
|
|
||||||
|
def on_key(self, event) -> None:
|
||||||
|
key = event.key
|
||||||
|
if key in "0123456789":
|
||||||
|
cur_value = self.pieces[self.selected]
|
||||||
|
# If we can append the values like 1+2 = 12
|
||||||
|
# then do that, otherwise reset the value
|
||||||
|
# so (for months) 1+3 = 3
|
||||||
|
appended = int(str(cur_value) + key)
|
||||||
|
if appended <= self.max_for(self.selected):
|
||||||
|
cur_value = appended
|
||||||
|
else:
|
||||||
|
cur_value = int(key)
|
||||||
|
self.pieces[self.selected] = cur_value
|
||||||
|
self._move_piece(0)
|
||||||
|
event.prevent_default()
|
||||||
|
|
||||||
|
def action_select(self):
|
||||||
|
date = "-".join(str(p) for p in self.pieces)
|
||||||
|
self.dismiss(date)
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
self.app.pop_screen()
|
@ -11,6 +11,7 @@ from ..db import GeneratorType
|
|||||||
from .editor import (
|
from .editor import (
|
||||||
TableEditor,
|
TableEditor,
|
||||||
TableColumnConfig,
|
TableColumnConfig,
|
||||||
|
EnumColumnConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -18,14 +19,14 @@ class TaskGenEditor(TableEditor):
|
|||||||
TABLE_CONFIG = (
|
TABLE_CONFIG = (
|
||||||
TableColumnConfig("id", "ID"),
|
TableColumnConfig("id", "ID"),
|
||||||
TableColumnConfig("template", "Template", default="recur {val}"),
|
TableColumnConfig("template", "Template", default="recur {val}"),
|
||||||
TableColumnConfig(
|
EnumColumnConfig(
|
||||||
"type",
|
"type",
|
||||||
"Type",
|
"Type",
|
||||||
default=GeneratorType.DAYS_BETWEEN.value,
|
default=GeneratorType.DAYS_BETWEEN.value,
|
||||||
enum=GeneratorType,
|
enum=GeneratorType,
|
||||||
),
|
),
|
||||||
TableColumnConfig("val", "Value", default="1"),
|
TableColumnConfig("val", "Value", default="1"),
|
||||||
TableColumnConfig("next_at", "Next @", default="", read_only=True),
|
TableColumnConfig("next_at", "Next @", read_only=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
@ -2,47 +2,40 @@ import json
|
|||||||
from textual.widgets import Input
|
from textual.widgets import Input
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .. import config
|
||||||
from ..controller.tasks import (
|
from ..controller.tasks import (
|
||||||
get_task,
|
get_task,
|
||||||
get_tasks,
|
get_tasks,
|
||||||
add_task,
|
add_task,
|
||||||
update_task,
|
update_task,
|
||||||
TaskStatus,
|
|
||||||
save_view,
|
save_view,
|
||||||
get_saved_view,
|
get_saved_view,
|
||||||
)
|
)
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
get_colored_category,
|
get_color_enum,
|
||||||
get_colored_status,
|
|
||||||
get_colored_date,
|
get_colored_date,
|
||||||
)
|
)
|
||||||
from .editor import (
|
from .editor import (
|
||||||
TableEditor,
|
TableEditor,
|
||||||
TableColumnConfig,
|
TableColumnConfig,
|
||||||
NotifyValidationError,
|
EnumColumnConfig,
|
||||||
|
DateColumnConfig,
|
||||||
ELLIPSIS,
|
ELLIPSIS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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(TableEditor):
|
class TT(TableEditor):
|
||||||
TABLE_CONFIG = (
|
TABLE_CONFIG = (
|
||||||
TableColumnConfig("id", "ID"),
|
TableColumnConfig("id", "ID"),
|
||||||
TableColumnConfig("text", "Task", default="new task", enable_editor=True),
|
TableColumnConfig("text", "Task", default="new task", enable_editor=True),
|
||||||
TableColumnConfig(
|
EnumColumnConfig(
|
||||||
"status",
|
"status",
|
||||||
"Status",
|
"Status",
|
||||||
|
enum=config.STATUSES,
|
||||||
default="zero",
|
default="zero",
|
||||||
enum=TaskStatus,
|
|
||||||
),
|
),
|
||||||
TableColumnConfig("type", "Type", default=""),
|
TableColumnConfig("type", "Type", default=""),
|
||||||
TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor),
|
DateColumnConfig("due", "Due", default=""),
|
||||||
TableColumnConfig("category", "Category", default="main"),
|
TableColumnConfig("category", "Category", default="main"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -96,10 +89,12 @@ class TT(TableEditor):
|
|||||||
sort=self.sort_string,
|
sort=self.sort_string,
|
||||||
)
|
)
|
||||||
for item in items:
|
for item in items:
|
||||||
category = get_colored_category(
|
category = get_color_enum(
|
||||||
item.category.name if item.category else " - "
|
item.category.name if item.category else " - ",
|
||||||
|
config.PROJECTS,
|
||||||
|
"grey"
|
||||||
)
|
)
|
||||||
status = get_colored_status(item.status)
|
status = get_color_enum(item.status, config.STATUSES, "red")
|
||||||
due = get_colored_date(item.due)
|
due = get_colored_date(item.due)
|
||||||
|
|
||||||
if "\n" in item.text:
|
if "\n" in item.text:
|
||||||
|
@ -32,15 +32,9 @@ def advance_enum_val(enum_type, cur_val):
|
|||||||
return members[next_idx]
|
return members[next_idx]
|
||||||
|
|
||||||
|
|
||||||
def get_colored_status(status: str) -> str:
|
def get_color_enum(value: str, enum: dict[str, dict], default: str) -> str:
|
||||||
colors = {
|
color = enum.get(value, {"color": default})["color"]
|
||||||
"zero": "#666666",
|
return f"[{color}]{value}[/]"
|
||||||
"wip": "#33aa99",
|
|
||||||
"blocked": "#cc9900",
|
|
||||||
"done": "#009900",
|
|
||||||
}
|
|
||||||
color = colors.get(status, "#666666")
|
|
||||||
return f"[{color}]{status}[/]"
|
|
||||||
|
|
||||||
|
|
||||||
def get_colored_category(category: str) -> str:
|
def get_colored_category(category: str) -> str:
|
||||||
|
21
tt.toml
Normal file
21
tt.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[[enums]]
|
||||||
|
name = "status"
|
||||||
|
values = [
|
||||||
|
{ value = "zero", color = "#666666" },
|
||||||
|
{ value = "blocked", color = "#33a99" },
|
||||||
|
{ value = "wip", color = "#cc9900" },
|
||||||
|
{ value = "done", color = "#009900" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[enums]]
|
||||||
|
name = "projects"
|
||||||
|
values = [
|
||||||
|
{ value = "SECT", color = "purple" },
|
||||||
|
{ value = "life", color = "#00cc00" },
|
||||||
|
{ value = "CAPP", color = "#cc0000" },
|
||||||
|
{ value = "ilikethis", color = "#cccc00" },
|
||||||
|
{ value = "krang", color = "#ff00ff"},
|
||||||
|
{ value = "artworld", color = "#0000cc"},
|
||||||
|
{ value = "TT", color = "#00ff00"},
|
||||||
|
]
|
||||||
|
|
14
uv.lock
generated
14
uv.lock
generated
@ -1,4 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
|
revision = 1
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -160,7 +161,7 @@ name = "click"
|
|||||||
version = "8.1.8"
|
version = "8.1.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||||
wheels = [
|
wheels = [
|
||||||
@ -901,6 +902,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
|
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomlkit"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tt"
|
name = "tt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -910,6 +920,7 @@ dependencies = [
|
|||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
{ name = "peewee" },
|
{ name = "peewee" },
|
||||||
{ name = "textual" },
|
{ name = "textual" },
|
||||||
|
{ name = "tomlkit" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -926,6 +937,7 @@ requires-dist = [
|
|||||||
{ name = "lxml", specifier = ">=5.3.0" },
|
{ name = "lxml", specifier = ">=5.3.0" },
|
||||||
{ name = "peewee", specifier = ">=3.17.8" },
|
{ name = "peewee", specifier = ">=3.17.8" },
|
||||||
{ name = "textual", specifier = ">=1.0.0" },
|
{ name = "textual", specifier = ">=1.0.0" },
|
||||||
|
{ name = "tomlkit", specifier = ">=0.13.2" },
|
||||||
{ name = "typer", specifier = ">=0.15.1" },
|
{ name = "typer", specifier = ">=0.15.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user