tasko 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tasko/__init__.py +1 -0
- tasko/cli/__init__.py +1 -0
- tasko/cli/main.py +89 -0
- tasko/core/__init__.py +1 -0
- tasko/core/core.py +267 -0
- tasko/core/errors.py +5 -0
- tasko/core/migrations.py +49 -0
- tasko/core/models.py +65 -0
- tasko/tui/__init__.py +1 -0
- tasko/tui/app.py +74 -0
- tasko/tui/screens/__init__.py +1 -0
- tasko/tui/screens/dialogs.py +342 -0
- tasko/tui/screens/projects.py +160 -0
- tasko/tui/screens/task_detail.py +223 -0
- tasko/tui/screens/task_list.py +335 -0
- tasko/tui/widgets.py +40 -0
- tasko-0.0.1.dist-info/METADATA +51 -0
- tasko-0.0.1.dist-info/RECORD +21 -0
- tasko-0.0.1.dist-info/WHEEL +4 -0
- tasko-0.0.1.dist-info/entry_points.txt +3 -0
- tasko-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Task detail screen: full details of one task with per-field editing."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
5
|
+
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.containers import VerticalScroll
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from tasko.core.errors import AppError
|
|
13
|
+
from tasko.core.models import Priority, Status, Task
|
|
14
|
+
from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog, SelectDialog, TextDialog
|
|
15
|
+
from tasko.tui.widgets import StatusBar
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from textual.app import ComposeResult
|
|
21
|
+
from textual.binding import BindingType
|
|
22
|
+
|
|
23
|
+
from tasko.core.core import Core
|
|
24
|
+
|
|
25
|
+
# Same glyphs and colors as the table on the main screen, spelled out with the word.
|
|
26
|
+
_STATUS_TEXT: Final = {
|
|
27
|
+
Status.TODO: Text("○ todo", style="dim"),
|
|
28
|
+
Status.DOING: Text("◐ doing", style="yellow"),
|
|
29
|
+
Status.DONE: Text("● done", style="green"),
|
|
30
|
+
}
|
|
31
|
+
_PRIORITY_TEXT: Final = {
|
|
32
|
+
Priority.HIGH: Text("high", style="red"),
|
|
33
|
+
Priority.MEDIUM: Text("medium"),
|
|
34
|
+
Priority.LOW: Text("low", style="dim"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _format_ts(ts: int | None) -> str:
|
|
39
|
+
"""Render unix seconds in local time, or an em dash when absent."""
|
|
40
|
+
if ts is None:
|
|
41
|
+
return "—"
|
|
42
|
+
return datetime.fromtimestamp(ts, tz=UTC).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TaskDetailScreen(Screen[None]):
|
|
46
|
+
"""Details of one task — reached from the task table with Enter."""
|
|
47
|
+
|
|
48
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
49
|
+
("e", "edit_title", "Title"),
|
|
50
|
+
("b", "edit_body", "Body"),
|
|
51
|
+
("d", "toggle_done", "Done"),
|
|
52
|
+
("s", "toggle_doing", "Doing"),
|
|
53
|
+
("p", "edit_priority", "Priority"),
|
|
54
|
+
("P", "edit_project", "Project"),
|
|
55
|
+
("x", "delete", "Delete"),
|
|
56
|
+
("escape", "app.pop_screen", "Back"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# What ?/i shows: the user-facing key reference for this screen, in reading order.
|
|
60
|
+
HELP_KEYS: ClassVar[list[tuple[str, str]]] = [
|
|
61
|
+
("e", "edit the title"),
|
|
62
|
+
("b", "edit the body"),
|
|
63
|
+
("d", "mark done / back to todo"),
|
|
64
|
+
("s", "mark doing / back to todo"),
|
|
65
|
+
("p", "change the priority"),
|
|
66
|
+
("P", "move to another project"),
|
|
67
|
+
("x", "delete the task"),
|
|
68
|
+
("esc", "back to the task list"),
|
|
69
|
+
("? i", "this help"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
DEFAULT_CSS = """
|
|
73
|
+
TaskDetailScreen VerticalScroll {
|
|
74
|
+
padding: 1 2;
|
|
75
|
+
}
|
|
76
|
+
TaskDetailScreen #title {
|
|
77
|
+
text-style: bold;
|
|
78
|
+
margin-bottom: 1;
|
|
79
|
+
}
|
|
80
|
+
TaskDetailScreen #meta {
|
|
81
|
+
margin-bottom: 1;
|
|
82
|
+
}
|
|
83
|
+
TaskDetailScreen #body {
|
|
84
|
+
color: $text-muted;
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, core: Core, task_id: int) -> None:
|
|
89
|
+
"""Wire the screen to the core and the task to show."""
|
|
90
|
+
super().__init__()
|
|
91
|
+
self._core = core # core operations
|
|
92
|
+
self._task_id = task_id # id of the task on display
|
|
93
|
+
self._loaded_task: Task | None = None # last loaded state; None until the first reload
|
|
94
|
+
|
|
95
|
+
def compose(self) -> ComposeResult:
|
|
96
|
+
"""Render the scrollable details (title, metadata, body) and the status bar."""
|
|
97
|
+
with VerticalScroll():
|
|
98
|
+
yield Static(id="title")
|
|
99
|
+
yield Static(id="meta")
|
|
100
|
+
yield Static(id="body")
|
|
101
|
+
yield StatusBar()
|
|
102
|
+
|
|
103
|
+
def on_mount(self) -> None:
|
|
104
|
+
"""Fill the screen."""
|
|
105
|
+
self.reload()
|
|
106
|
+
|
|
107
|
+
def on_screen_resume(self) -> None:
|
|
108
|
+
"""Re-read the task when coming back from an edit dialog."""
|
|
109
|
+
# A resume can arrive after the screen was already popped (dialog dismiss posts it
|
|
110
|
+
# before action_delete's callback runs); reloading then would pop a second screen.
|
|
111
|
+
if self.is_current:
|
|
112
|
+
self.reload()
|
|
113
|
+
|
|
114
|
+
def reload(self) -> None:
|
|
115
|
+
"""Refresh all fields from the database; leave the screen if the task is gone."""
|
|
116
|
+
try:
|
|
117
|
+
task = self._core.get_task(self._task_id)
|
|
118
|
+
except AppError as e:
|
|
119
|
+
self.notify(str(e), severity="error")
|
|
120
|
+
self.app.pop_screen()
|
|
121
|
+
return
|
|
122
|
+
self._loaded_task = task
|
|
123
|
+
self.query_one(StatusBar).set_context(Text.assemble(f"task #{task.id}", (f" · {task.project_name}", "dim")))
|
|
124
|
+
|
|
125
|
+
self.query_one("#title", Static).update(Text(task.title))
|
|
126
|
+
# Two column groups: live fields on the left (main-table order), dates on the right, all dim.
|
|
127
|
+
meta = Table.grid(padding=(0, 2))
|
|
128
|
+
meta.add_column(style="dim")
|
|
129
|
+
meta.add_column()
|
|
130
|
+
meta.add_column(style="dim")
|
|
131
|
+
meta.add_column(style="dim")
|
|
132
|
+
meta.add_row("Status", _STATUS_TEXT[task.status], "Created", _format_ts(task.created_at))
|
|
133
|
+
meta.add_row("Priority", _PRIORITY_TEXT[task.priority], "Updated", _format_ts(task.updated_at))
|
|
134
|
+
meta.add_row("Project", task.project_name, "Done", _format_ts(task.done_at))
|
|
135
|
+
self.query_one("#meta", Static).update(meta)
|
|
136
|
+
body = self.query_one("#body", Static)
|
|
137
|
+
body.update(task.body or Text("(no body)", style="dim"))
|
|
138
|
+
|
|
139
|
+
def _apply(self, operation: Callable[[], object]) -> None:
|
|
140
|
+
"""Run a core operation; refresh the screen on success, toast the message on AppError."""
|
|
141
|
+
try:
|
|
142
|
+
operation()
|
|
143
|
+
except AppError as e:
|
|
144
|
+
self.notify(str(e), severity="error")
|
|
145
|
+
else:
|
|
146
|
+
self.reload()
|
|
147
|
+
|
|
148
|
+
def action_edit_title(self) -> None:
|
|
149
|
+
"""Prompt for a new title, pre-filled with the current one."""
|
|
150
|
+
if self._loaded_task is None:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
def on_result(title: str | None) -> None:
|
|
154
|
+
if title:
|
|
155
|
+
self._apply(lambda: self._core.set_title(self._task_id, title))
|
|
156
|
+
|
|
157
|
+
self.app.push_screen(InputDialog("Task title:", value=self._loaded_task.title), on_result)
|
|
158
|
+
|
|
159
|
+
def action_edit_body(self) -> None:
|
|
160
|
+
"""Edit the body in a multi-line dialog; saving empty text clears it."""
|
|
161
|
+
if self._loaded_task is None:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
def on_result(text: str | None) -> None:
|
|
165
|
+
if text is not None:
|
|
166
|
+
self._apply(lambda: self._core.set_body(self._task_id, text))
|
|
167
|
+
|
|
168
|
+
self.app.push_screen(TextDialog("Task body:", self._loaded_task.body or ""), on_result)
|
|
169
|
+
|
|
170
|
+
def action_toggle_done(self) -> None:
|
|
171
|
+
"""Toggle done, same as on the task list: not-done → done, done → todo."""
|
|
172
|
+
if self._loaded_task is None:
|
|
173
|
+
return
|
|
174
|
+
status = Status.TODO if self._loaded_task.status is Status.DONE else Status.DONE
|
|
175
|
+
self._apply(lambda: self._core.set_status(self._task_id, status))
|
|
176
|
+
|
|
177
|
+
def action_toggle_doing(self) -> None:
|
|
178
|
+
"""Toggle doing, same as on the task list: not-doing → doing (even from done), doing → todo."""
|
|
179
|
+
if self._loaded_task is None:
|
|
180
|
+
return
|
|
181
|
+
status = Status.TODO if self._loaded_task.status is Status.DOING else Status.DOING
|
|
182
|
+
self._apply(lambda: self._core.set_status(self._task_id, status))
|
|
183
|
+
|
|
184
|
+
def action_edit_priority(self) -> None:
|
|
185
|
+
"""Pick a new priority, starting on the current one."""
|
|
186
|
+
if self._loaded_task is None:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
def on_result(choice: str | None) -> None:
|
|
190
|
+
if choice is not None:
|
|
191
|
+
self._apply(lambda: self._core.set_priority(self._task_id, Priority(choice)))
|
|
192
|
+
|
|
193
|
+
options = [(priority.value, priority.value) for priority in Priority]
|
|
194
|
+
self.app.push_screen(SelectDialog("Priority:", options, self._loaded_task.priority.value, filterable=False), on_result)
|
|
195
|
+
|
|
196
|
+
def action_delete(self) -> None:
|
|
197
|
+
"""Confirm and delete the task, then return to the task list."""
|
|
198
|
+
if self._loaded_task is None:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
def on_result(confirmed: bool | None) -> None:
|
|
202
|
+
if not confirmed:
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
self._core.delete_task(self._task_id)
|
|
206
|
+
except AppError as e:
|
|
207
|
+
self.notify(str(e), severity="error")
|
|
208
|
+
else:
|
|
209
|
+
self.app.pop_screen()
|
|
210
|
+
|
|
211
|
+
self.app.push_screen(ConfirmDialog(f"Delete task '{self._loaded_task.title}'?"), on_result)
|
|
212
|
+
|
|
213
|
+
def action_edit_project(self) -> None:
|
|
214
|
+
"""Pick a new project, starting on the current one."""
|
|
215
|
+
if self._loaded_task is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
def on_result(choice: str | None) -> None:
|
|
219
|
+
if choice is not None:
|
|
220
|
+
self._apply(lambda: self._core.set_project(self._task_id, int(choice)))
|
|
221
|
+
|
|
222
|
+
options = [(str(project.id), project.name) for project in self._core.list_projects()]
|
|
223
|
+
self.app.push_screen(SelectDialog("Project:", options, str(self._loaded_task.project_id)), on_result)
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Main screen: the task table."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
4
|
+
|
|
5
|
+
from rich.cells import cell_len
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widgets import DataTable
|
|
9
|
+
|
|
10
|
+
from tasko.core.errors import AppError
|
|
11
|
+
from tasko.core.models import Priority, Status
|
|
12
|
+
from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog, SelectDialog
|
|
13
|
+
from tasko.tui.screens.projects import ProjectsScreen
|
|
14
|
+
from tasko.tui.screens.task_detail import TaskDetailScreen
|
|
15
|
+
from tasko.tui.widgets import StatusBar
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.binding import BindingType
|
|
20
|
+
|
|
21
|
+
from tasko.core.core import Core
|
|
22
|
+
|
|
23
|
+
# One-character, color-coded cells: the S/P/B columns must not waste width.
|
|
24
|
+
_STATUS_CELLS: Final = {
|
|
25
|
+
Status.TODO: Text("○", style="dim"),
|
|
26
|
+
Status.DOING: Text("◐", style="yellow"),
|
|
27
|
+
Status.DONE: Text("●", style="green"),
|
|
28
|
+
}
|
|
29
|
+
_PRIORITY_CELLS: Final = {
|
|
30
|
+
Priority.HIGH: Text("↑", style="red"),
|
|
31
|
+
Priority.MEDIUM: Text(""), # the default level is visual silence
|
|
32
|
+
Priority.LOW: Text("↓", style="dim"),
|
|
33
|
+
}
|
|
34
|
+
_BODY_CELL: Final = Text("≡", style="dim") # marks that a task has a body; the text itself is shown elsewhere
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _title_cell(title: str, status: Status) -> Text:
|
|
38
|
+
"""Render a title cell: done tasks are struck through."""
|
|
39
|
+
return Text(title, style="strike") if status is Status.DONE else Text(title)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TaskListScreen(Screen[None]):
|
|
43
|
+
"""Task list — the screen the app lives on."""
|
|
44
|
+
|
|
45
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
46
|
+
("j", "cursor_down", "Down"),
|
|
47
|
+
("k", "cursor_up", "Up"),
|
|
48
|
+
("a", "add", "Add"),
|
|
49
|
+
("d", "toggle_done", "Done"),
|
|
50
|
+
("s", "toggle_doing", "Doing"),
|
|
51
|
+
("plus,equals_sign", "priority_up", "Priority up"), # = raises too: + is shifted on most layouts
|
|
52
|
+
("minus", "priority_down", "Priority down"),
|
|
53
|
+
("x", "delete", "Delete"),
|
|
54
|
+
("D", "toggle_show_done", "Show done"),
|
|
55
|
+
("f", "focus_project", "Focus"),
|
|
56
|
+
("p", "select_project", "Project"),
|
|
57
|
+
("r", "refresh", "Refresh"),
|
|
58
|
+
("P", "projects", "Projects"),
|
|
59
|
+
("q", "app.quit", "Quit"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# What ?/i shows: the user-facing key reference for this screen, in reading order.
|
|
63
|
+
HELP_KEYS: ClassVar[list[tuple[str, str]]] = [
|
|
64
|
+
("↑ ↓ j k", "move the cursor"),
|
|
65
|
+
("enter", "open the task under the cursor"),
|
|
66
|
+
("a", "add a task into the cursor row's project"),
|
|
67
|
+
("d", "mark done / back to todo"),
|
|
68
|
+
("s", "mark doing / back to todo"),
|
|
69
|
+
("+ -", "raise / lower the priority"),
|
|
70
|
+
("x", "delete the task"),
|
|
71
|
+
("D", "show or hide done tasks"),
|
|
72
|
+
("f", "focus the cursor task's project / back to all"),
|
|
73
|
+
("p", "filter by project"),
|
|
74
|
+
("r", "refresh the list"),
|
|
75
|
+
("P", "manage projects"),
|
|
76
|
+
("q", "quit"),
|
|
77
|
+
("? i", "this help"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def __init__(self, core: Core) -> None:
|
|
81
|
+
"""Wire the screen to the core."""
|
|
82
|
+
super().__init__()
|
|
83
|
+
self._core = core # core operations
|
|
84
|
+
self._show_done = False # session-only toggle; done tasks are hidden by default
|
|
85
|
+
|
|
86
|
+
def compose(self) -> ComposeResult:
|
|
87
|
+
"""Render the task table and the status bar."""
|
|
88
|
+
yield DataTable()
|
|
89
|
+
yield StatusBar()
|
|
90
|
+
|
|
91
|
+
def on_mount(self) -> None:
|
|
92
|
+
"""Configure and fill the table."""
|
|
93
|
+
table = self.query_one(DataTable)
|
|
94
|
+
table.cursor_type = "row" # navigate whole rows; row actions will hang off the cursor row
|
|
95
|
+
self.reload()
|
|
96
|
+
|
|
97
|
+
def on_screen_resume(self) -> None:
|
|
98
|
+
"""Re-read the database when coming back from another screen (projects may have changed)."""
|
|
99
|
+
self.reload()
|
|
100
|
+
|
|
101
|
+
def reload(self) -> None:
|
|
102
|
+
"""Refill the table from the database, keeping the cursor on the same task when possible."""
|
|
103
|
+
table = self.query_one(DataTable)
|
|
104
|
+
previous_row = table.cursor_row
|
|
105
|
+
previous_id = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value if table.row_count else None
|
|
106
|
+
state = self._core.app_state()
|
|
107
|
+
tasks = self._core.list_tasks(project_id=state.selected_project_id, include_done=self._show_done)
|
|
108
|
+
# The P column earns its width only when some visible task deviates from the default
|
|
109
|
+
# priority. DataTable cannot hide a column in place, so columns are rebuilt every reload.
|
|
110
|
+
show_priority = any(task.priority is not Priority.MEDIUM for task in tasks)
|
|
111
|
+
table.clear(columns=True)
|
|
112
|
+
# Explicit widths sized to the content being added: auto-width is recomputed in a
|
|
113
|
+
# deferred idle message, and a repaint racing ahead of it caches cells at label-only
|
|
114
|
+
# widths (DataTable's cell render cache ignores width), leaving rows misaligned.
|
|
115
|
+
id_width = max([1, *(len(str(task.id)) for task in tasks)])
|
|
116
|
+
project_width = max([len("Project"), *(cell_len(task.project_name) for task in tasks)])
|
|
117
|
+
title_width = max([len("Title"), *(cell_len(task.title) for task in tasks)])
|
|
118
|
+
# One-letter labels for the status/priority glyph columns; the body marker column explains itself.
|
|
119
|
+
table.add_column(Text("#", justify="right"), width=id_width)
|
|
120
|
+
self._status_column = table.add_column("S", width=1) # column keys for the in-place updates in _toggle_*
|
|
121
|
+
self._priority_column = table.add_column("P", width=1) if show_priority else None # None while hidden
|
|
122
|
+
table.add_column("Project", width=project_width)
|
|
123
|
+
self._title_column = table.add_column("Title", width=title_width)
|
|
124
|
+
table.add_column("", width=1)
|
|
125
|
+
# The status bar shows the context: the project filter, plus task counts under it.
|
|
126
|
+
# The filter project's name cannot come from the task rows — it may have zero tasks.
|
|
127
|
+
if state.selected_project_id is not None:
|
|
128
|
+
title = self._core.get_project(state.selected_project_id).name
|
|
129
|
+
else:
|
|
130
|
+
title = "all projects"
|
|
131
|
+
open_count = sum(1 for task in tasks if task.status is not Status.DONE)
|
|
132
|
+
counts = f" · {open_count} open · {len(tasks) - open_count} done" if self._show_done else f" · {open_count} open"
|
|
133
|
+
self.query_one(StatusBar).set_context(Text.assemble(title, (counts, "dim")))
|
|
134
|
+
ids = []
|
|
135
|
+
for task in tasks:
|
|
136
|
+
ids.append(str(task.id))
|
|
137
|
+
# Ink hierarchy: the title is the content and stays bright; id and project are context and dim.
|
|
138
|
+
table.add_row(
|
|
139
|
+
Text(str(task.id), style="dim", justify="right"),
|
|
140
|
+
_STATUS_CELLS[task.status],
|
|
141
|
+
*([_PRIORITY_CELLS[task.priority]] if show_priority else []),
|
|
142
|
+
Text(task.project_name, style="dim"),
|
|
143
|
+
_title_cell(task.title, task.status),
|
|
144
|
+
_BODY_CELL if task.body else "",
|
|
145
|
+
key=str(task.id),
|
|
146
|
+
)
|
|
147
|
+
if table.row_count:
|
|
148
|
+
# Follow the task by id; if it is gone, clamp the old position.
|
|
149
|
+
row = ids.index(previous_id) if previous_id in ids else min(previous_row, table.row_count - 1)
|
|
150
|
+
table.move_cursor(row=row)
|
|
151
|
+
|
|
152
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
153
|
+
"""Enter on a row opens the task screen."""
|
|
154
|
+
if event.row_key.value is not None:
|
|
155
|
+
self.app.push_screen(TaskDetailScreen(self._core, int(event.row_key.value)))
|
|
156
|
+
|
|
157
|
+
def action_add(self) -> None:
|
|
158
|
+
"""Prompt for a title and create a task in the project named in the prompt.
|
|
159
|
+
|
|
160
|
+
The target is the project of the row under the cursor — the context the user is
|
|
161
|
+
looking at (it matters under the "all projects" filter); on an empty table it
|
|
162
|
+
falls back to the selected, then the default project. Wrong target? Escape, move
|
|
163
|
+
the cursor to a row of the right project, or change the project on the task later.
|
|
164
|
+
"""
|
|
165
|
+
task_id = self._cursor_task_id()
|
|
166
|
+
if task_id is not None:
|
|
167
|
+
task = self._core.get_task(task_id)
|
|
168
|
+
project_id, name = task.project_id, task.project_name
|
|
169
|
+
else:
|
|
170
|
+
state = self._core.app_state()
|
|
171
|
+
project_id = state.selected_project_id or state.default_project_id
|
|
172
|
+
name = self._core.get_project(project_id).name
|
|
173
|
+
|
|
174
|
+
def on_result(title: str | None) -> None:
|
|
175
|
+
if not title:
|
|
176
|
+
return
|
|
177
|
+
try:
|
|
178
|
+
self._core.add_task(title, project_id=project_id)
|
|
179
|
+
except AppError as e:
|
|
180
|
+
self.notify(str(e), severity="error")
|
|
181
|
+
else:
|
|
182
|
+
self.reload()
|
|
183
|
+
|
|
184
|
+
self.app.push_screen(InputDialog(f'New task in "{name}":'), on_result)
|
|
185
|
+
|
|
186
|
+
def _cursor_task_id(self) -> int | None:
|
|
187
|
+
"""Return the id of the task under the cursor, or None on an empty table.
|
|
188
|
+
|
|
189
|
+
The id comes from the row key, never from cell content — cells are styled
|
|
190
|
+
display and their order is free to change.
|
|
191
|
+
"""
|
|
192
|
+
table = self.query_one(DataTable)
|
|
193
|
+
if table.row_count == 0:
|
|
194
|
+
return None
|
|
195
|
+
key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
|
|
196
|
+
return int(key) if key is not None else None
|
|
197
|
+
|
|
198
|
+
def _toggle_status(self, target: Status) -> None:
|
|
199
|
+
"""Flip the cursor task into the target status (or back to todo), updating its row in place.
|
|
200
|
+
|
|
201
|
+
Deliberately no reload: the row stays where it is, so an accidental press is
|
|
202
|
+
visible and undone with the same key. The task re-sorts or disappears on the
|
|
203
|
+
next natural reload.
|
|
204
|
+
"""
|
|
205
|
+
task_id = self._cursor_task_id()
|
|
206
|
+
if task_id is None:
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
task = self._core.get_task(task_id)
|
|
210
|
+
status = Status.TODO if task.status is target else target
|
|
211
|
+
self._core.set_status(task_id, status)
|
|
212
|
+
except AppError as e:
|
|
213
|
+
self.notify(str(e), severity="error")
|
|
214
|
+
return
|
|
215
|
+
table = self.query_one(DataTable)
|
|
216
|
+
table.update_cell(str(task_id), self._status_column, _STATUS_CELLS[status])
|
|
217
|
+
table.update_cell(str(task_id), self._title_column, _title_cell(task.title, status))
|
|
218
|
+
|
|
219
|
+
def _shift_priority(self, step: int) -> None:
|
|
220
|
+
"""Move the cursor task one step along low → medium → high, updating its row in place.
|
|
221
|
+
|
|
222
|
+
Same philosophy as _toggle_status: no reload, the change is visible under the
|
|
223
|
+
cursor and the opposite key undoes it; the row re-sorts on the next reload. The
|
|
224
|
+
exception is a hidden P column (everything was medium): only a reload can bring
|
|
225
|
+
it back, and the cursor still follows the task by id.
|
|
226
|
+
"""
|
|
227
|
+
task_id = self._cursor_task_id()
|
|
228
|
+
if task_id is None:
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
task = self._core.get_task(task_id)
|
|
232
|
+
scale = list(Priority) # declared in scale order: low, medium, high
|
|
233
|
+
index = scale.index(task.priority) + step
|
|
234
|
+
if not 0 <= index < len(scale):
|
|
235
|
+
return # already at the end of the scale
|
|
236
|
+
priority = scale[index]
|
|
237
|
+
self._core.set_priority(task_id, priority)
|
|
238
|
+
except AppError as e:
|
|
239
|
+
self.notify(str(e), severity="error")
|
|
240
|
+
return
|
|
241
|
+
column = self._priority_column
|
|
242
|
+
if column is None:
|
|
243
|
+
self.reload()
|
|
244
|
+
else:
|
|
245
|
+
self.query_one(DataTable).update_cell(str(task_id), column, _PRIORITY_CELLS[priority])
|
|
246
|
+
|
|
247
|
+
def action_toggle_done(self) -> None:
|
|
248
|
+
"""Toggle done for the task under the cursor: not-done → done, done → todo."""
|
|
249
|
+
self._toggle_status(Status.DONE)
|
|
250
|
+
|
|
251
|
+
def action_toggle_doing(self) -> None:
|
|
252
|
+
"""Toggle doing for the task under the cursor: not-doing → doing (even from done), doing → todo."""
|
|
253
|
+
self._toggle_status(Status.DOING)
|
|
254
|
+
|
|
255
|
+
def action_delete(self) -> None:
|
|
256
|
+
"""Confirm and delete the task under the cursor — the same dialog as on the detail screen."""
|
|
257
|
+
task_id = self._cursor_task_id()
|
|
258
|
+
if task_id is None:
|
|
259
|
+
return
|
|
260
|
+
try:
|
|
261
|
+
task = self._core.get_task(task_id)
|
|
262
|
+
except AppError as e:
|
|
263
|
+
self.notify(str(e), severity="error")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
def on_result(confirmed: bool | None) -> None:
|
|
267
|
+
if not confirmed:
|
|
268
|
+
return
|
|
269
|
+
try:
|
|
270
|
+
self._core.delete_task(task_id)
|
|
271
|
+
except AppError as e:
|
|
272
|
+
self.notify(str(e), severity="error")
|
|
273
|
+
else:
|
|
274
|
+
self.reload()
|
|
275
|
+
|
|
276
|
+
self.app.push_screen(ConfirmDialog(f"Delete task '{task.title}'?"), on_result)
|
|
277
|
+
|
|
278
|
+
def action_cursor_down(self) -> None:
|
|
279
|
+
"""Move the cursor down — the vim-trained alias for the down arrow."""
|
|
280
|
+
self.query_one(DataTable).action_cursor_down()
|
|
281
|
+
|
|
282
|
+
def action_cursor_up(self) -> None:
|
|
283
|
+
"""Move the cursor up — the vim-trained alias for the up arrow."""
|
|
284
|
+
self.query_one(DataTable).action_cursor_up()
|
|
285
|
+
|
|
286
|
+
def action_priority_up(self) -> None:
|
|
287
|
+
"""Raise the cursor task's priority one step."""
|
|
288
|
+
self._shift_priority(1)
|
|
289
|
+
|
|
290
|
+
def action_priority_down(self) -> None:
|
|
291
|
+
"""Lower the cursor task's priority one step."""
|
|
292
|
+
self._shift_priority(-1)
|
|
293
|
+
|
|
294
|
+
def action_toggle_show_done(self) -> None:
|
|
295
|
+
"""Show or hide done tasks."""
|
|
296
|
+
self._show_done = not self._show_done
|
|
297
|
+
self.reload()
|
|
298
|
+
|
|
299
|
+
def action_refresh(self) -> None:
|
|
300
|
+
"""Reload the list: apply pending re-sorts/hides from d/s and pick up outside changes (e.g. CLI adds)."""
|
|
301
|
+
self.reload()
|
|
302
|
+
|
|
303
|
+
def action_focus_project(self) -> None:
|
|
304
|
+
"""Toggle the filter: all projects → the cursor task's project, any project → all.
|
|
305
|
+
|
|
306
|
+
The quick alternative to the picker: when narrowed, every visible task belongs
|
|
307
|
+
to the filter project, so the two directions never overlap on one key.
|
|
308
|
+
"""
|
|
309
|
+
if self._core.app_state().selected_project_id is not None:
|
|
310
|
+
self._core.set_selected_project(None)
|
|
311
|
+
else:
|
|
312
|
+
task_id = self._cursor_task_id()
|
|
313
|
+
if task_id is None:
|
|
314
|
+
return
|
|
315
|
+
self._core.set_selected_project(self._core.get_task(task_id).project_id)
|
|
316
|
+
self.reload()
|
|
317
|
+
|
|
318
|
+
def action_select_project(self) -> None:
|
|
319
|
+
"""Choose the project filter: a single project or all of them."""
|
|
320
|
+
selected_id = self._core.app_state().selected_project_id
|
|
321
|
+
# The meta-option is styled apart so it reads as a mode, not a project.
|
|
322
|
+
options: list[tuple[str, str | Text]] = [("all", Text("All projects", style="italic dim"))]
|
|
323
|
+
options += [(str(p.id), p.name) for p in self._core.list_projects()]
|
|
324
|
+
current = "all" if selected_id is None else str(selected_id)
|
|
325
|
+
|
|
326
|
+
def on_result(choice: str | None) -> None:
|
|
327
|
+
if choice is not None:
|
|
328
|
+
self._core.set_selected_project(None if choice == "all" else int(choice))
|
|
329
|
+
self.reload()
|
|
330
|
+
|
|
331
|
+
self.app.push_screen(SelectDialog("Project:", options, current), on_result)
|
|
332
|
+
|
|
333
|
+
def action_projects(self) -> None:
|
|
334
|
+
"""Open the projects screen."""
|
|
335
|
+
self.app.push_screen(ProjectsScreen(self._core))
|
tasko/tui/widgets.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Shared TUI widgets."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.widgets import Label
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.visual import VisualType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StatusBar(Horizontal):
|
|
15
|
+
"""One-line bottom bar: the screen's context on the left, the help key hint on the right.
|
|
16
|
+
|
|
17
|
+
Replaces both the stock Header (context) and Footer (key hints): the full key list
|
|
18
|
+
lives behind the global help key, so the bar stays a single quiet line.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
DEFAULT_CSS = """
|
|
22
|
+
StatusBar {
|
|
23
|
+
dock: bottom;
|
|
24
|
+
height: 1;
|
|
25
|
+
padding: 0 1;
|
|
26
|
+
background: $panel;
|
|
27
|
+
}
|
|
28
|
+
StatusBar > #context {
|
|
29
|
+
width: 1fr;
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def compose(self) -> ComposeResult:
|
|
34
|
+
"""Render the context slot and the help hint."""
|
|
35
|
+
yield Label(id="context")
|
|
36
|
+
yield Label(Text.assemble(("?", "bold"), (" help", "dim")))
|
|
37
|
+
|
|
38
|
+
def set_context(self, context: VisualType) -> None:
|
|
39
|
+
"""Put the screen's context (filter, counts, ...) on the left side of the bar."""
|
|
40
|
+
self.query_one("#context", Label).update(context)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tasko
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Personal task manager
|
|
5
|
+
Author: pybass
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
10
|
+
Classifier: Topic :: Office/Business
|
|
11
|
+
Requires-Dist: rich~=15.0
|
|
12
|
+
Requires-Dist: textual~=8.2
|
|
13
|
+
Requires-Python: >=3.14
|
|
14
|
+
Project-URL: Repository, https://github.com/pybass/tasko
|
|
15
|
+
Project-URL: Issues, https://github.com/pybass/tasko/issues
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# tasko
|
|
19
|
+
|
|
20
|
+
A personal task manager: a fast Textual TUI over local SQLite, with a minimal
|
|
21
|
+
CLI for quick capture.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
`tasko` with no arguments launches the TUI — the primary interface, where all
|
|
26
|
+
task and project management lives.
|
|
27
|
+
|
|
28
|
+
The CLI is deliberately minimal (see
|
|
29
|
+
[docs/architecture.md](docs/architecture.md) for why):
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
tasko add "fix nginx" -p infra # quick capture from the shell
|
|
33
|
+
tasko list # a quick look without launching the TUI
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
|
|
37
|
+
`~/.local/share/tasko`); override the directory with `--data-dir` or the
|
|
38
|
+
`TASKO_DATA_DIR` env var.
|
|
39
|
+
|
|
40
|
+
## Design
|
|
41
|
+
|
|
42
|
+
- [docs/architecture.md](docs/architecture.md) — layering (`core/` + `cli/` +
|
|
43
|
+
`tui/`), the TUI-first UI strategy, tool choices.
|
|
44
|
+
- [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
|
|
45
|
+
and the reasoning behind it.
|
|
46
|
+
- [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
|
|
47
|
+
and why.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
tasko/__init__.py,sha256=O_CB1FBAZ2fi8AtXKnX9ZCJz4ridZ1RMW3huRMwFqBU,29
|
|
2
|
+
tasko/cli/__init__.py,sha256=_9IFQCzIbCfOPc_43dp1PQ7UsSJt45iKCYwXaTDQ7mo,19
|
|
3
|
+
tasko/cli/main.py,sha256=kVgdlM1Vx1IsRahJbxn_cT_BnPwjDWTr3RotRd3vELo,4067
|
|
4
|
+
tasko/core/__init__.py,sha256=3Y-3aJJJVvrlbJ-pwWzEd7lJ39V8F6xxFiThsgU6ycw,58
|
|
5
|
+
tasko/core/core.py,sha256=4FInUSkIKoeLZ7mMwntZ-hnw5iFQOH4aIOJPkTIYMJI,12393
|
|
6
|
+
tasko/core/errors.py,sha256=D5Ex4zMeKBs1qLq5B_s_6bdXSkilpFW_MNiKJn5JFzc,165
|
|
7
|
+
tasko/core/migrations.py,sha256=yNb4ZUz-5_X3ZAllmytfWg2fZhNVp5ZcZVNiokS9ogk,2403
|
|
8
|
+
tasko/core/models.py,sha256=PsfV4cieJVbJHc_OqmXQZRhjArwfM29l4QL9BPG6EBU,2057
|
|
9
|
+
tasko/tui/__init__.py,sha256=iQyEPlTc_zdZ2BwuED8nfu-Mx_9J0wMwwKqKJhaToWQ,45
|
|
10
|
+
tasko/tui/app.py,sha256=Jiih5GWe_kVQGfWHVjGWc0W6WQlQZ5Fnkd9UPjECnoo,2742
|
|
11
|
+
tasko/tui/screens/__init__.py,sha256=TA5fz2Wf07qklNN_UsJautJGnDBMjDrl0aaa4LB8WPg,19
|
|
12
|
+
tasko/tui/screens/dialogs.py,sha256=cMBs6cabdIOHpN6wZBBMc1BHaMS8cZ1J-DNVO922NcU,12948
|
|
13
|
+
tasko/tui/screens/projects.py,sha256=o4yB0ugJW99dHV_02kiE6b5L2Dg0-1KJFYSfDGCR08A,6274
|
|
14
|
+
tasko/tui/screens/task_detail.py,sha256=4HaDKb45X0M6Xh1iqMJMPNLIOD_zAL3x_FU78hg-xZ4,8622
|
|
15
|
+
tasko/tui/screens/task_list.py,sha256=JtDfhGojTGvyi_DDboRjooV7zJWRUsLu4yiRyelSPzI,15075
|
|
16
|
+
tasko/tui/widgets.py,sha256=mKvgYGhd1CKgEyYSYtU2MbMjWskvas7yBCr15kM5zw8,1166
|
|
17
|
+
tasko-0.0.1.dist-info/licenses/LICENSE,sha256=OWJejNf5MAITcVCDlKKxTjfNr_8M2ZxZKG3JPjsvor4,1063
|
|
18
|
+
tasko-0.0.1.dist-info/WHEEL,sha256=YR4QUxGCbsyoOnWBVTv-p1I4yc3seKGPev46mvmc8Cg,81
|
|
19
|
+
tasko-0.0.1.dist-info/entry_points.txt,sha256=idk-M3oFDezFCtGpJNLJlxTGkOJ0Cw1vjdZiAxVfv2Q,47
|
|
20
|
+
tasko-0.0.1.dist-info/METADATA,sha256=D0baOrY46DSGyq4rupgYSvhAVEdmBH7rUN6Dp31ErR4,1515
|
|
21
|
+
tasko-0.0.1.dist-info/RECORD,,
|