tasko 0.0.2__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.
- {tasko-0.0.2 → tasko-0.0.3}/PKG-INFO +1 -1
- tasko-0.0.3/pyproject.toml +121 -0
- tasko-0.0.2/pyproject.toml → tasko-0.0.3/pyproject.toml.orig +9 -9
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/core/core.py +5 -12
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/core/migrations.py +2 -2
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/core/models.py +2 -4
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/app.py +3 -6
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/screens/dialogs.py +10 -32
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/screens/projects.py +6 -10
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/screens/task_detail.py +9 -13
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/screens/task_list.py +19 -25
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/widgets.py +2 -6
- {tasko-0.0.2 → tasko-0.0.3}/LICENSE +0 -0
- {tasko-0.0.2 → tasko-0.0.3}/README.md +0 -0
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/__init__.py +0 -0
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/core/__init__.py +0 -0
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/core/errors.py +0 -0
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/main.py +0 -0
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/__init__.py +0 -0
- {tasko-0.0.2 → tasko-0.0.3}/src/tasko/tui/screens/__init__.py +0 -0
|
@@ -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.
|
|
6
|
+
version = "0.0.3"
|
|
7
7
|
description = "Personal task manager"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
authors = [{ name = "pybass" }]
|
|
@@ -35,7 +35,7 @@ tasko = "tasko.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.
|
|
38
|
+
requires = ["uv_build>=0.11,<0.13"]
|
|
39
39
|
build-backend = "uv_build"
|
|
40
40
|
|
|
41
41
|
|
|
@@ -45,7 +45,7 @@ build-backend = "uv_build"
|
|
|
45
45
|
[dependency-groups]
|
|
46
46
|
dev = [
|
|
47
47
|
# Linting & formatting
|
|
48
|
-
"ruff~=0.
|
|
48
|
+
"ruff~=0.16.2",
|
|
49
49
|
# Type checking
|
|
50
50
|
"mypy~=2.3.0",
|
|
51
51
|
# Testing & coverage
|
|
@@ -58,7 +58,7 @@ dev = [
|
|
|
58
58
|
# Dependency hygiene — imports vs declared deps
|
|
59
59
|
"deptry~=0.25.1",
|
|
60
60
|
# Git pre-commit hooks
|
|
61
|
-
"pre-commit~=4.6.
|
|
61
|
+
"pre-commit~=4.6.2",
|
|
62
62
|
]
|
|
63
63
|
|
|
64
64
|
|
|
@@ -74,7 +74,6 @@ select = ["ALL"]
|
|
|
74
74
|
ignore = [
|
|
75
75
|
# --- Ecosystem / tool conflicts ---
|
|
76
76
|
"COM812", # trailing-comma — handled by ruff formatter, must be off here
|
|
77
|
-
"ISC001", # single-line-implicit-string-concat — conflicts with ruff formatter
|
|
78
77
|
"D203", # one-blank-line-before-class — conflicts with D211
|
|
79
78
|
"D213", # multi-line-summary-second-line — conflicts with D212
|
|
80
79
|
|
|
@@ -94,6 +93,8 @@ ignore = [
|
|
|
94
93
|
"FIX", # flake8-fixme — TODOs are fine; tracked elsewhere
|
|
95
94
|
"FBT", # boolean-trap — too restrictive
|
|
96
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)
|
|
97
98
|
"TRY003", # raise-vanilla-args — too noisy
|
|
98
99
|
"C901", # complex-structure — judgment call
|
|
99
100
|
|
|
@@ -103,14 +104,13 @@ ignore = [
|
|
|
103
104
|
"PLR0913", # too-many-arguments
|
|
104
105
|
"PLR2004", # magic-value-comparison
|
|
105
106
|
|
|
106
|
-
# --- TODO metadata (optional) ---
|
|
107
|
-
"TD002", # missing-todo-author
|
|
108
|
-
"TD003", # missing-todo-link
|
|
109
|
-
|
|
110
107
|
# --- Async edge case ---
|
|
111
108
|
"ASYNC109", # async-function-with-timeout — debatable, off
|
|
112
109
|
]
|
|
113
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
|
+
|
|
114
114
|
[tool.ruff.lint.per-file-ignores]
|
|
115
115
|
"tests/**/*.py" = ["ANN", "S"] # tests skip type-annotation (ANN) and assert/security (S) rules
|
|
116
116
|
|
|
@@ -2,15 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import sqlite3
|
|
4
4
|
import time
|
|
5
|
-
from
|
|
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."""
|
|
@@ -82,28 +79,24 @@ class Core:
|
|
|
82
79
|
raise AppError(f"Task #{task_id} not found.")
|
|
83
80
|
return Task.from_row(row)
|
|
84
81
|
|
|
85
|
-
def list_tasks(
|
|
86
|
-
|
|
87
|
-
) -> list[Task]:
|
|
88
|
-
"""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.
|
|
89
84
|
|
|
90
85
|
Working order: doing first, then by priority, then recently updated; done tasks
|
|
91
|
-
sink to the bottom (recently finished first, since done_at == updated_at).
|
|
92
|
-
are excluded by default; asking for status DONE explicitly implies them.
|
|
86
|
+
sink to the bottom (recently finished first, since done_at == updated_at).
|
|
93
87
|
"""
|
|
94
88
|
rows = self._conn.execute(
|
|
95
89
|
"""
|
|
96
90
|
SELECT t.*, p.name AS project_name
|
|
97
91
|
FROM tasks t JOIN projects p ON p.id = t.project_id
|
|
98
92
|
WHERE (:project_id IS NULL OR t.project_id = :project_id)
|
|
99
|
-
AND (:status IS NULL OR t.status = :status)
|
|
100
93
|
AND (:include_done OR t.status != 'done')
|
|
101
94
|
ORDER BY t.status = 'done',
|
|
102
95
|
t.status = 'doing' DESC,
|
|
103
96
|
CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
|
|
104
97
|
t.updated_at DESC
|
|
105
98
|
""",
|
|
106
|
-
{"project_id": project_id, "
|
|
99
|
+
{"project_id": project_id, "include_done": include_done},
|
|
107
100
|
).fetchall()
|
|
108
101
|
return [Task.from_row(row) for row in rows]
|
|
109
102
|
|
|
@@ -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
|
|
4
|
-
# in Core._migrate
|
|
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
|
|
6
|
-
|
|
7
|
-
if TYPE_CHECKING:
|
|
8
|
-
import sqlite3
|
|
6
|
+
from typing import Any, Self
|
|
9
7
|
|
|
10
8
|
|
|
11
9
|
class Status(StrEnum):
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
"""Textual application: a thin shell that mounts the main screen."""
|
|
2
2
|
|
|
3
|
-
from typing import
|
|
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."""
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
28
|
-
Status.DOING: Text("
|
|
29
|
-
Status.DONE: Text("
|
|
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
|
|
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
|
|
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
|
-
|
|
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("
|
|
26
|
-
Status.DOING: Text("
|
|
27
|
-
Status.DONE: Text("
|
|
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"),
|
|
@@ -164,14 +161,8 @@ class TaskListScreen(Screen[None]):
|
|
|
164
161
|
falls back to the selected, then the default project. Wrong target? Escape, move
|
|
165
162
|
the cursor to a row of the right project, or change the project on the task later.
|
|
166
163
|
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
task = self._core.get_task(task_id)
|
|
170
|
-
project_id, name = task.project_id, task.project_name
|
|
171
|
-
else:
|
|
172
|
-
state = self._core.app_state()
|
|
173
|
-
project_id = state.selected_project_id or state.default_project_id
|
|
174
|
-
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
|
|
175
166
|
|
|
176
167
|
def on_result(title: str | None) -> None:
|
|
177
168
|
if not title:
|
|
@@ -192,12 +183,7 @@ class TaskListScreen(Screen[None]):
|
|
|
192
183
|
"""
|
|
193
184
|
options: list[tuple[str, str | Text]] = [(str(p.id), p.name) for p in self._core.list_projects()]
|
|
194
185
|
# Preselect the project plain 'a' would target, so Enter-Enter behaves like 'a'.
|
|
195
|
-
|
|
196
|
-
if task_id is not None:
|
|
197
|
-
current = str(self._core.get_task(task_id).project_id)
|
|
198
|
-
else:
|
|
199
|
-
state = self._core.app_state()
|
|
200
|
-
current = str(state.selected_project_id or state.default_project_id)
|
|
186
|
+
current = str(self._add_target_project_id())
|
|
201
187
|
|
|
202
188
|
def on_project(choice: str | None) -> None:
|
|
203
189
|
if choice is None:
|
|
@@ -223,6 +209,14 @@ class TaskListScreen(Screen[None]):
|
|
|
223
209
|
|
|
224
210
|
self.app.push_screen(SelectDialog("Add task to project:", options, current), on_project)
|
|
225
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
|
+
|
|
226
220
|
def _cursor_task_id(self) -> int | None:
|
|
227
221
|
"""Return the id of the task under the cursor, or None on an empty table.
|
|
228
222
|
|
|
@@ -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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|