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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.29
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ tasko = tasko.cli.main:main
3
+