tasko 0.0.1__tar.gz → 0.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasko
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Personal task manager
5
5
  Author: pybass
6
6
  License-Expression: MIT
@@ -17,30 +17,20 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  # tasko
19
19
 
20
- A personal task manager: a fast Textual TUI over local SQLite, with a minimal
21
- CLI for quick capture.
20
+ A personal task manager: a fast Textual TUI over local SQLite.
22
21
 
23
22
  ## Usage
24
23
 
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
- ```
24
+ `tasko` launches the TUI — the only interface, where all task and project
25
+ management lives.
35
26
 
36
27
  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.
28
+ `~/.local/share/tasko`); override the directory with `--data-dir`.
39
29
 
40
30
  ## Design
41
31
 
42
- - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `cli/` +
43
- `tui/`), the TUI-first UI strategy, tool choices.
32
+ - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `tui/`),
33
+ the TUI-only UI strategy, tool choices.
44
34
  - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
45
35
  and the reasoning behind it.
46
36
  - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
tasko-0.0.3/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # tasko
2
+
3
+ A personal task manager: a fast Textual TUI over local SQLite.
4
+
5
+ ## Usage
6
+
7
+ `tasko` launches the TUI — the only interface, where all task and project
8
+ management lives.
9
+
10
+ Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
11
+ `~/.local/share/tasko`); override the directory with `--data-dir`.
12
+
13
+ ## Design
14
+
15
+ - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `tui/`),
16
+ the TUI-only UI strategy, tool choices.
17
+ - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
18
+ and the reasoning behind it.
19
+ - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
20
+ and why.
21
+
22
+ ## License
23
+
24
+ [MIT](LICENSE)
@@ -0,0 +1,121 @@
1
+ [project]
2
+ name = "tasko"
3
+ version = "0.0.3"
4
+ description = "Personal task manager"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ classifiers = [
10
+ "Environment :: Console",
11
+ "Programming Language :: Python :: 3.14",
12
+ "Topic :: Office/Business",
13
+ ]
14
+ dependencies = [
15
+ "rich~=15.0",
16
+ "textual~=8.2",
17
+ ]
18
+
19
+ [[project.authors]]
20
+ name = "pybass"
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/pybass/tasko"
24
+ Issues = "https://github.com/pybass/tasko/issues"
25
+
26
+ [project.scripts]
27
+ tasko = "tasko.main:main"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.11,<0.13"]
31
+ build-backend = "uv_build"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "ruff~=0.16.2",
36
+ "mypy~=2.3.0",
37
+ "pytest~=9.1.1",
38
+ "pytest-asyncio~=1.4.0",
39
+ "pytest-cov~=7.1.0",
40
+ "pytest-xdist~=3.8.0",
41
+ "pip-audit~=2.10.1",
42
+ "deptry~=0.25.1",
43
+ "pre-commit~=4.6.2",
44
+ ]
45
+
46
+ [tool.ruff]
47
+ line-length = 130
48
+ target-version = "py314"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["ALL"]
52
+ ignore = [
53
+ "COM812",
54
+ "D203",
55
+ "D213",
56
+ "A005",
57
+ "PLC0414",
58
+ "RET503",
59
+ "G004",
60
+ "PT001",
61
+ "PT006",
62
+ "PT007",
63
+ "PT023",
64
+ "FIX",
65
+ "FBT",
66
+ "EM",
67
+ "CPY",
68
+ "TC",
69
+ "TRY003",
70
+ "C901",
71
+ "PLR0911",
72
+ "PLR0912",
73
+ "PLR0913",
74
+ "PLR2004",
75
+ "ASYNC109",
76
+ ]
77
+
78
+ [tool.ruff.lint.pep8-naming]
79
+ classmethod-decorators = ["pydantic.field_validator"]
80
+
81
+ [tool.ruff.lint.per-file-ignores]
82
+ "tests/**/*.py" = [
83
+ "ANN",
84
+ "S",
85
+ ]
86
+
87
+ [tool.ruff.format]
88
+ quote-style = "double"
89
+ indent-style = "space"
90
+ docstring-code-format = true
91
+
92
+ [tool.mypy]
93
+ python_version = "3.14"
94
+ strict = true
95
+
96
+ [[tool.mypy.overrides]]
97
+ module = "tests.*"
98
+ disallow_untyped_defs = false
99
+ disallow_incomplete_defs = false
100
+ disallow_untyped_decorators = false
101
+ disallow_untyped_calls = false
102
+ warn_unused_ignores = false
103
+
104
+ [tool.codespell]
105
+ skip = "uv.lock,.git,.venv,.mypy_cache,.ruff_cache,.pytest_cache,htmlcov"
106
+
107
+ [tool.pytest.ini_options]
108
+ testpaths = ["tests"]
109
+ addopts = "-ra --strict-markers --strict-config --import-mode=importlib"
110
+ xfail_strict = true
111
+ asyncio_mode = "auto"
112
+ asyncio_default_fixture_loop_scope = "function"
113
+ filterwarnings = ["error"]
114
+
115
+ [tool.coverage.run]
116
+ branch = true
117
+ source = ["src"]
118
+
119
+ [tool.coverage.report]
120
+ show_missing = true
121
+ skip_covered = false
@@ -3,7 +3,7 @@
3
3
  # ============================================================================
4
4
  [project]
5
5
  name = "tasko"
6
- version = "0.0.1"
6
+ version = "0.0.3"
7
7
  description = "Personal task manager"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "pybass" }]
@@ -25,7 +25,7 @@ Repository = "https://github.com/pybass/tasko"
25
25
  Issues = "https://github.com/pybass/tasko/issues"
26
26
 
27
27
  [project.scripts]
28
- tasko = "tasko.cli.main:main"
28
+ tasko = "tasko.main:main"
29
29
 
30
30
 
31
31
  # ============================================================================
@@ -35,7 +35,7 @@ tasko = "tasko.cli.main:main"
35
35
  # uv.lock does not cover, so an open-ended requirement would let the same tag build differently
36
36
  # over time.
37
37
  [build-system]
38
- requires = ["uv_build>=0.11,<0.12"]
38
+ requires = ["uv_build>=0.11,<0.13"]
39
39
  build-backend = "uv_build"
40
40
 
41
41
 
@@ -45,10 +45,9 @@ build-backend = "uv_build"
45
45
  [dependency-groups]
46
46
  dev = [
47
47
  # Linting & formatting
48
- "ruff~=0.15.22",
48
+ "ruff~=0.16.2",
49
49
  # Type checking
50
50
  "mypy~=2.3.0",
51
- "ty~=0.0.60",
52
51
  # Testing & coverage
53
52
  "pytest~=9.1.1",
54
53
  "pytest-asyncio~=1.4.0",
@@ -59,7 +58,7 @@ dev = [
59
58
  # Dependency hygiene — imports vs declared deps
60
59
  "deptry~=0.25.1",
61
60
  # Git pre-commit hooks
62
- "pre-commit~=4.6.0",
61
+ "pre-commit~=4.6.2",
63
62
  ]
64
63
 
65
64
 
@@ -75,7 +74,6 @@ select = ["ALL"]
75
74
  ignore = [
76
75
  # --- Ecosystem / tool conflicts ---
77
76
  "COM812", # trailing-comma — handled by ruff formatter, must be off here
78
- "ISC001", # single-line-implicit-string-concat — conflicts with ruff formatter
79
77
  "D203", # one-blank-line-before-class — conflicts with D211
80
78
  "D213", # multi-line-summary-second-line — conflicts with D212
81
79
 
@@ -95,6 +93,8 @@ ignore = [
95
93
  "FIX", # flake8-fixme — TODOs are fine; tracked elsewhere
96
94
  "FBT", # boolean-trap — too restrictive
97
95
  "EM", # error-message strings — inline messages accepted
96
+ "CPY", # flake8-copyright — no copyright headers: internal-only code, ships no license by design (README)
97
+ "TC", # flake8-type-checking — TYPE_CHECKING blocks save import time we don't care about; cost: import noise + broken runtime annotations (pydantic)
98
98
  "TRY003", # raise-vanilla-args — too noisy
99
99
  "C901", # complex-structure — judgment call
100
100
 
@@ -104,14 +104,13 @@ ignore = [
104
104
  "PLR0913", # too-many-arguments
105
105
  "PLR2004", # magic-value-comparison
106
106
 
107
- # --- TODO metadata (optional) ---
108
- "TD002", # missing-todo-author
109
- "TD003", # missing-todo-link
110
-
111
107
  # --- Async edge case ---
112
108
  "ASYNC109", # async-function-with-timeout — debatable, off
113
109
  ]
114
110
 
111
+ [tool.ruff.lint.pep8-naming]
112
+ classmethod-decorators = ["pydantic.field_validator"] # N805: @field_validator implies classmethod — cls is correct without a stacked @classmethod
113
+
115
114
  [tool.ruff.lint.per-file-ignores]
116
115
  "tests/**/*.py" = ["ANN", "S"] # tests skip type-annotation (ANN) and assert/security (S) rules
117
116
 
@@ -122,7 +121,7 @@ docstring-code-format = true # keep code examples in docstrings formatted like
122
121
 
123
122
 
124
123
  # ============================================================================
125
- # Type checking — mypy (strict) + ty
124
+ # Type checking — mypy (strict)
126
125
  # ============================================================================
127
126
  [tool.mypy]
128
127
  python_version = "3.14"
@@ -137,16 +136,6 @@ disallow_untyped_decorators = false
137
136
  disallow_untyped_calls = false
138
137
  warn_unused_ignores = false
139
138
 
140
- [tool.ty.environment]
141
- python-version = "3.14"
142
-
143
- # Same intent as the mypy test override: silence test-only false positives.
144
- [[tool.ty.overrides]]
145
- include = ["tests/**"]
146
- [tool.ty.overrides.rules]
147
- possibly-missing-attribute = "ignore"
148
- invalid-assignment = "ignore"
149
-
150
139
 
151
140
  # ============================================================================
152
141
  # Spell checking — codespell
@@ -2,15 +2,12 @@
2
2
 
3
3
  import sqlite3
4
4
  import time
5
- from typing import TYPE_CHECKING
5
+ from pathlib import Path
6
6
 
7
7
  from tasko.core.errors import AppError
8
8
  from tasko.core.migrations import MIGRATIONS
9
9
  from tasko.core.models import AppState, Priority, Project, Status, Task
10
10
 
11
- if TYPE_CHECKING:
12
- from pathlib import Path
13
-
14
11
 
15
12
  def _now() -> int:
16
13
  """Return the current unix time in whole seconds."""
@@ -20,13 +17,15 @@ def _now() -> int:
20
17
  class Core:
21
18
  """The application without UI: owns the SQLite connection and every operation on it.
22
19
 
23
- Adapters (CLI, TUI) call these methods and show AppError messages to the user.
20
+ Adapters (the TUI) call these methods and show AppError messages to the user.
24
21
  """
25
22
 
26
23
  def __init__(self, db_path: Path) -> None:
27
24
  """Open the database (creating the file and its directory if needed) and migrate it."""
28
25
  db_path.parent.mkdir(parents=True, exist_ok=True)
29
- self._conn = sqlite3.connect(db_path) # single shared connection
26
+ # autocommit: every write is a single self-committing statement; anything that ever
27
+ # needs multi-statement atomicity must use explicit BEGIN/COMMIT (as _migrate does).
28
+ self._conn = sqlite3.connect(db_path, autocommit=True) # single shared connection
30
29
  self._conn.row_factory = sqlite3.Row
31
30
  self._conn.execute("PRAGMA journal_mode = WAL")
32
31
  self._conn.execute("PRAGMA busy_timeout = 5000")
@@ -68,7 +67,6 @@ class Core:
68
67
  ).fetchone()
69
68
  except sqlite3.IntegrityError as e: # foreign key: no such project
70
69
  raise AppError(f"Project #{project_id} not found.") from e
71
- self._conn.commit()
72
70
  return self.get_task(int(row["id"]))
73
71
 
74
72
  def get_task(self, task_id: int) -> Task:
@@ -81,28 +79,24 @@ class Core:
81
79
  raise AppError(f"Task #{task_id} not found.")
82
80
  return Task.from_row(row)
83
81
 
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".
82
+ def list_tasks(self, *, project_id: int | None = None, include_done: bool = False) -> list[Task]:
83
+ """Return tasks in working order, optionally filtered to one project; done tasks appear only with include_done.
88
84
 
89
85
  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.
86
+ sink to the bottom (recently finished first, since done_at == updated_at).
92
87
  """
93
88
  rows = self._conn.execute(
94
89
  """
95
90
  SELECT t.*, p.name AS project_name
96
91
  FROM tasks t JOIN projects p ON p.id = t.project_id
97
92
  WHERE (:project_id IS NULL OR t.project_id = :project_id)
98
- AND (:status IS NULL OR t.status = :status)
99
93
  AND (:include_done OR t.status != 'done')
100
94
  ORDER BY t.status = 'done',
101
95
  t.status = 'doing' DESC,
102
96
  CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
103
97
  t.updated_at DESC
104
98
  """,
105
- {"project_id": project_id, "status": status, "include_done": include_done or status is Status.DONE},
99
+ {"project_id": project_id, "include_done": include_done},
106
100
  ).fetchall()
107
101
  return [Task.from_row(row) for row in rows]
108
102
 
@@ -121,7 +115,6 @@ class Core:
121
115
  "UPDATE tasks SET status = :status, done_at = :done_at, updated_at = :now WHERE id = :id",
122
116
  {"status": status, "done_at": done_at, "now": now, "id": task_id},
123
117
  )
124
- self._conn.commit()
125
118
  if cur.rowcount == 0:
126
119
  raise AppError(f"Task #{task_id} not found.")
127
120
 
@@ -134,7 +127,6 @@ class Core:
134
127
  "UPDATE tasks SET title = :title, updated_at = :now WHERE id = :id",
135
128
  {"title": title, "now": _now(), "id": task_id},
136
129
  )
137
- self._conn.commit()
138
130
  if cur.rowcount == 0:
139
131
  raise AppError(f"Task #{task_id} not found.")
140
132
 
@@ -146,7 +138,6 @@ class Core:
146
138
  "UPDATE tasks SET body = :body, updated_at = :now WHERE id = :id",
147
139
  {"body": body, "now": _now(), "id": task_id},
148
140
  )
149
- self._conn.commit()
150
141
  if cur.rowcount == 0:
151
142
  raise AppError(f"Task #{task_id} not found.")
152
143
 
@@ -156,7 +147,6 @@ class Core:
156
147
  "UPDATE tasks SET priority = :priority, updated_at = :now WHERE id = :id",
157
148
  {"priority": priority, "now": _now(), "id": task_id},
158
149
  )
159
- self._conn.commit()
160
150
  if cur.rowcount == 0:
161
151
  raise AppError(f"Task #{task_id} not found.")
162
152
 
@@ -169,14 +159,12 @@ class Core:
169
159
  )
170
160
  except sqlite3.IntegrityError as e: # foreign key: no such project
171
161
  raise AppError(f"Project #{project_id} not found.") from e
172
- self._conn.commit()
173
162
  if cur.rowcount == 0:
174
163
  raise AppError(f"Task #{task_id} not found.")
175
164
 
176
165
  def delete_task(self, task_id: int) -> None:
177
166
  """Delete a task permanently."""
178
167
  cur = self._conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
179
- self._conn.commit()
180
168
  if cur.rowcount == 0:
181
169
  raise AppError(f"Task #{task_id} not found.")
182
170
 
@@ -194,26 +182,16 @@ class Core:
194
182
  raise AppError(f"Project #{project_id} not found.")
195
183
  return Project(**dict(row))
196
184
 
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."""
185
+ def create_project(self, name: str) -> Project:
186
+ """Create a project with a unique non-empty name and return it."""
209
187
  name = name.strip()
210
188
  if not name:
211
189
  raise AppError("Project name cannot be empty.")
212
190
  try:
213
- self._conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
191
+ row = self._conn.execute("INSERT INTO projects (name) VALUES (?) RETURNING id", (name,)).fetchone()
214
192
  except sqlite3.IntegrityError as e:
215
193
  raise AppError(f"Project '{name}' already exists.") from e
216
- self._conn.commit()
194
+ return Project(id=row["id"], name=name)
217
195
 
218
196
  def rename_project(self, project_id: int, new_name: str) -> None:
219
197
  """Rename a project; the new name must be unique and non-empty."""
@@ -224,7 +202,6 @@ class Core:
224
202
  cur = self._conn.execute("UPDATE projects SET name = ? WHERE id = ?", (new_name, project_id))
225
203
  except sqlite3.IntegrityError as e:
226
204
  raise AppError(f"Project '{new_name}' already exists.") from e
227
- self._conn.commit()
228
205
  if cur.rowcount == 0:
229
206
  raise AppError(f"Project #{project_id} not found.")
230
207
 
@@ -233,7 +210,6 @@ class Core:
233
210
  if project_id == self.app_state().default_project_id:
234
211
  raise AppError("Cannot delete the default project; make another project the default first.")
235
212
  cur = self._conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
236
- self._conn.commit()
237
213
  if cur.rowcount == 0:
238
214
  raise AppError(f"Project #{project_id} not found.")
239
215
 
@@ -251,12 +227,10 @@ class Core:
251
227
  def set_selected_project(self, project_id: int | None) -> None:
252
228
  """Set the persisted project filter; None means all projects."""
253
229
  self._conn.execute("UPDATE app_state SET selected_project_id = ? WHERE id = 1", (project_id,))
254
- self._conn.commit()
255
230
 
256
231
  def set_theme(self, theme: str) -> None:
257
232
  """Persist the UI theme choice."""
258
233
  self._conn.execute("UPDATE app_state SET theme = ? WHERE id = 1", (theme,))
259
- self._conn.commit()
260
234
 
261
235
  def set_default_project(self, project_id: int) -> None:
262
236
  """Make the given project the default target for quick capture."""
@@ -264,4 +238,3 @@ class Core:
264
238
  self._conn.execute("UPDATE app_state SET default_project_id = ? WHERE id = 1", (project_id,))
265
239
  except sqlite3.IntegrityError as e: # foreign key: no such project
266
240
  raise AppError(f"Project #{project_id} not found.") from e
267
- self._conn.commit()
@@ -1,7 +1,7 @@
1
1
  """Database schema migrations, applied in order by Core."""
2
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.
3
+ # Schema v1 (see docs/data-model.md). IF NOT EXISTS / OR IGNORE make a rerun a no-op;
4
+ # with the atomic runner in Core._migrate, new migrations don't need to be rerun-safe.
5
5
  _MIGRATION_V1 = """
6
6
  CREATE TABLE IF NOT EXISTS projects (
7
7
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1,11 +1,9 @@
1
1
  """Domain models: projects, tasks, and their enums."""
2
2
 
3
+ import sqlite3
3
4
  from dataclasses import dataclass
4
5
  from enum import StrEnum
5
- from typing import TYPE_CHECKING, Any, Self
6
-
7
- if TYPE_CHECKING:
8
- import sqlite3
6
+ from typing import Any, Self
9
7
 
10
8
 
11
9
  class Status(StrEnum):
@@ -0,0 +1,33 @@
1
+ """Entry point: resolve the data directory, open the database, run the TUI."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+
9
+ from tasko.core.core import Core
10
+ from tasko.core.errors import AppError
11
+ from tasko.tui.app import TaskoApp
12
+
13
+
14
+ def main() -> None:
15
+ """Launch the TUI against the resolved database."""
16
+ parser = argparse.ArgumentParser(prog="tasko", description="Personal task manager.")
17
+ parser.add_argument("--version", action="version", version=version("tasko"))
18
+ parser.add_argument("--data-dir", type=Path, help="data directory (default: XDG data dir)")
19
+ args = parser.parse_args()
20
+ core = Core(_resolve_db_path(args.data_dir))
21
+ try:
22
+ TaskoApp(core).run()
23
+ except AppError as e:
24
+ sys.exit(f"error: {e}")
25
+ finally:
26
+ core.close()
27
+
28
+
29
+ def _resolve_db_path(data_dir: Path | None) -> Path:
30
+ """Database file path; without an explicit directory the XDG data dir is used."""
31
+ if data_dir is None:
32
+ data_dir = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "tasko"
33
+ return data_dir / "tasko.db"
@@ -1,19 +1,16 @@
1
1
  """Textual application: a thin shell that mounts the main screen."""
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar
3
+ from typing import ClassVar
4
4
 
5
5
  from textual.app import App
6
+ from textual.binding import BindingType
6
7
 
8
+ from tasko.core.core import Core
7
9
  from tasko.tui.screens.dialogs import HelpDialog
8
10
  from tasko.tui.screens.projects import ProjectsScreen
9
11
  from tasko.tui.screens.task_detail import TaskDetailScreen
10
12
  from tasko.tui.screens.task_list import TaskListScreen
11
13
 
12
- if TYPE_CHECKING:
13
- from textual.binding import BindingType
14
-
15
- from tasko.core.core import Core
16
-
17
14
 
18
15
  class TaskoApp(App[None]):
19
16
  """Interactive TUI for managing tasks."""
@@ -42,7 +39,7 @@ class TaskoApp(App[None]):
42
39
  def __init__(self, core: Core) -> None:
43
40
  """Wire the app to the shared core."""
44
41
  super().__init__()
45
- self._core = core # application core, shared with the CLI
42
+ self._core = core # application core, built by main() and owned by it
46
43
 
47
44
  def action_help(self) -> None:
48
45
  """Show the current screen's keys; screens opt in by defining a HELP_KEYS attribute."""
@@ -64,8 +61,9 @@ class TaskoApp(App[None]):
64
61
  def on_app_focus(self) -> None:
65
62
  """Reload the visible screen when the terminal regains focus.
66
63
 
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
64
+ The database can change under a running instance — a second tasko in another
65
+ terminal — so alt-tabbing back shows a fresh list rather than a stale one.
66
+ Skipped while a dialog is on top — reloading under a modal could
69
67
  fight the interaction in progress (r covers that case), and TaskDetailScreen.reload
70
68
  pops the top screen when its task is gone, which must never hit a dialog.
71
69
  """
@@ -1,11 +1,13 @@
1
- """Reusable modal dialogs: one-line text input, yes/no confirmation, option picking, and key help."""
1
+ """Reusable modal dialogs: one-line and multi-line text input, yes/no confirmation, option picking, and key help."""
2
2
 
3
+ from collections.abc import Sequence
3
4
  from importlib.metadata import version
4
- from typing import TYPE_CHECKING, ClassVar, Final
5
+ from typing import ClassVar, Final
5
6
 
6
7
  from rich.style import Style
7
8
  from rich.table import Table
8
9
  from rich.text import Text
10
+ from textual.app import ComposeResult
9
11
  from textual.binding import Binding, BindingType
10
12
  from textual.containers import Vertical
11
13
  from textual.fuzzy import FuzzySearch
@@ -13,19 +15,11 @@ from textual.screen import ModalScreen
13
15
  from textual.widgets import Input, Label, OptionList, Static, TextArea
14
16
  from textual.widgets.option_list import Option
15
17
 
16
- if TYPE_CHECKING:
17
- from collections.abc import Sequence
18
-
19
- from textual.app import ComposeResult
20
-
21
18
  _MATCH_STYLE: Final = Style(bold=True, underline=True) # characters of a label the fuzzy query matched
22
19
 
23
20
 
24
21
  class Dialog[ResultT](ModalScreen[ResultT]):
25
- """Base modal dialog: a centered bordered panel over the dimmed screen; Escape cancels.
26
-
27
- Subclasses define action_cancel with their own "cancelled" result.
28
- """
22
+ """Base modal dialog: a centered bordered panel over the dimmed screen; Escape cancels with None."""
29
23
 
30
24
  DEFAULT_CSS = """
31
25
  Dialog {
@@ -46,6 +40,10 @@ class Dialog[ResultT](ModalScreen[ResultT]):
46
40
 
47
41
  BINDINGS: ClassVar[list[BindingType]] = [("escape", "cancel", "Cancel")]
48
42
 
43
+ def action_cancel(self) -> None:
44
+ """Cancel the dialog without a result."""
45
+ self.dismiss(None)
46
+
49
47
 
50
48
  class InputDialog(Dialog[str | None]):
51
49
  """Prompt for one line of text; dismisses with the value, or None on cancel."""
@@ -58,7 +56,7 @@ class InputDialog(Dialog[str | None]):
58
56
  """
59
57
 
60
58
  def __init__(self, title: str, value: str = "") -> None:
61
- """Set the prompt title and the optional pre-filled value (used by rename)."""
59
+ """Set the prompt title and the optional pre-filled value."""
62
60
  super().__init__()
63
61
  self._title = title # prompt shown above the input
64
62
  self._value = value # initial input content
@@ -77,10 +75,6 @@ class InputDialog(Dialog[str | None]):
77
75
  """Enter accepts the value."""
78
76
  self.dismiss(event.value)
79
77
 
80
- def action_cancel(self) -> None:
81
- """Escape cancels without a value."""
82
- self.dismiss(None)
83
-
84
78
 
85
79
  class TextDialog(Dialog[str | None]):
86
80
  """Edit multi-line text; dismisses with the text on Ctrl+S, or None on cancel.
@@ -135,10 +129,6 @@ class TextDialog(Dialog[str | None]):
135
129
  """Ctrl+A selects the whole text, e.g. to replace or delete a long body at once."""
136
130
  self.query_one(TextArea).select_all()
137
131
 
138
- def action_cancel(self) -> None:
139
- """Escape cancels without a value."""
140
- self.dismiss(None)
141
-
142
132
 
143
133
  class ConfirmDialog(Dialog[bool]):
144
134
  """Ask a yes/no question; dismisses with True only on explicit confirmation."""
@@ -170,10 +160,6 @@ class ConfirmDialog(Dialog[bool]):
170
160
  """Confirm the action."""
171
161
  self.dismiss(True)
172
162
 
173
- def action_cancel(self) -> None:
174
- """Cancel the action."""
175
- self.dismiss(False)
176
-
177
163
 
178
164
  class HelpDialog(Dialog[None]):
179
165
  """Show the current screen's keys as a two-column list; Escape, ? or i closes it."""
@@ -207,10 +193,6 @@ class HelpDialog(Dialog[None]):
207
193
  yield Static(grid, id="keys")
208
194
  yield Label(f"tasko v{version('tasko')}", id="about")
209
195
 
210
- def action_cancel(self) -> None:
211
- """Close the help."""
212
- self.dismiss(None)
213
-
214
196
 
215
197
  class SelectDialog(Dialog[str | None]):
216
198
  """Pick one option with arrows and Enter; dismisses with the option id, or None on cancel.
@@ -336,7 +318,3 @@ class SelectDialog(Dialog[str | None]):
336
318
  def action_last(self) -> None:
337
319
  """Jump the list highlight to the last option without leaving the input."""
338
320
  self.query_one(OptionList).action_last()
339
-
340
- def action_cancel(self) -> None:
341
- """Escape cancels without a choice."""
342
- self.dismiss(None)
@@ -1,28 +1,24 @@
1
1
  """Projects screen: a table of projects with management actions."""
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar, Final
3
+ from collections.abc import Callable
4
+ from typing import ClassVar, Final
4
5
 
5
6
  from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.binding import BindingType
6
9
  from textual.screen import Screen
7
10
  from textual.widgets import DataTable
8
11
 
12
+ from tasko.core.core import Core
9
13
  from tasko.core.errors import AppError
10
14
  from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog
11
15
  from tasko.tui.widgets import StatusBar
12
16
 
13
- if TYPE_CHECKING:
14
- from collections.abc import Callable
15
-
16
- from textual.app import ComposeResult
17
- from textual.binding import BindingType
18
-
19
- from tasko.core.core import Core
20
-
21
17
  _DEFAULT_CELL: Final = Text("default", style="green") # marks the default project; space is not scarce here
22
18
 
23
19
 
24
20
  class ProjectsScreen(Screen[None]):
25
- """Project management — rarely visited, reached from the task screen with P."""
21
+ """Project management — rarely visited, reached from the task list with P."""
26
22
 
27
23
  BINDINGS: ClassVar[list[BindingType]] = [
28
24
  ("j", "cursor_down", "Down"),
@@ -1,32 +1,28 @@
1
1
  """Task detail screen: full details of one task with per-field editing."""
2
2
 
3
+ from collections.abc import Callable
3
4
  from datetime import UTC, datetime
4
- from typing import TYPE_CHECKING, ClassVar, Final
5
+ from typing import ClassVar, Final
5
6
 
6
7
  from rich.table import Table
7
8
  from rich.text import Text
9
+ from textual.app import ComposeResult
10
+ from textual.binding import BindingType
8
11
  from textual.containers import VerticalScroll
9
12
  from textual.screen import Screen
10
13
  from textual.widgets import Static
11
14
 
15
+ from tasko.core.core import Core
12
16
  from tasko.core.errors import AppError
13
17
  from tasko.core.models import Priority, Status, Task
14
18
  from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog, SelectDialog, TextDialog
15
19
  from tasko.tui.widgets import StatusBar
16
20
 
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
21
  # Same glyphs and colors as the table on the main screen, spelled out with the word.
26
22
  _STATUS_TEXT: Final = {
27
- Status.TODO: Text("○ todo", style="dim"),
28
- Status.DOING: Text("◐ doing", style="yellow"),
29
- Status.DONE: Text("● done", style="green"),
23
+ Status.TODO: Text("· todo", style="dim"),
24
+ Status.DOING: Text("» doing", style="yellow"),
25
+ Status.DONE: Text("✓ done", style="green"),
30
26
  }
31
27
  _PRIORITY_TEXT: Final = {
32
28
  Priority.HIGH: Text("high", style="red"),
@@ -123,7 +119,7 @@ class TaskDetailScreen(Screen[None]):
123
119
  self.query_one(StatusBar).set_context(Text.assemble(f"task #{task.id}", (f" · {task.project_name}", "dim")))
124
120
 
125
121
  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.
122
+ # Two column groups: live fields on the left (main-table order), dates on the right; all dim but the field values.
127
123
  meta = Table.grid(padding=(0, 2))
128
124
  meta.add_column(style="dim")
129
125
  meta.add_column()
@@ -1,12 +1,15 @@
1
1
  """Main screen: the task table."""
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar, Final
3
+ from typing import ClassVar, Final
4
4
 
5
5
  from rich.cells import cell_len
6
6
  from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.binding import BindingType
7
9
  from textual.screen import Screen
8
10
  from textual.widgets import DataTable
9
11
 
12
+ from tasko.core.core import Core
10
13
  from tasko.core.errors import AppError
11
14
  from tasko.core.models import Priority, Status
12
15
  from tasko.tui.screens.dialogs import ConfirmDialog, InputDialog, SelectDialog
@@ -14,17 +17,11 @@ from tasko.tui.screens.projects import ProjectsScreen
14
17
  from tasko.tui.screens.task_detail import TaskDetailScreen
15
18
  from tasko.tui.widgets import StatusBar
16
19
 
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.
20
+ # One-character, color-coded cells: the status, priority and body columns must not waste width.
24
21
  _STATUS_CELLS: Final = {
25
- Status.TODO: Text("○", style="dim"),
26
- Status.DOING: Text("◐", style="yellow"),
27
- Status.DONE: Text("●", style="green"),
22
+ Status.TODO: Text("·", style="dim"),
23
+ Status.DOING: Text("»", style="yellow"),
24
+ Status.DONE: Text("✓", style="green"),
28
25
  }
29
26
  _PRIORITY_CELLS: Final = {
30
27
  Priority.HIGH: Text("↑", style="red"),
@@ -46,6 +43,7 @@ class TaskListScreen(Screen[None]):
46
43
  ("j", "cursor_down", "Down"),
47
44
  ("k", "cursor_up", "Up"),
48
45
  ("a", "add", "Add"),
46
+ ("A", "add_with_project", "Add to project"),
49
47
  ("d", "toggle_done", "Done"),
50
48
  ("s", "toggle_doing", "Doing"),
51
49
  ("plus,equals_sign", "priority_up", "Priority up"), # = raises too: + is shifted on most layouts
@@ -64,6 +62,7 @@ class TaskListScreen(Screen[None]):
64
62
  ("↑ ↓ j k", "move the cursor"),
65
63
  ("enter", "open the task under the cursor"),
66
64
  ("a", "add a task into the cursor row's project"),
65
+ ("A", "add a task, choosing the project first"),
67
66
  ("d", "mark done / back to todo"),
68
67
  ("s", "mark doing / back to todo"),
69
68
  ("+ -", "raise / lower the priority"),
@@ -162,14 +161,8 @@ class TaskListScreen(Screen[None]):
162
161
  falls back to the selected, then the default project. Wrong target? Escape, move
163
162
  the cursor to a row of the right project, or change the project on the task later.
164
163
  """
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
164
+ project_id = self._add_target_project_id()
165
+ name = self._core.get_project(project_id).name
173
166
 
174
167
  def on_result(title: str | None) -> None:
175
168
  if not title:
@@ -183,6 +176,47 @@ class TaskListScreen(Screen[None]):
183
176
 
184
177
  self.app.push_screen(InputDialog(f'New task in "{name}":'), on_result)
185
178
 
179
+ def action_add_with_project(self) -> None:
180
+ """Pick the target project from the full list, then prompt for a title.
181
+
182
+ The escape hatch for 'a': used when no visible row carries the wanted project.
183
+ """
184
+ options: list[tuple[str, str | Text]] = [(str(p.id), p.name) for p in self._core.list_projects()]
185
+ # Preselect the project plain 'a' would target, so Enter-Enter behaves like 'a'.
186
+ current = str(self._add_target_project_id())
187
+
188
+ def on_project(choice: str | None) -> None:
189
+ if choice is None:
190
+ return
191
+ project_id = int(choice)
192
+ name = self._core.get_project(project_id).name
193
+
194
+ def on_title(title: str | None) -> None:
195
+ if not title:
196
+ return
197
+ try:
198
+ self._core.add_task(title, project_id=project_id)
199
+ except AppError as e:
200
+ self.notify(str(e), severity="error")
201
+ return
202
+ self.reload()
203
+ # The task is invisible when a different project filter is active — confirm it landed.
204
+ filter_id = self._core.app_state().selected_project_id
205
+ if filter_id is not None and filter_id != project_id:
206
+ self.notify(f'Added to "{name}"')
207
+
208
+ self.app.push_screen(InputDialog(f'New task in "{name}":'), on_title)
209
+
210
+ self.app.push_screen(SelectDialog("Add task to project:", options, current), on_project)
211
+
212
+ def _add_target_project_id(self) -> int:
213
+ """Project a plain add targets: the cursor row's project, else the selected, else the default one."""
214
+ task_id = self._cursor_task_id()
215
+ if task_id is not None:
216
+ return self._core.get_task(task_id).project_id
217
+ state = self._core.app_state()
218
+ return state.selected_project_id or state.default_project_id
219
+
186
220
  def _cursor_task_id(self) -> int | None:
187
221
  """Return the id of the task under the cursor, or None on an empty table.
188
222
 
@@ -297,7 +331,7 @@ class TaskListScreen(Screen[None]):
297
331
  self.reload()
298
332
 
299
333
  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)."""
334
+ """Reload the list: apply pending re-sorts/hides from d/s and pick up changes from another instance."""
301
335
  self.reload()
302
336
 
303
337
  def action_focus_project(self) -> None:
@@ -1,15 +1,11 @@
1
1
  """Shared TUI widgets."""
2
2
 
3
- from typing import TYPE_CHECKING
4
-
5
3
  from rich.text import Text
4
+ from textual.app import ComposeResult
6
5
  from textual.containers import Horizontal
6
+ from textual.visual import VisualType
7
7
  from textual.widgets import Label
8
8
 
9
- if TYPE_CHECKING:
10
- from textual.app import ComposeResult
11
- from textual.visual import VisualType
12
-
13
9
 
14
10
  class StatusBar(Horizontal):
15
11
  """One-line bottom bar: the screen's context on the left, the help key hint on the right.
tasko-0.0.1/README.md DELETED
@@ -1,34 +0,0 @@
1
- # tasko
2
-
3
- A personal task manager: a fast Textual TUI over local SQLite, with a minimal
4
- CLI for quick capture.
5
-
6
- ## Usage
7
-
8
- `tasko` with no arguments launches the TUI — the primary interface, where all
9
- task and project management lives.
10
-
11
- The CLI is deliberately minimal (see
12
- [docs/architecture.md](docs/architecture.md) for why):
13
-
14
- ```sh
15
- tasko add "fix nginx" -p infra # quick capture from the shell
16
- tasko list # a quick look without launching the TUI
17
- ```
18
-
19
- Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
20
- `~/.local/share/tasko`); override the directory with `--data-dir` or the
21
- `TASKO_DATA_DIR` env var.
22
-
23
- ## Design
24
-
25
- - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `cli/` +
26
- `tui/`), the TUI-first UI strategy, tool choices.
27
- - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
28
- and the reasoning behind it.
29
- - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
30
- and why.
31
-
32
- ## License
33
-
34
- [MIT](LICENSE)
@@ -1 +0,0 @@
1
- """CLI adapter."""
@@ -1,89 +0,0 @@
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())
File without changes
File without changes
File without changes
File without changes
File without changes