rook-cli 0.2.0__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.
- rook/__init__.py +1 -0
- rook/__main__.py +48 -0
- rook/app.py +222 -0
- rook/branding.py +69 -0
- rook/domain/__init__.py +0 -0
- rook/domain/tasks.py +55 -0
- rook/formatting.py +10 -0
- rook/paths.py +31 -0
- rook/persistence/__init__.py +0 -0
- rook/persistence/archive.py +72 -0
- rook/persistence/database.py +18 -0
- rook/persistence/metadata.py +56 -0
- rook/persistence/migrations.py +111 -0
- rook/persistence/tasks.py +185 -0
- rook/services/__init__.py +0 -0
- rook/services/rollover.py +94 -0
- rook/services/tasks.py +85 -0
- rook/services/undo.py +72 -0
- rook/symbols.py +40 -0
- rook/widgets/__init__.py +0 -0
- rook/widgets/archive_screen.py +162 -0
- rook/widgets/shortcut_footer.py +79 -0
- rook/widgets/task_line_input.py +44 -0
- rook/widgets/task_list.py +415 -0
- rook/widgets/task_row.py +155 -0
- rook_cli-0.2.0.dist-info/METADATA +120 -0
- rook_cli-0.2.0.dist-info/RECORD +30 -0
- rook_cli-0.2.0.dist-info/WHEEL +4 -0
- rook_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rook_cli-0.2.0.dist-info/licenses/LICENSE +10 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from datetime import date, timedelta
|
|
3
|
+
|
|
4
|
+
from rich.markup import escape
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import VerticalScroll
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
from rook import branding
|
|
12
|
+
from rook.domain.tasks import TaskState
|
|
13
|
+
from rook.persistence.archive import ArchiveRepository, week_start_for
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _format_week_range(week_start: date) -> str:
|
|
17
|
+
week_end = week_start + timedelta(days=6)
|
|
18
|
+
if week_start.year == week_end.year:
|
|
19
|
+
if week_start.month == week_end.month:
|
|
20
|
+
return f"{week_start:%B} {week_start.day}–{week_end.day}, {week_start:%Y}"
|
|
21
|
+
return f"{week_start:%B} {week_start.day}–{week_end:%B} {week_end.day}, {week_start:%Y}"
|
|
22
|
+
return (
|
|
23
|
+
f"{week_start:%B} {week_start.day}, {week_start:%Y}"
|
|
24
|
+
f"–{week_end:%B} {week_end.day}, {week_end:%Y}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArchiveScreen(Screen[None]):
|
|
29
|
+
"""Read-only weekly archive view (Section 21.14)."""
|
|
30
|
+
|
|
31
|
+
BINDINGS = [
|
|
32
|
+
Binding("escape", "go_today", "Today", show=False),
|
|
33
|
+
Binding("q", "go_today", "Today", show=False),
|
|
34
|
+
Binding("left", "prev_week", "Older", show=False),
|
|
35
|
+
Binding("right", "next_week", "Newer", show=False),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
CSS = """
|
|
39
|
+
ArchiveScreen {
|
|
40
|
+
layout: vertical;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ArchiveScreen #archive-header {
|
|
44
|
+
height: 1;
|
|
45
|
+
text-style: bold;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ArchiveScreen #archive-spacer {
|
|
49
|
+
height: 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ArchiveScreen #archive-scroll {
|
|
53
|
+
height: 1fr;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ArchiveScreen #archive-footer {
|
|
57
|
+
height: 1;
|
|
58
|
+
dock: bottom;
|
|
59
|
+
}
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, *, connection: sqlite3.Connection, first_weekday: int) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self._repo = ArchiveRepository(connection)
|
|
65
|
+
self._first_weekday = first_weekday
|
|
66
|
+
self._all_dates: list[date] = []
|
|
67
|
+
self._current_week_start: date | None = None
|
|
68
|
+
|
|
69
|
+
def compose(self) -> ComposeResult:
|
|
70
|
+
yield Static("", id="archive-header", markup=False)
|
|
71
|
+
yield Static("", id="archive-spacer")
|
|
72
|
+
yield VerticalScroll(
|
|
73
|
+
Static("", id="archive-content"),
|
|
74
|
+
id="archive-scroll",
|
|
75
|
+
)
|
|
76
|
+
yield Static("", id="archive-footer", markup=False)
|
|
77
|
+
|
|
78
|
+
def on_mount(self) -> None:
|
|
79
|
+
self._all_dates = self._repo.list_archive_dates()
|
|
80
|
+
if self._all_dates:
|
|
81
|
+
self._current_week_start = week_start_for(
|
|
82
|
+
self._all_dates[0], first_weekday=self._first_weekday
|
|
83
|
+
)
|
|
84
|
+
self._refresh_view()
|
|
85
|
+
|
|
86
|
+
def _refresh_view(self) -> None:
|
|
87
|
+
self._update_header()
|
|
88
|
+
self._update_content()
|
|
89
|
+
self._update_footer()
|
|
90
|
+
|
|
91
|
+
def _update_header(self) -> None:
|
|
92
|
+
header = self.query_one("#archive-header", Static)
|
|
93
|
+
if self._current_week_start is None:
|
|
94
|
+
header.update(f"{branding.DISPLAY_NAME.lower()} {branding.ICON} Archive")
|
|
95
|
+
else:
|
|
96
|
+
week_range = _format_week_range(self._current_week_start)
|
|
97
|
+
header.update(
|
|
98
|
+
f"{branding.DISPLAY_NAME.lower()} {branding.ICON} Archive — {week_range}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _update_content(self) -> None:
|
|
102
|
+
content = self.query_one("#archive-content", Static)
|
|
103
|
+
if self._current_week_start is None:
|
|
104
|
+
content.update("No archived tasks yet.")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
week_end = self._current_week_start + timedelta(days=7)
|
|
108
|
+
days = self._repo.list_week_items(self._current_week_start, week_end)
|
|
109
|
+
if not days:
|
|
110
|
+
content.update("No tasks archived this week.")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
lines: list[str] = []
|
|
114
|
+
for i, (day, tasks) in enumerate(days):
|
|
115
|
+
if i > 0:
|
|
116
|
+
lines.append("")
|
|
117
|
+
lines.append(f"{day:%A, %B} {day.day}")
|
|
118
|
+
for task in tasks:
|
|
119
|
+
safe_text = escape(task.text)
|
|
120
|
+
if task.state == TaskState.COMPLETED:
|
|
121
|
+
lines.append(f" × {safe_text}")
|
|
122
|
+
else:
|
|
123
|
+
lines.append(f" [strike]• {safe_text}[/strike]")
|
|
124
|
+
content.update("\n".join(lines))
|
|
125
|
+
|
|
126
|
+
def _update_footer(self) -> None:
|
|
127
|
+
footer = self.query_one("#archive-footer", Static)
|
|
128
|
+
parts: list[str] = []
|
|
129
|
+
if self._has_older_week():
|
|
130
|
+
parts.append("[←] older")
|
|
131
|
+
if self._has_newer_week():
|
|
132
|
+
parts.append("[→] newer")
|
|
133
|
+
parts.append("[Esc] today")
|
|
134
|
+
footer.update(" ".join(parts))
|
|
135
|
+
|
|
136
|
+
def _has_older_week(self) -> bool:
|
|
137
|
+
if not self._all_dates or self._current_week_start is None:
|
|
138
|
+
return False
|
|
139
|
+
return self._all_dates[-1] < self._current_week_start
|
|
140
|
+
|
|
141
|
+
def _has_newer_week(self) -> bool:
|
|
142
|
+
if not self._all_dates or self._current_week_start is None:
|
|
143
|
+
return False
|
|
144
|
+
return self._all_dates[0] >= self._current_week_start + timedelta(days=7)
|
|
145
|
+
|
|
146
|
+
def action_go_today(self) -> None:
|
|
147
|
+
self.app.pop_screen()
|
|
148
|
+
|
|
149
|
+
def action_prev_week(self) -> None:
|
|
150
|
+
if not self._has_older_week():
|
|
151
|
+
return
|
|
152
|
+
target = next(d for d in self._all_dates if d < self._current_week_start)
|
|
153
|
+
self._current_week_start = week_start_for(target, first_weekday=self._first_weekday)
|
|
154
|
+
self._refresh_view()
|
|
155
|
+
|
|
156
|
+
def action_next_week(self) -> None:
|
|
157
|
+
if not self._has_newer_week():
|
|
158
|
+
return
|
|
159
|
+
next_start = self._current_week_start + timedelta(days=7)
|
|
160
|
+
target = next(d for d in reversed(self._all_dates) if d >= next_start)
|
|
161
|
+
self._current_week_start = week_start_for(target, first_weekday=self._first_weekday)
|
|
162
|
+
self._refresh_view()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from textual import events
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class FooterVariant:
|
|
9
|
+
wide: str
|
|
10
|
+
medium: str
|
|
11
|
+
compact: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Section 9.14: the full Today footer, and its progressively shortened forms.
|
|
15
|
+
# Help overlay removed from the app (Section 10.14); [?] is not bound.
|
|
16
|
+
TODAY_FOOTER = FooterVariant(
|
|
17
|
+
wide=(
|
|
18
|
+
"[n] new [e/Ent] edit [x] complete [>] migrate [d] delete "
|
|
19
|
+
"[u] undo [a] archive [q] quit"
|
|
20
|
+
),
|
|
21
|
+
medium=(
|
|
22
|
+
"n new e/Ent edit x done > migrate d delete u undo a archive q quit"
|
|
23
|
+
),
|
|
24
|
+
compact="n e x > d u a q",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Section 10.15: Today's empty state omits keys that act on a Task, since
|
|
28
|
+
# none exists yet. The wide/medium/compact tiers follow the same shortening
|
|
29
|
+
# pattern as the populated footer.
|
|
30
|
+
TODAY_EMPTY_FOOTER = FooterVariant(
|
|
31
|
+
wide="[n] new [a] archive [q] quit",
|
|
32
|
+
medium="n new a archive q quit",
|
|
33
|
+
compact="n a q",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Section 10.7: while creating or editing a Task inline, the footer shows
|
|
37
|
+
# only the controls that apply to text entry.
|
|
38
|
+
EDITING_FOOTER_TEXT = "[Ent] save [Esc] cancel"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def select_footer_text(variant: FooterVariant, width: int) -> str:
|
|
42
|
+
"""The widest footer tier that fits without wrapping."""
|
|
43
|
+
for candidate in (variant.wide, variant.medium, variant.compact):
|
|
44
|
+
if len(candidate) <= width:
|
|
45
|
+
return candidate
|
|
46
|
+
return variant.compact
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ShortcutFooter(Static):
|
|
50
|
+
"""The one-row shortcut footer, responsive to available width."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, has_tasks: bool, id: str | None = None) -> None:
|
|
53
|
+
# markup=False: the "[key]" hints are literal text, not Rich console
|
|
54
|
+
# markup tags, so brackets like "[n]" must not be parsed as styling.
|
|
55
|
+
super().__init__(id=id, markup=False)
|
|
56
|
+
self._has_tasks = has_tasks
|
|
57
|
+
self._editing = False
|
|
58
|
+
|
|
59
|
+
def on_mount(self) -> None:
|
|
60
|
+
self._refresh_content()
|
|
61
|
+
|
|
62
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
63
|
+
self._refresh_content()
|
|
64
|
+
|
|
65
|
+
def set_editing(self, editing: bool) -> None:
|
|
66
|
+
self._editing = editing
|
|
67
|
+
self._refresh_content()
|
|
68
|
+
|
|
69
|
+
def set_has_tasks(self, has_tasks: bool) -> None:
|
|
70
|
+
self._has_tasks = has_tasks
|
|
71
|
+
self._refresh_content()
|
|
72
|
+
|
|
73
|
+
def _refresh_content(self) -> None:
|
|
74
|
+
if self._editing:
|
|
75
|
+
self.update(EDITING_FOOTER_TEXT)
|
|
76
|
+
return
|
|
77
|
+
variant = TODAY_FOOTER if self._has_tasks else TODAY_EMPTY_FOOTER
|
|
78
|
+
width = self.size.width or 80
|
|
79
|
+
self.update(select_footer_text(variant, width))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from textual import events
|
|
2
|
+
from textual.message import Message
|
|
3
|
+
from textual.widgets import Input
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskLineInput(Input):
|
|
7
|
+
"""A single-line text editor for inline Task creation and editing.
|
|
8
|
+
|
|
9
|
+
Extends Textual's ``Input`` with two Rook-specific behaviors:
|
|
10
|
+
pasted newlines normalize to spaces instead of truncating to the
|
|
11
|
+
first line (Section 18.6), and Backspace on already-empty content
|
|
12
|
+
is reported rather than silently doing nothing, so the owning
|
|
13
|
+
``TaskListView`` can cancel an in-progress new Task (Section 9.11).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
class EmptyBackspace(Message):
|
|
17
|
+
"""Posted when Backspace is pressed while the value is already empty."""
|
|
18
|
+
|
|
19
|
+
def _on_paste(self, event: events.Paste) -> None:
|
|
20
|
+
if not event.text:
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
# prevent_default() (not just stop()) is required: Textual calls
|
|
24
|
+
# every class's own _on_paste up the MRO in turn, so without it
|
|
25
|
+
# Input's own _on_paste would also run and insert its unnormalized
|
|
26
|
+
# first-line-only text right after ours.
|
|
27
|
+
event.stop()
|
|
28
|
+
event.prevent_default()
|
|
29
|
+
|
|
30
|
+
normalized = " ".join(event.text.split())
|
|
31
|
+
if not normalized:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
selection = self.selection
|
|
35
|
+
if selection.is_empty:
|
|
36
|
+
self.insert_text_at_cursor(normalized)
|
|
37
|
+
else:
|
|
38
|
+
self.replace(normalized, *selection)
|
|
39
|
+
|
|
40
|
+
def action_delete_left(self) -> None:
|
|
41
|
+
if not self.value:
|
|
42
|
+
self.post_message(self.EmptyBackspace())
|
|
43
|
+
return
|
|
44
|
+
super().action_delete_left()
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding, BindingType
|
|
6
|
+
from textual.containers import VerticalScroll
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
from textual.widgets import Input, Static
|
|
9
|
+
|
|
10
|
+
from rook.domain.tasks import Task, TaskState, initial_selection
|
|
11
|
+
from rook.domain.tasks import toggle_completed as compute_toggled_completed
|
|
12
|
+
from rook.domain.tasks import toggle_migrated as compute_toggled_migrated
|
|
13
|
+
from rook.services.tasks import PersistenceError, TaskService
|
|
14
|
+
from rook.services.undo import (
|
|
15
|
+
DeleteCreatedTask,
|
|
16
|
+
RestoreTaskSnapshot,
|
|
17
|
+
RestoreTaskState,
|
|
18
|
+
RestoreTaskText,
|
|
19
|
+
UndoManager,
|
|
20
|
+
)
|
|
21
|
+
from rook.widgets.task_line_input import TaskLineInput
|
|
22
|
+
from rook.widgets.task_row import TaskRow
|
|
23
|
+
|
|
24
|
+
EMPTY_TODAY_MESSAGE = "No tasks yet. Press n to write the first one."
|
|
25
|
+
|
|
26
|
+
# A brand new, not-yet-saved Task has no database id yet. SQLite's
|
|
27
|
+
# AUTOINCREMENT never assigns 0 or a negative id, so this sentinel can't
|
|
28
|
+
# collide with a real Task while the blank row is still being typed.
|
|
29
|
+
_NEW_TASK_SENTINEL_ID = -1
|
|
30
|
+
|
|
31
|
+
_SAVE_FAILED_MESSAGE = "Could not save. Your change was not applied."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TaskListView(VerticalScroll):
|
|
35
|
+
"""Today's scrollable task list with a single bounded selection cursor.
|
|
36
|
+
|
|
37
|
+
Selection is tracked by Task id rather than row index (Section 21.8),
|
|
38
|
+
so it stays meaningful if the underlying task order ever changes.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# This container itself never takes focus (see can_focus=False below),
|
|
42
|
+
# but a binding declared here still applies while a descendant (the
|
|
43
|
+
# TaskLineInput being edited) has focus, because Textual's binding
|
|
44
|
+
# resolution walks up the full ancestor chain from the focused widget.
|
|
45
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
46
|
+
Binding("escape", "cancel_edit", "Cancel", show=False),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
class StatusMessage(Message):
|
|
50
|
+
"""A one-line status hint for the App to display (Section 10.16)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, text: str) -> None:
|
|
53
|
+
self.text = text
|
|
54
|
+
super().__init__()
|
|
55
|
+
|
|
56
|
+
class EditingChanged(Message):
|
|
57
|
+
"""Whether a Task row is currently being created or edited."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, editing: bool) -> None:
|
|
60
|
+
self.editing = editing
|
|
61
|
+
super().__init__()
|
|
62
|
+
|
|
63
|
+
class TasksEmptyChanged(Message):
|
|
64
|
+
"""Whether the list has transitioned to/from having no Tasks."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, has_tasks: bool) -> None:
|
|
67
|
+
self.has_tasks = has_tasks
|
|
68
|
+
super().__init__()
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
tasks: Sequence[Task],
|
|
73
|
+
*,
|
|
74
|
+
task_service: TaskService,
|
|
75
|
+
undo_manager: UndoManager,
|
|
76
|
+
safe_symbols: bool = False,
|
|
77
|
+
id: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
# can_focus=False: ScrollableContainer is focusable and binds
|
|
80
|
+
# up/down to its own scroll_up/scroll_down actions (inherited
|
|
81
|
+
# bindings can't be cleared by overriding BINDINGS in a subclass -
|
|
82
|
+
# Textual's binding resolution merges the whole class hierarchy).
|
|
83
|
+
# Once this container is scrollable, that would swallow the key
|
|
84
|
+
# before it ever reaches RookApp's selection bindings. Scrolling
|
|
85
|
+
# here must be a side effect of moving the selection (Section
|
|
86
|
+
# 6.4), not an independent action, so this widget never takes
|
|
87
|
+
# focus and arrow keys go straight to the App.
|
|
88
|
+
super().__init__(id=id, can_focus=False)
|
|
89
|
+
self._tasks = list(tasks)
|
|
90
|
+
self._task_service = task_service
|
|
91
|
+
self._undo_manager = undo_manager
|
|
92
|
+
self._safe_symbols = safe_symbols
|
|
93
|
+
self.selected_task_id: int | None = initial_selection(self._tasks)
|
|
94
|
+
self._editing_task_id: int | None = None
|
|
95
|
+
self._creating = False
|
|
96
|
+
self._pending_edit_value = ""
|
|
97
|
+
self._pre_edit_selected_task_id: int | None = None
|
|
98
|
+
self._has_tasks = bool(self._tasks)
|
|
99
|
+
|
|
100
|
+
def compose(self) -> ComposeResult:
|
|
101
|
+
if not self._tasks:
|
|
102
|
+
yield Static(f" {EMPTY_TODAY_MESSAGE}", markup=False)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
for task in self._tasks:
|
|
106
|
+
is_editing_this = task.id == self._editing_task_id
|
|
107
|
+
yield TaskRow(
|
|
108
|
+
task,
|
|
109
|
+
selected=(task.id == self.selected_task_id),
|
|
110
|
+
safe_symbols=self._safe_symbols,
|
|
111
|
+
editing=is_editing_this,
|
|
112
|
+
edit_value=self._pending_edit_value if is_editing_this else "",
|
|
113
|
+
id=f"task-row-{task.id}",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_editing(self) -> bool:
|
|
118
|
+
"""Whether a Task row is currently being created or edited
|
|
119
|
+
(Section 18.12: a live Day Rollover must defer while this is true).
|
|
120
|
+
"""
|
|
121
|
+
return self._editing_task_id is not None
|
|
122
|
+
|
|
123
|
+
async def reload(self, tasks: Sequence[Task]) -> None:
|
|
124
|
+
"""Replace the active Task list after a live Day Rollover
|
|
125
|
+
(Section 6.15), preserving selection by Task id where possible."""
|
|
126
|
+
self._tasks = list(tasks)
|
|
127
|
+
if self._index_of_selected() is None:
|
|
128
|
+
self.selected_task_id = initial_selection(self._tasks)
|
|
129
|
+
self._sync_has_tasks()
|
|
130
|
+
await self.recompose()
|
|
131
|
+
self._apply_selection()
|
|
132
|
+
|
|
133
|
+
# --- Navigation (Phase 3) -------------------------------------------
|
|
134
|
+
|
|
135
|
+
def select_previous(self) -> None:
|
|
136
|
+
if self._editing_task_id is not None:
|
|
137
|
+
return
|
|
138
|
+
self._move_selection(-1)
|
|
139
|
+
|
|
140
|
+
def select_next(self) -> None:
|
|
141
|
+
if self._editing_task_id is not None:
|
|
142
|
+
return
|
|
143
|
+
self._move_selection(1)
|
|
144
|
+
|
|
145
|
+
def _move_selection(self, delta: int) -> None:
|
|
146
|
+
index = self._index_of_selected()
|
|
147
|
+
if index is None:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
new_index = max(0, min(len(self._tasks) - 1, index + delta))
|
|
151
|
+
if new_index == index:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
self.selected_task_id = self._tasks[new_index].id
|
|
155
|
+
self._apply_selection()
|
|
156
|
+
|
|
157
|
+
def _index_of_selected(self) -> int | None:
|
|
158
|
+
for index, task in enumerate(self._tasks):
|
|
159
|
+
if task.id == self.selected_task_id:
|
|
160
|
+
return index
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _apply_selection(self) -> None:
|
|
164
|
+
for row in self.query(TaskRow):
|
|
165
|
+
row.set_selected(row.item.id == self.selected_task_id)
|
|
166
|
+
if row.selected:
|
|
167
|
+
row.scroll_visible(animate=False)
|
|
168
|
+
|
|
169
|
+
def _scroll_to_editing_row(self) -> None:
|
|
170
|
+
try:
|
|
171
|
+
row = self.query_one(f"#task-row-{_NEW_TASK_SENTINEL_ID}", TaskRow)
|
|
172
|
+
row.scroll_visible(animate=False)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# --- Creation and editing (Phase 4/5) --------------------------------
|
|
177
|
+
|
|
178
|
+
async def begin_create(self) -> None:
|
|
179
|
+
if self._editing_task_id is not None:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
self._pre_edit_selected_task_id = self.selected_task_id
|
|
183
|
+
self._open_blank_row()
|
|
184
|
+
self._creating = True
|
|
185
|
+
self.post_message(self.EditingChanged(True))
|
|
186
|
+
await self.recompose()
|
|
187
|
+
self._scroll_to_editing_row()
|
|
188
|
+
|
|
189
|
+
async def begin_edit(self) -> None:
|
|
190
|
+
if self._editing_task_id is not None:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
index = self._index_of_selected()
|
|
194
|
+
if index is None:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
task = self._tasks[index]
|
|
198
|
+
self._editing_task_id = task.id
|
|
199
|
+
self._creating = False
|
|
200
|
+
self._pending_edit_value = task.text
|
|
201
|
+
self.post_message(self.EditingChanged(True))
|
|
202
|
+
await self.recompose()
|
|
203
|
+
|
|
204
|
+
async def action_cancel_edit(self) -> None:
|
|
205
|
+
if self._editing_task_id is None:
|
|
206
|
+
return
|
|
207
|
+
if self._creating:
|
|
208
|
+
await self._cancel_creation()
|
|
209
|
+
else:
|
|
210
|
+
await self._exit_editing()
|
|
211
|
+
|
|
212
|
+
async def on_input_submitted(self, message: Input.Submitted) -> None:
|
|
213
|
+
message.stop()
|
|
214
|
+
editing_task_id = self._editing_task_id
|
|
215
|
+
if editing_task_id is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
is_blank = message.value.strip() == ""
|
|
219
|
+
|
|
220
|
+
if self._creating:
|
|
221
|
+
if is_blank:
|
|
222
|
+
# Section 6.5: Enter on a blank bullet ends the chain.
|
|
223
|
+
await self._cancel_creation()
|
|
224
|
+
else:
|
|
225
|
+
await self._save_new_task(message.value)
|
|
226
|
+
elif is_blank:
|
|
227
|
+
self.post_message(self.StatusMessage("Task cannot be blank."))
|
|
228
|
+
else:
|
|
229
|
+
await self._save_edited_task(editing_task_id, message.value)
|
|
230
|
+
|
|
231
|
+
async def on_task_line_input_empty_backspace(
|
|
232
|
+
self, message: TaskLineInput.EmptyBackspace
|
|
233
|
+
) -> None:
|
|
234
|
+
message.stop()
|
|
235
|
+
# Section 9.5: Backspace on an already-empty *existing* Task edit
|
|
236
|
+
# must not delete or exit. Only a brand new, unsaved Task cancels.
|
|
237
|
+
if self._creating:
|
|
238
|
+
await self._cancel_creation()
|
|
239
|
+
|
|
240
|
+
async def _save_new_task(self, text: str) -> None:
|
|
241
|
+
try:
|
|
242
|
+
created = self._task_service.create_task(text)
|
|
243
|
+
except PersistenceError:
|
|
244
|
+
self.post_message(self.StatusMessage(_SAVE_FAILED_MESSAGE))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
index = self._index_of_selected()
|
|
248
|
+
if index is not None:
|
|
249
|
+
self._tasks[index] = created
|
|
250
|
+
self._undo_manager.record(DeleteCreatedTask(created.id))
|
|
251
|
+
|
|
252
|
+
# Section 6.5: stay in creation mode and open another blank bullet,
|
|
253
|
+
# so several Tasks can be written in a row with a single `n` (the
|
|
254
|
+
# paper-bullet-journal behavior of Section 1.5/2.2). Cancelling
|
|
255
|
+
# this next blank restores selection to the Task just saved, not
|
|
256
|
+
# all the way back to the pre-chain selection.
|
|
257
|
+
self._pre_edit_selected_task_id = created.id
|
|
258
|
+
self._open_blank_row()
|
|
259
|
+
await self.recompose()
|
|
260
|
+
self._scroll_to_editing_row()
|
|
261
|
+
|
|
262
|
+
async def _save_edited_task(self, task_id: int, text: str) -> None:
|
|
263
|
+
previous_text = next((task.text for task in self._tasks if task.id == task_id), None)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
updated = self._task_service.update_task_text(task_id, text)
|
|
267
|
+
except PersistenceError:
|
|
268
|
+
self.post_message(self.StatusMessage(_SAVE_FAILED_MESSAGE))
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
for index, task in enumerate(self._tasks):
|
|
272
|
+
if task.id == updated.id:
|
|
273
|
+
self._tasks[index] = updated
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
if previous_text is not None:
|
|
277
|
+
self._undo_manager.record(RestoreTaskText(task_id, previous_text))
|
|
278
|
+
await self._exit_editing()
|
|
279
|
+
|
|
280
|
+
def _open_blank_row(self) -> None:
|
|
281
|
+
self._tasks.append(Task(id=_NEW_TASK_SENTINEL_ID, text="", state=TaskState.OPEN))
|
|
282
|
+
self.selected_task_id = _NEW_TASK_SENTINEL_ID
|
|
283
|
+
self._editing_task_id = _NEW_TASK_SENTINEL_ID
|
|
284
|
+
self._pending_edit_value = ""
|
|
285
|
+
self._sync_has_tasks()
|
|
286
|
+
|
|
287
|
+
async def _cancel_creation(self) -> None:
|
|
288
|
+
self._tasks = [task for task in self._tasks if task.id != _NEW_TASK_SENTINEL_ID]
|
|
289
|
+
self.selected_task_id = self._pre_edit_selected_task_id
|
|
290
|
+
self._sync_has_tasks()
|
|
291
|
+
await self._exit_editing()
|
|
292
|
+
|
|
293
|
+
async def _exit_editing(self) -> None:
|
|
294
|
+
self._editing_task_id = None
|
|
295
|
+
self._creating = False
|
|
296
|
+
self._pending_edit_value = ""
|
|
297
|
+
self.post_message(self.EditingChanged(False))
|
|
298
|
+
await self.recompose()
|
|
299
|
+
|
|
300
|
+
def _sync_has_tasks(self) -> None:
|
|
301
|
+
has_tasks = bool(self._tasks)
|
|
302
|
+
if has_tasks != self._has_tasks:
|
|
303
|
+
self._has_tasks = has_tasks
|
|
304
|
+
self.post_message(self.TasksEmptyChanged(has_tasks))
|
|
305
|
+
|
|
306
|
+
# --- Task state changes (Phase 6) ------------------------------------
|
|
307
|
+
|
|
308
|
+
async def toggle_completed(self) -> None:
|
|
309
|
+
await self._change_state(compute_toggled_completed)
|
|
310
|
+
|
|
311
|
+
async def toggle_migrated(self) -> None:
|
|
312
|
+
await self._change_state(compute_toggled_migrated)
|
|
313
|
+
|
|
314
|
+
async def delete_or_remove(self) -> None:
|
|
315
|
+
if self._editing_task_id is not None:
|
|
316
|
+
return
|
|
317
|
+
index = self._index_of_selected()
|
|
318
|
+
if index is None:
|
|
319
|
+
return
|
|
320
|
+
task = self._tasks[index]
|
|
321
|
+
|
|
322
|
+
if task.state is not TaskState.DELETED:
|
|
323
|
+
await self._change_state(lambda _state: TaskState.DELETED)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
snapshot = self._task_service.delete_task(task.id)
|
|
328
|
+
except PersistenceError:
|
|
329
|
+
self.post_message(self.StatusMessage(_SAVE_FAILED_MESSAGE))
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
del self._tasks[index]
|
|
333
|
+
self.selected_task_id = self._select_after_removal(index)
|
|
334
|
+
self._undo_manager.record(RestoreTaskSnapshot(index=index, snapshot=snapshot))
|
|
335
|
+
self._sync_has_tasks()
|
|
336
|
+
await self.recompose()
|
|
337
|
+
|
|
338
|
+
async def _change_state(self, transform: Callable[[TaskState], TaskState]) -> None:
|
|
339
|
+
if self._editing_task_id is not None:
|
|
340
|
+
return
|
|
341
|
+
index = self._index_of_selected()
|
|
342
|
+
if index is None:
|
|
343
|
+
return
|
|
344
|
+
task = self._tasks[index]
|
|
345
|
+
previous_state = task.state
|
|
346
|
+
new_state = transform(previous_state)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
updated = self._task_service.set_task_state(task.id, new_state)
|
|
350
|
+
except PersistenceError:
|
|
351
|
+
self.post_message(self.StatusMessage(_SAVE_FAILED_MESSAGE))
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
self._undo_manager.record(RestoreTaskState(task.id, previous_state))
|
|
355
|
+
self._replace_task_in_place(updated)
|
|
356
|
+
|
|
357
|
+
def _select_after_removal(self, removed_index: int) -> int | None:
|
|
358
|
+
if removed_index < len(self._tasks):
|
|
359
|
+
return self._tasks[removed_index].id
|
|
360
|
+
if removed_index > 0:
|
|
361
|
+
return self._tasks[removed_index - 1].id
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
# --- Undo (Phase 7) ---------------------------------------------------
|
|
365
|
+
|
|
366
|
+
async def undo(self) -> None:
|
|
367
|
+
if self._editing_task_id is not None:
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
command = self._undo_manager.take()
|
|
371
|
+
if command is None:
|
|
372
|
+
self.post_message(self.StatusMessage("Nothing to undo."))
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
if isinstance(command, DeleteCreatedTask):
|
|
377
|
+
self._task_service.discard_created_task(command.task_id)
|
|
378
|
+
index = next(
|
|
379
|
+
(i for i, task in enumerate(self._tasks) if task.id == command.task_id),
|
|
380
|
+
None,
|
|
381
|
+
)
|
|
382
|
+
if index is not None:
|
|
383
|
+
del self._tasks[index]
|
|
384
|
+
self.selected_task_id = self._select_after_removal(index)
|
|
385
|
+
self._sync_has_tasks()
|
|
386
|
+
await self.recompose()
|
|
387
|
+
|
|
388
|
+
elif isinstance(command, RestoreTaskText):
|
|
389
|
+
updated = self._task_service.update_task_text(command.task_id, command.text)
|
|
390
|
+
self._replace_task_in_place(updated)
|
|
391
|
+
|
|
392
|
+
elif isinstance(command, RestoreTaskState):
|
|
393
|
+
updated = self._task_service.set_task_state(command.task_id, command.state)
|
|
394
|
+
self._replace_task_in_place(updated)
|
|
395
|
+
|
|
396
|
+
elif isinstance(command, RestoreTaskSnapshot):
|
|
397
|
+
restored = self._task_service.restore_task(command.snapshot)
|
|
398
|
+
self._tasks.insert(command.index, restored)
|
|
399
|
+
self.selected_task_id = restored.id
|
|
400
|
+
self._sync_has_tasks()
|
|
401
|
+
await self.recompose()
|
|
402
|
+
|
|
403
|
+
except PersistenceError:
|
|
404
|
+
self.post_message(self.StatusMessage(_SAVE_FAILED_MESSAGE))
|
|
405
|
+
# Leave the database/UI exactly as they were, and re-arm the
|
|
406
|
+
# same command so the user can retry once persistence recovers.
|
|
407
|
+
self._undo_manager.record(command)
|
|
408
|
+
|
|
409
|
+
def _replace_task_in_place(self, updated: Task) -> None:
|
|
410
|
+
for index, task in enumerate(self._tasks):
|
|
411
|
+
if task.id == updated.id:
|
|
412
|
+
self._tasks[index] = updated
|
|
413
|
+
break
|
|
414
|
+
row = self.query_one(f"#task-row-{updated.id}", TaskRow)
|
|
415
|
+
row.set_item(updated)
|