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.
@@ -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)