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 ADDED
@@ -0,0 +1 @@
1
+ """Personal task manager."""
tasko/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI adapter."""
tasko/cli/main.py ADDED
@@ -0,0 +1,89 @@
1
+ """CLI adapter: quick capture and a quick look at the list. The TUI is the primary interface."""
2
+
3
+ # ruff: noqa: T201 -- printing to stdout is this module's purpose: every command's user-facing
4
+ # output goes through print(); the rule targets stray debug prints, which cannot exist here.
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+
12
+ from tasko.core.core import Core
13
+ from tasko.core.errors import AppError
14
+ from tasko.core.models import Priority, Status
15
+
16
+ DATA_DIR_ENV_VAR = "TASKO_DATA_DIR" # overrides the default data directory
17
+
18
+
19
+ def main() -> None:
20
+ """Entry point: no subcommand launches the TUI; `add` and `list` serve capture and a quick look."""
21
+ args = _build_parser().parse_args()
22
+ core = Core(_resolve_db_path(args.data_dir))
23
+ try:
24
+ if args.command is None:
25
+ # Lazy import: quick capture (`tasko add`) must not pay the TUI framework's import cost.
26
+ from tasko.tui.app import TaskoApp # noqa: PLC0415
27
+
28
+ TaskoApp(core).run()
29
+ elif args.command == "add":
30
+ _cmd_add(core, args)
31
+ else:
32
+ _cmd_list(core, args)
33
+ except AppError as e:
34
+ sys.exit(f"error: {e}")
35
+ finally:
36
+ core.close()
37
+
38
+
39
+ def _resolve_db_path(data_dir: Path | None = None) -> Path:
40
+ """Path to the SQLite database file, resolving the data directory as CLI arg → env var → XDG default."""
41
+ if data_dir is None:
42
+ env = os.environ.get(DATA_DIR_ENV_VAR)
43
+ if env:
44
+ data_dir = Path(env)
45
+ else:
46
+ xdg_data_home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
47
+ data_dir = xdg_data_home / "tasko"
48
+ return data_dir / "tasko.db"
49
+
50
+
51
+ def _build_parser() -> argparse.ArgumentParser:
52
+ """Build the argument parser with the `add` and `list` subcommands."""
53
+ parser = argparse.ArgumentParser(prog="tasko", description="Personal task manager.")
54
+ parser.add_argument("--version", action="version", version=version("tasko"))
55
+ parser.add_argument("--data-dir", type=Path, help=f"data directory (env: {DATA_DIR_ENV_VAR}; default: XDG data dir)")
56
+ subparsers = parser.add_subparsers(dest="command")
57
+
58
+ add = subparsers.add_parser("add", help="add a task")
59
+ add.add_argument("title", help="task title")
60
+ add.add_argument("-p", "--project", help="project name (default: the default project)")
61
+ add.add_argument("-b", "--body", help="longer description")
62
+ add.add_argument("--priority", type=Priority, choices=list(Priority), default=Priority.MEDIUM, help="task priority")
63
+
64
+ list_ = subparsers.add_parser("list", help="list tasks (open ones by default)")
65
+ list_.add_argument("-p", "--project", help="filter by project name")
66
+ list_.add_argument("-s", "--status", type=Status, choices=list(Status), help="filter by status")
67
+ list_.add_argument("--all", action="store_true", help="include done tasks")
68
+ return parser
69
+
70
+
71
+ def _cmd_add(core: Core, args: argparse.Namespace) -> None:
72
+ """Create a task and confirm where it landed."""
73
+ project_id = core.get_project_by_name(args.project).id if args.project is not None else None
74
+ task = core.add_task(args.title, project_id=project_id, body=args.body, priority=args.priority)
75
+ print(f"Task #{task.id} created in project '{task.project_name}'.")
76
+
77
+
78
+ def _cmd_list(core: Core, args: argparse.Namespace) -> None:
79
+ """Print tasks as an aligned table."""
80
+ project_id = core.get_project_by_name(args.project).id if args.project is not None else None
81
+ tasks = core.list_tasks(project_id=project_id, status=args.status, include_done=args.all)
82
+ if not tasks:
83
+ print("No tasks.")
84
+ return
85
+ header = ("ID", "Project", "Status", "Priority", "Title")
86
+ rows = [(str(t.id), t.project_name, t.status, t.priority, t.title) for t in tasks]
87
+ widths = [max(len(row[i]) for row in (header, *rows)) for i in range(len(header))]
88
+ for row in (header, *rows):
89
+ print(" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip())
tasko/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core business logic — the application without UI."""
tasko/core/core.py ADDED
@@ -0,0 +1,267 @@
1
+ """Application core: SQLite storage and business operations in one class."""
2
+
3
+ import sqlite3
4
+ import time
5
+ from typing import TYPE_CHECKING
6
+
7
+ from tasko.core.errors import AppError
8
+ from tasko.core.migrations import MIGRATIONS
9
+ from tasko.core.models import AppState, Priority, Project, Status, Task
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ def _now() -> int:
16
+ """Return the current unix time in whole seconds."""
17
+ return int(time.time())
18
+
19
+
20
+ class Core:
21
+ """The application without UI: owns the SQLite connection and every operation on it.
22
+
23
+ Adapters (CLI, TUI) call these methods and show AppError messages to the user.
24
+ """
25
+
26
+ def __init__(self, db_path: Path) -> None:
27
+ """Open the database (creating the file and its directory if needed) and migrate it."""
28
+ db_path.parent.mkdir(parents=True, exist_ok=True)
29
+ self._conn = sqlite3.connect(db_path) # single shared connection
30
+ self._conn.row_factory = sqlite3.Row
31
+ self._conn.execute("PRAGMA journal_mode = WAL")
32
+ self._conn.execute("PRAGMA busy_timeout = 5000")
33
+ self._conn.execute("PRAGMA foreign_keys = ON")
34
+ self._migrate()
35
+
36
+ def close(self) -> None:
37
+ """Close the connection."""
38
+ self._conn.close()
39
+
40
+ def _migrate(self) -> None:
41
+ """Apply pending migrations, tracked via PRAGMA user_version."""
42
+ version = int(self._conn.execute("PRAGMA user_version").fetchone()[0])
43
+ for number, script in enumerate(MIGRATIONS[version:], start=version + 1):
44
+ # The script and the version bump commit together: a crash mid-migration rolls back
45
+ # cleanly, so a migration is either fully applied and recorded, or not at all.
46
+ self._conn.executescript(f"BEGIN;\n{script}\nPRAGMA user_version = {number};\nCOMMIT;")
47
+
48
+ # --- Tasks ---
49
+
50
+ def add_task(
51
+ self, title: str, *, project_id: int | None = None, body: str | None = None, priority: Priority = Priority.MEDIUM
52
+ ) -> Task:
53
+ """Create a task and return it. Without a project id, it goes to the selected or default project."""
54
+ title = title.strip()
55
+ if not title:
56
+ raise AppError("Task title cannot be empty.")
57
+ if project_id is None:
58
+ state = self.app_state()
59
+ project_id = state.selected_project_id or state.default_project_id
60
+ try:
61
+ row = self._conn.execute(
62
+ """
63
+ INSERT INTO tasks (project_id, title, body, priority, created_at, updated_at)
64
+ VALUES (:project_id, :title, :body, :priority, :now, :now)
65
+ RETURNING id
66
+ """,
67
+ {"project_id": project_id, "title": title, "body": body, "priority": priority, "now": _now()},
68
+ ).fetchone()
69
+ except sqlite3.IntegrityError as e: # foreign key: no such project
70
+ raise AppError(f"Project #{project_id} not found.") from e
71
+ self._conn.commit()
72
+ return self.get_task(int(row["id"]))
73
+
74
+ def get_task(self, task_id: int) -> Task:
75
+ """Return a task by id; raises AppError when it does not exist."""
76
+ row = self._conn.execute(
77
+ "SELECT t.*, p.name AS project_name FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.id = ?",
78
+ (task_id,),
79
+ ).fetchone()
80
+ if row is None:
81
+ raise AppError(f"Task #{task_id} not found.")
82
+ return Task.from_row(row)
83
+
84
+ def list_tasks(
85
+ self, *, project_id: int | None = None, status: Status | None = None, include_done: bool = False
86
+ ) -> list[Task]:
87
+ """Return tasks in working order, optionally filtered by project id and/or status; a None filter means "any".
88
+
89
+ Working order: doing first, then by priority, then recently updated; done tasks
90
+ sink to the bottom (recently finished first, since done_at == updated_at). They
91
+ are excluded by default; asking for status DONE explicitly implies them.
92
+ """
93
+ rows = self._conn.execute(
94
+ """
95
+ SELECT t.*, p.name AS project_name
96
+ FROM tasks t JOIN projects p ON p.id = t.project_id
97
+ WHERE (:project_id IS NULL OR t.project_id = :project_id)
98
+ AND (:status IS NULL OR t.status = :status)
99
+ AND (:include_done OR t.status != 'done')
100
+ ORDER BY t.status = 'done',
101
+ t.status = 'doing' DESC,
102
+ CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
103
+ t.updated_at DESC
104
+ """,
105
+ {"project_id": project_id, "status": status, "include_done": include_done or status is Status.DONE},
106
+ ).fetchall()
107
+ return [Task.from_row(row) for row in rows]
108
+
109
+ def project_task_counts(self) -> dict[int, tuple[int, int]]:
110
+ """Return {project_id: (open, total)} task counts; projects without tasks are absent."""
111
+ rows = self._conn.execute(
112
+ "SELECT project_id, sum(status != 'done') AS open, count(*) AS total FROM tasks GROUP BY project_id"
113
+ ).fetchall()
114
+ return {int(row["project_id"]): (int(row["open"]), int(row["total"])) for row in rows}
115
+
116
+ def set_status(self, task_id: int, status: Status) -> None:
117
+ """Move a task to the given status, maintaining done_at."""
118
+ now = _now() # one timestamp for both columns: on completion done_at == updated_at
119
+ done_at = now if status is Status.DONE else None
120
+ cur = self._conn.execute(
121
+ "UPDATE tasks SET status = :status, done_at = :done_at, updated_at = :now WHERE id = :id",
122
+ {"status": status, "done_at": done_at, "now": now, "id": task_id},
123
+ )
124
+ self._conn.commit()
125
+ if cur.rowcount == 0:
126
+ raise AppError(f"Task #{task_id} not found.")
127
+
128
+ def set_title(self, task_id: int, title: str) -> None:
129
+ """Rename a task; the new title must be non-empty."""
130
+ title = title.strip()
131
+ if not title:
132
+ raise AppError("Task title cannot be empty.")
133
+ cur = self._conn.execute(
134
+ "UPDATE tasks SET title = :title, updated_at = :now WHERE id = :id",
135
+ {"title": title, "now": _now(), "id": task_id},
136
+ )
137
+ self._conn.commit()
138
+ if cur.rowcount == 0:
139
+ raise AppError(f"Task #{task_id} not found.")
140
+
141
+ def set_body(self, task_id: int, body: str | None) -> None:
142
+ """Replace a task's body; empty or whitespace-only text clears it."""
143
+ if body is not None:
144
+ body = body.strip() or None
145
+ cur = self._conn.execute(
146
+ "UPDATE tasks SET body = :body, updated_at = :now WHERE id = :id",
147
+ {"body": body, "now": _now(), "id": task_id},
148
+ )
149
+ self._conn.commit()
150
+ if cur.rowcount == 0:
151
+ raise AppError(f"Task #{task_id} not found.")
152
+
153
+ def set_priority(self, task_id: int, priority: Priority) -> None:
154
+ """Change a task's priority."""
155
+ cur = self._conn.execute(
156
+ "UPDATE tasks SET priority = :priority, updated_at = :now WHERE id = :id",
157
+ {"priority": priority, "now": _now(), "id": task_id},
158
+ )
159
+ self._conn.commit()
160
+ if cur.rowcount == 0:
161
+ raise AppError(f"Task #{task_id} not found.")
162
+
163
+ def set_project(self, task_id: int, project_id: int) -> None:
164
+ """Move a task to another project."""
165
+ try:
166
+ cur = self._conn.execute(
167
+ "UPDATE tasks SET project_id = :project_id, updated_at = :now WHERE id = :id",
168
+ {"project_id": project_id, "now": _now(), "id": task_id},
169
+ )
170
+ except sqlite3.IntegrityError as e: # foreign key: no such project
171
+ raise AppError(f"Project #{project_id} not found.") from e
172
+ self._conn.commit()
173
+ if cur.rowcount == 0:
174
+ raise AppError(f"Task #{task_id} not found.")
175
+
176
+ def delete_task(self, task_id: int) -> None:
177
+ """Delete a task permanently."""
178
+ cur = self._conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
179
+ self._conn.commit()
180
+ if cur.rowcount == 0:
181
+ raise AppError(f"Task #{task_id} not found.")
182
+
183
+ # --- Projects ---
184
+
185
+ def list_projects(self) -> list[Project]:
186
+ """Return all projects ordered by name."""
187
+ rows = self._conn.execute("SELECT id, name FROM projects ORDER BY name").fetchall()
188
+ return [Project(**dict(row)) for row in rows]
189
+
190
+ def get_project(self, project_id: int) -> Project:
191
+ """Return a project by id; raises AppError when it does not exist."""
192
+ row = self._conn.execute("SELECT id, name FROM projects WHERE id = ?", (project_id,)).fetchone()
193
+ if row is None:
194
+ raise AppError(f"Project #{project_id} not found.")
195
+ return Project(**dict(row))
196
+
197
+ def get_project_by_name(self, name: str) -> Project:
198
+ """Return a project by its unique name; raises AppError when it does not exist.
199
+
200
+ For adapters whose input addresses projects by name (the CLI); everything else uses ids.
201
+ """
202
+ row = self._conn.execute("SELECT id, name FROM projects WHERE name = ?", (name,)).fetchone()
203
+ if row is None:
204
+ raise AppError(f"Project '{name}' not found.")
205
+ return Project(**dict(row))
206
+
207
+ def create_project(self, name: str) -> None:
208
+ """Create a project with a unique non-empty name."""
209
+ name = name.strip()
210
+ if not name:
211
+ raise AppError("Project name cannot be empty.")
212
+ try:
213
+ self._conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
214
+ except sqlite3.IntegrityError as e:
215
+ raise AppError(f"Project '{name}' already exists.") from e
216
+ self._conn.commit()
217
+
218
+ def rename_project(self, project_id: int, new_name: str) -> None:
219
+ """Rename a project; the new name must be unique and non-empty."""
220
+ new_name = new_name.strip()
221
+ if not new_name:
222
+ raise AppError("Project name cannot be empty.")
223
+ try:
224
+ cur = self._conn.execute("UPDATE projects SET name = ? WHERE id = ?", (new_name, project_id))
225
+ except sqlite3.IntegrityError as e:
226
+ raise AppError(f"Project '{new_name}' already exists.") from e
227
+ self._conn.commit()
228
+ if cur.rowcount == 0:
229
+ raise AppError(f"Project #{project_id} not found.")
230
+
231
+ def delete_project(self, project_id: int) -> None:
232
+ """Delete a project together with its tasks (ON DELETE CASCADE); the default project cannot be deleted."""
233
+ if project_id == self.app_state().default_project_id:
234
+ raise AppError("Cannot delete the default project; make another project the default first.")
235
+ cur = self._conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
236
+ self._conn.commit()
237
+ if cur.rowcount == 0:
238
+ raise AppError(f"Project #{project_id} not found.")
239
+
240
+ # --- App state ---
241
+
242
+ def app_state(self) -> AppState:
243
+ """Return the singleton application state."""
244
+ # Explicit columns, not *: the row also carries the singleton id, which AppState
245
+ # does not model and the dataclass constructor would reject.
246
+ row = self._conn.execute("SELECT default_project_id, selected_project_id, theme FROM app_state").fetchone()
247
+ if row is None: # invariant broken — the database was edited outside the app; fail loudly
248
+ raise RuntimeError("database invariant violated: app_state row missing")
249
+ return AppState(**dict(row))
250
+
251
+ def set_selected_project(self, project_id: int | None) -> None:
252
+ """Set the persisted project filter; None means all projects."""
253
+ self._conn.execute("UPDATE app_state SET selected_project_id = ? WHERE id = 1", (project_id,))
254
+ self._conn.commit()
255
+
256
+ def set_theme(self, theme: str) -> None:
257
+ """Persist the UI theme choice."""
258
+ self._conn.execute("UPDATE app_state SET theme = ? WHERE id = 1", (theme,))
259
+ self._conn.commit()
260
+
261
+ def set_default_project(self, project_id: int) -> None:
262
+ """Make the given project the default target for quick capture."""
263
+ try:
264
+ self._conn.execute("UPDATE app_state SET default_project_id = ? WHERE id = 1", (project_id,))
265
+ except sqlite3.IntegrityError as e: # foreign key: no such project
266
+ raise AppError(f"Project #{project_id} not found.") from e
267
+ self._conn.commit()
tasko/core/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """Application errors shared by all adapters."""
2
+
3
+
4
+ class AppError(Exception):
5
+ """Business-rule violation or bad input; adapters show the message to the user."""
@@ -0,0 +1,49 @@
1
+ """Database schema migrations, applied in order by Core."""
2
+
3
+ # Schema v1 (see docs/data-model.md). IF NOT EXISTS / OR IGNORE predate the atomic runner
4
+ # in Core._migrate and are harmless; new migrations don't need to be rerun-safe.
5
+ _MIGRATION_V1 = """
6
+ CREATE TABLE IF NOT EXISTS projects (
7
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ name TEXT NOT NULL UNIQUE CHECK (name <> '')
9
+ ) STRICT;
10
+
11
+ -- Singleton app state. default_project_id is NOT NULL with no ON DELETE action, so a
12
+ -- default project always exists and deleting it is refused by the database itself.
13
+ -- selected_project_id is the persisted UI project filter; deleting that project resets it.
14
+ -- theme has no CHECK: valid names come from the UI framework and change across its
15
+ -- versions; the TUI validates the value and falls back to its default.
16
+ CREATE TABLE IF NOT EXISTS app_state (
17
+ id INTEGER PRIMARY KEY CHECK (id = 1),
18
+ default_project_id INTEGER NOT NULL REFERENCES projects(id),
19
+ selected_project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
20
+ theme TEXT NOT NULL DEFAULT 'textual-dark'
21
+ ) STRICT;
22
+
23
+ CREATE TABLE IF NOT EXISTS tasks (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
26
+ title TEXT NOT NULL CHECK (title <> ''),
27
+ body TEXT,
28
+ status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'doing', 'done')),
29
+ priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
30
+ created_at INTEGER NOT NULL,
31
+ updated_at INTEGER NOT NULL,
32
+ done_at INTEGER,
33
+ CHECK ((status = 'done') = (done_at IS NOT NULL))
34
+ ) STRICT;
35
+
36
+ CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
37
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
38
+
39
+ -- Readable listing for manual DB inspection; the application does not use it.
40
+ CREATE VIEW IF NOT EXISTS tasks_v AS
41
+ SELECT t.id, p.name AS project, t.status, t.priority, t.title, t.created_at, t.done_at
42
+ FROM tasks t JOIN projects p ON p.id = t.project_id;
43
+
44
+ -- Seed: a fresh database always starts with one project, which is the default.
45
+ INSERT OR IGNORE INTO projects (name) VALUES ('inbox');
46
+ INSERT OR IGNORE INTO app_state (id, default_project_id) SELECT 1, id FROM projects WHERE name = 'inbox';
47
+ """
48
+
49
+ MIGRATIONS = (_MIGRATION_V1,) # append-only; index+1 = PRAGMA user_version
tasko/core/models.py ADDED
@@ -0,0 +1,65 @@
1
+ """Domain models: projects, tasks, and their enums."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import TYPE_CHECKING, Any, Self
6
+
7
+ if TYPE_CHECKING:
8
+ import sqlite3
9
+
10
+
11
+ class Status(StrEnum):
12
+ """Task lifecycle state; values match the TEXT stored in SQLite."""
13
+
14
+ TODO = "todo"
15
+ DOING = "doing"
16
+ DONE = "done"
17
+
18
+
19
+ class Priority(StrEnum):
20
+ """Task priority; values match the TEXT stored in SQLite."""
21
+
22
+ LOW = "low"
23
+ MEDIUM = "medium"
24
+ HIGH = "high"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Project:
29
+ """A project — the mandatory container every task belongs to."""
30
+
31
+ id: int # stable identity; the display name can change freely
32
+ name: str # unique, non-empty
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class Task:
37
+ """A single task, always scoped to a project."""
38
+
39
+ id: int # stable id, never reused (AUTOINCREMENT)
40
+ project_id: int # owning project
41
+ project_name: str # owning project's name, joined in by core queries
42
+ title: str # short summary, non-empty
43
+ body: str | None # optional long description
44
+ status: Status # current lifecycle state
45
+ priority: Priority # low / medium / high
46
+ created_at: int # unix seconds
47
+ updated_at: int # unix seconds; equals created_at until the first modification
48
+ done_at: int | None # unix seconds; set exactly when status is done
49
+
50
+ @classmethod
51
+ def from_row(cls, row: sqlite3.Row) -> Self:
52
+ """Build a Task from a database row, coercing the TEXT status/priority into enums."""
53
+ data: dict[str, Any] = dict(row)
54
+ data["status"] = Status(data["status"])
55
+ data["priority"] = Priority(data["priority"])
56
+ return cls(**data)
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class AppState:
61
+ """Singleton application state (the app_state table)."""
62
+
63
+ default_project_id: int # quick-capture target when no project is selected
64
+ selected_project_id: int | None # persisted project filter; None = all projects
65
+ theme: str # persisted UI theme name (a Textual built-in theme)
tasko/tui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """TUI adapter — the primary interface."""
tasko/tui/app.py ADDED
@@ -0,0 +1,74 @@
1
+ """Textual application: a thin shell that mounts the main screen."""
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from textual.app import App
6
+
7
+ from tasko.tui.screens.dialogs import HelpDialog
8
+ from tasko.tui.screens.projects import ProjectsScreen
9
+ from tasko.tui.screens.task_detail import TaskDetailScreen
10
+ from tasko.tui.screens.task_list import TaskListScreen
11
+
12
+ if TYPE_CHECKING:
13
+ from textual.binding import BindingType
14
+
15
+ from tasko.core.core import Core
16
+
17
+
18
+ class TaskoApp(App[None]):
19
+ """Interactive TUI for managing tasks."""
20
+
21
+ TITLE = "tasko" # terminal window/tab title
22
+
23
+ BINDINGS: ClassVar[list[BindingType]] = [("question_mark,i", "help", "Help")]
24
+
25
+ # One canvas: the screens share the table's background so content sits on a single surface.
26
+ # Deliberately not a bare `Screen` selector — that would also repaint the modal dialogs
27
+ # and kill their dimmed overlay.
28
+ CSS = """
29
+ TaskListScreen, TaskDetailScreen, ProjectsScreen {
30
+ background: $surface;
31
+ }
32
+ DataTable {
33
+ height: 1fr;
34
+ }
35
+ DataTable > .datatable--header {
36
+ background: transparent;
37
+ text-style: none;
38
+ color: $text-muted;
39
+ }
40
+ """
41
+
42
+ def __init__(self, core: Core) -> None:
43
+ """Wire the app to the shared core."""
44
+ super().__init__()
45
+ self._core = core # application core, shared with the CLI
46
+
47
+ def action_help(self) -> None:
48
+ """Show the current screen's keys; screens opt in by defining a HELP_KEYS attribute."""
49
+ rows = getattr(self.screen, "HELP_KEYS", None)
50
+ if rows:
51
+ self.push_screen(HelpDialog(rows))
52
+
53
+ def on_mount(self) -> None:
54
+ """Restore the persisted theme and mount the main screen."""
55
+ theme = self._core.app_state().theme
56
+ if theme in self.available_themes: # a stale name (theme gone from Textual) keeps the default
57
+ self.theme = theme
58
+ self.push_screen(TaskListScreen(self._core))
59
+
60
+ def watch_theme(self, theme: str) -> None:
61
+ """Persist every theme change (made via the ctrl+p command palette) across runs."""
62
+ self._core.set_theme(theme)
63
+
64
+ def on_app_focus(self) -> None:
65
+ """Reload the visible screen when the terminal regains focus.
66
+
67
+ This closes the quick-capture loop: `tasko add` from a shell, alt-tab back, the
68
+ list is fresh. Skipped while a dialog is on top — reloading under a modal could
69
+ fight the interaction in progress (r covers that case), and TaskDetailScreen.reload
70
+ pops the top screen when its task is gone, which must never hit a dialog.
71
+ """
72
+ screen = self.screen
73
+ if isinstance(screen, TaskListScreen | TaskDetailScreen | ProjectsScreen):
74
+ screen.reload()
@@ -0,0 +1 @@
1
+ """TUI screens."""