td-task 0.2.2__tar.gz → 0.2.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.
- {td_task-0.2.2 → td_task-0.2.3}/PKG-INFO +7 -1
- {td_task-0.2.2 → td_task-0.2.3}/README.md +6 -0
- {td_task-0.2.2 → td_task-0.2.3}/pyproject.toml +1 -1
- {td_task-0.2.2 → td_task-0.2.3}/src/td/CHANGELOG.md +6 -0
- {td_task-0.2.2 → td_task-0.2.3}/src/td/terminal.py +4 -0
- {td_task-0.2.2 → td_task-0.2.3}/src/td/tui.py +376 -46
- {td_task-0.2.2 → td_task-0.2.3}/tests/test_tui_notes.py +104 -0
- {td_task-0.2.2 → td_task-0.2.3}/uv.lock +1 -1
- {td_task-0.2.2 → td_task-0.2.3}/.env.enc +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/.github/workflows/publish.yml +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/.gitignore +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/.python-version +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/assets/demo.gif +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/demo.tape +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/src/td/__init__.py +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/src/td/__main__.py +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/src/td/db.py +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/tests/test_cli_notes.py +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/tests/test_notes.py +0 -0
- {td_task-0.2.2 → td_task-0.2.3}/tests/test_update.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: td-task
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Fast TUI and CLI manager for tasks and Markdown notes
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: cryptography>=42.0.0
|
|
@@ -124,6 +124,7 @@ td notes delete 12 --json
|
|
|
124
124
|
| `c` | Archive all completed tasks |
|
|
125
125
|
| `y` | Copy task list to clipboard |
|
|
126
126
|
| `↑` / `k` `↓` / `j` | Navigate tasks |
|
|
127
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
127
128
|
| `Ctrl+↑` / `Ctrl+↓` | Reorder task position |
|
|
128
129
|
| `Alt+↑` / `Alt+↓` | Duplicate task above / below |
|
|
129
130
|
| `←` / `→` | Switch to previous / next list |
|
|
@@ -145,6 +146,7 @@ td notes delete 12 --json
|
|
|
145
146
|
| `Cmd+Backspace` on macOS / `Ctrl+Backspace` elsewhere | Clear current line |
|
|
146
147
|
| `Tab` / `Shift+Tab` | Indent or outdent current line |
|
|
147
148
|
| Arrow keys / `Home` / `End` | Move cursor |
|
|
149
|
+
| `PageUp` / `PageDown` | Move one wrapped viewport |
|
|
148
150
|
| `Backspace` / `Delete` | Delete text or join lines |
|
|
149
151
|
|
|
150
152
|
The active line shows raw Markdown. Other lines show a live preview. The editor
|
|
@@ -165,6 +167,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
165
167
|
| `d` | Delete list (with all its tasks) |
|
|
166
168
|
| `Shift+↑` / `Shift+↓` | Reorder list position |
|
|
167
169
|
| `↑` / `k` `↓` / `j` | Navigate lists |
|
|
170
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
168
171
|
| `q` / `Esc` | Quit |
|
|
169
172
|
|
|
170
173
|
### Archived Lists
|
|
@@ -174,6 +177,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
174
177
|
| `Enter` / `r` | Restore highlighted list |
|
|
175
178
|
| `d` | Permanently delete highlighted list and its items |
|
|
176
179
|
| `↑` / `k` `↓` / `j` | Navigate archived lists |
|
|
180
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
177
181
|
| `q` / `Esc` | Return to active lists |
|
|
178
182
|
|
|
179
183
|
### Archive
|
|
@@ -181,6 +185,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
181
185
|
| Key | Action |
|
|
182
186
|
|-----|--------|
|
|
183
187
|
| `↑` / `k` `↓` / `j` | Navigate |
|
|
188
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
184
189
|
| `r` | Restore task to active list |
|
|
185
190
|
| `d` | Delete task permanently |
|
|
186
191
|
| `c` | Clear all archived tasks |
|
|
@@ -193,6 +198,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
193
198
|
| `e` / `Enter` | Edit selected setting |
|
|
194
199
|
| `↑` / `↓` | Adjust numeric values (when editing) |
|
|
195
200
|
| `↑` / `k` `↓` / `j` | Navigate settings |
|
|
201
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
196
202
|
| `q` / `Esc` | Return |
|
|
197
203
|
|
|
198
204
|
Settings you can change: max tasks per list (3–50), max starred tasks, whole-database
|
|
@@ -113,6 +113,7 @@ td notes delete 12 --json
|
|
|
113
113
|
| `c` | Archive all completed tasks |
|
|
114
114
|
| `y` | Copy task list to clipboard |
|
|
115
115
|
| `↑` / `k` `↓` / `j` | Navigate tasks |
|
|
116
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
116
117
|
| `Ctrl+↑` / `Ctrl+↓` | Reorder task position |
|
|
117
118
|
| `Alt+↑` / `Alt+↓` | Duplicate task above / below |
|
|
118
119
|
| `←` / `→` | Switch to previous / next list |
|
|
@@ -134,6 +135,7 @@ td notes delete 12 --json
|
|
|
134
135
|
| `Cmd+Backspace` on macOS / `Ctrl+Backspace` elsewhere | Clear current line |
|
|
135
136
|
| `Tab` / `Shift+Tab` | Indent or outdent current line |
|
|
136
137
|
| Arrow keys / `Home` / `End` | Move cursor |
|
|
138
|
+
| `PageUp` / `PageDown` | Move one wrapped viewport |
|
|
137
139
|
| `Backspace` / `Delete` | Delete text or join lines |
|
|
138
140
|
|
|
139
141
|
The active line shows raw Markdown. Other lines show a live preview. The editor
|
|
@@ -154,6 +156,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
154
156
|
| `d` | Delete list (with all its tasks) |
|
|
155
157
|
| `Shift+↑` / `Shift+↓` | Reorder list position |
|
|
156
158
|
| `↑` / `k` `↓` / `j` | Navigate lists |
|
|
159
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
157
160
|
| `q` / `Esc` | Quit |
|
|
158
161
|
|
|
159
162
|
### Archived Lists
|
|
@@ -163,6 +166,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
163
166
|
| `Enter` / `r` | Restore highlighted list |
|
|
164
167
|
| `d` | Permanently delete highlighted list and its items |
|
|
165
168
|
| `↑` / `k` `↓` / `j` | Navigate archived lists |
|
|
169
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
166
170
|
| `q` / `Esc` | Return to active lists |
|
|
167
171
|
|
|
168
172
|
### Archive
|
|
@@ -170,6 +174,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
170
174
|
| Key | Action |
|
|
171
175
|
|-----|--------|
|
|
172
176
|
| `↑` / `k` `↓` / `j` | Navigate |
|
|
177
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
173
178
|
| `r` | Restore task to active list |
|
|
174
179
|
| `d` | Delete task permanently |
|
|
175
180
|
| `c` | Clear all archived tasks |
|
|
@@ -182,6 +187,7 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
182
187
|
| `e` / `Enter` | Edit selected setting |
|
|
183
188
|
| `↑` / `↓` | Adjust numeric values (when editing) |
|
|
184
189
|
| `↑` / `k` `↓` / `j` | Navigate settings |
|
|
190
|
+
| `PageUp` / `PageDown` | Move one viewport |
|
|
185
191
|
| `q` / `Esc` | Return |
|
|
186
192
|
|
|
187
193
|
Settings you can change: max tasks per list (3–50), max starred tasks, whole-database
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.3] - 2026-08-05
|
|
4
|
+
- Scroll large task lists inside a fixed header and footer viewport.
|
|
5
|
+
- Buffer complete TUI frames to prevent flicker and partial header rendering.
|
|
6
|
+
- Prevent a trailing frame newline from scrolling the header off-screen.
|
|
7
|
+
- Add PageUp/PageDown navigation across lists, archives, settings, and the note editor.
|
|
8
|
+
|
|
3
9
|
## [0.2.2] - 2026-08-05
|
|
4
10
|
- Fix `td update` silently reporting "already up-to-date" on installs made from a local checkout.
|
|
5
11
|
- Pull the source checkout with `git pull --ff-only` and force a rebuild before upgrading such installs.
|
|
@@ -19,6 +19,8 @@ KEY_ARROW_LEFT = "\x1b[D"
|
|
|
19
19
|
KEY_ARROW_RIGHT = "\x1b[C"
|
|
20
20
|
KEY_HOME = "\x1b[H"
|
|
21
21
|
KEY_END = "\x1b[F"
|
|
22
|
+
KEY_PAGE_UP = "\x1b[5~"
|
|
23
|
+
KEY_PAGE_DOWN = "\x1b[6~"
|
|
22
24
|
KEY_SHIFT_ARROW_UP = "\x1b[1;2A"
|
|
23
25
|
KEY_SHIFT_ARROW_DOWN = "\x1b[1;2B"
|
|
24
26
|
KEY_SHIFT_TAB = "\x1b[Z"
|
|
@@ -52,6 +54,8 @@ if IS_WINDOWS:
|
|
|
52
54
|
"M": KEY_ARROW_RIGHT,
|
|
53
55
|
"G": KEY_HOME,
|
|
54
56
|
"O": KEY_END,
|
|
57
|
+
"I": KEY_PAGE_UP,
|
|
58
|
+
"Q": KEY_PAGE_DOWN,
|
|
55
59
|
"S": KEY_DELETE,
|
|
56
60
|
"\x0f": KEY_SHIFT_TAB,
|
|
57
61
|
"\x8d": KEY_CTRL_ARROW_UP,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
import textwrap
|
|
4
5
|
from datetime import datetime, timezone
|
|
6
|
+
from functools import wraps
|
|
5
7
|
|
|
6
8
|
from rich.console import Console
|
|
7
9
|
from rich.text import Text
|
|
@@ -14,6 +16,22 @@ console = Console()
|
|
|
14
16
|
_VERSION: str | None = None
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
def _buffered_frame(render):
|
|
20
|
+
"""Render a complete terminal frame, then emit it with one write."""
|
|
21
|
+
@wraps(render)
|
|
22
|
+
def buffered(*args, **kwargs):
|
|
23
|
+
with console.capture() as capture:
|
|
24
|
+
render(*args, **kwargs)
|
|
25
|
+
content = capture.get()
|
|
26
|
+
if content.endswith("\n"):
|
|
27
|
+
content = content[:-1]
|
|
28
|
+
frame = "\033[H" + content + "\033[J"
|
|
29
|
+
console.file.write(frame)
|
|
30
|
+
console.file.flush()
|
|
31
|
+
|
|
32
|
+
return buffered
|
|
33
|
+
|
|
34
|
+
|
|
17
35
|
def _app_version() -> str:
|
|
18
36
|
"""Return the installed package version, cached. Falls back to '?'."""
|
|
19
37
|
global _VERSION
|
|
@@ -145,9 +163,8 @@ def _normal_hint_text(lock_list: bool = False) -> str:
|
|
|
145
163
|
return " " + " │ ".join(parts)
|
|
146
164
|
|
|
147
165
|
|
|
166
|
+
@_buffered_frame
|
|
148
167
|
def _render_help_screen(lock_list: bool = False) -> None:
|
|
149
|
-
term.reset_cursor()
|
|
150
|
-
|
|
151
168
|
header = Text("help • ", style="bold")
|
|
152
169
|
header.append(Text("keybindings & commands", style="dim"))
|
|
153
170
|
console.print(header)
|
|
@@ -175,6 +192,7 @@ def _render_help_screen(lock_list: bool = False) -> None:
|
|
|
175
192
|
console.print(" Ctrl+S Save without closing")
|
|
176
193
|
console.print(" Enter Insert a new line")
|
|
177
194
|
console.print(" Continue and indent Markdown bullets automatically")
|
|
195
|
+
console.print(" PageUp/Down Move one viewport")
|
|
178
196
|
console.print(" Alt+↑/↓ Move current line")
|
|
179
197
|
clear_line_key = "Cmd+Backspace" if sys.platform == "darwin" else "Ctrl+Backspace"
|
|
180
198
|
console.print(f" {clear_line_key:<18}Clear current line")
|
|
@@ -184,7 +202,8 @@ def _render_help_screen(lock_list: bool = False) -> None:
|
|
|
184
202
|
|
|
185
203
|
# Group 2: Lists & Navigation
|
|
186
204
|
console.print(Text("Lists & Navigation:", style="bold yellow"))
|
|
187
|
-
console.print(" ↑/k ↓/j Navigate tasks / lists")
|
|
205
|
+
console.print(" ↑/k ↓/j Navigate and scroll tasks / lists")
|
|
206
|
+
console.print(" PageUp/Down Move one viewport at a time")
|
|
188
207
|
if not lock_list:
|
|
189
208
|
console.print(" l / Tab Open vertical Lists Menu")
|
|
190
209
|
console.print(" Ctrl+P Open 'go to list' fuzzy search dialog")
|
|
@@ -214,10 +233,82 @@ def _render_help_screen(lock_list: bool = False) -> None:
|
|
|
214
233
|
|
|
215
234
|
console.print(Text("─" * divider_width, style="dim"))
|
|
216
235
|
console.print(_footer_with_version(" Press any key to return...", divider_width))
|
|
217
|
-
sys.stdout.write("\033[J")
|
|
218
|
-
sys.stdout.flush()
|
|
219
236
|
|
|
220
237
|
|
|
238
|
+
def _task_visible_rows(mode: str, status_msg: str, term_height: int) -> int:
|
|
239
|
+
"""Return task rows that fit above the footer and transient messages."""
|
|
240
|
+
reserved_rows = 2 if mode in ("new_note", "confirm") or status_msg else 0
|
|
241
|
+
return max(1, term_height - 6 - reserved_rows)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _fuzzy_visible_rows(term_height: int) -> int:
|
|
245
|
+
"""Return rows available beneath the fuzzy-list query."""
|
|
246
|
+
return max(1, term_height - 9)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _page_index(index: int, count: int, page_size: int, direction: int) -> int:
|
|
250
|
+
"""Move an index by one page while keeping it inside the collection."""
|
|
251
|
+
if count <= 0:
|
|
252
|
+
return 0
|
|
253
|
+
step = max(1, page_size)
|
|
254
|
+
return max(0, min(count - 1, index + direction * step))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _task_visual_row_ranges(
|
|
258
|
+
tasks: list[dict],
|
|
259
|
+
hover: int,
|
|
260
|
+
divider_width: int,
|
|
261
|
+
mode: str = "normal",
|
|
262
|
+
) -> tuple[list[int], list[int], int]:
|
|
263
|
+
"""Return each task's visual row range and the total rendered rows."""
|
|
264
|
+
wrap_width = max(20, divider_width - 4)
|
|
265
|
+
starts: list[int] = []
|
|
266
|
+
ends: list[int] = []
|
|
267
|
+
total_rows = 0
|
|
268
|
+
previous_starred = False
|
|
269
|
+
|
|
270
|
+
for index, task in enumerate(tasks):
|
|
271
|
+
if index > 0 and previous_starred:
|
|
272
|
+
total_rows += 1
|
|
273
|
+
|
|
274
|
+
starts.append(total_rows)
|
|
275
|
+
if mode == "edit" and index == hover:
|
|
276
|
+
row_count = 1
|
|
277
|
+
else:
|
|
278
|
+
row_count = max(1, len(textwrap.wrap(task.get("text", ""), width=wrap_width)))
|
|
279
|
+
total_rows += row_count
|
|
280
|
+
ends.append(total_rows)
|
|
281
|
+
previous_starred = task.get("starred", 0) == 1
|
|
282
|
+
|
|
283
|
+
return starts, ends, total_rows
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _task_page_hover(
|
|
287
|
+
tasks: list[dict],
|
|
288
|
+
hover: int,
|
|
289
|
+
divider_width: int,
|
|
290
|
+
visible_rows: int,
|
|
291
|
+
direction: int,
|
|
292
|
+
) -> int:
|
|
293
|
+
"""Move task selection by roughly one viewport of rendered rows."""
|
|
294
|
+
if not tasks:
|
|
295
|
+
return 0
|
|
296
|
+
starts, _, _ = _task_visual_row_ranges(tasks, hover, divider_width)
|
|
297
|
+
target_row = starts[hover] + direction * max(1, visible_rows)
|
|
298
|
+
|
|
299
|
+
if direction > 0:
|
|
300
|
+
for index in range(hover + 1, len(tasks)):
|
|
301
|
+
if starts[index] >= target_row:
|
|
302
|
+
return index
|
|
303
|
+
return len(tasks) - 1
|
|
304
|
+
|
|
305
|
+
for index in range(hover - 1, -1, -1):
|
|
306
|
+
if starts[index] <= target_row:
|
|
307
|
+
return index
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@_buffered_frame
|
|
221
312
|
def _render_main(
|
|
222
313
|
tasks: list[dict],
|
|
223
314
|
hover: int,
|
|
@@ -230,9 +321,9 @@ def _render_main(
|
|
|
230
321
|
view: str = "tasks",
|
|
231
322
|
status_msg: str = "",
|
|
232
323
|
lists_scroll: int = 0,
|
|
324
|
+
tasks_scroll: int = 0,
|
|
325
|
+
fuzzy_scroll: int = 0,
|
|
233
326
|
) -> None:
|
|
234
|
-
term.reset_cursor()
|
|
235
|
-
|
|
236
327
|
divider_width = min(len(_normal_hint_text(lock_list)), console.width or 80)
|
|
237
328
|
|
|
238
329
|
if view == "lists_menu":
|
|
@@ -265,16 +356,16 @@ def _render_main(
|
|
|
265
356
|
elif mode == "confirm":
|
|
266
357
|
hint_parts = ["Enter:confirm", "Esc:cancel"]
|
|
267
358
|
elif mode == "fuzzy_list":
|
|
268
|
-
hint_parts = ["Esc:cancel", "Enter:go to list", "↑/↓:navigate
|
|
359
|
+
hint_parts = ["Esc:cancel", "Enter:go to list", "↑/↓:navigate", "PgUp/PgDn:page"]
|
|
269
360
|
else:
|
|
270
361
|
# mode == "normal"
|
|
271
362
|
if view == "lists_menu":
|
|
272
363
|
hint_parts = [
|
|
273
|
-
"a:add", "e:edit", "A:archive", ",:archived", "d:delete",
|
|
364
|
+
"PgUp/PgDn:page", "a:add", "e:edit", "A:archive", ",:archived", "d:delete",
|
|
274
365
|
"Enter:open", "q:quit",
|
|
275
366
|
]
|
|
276
367
|
else:
|
|
277
|
-
hint_parts = ["a:task", "A:note", "e/Enter:edit", "E:rename note", "d:delete", "Space:done", "s:star", "c:clear"]
|
|
368
|
+
hint_parts = ["PgUp/PgDn:page", "a:task", "A:note", "e/Enter:edit", "E:rename note", "d:delete", "Space:done", "s:star", "c:clear"]
|
|
278
369
|
if not lock_list:
|
|
279
370
|
hint_parts.append("l:view lists")
|
|
280
371
|
hint_parts.append("q:quit")
|
|
@@ -320,20 +411,28 @@ def _render_main(
|
|
|
320
411
|
|
|
321
412
|
if not matched:
|
|
322
413
|
console.print(Text(" No matching lists. Press Enter to create new list.", style="dim"), end="\033[K\n")
|
|
414
|
+
visible_count = 1
|
|
323
415
|
else:
|
|
324
|
-
|
|
416
|
+
visible_rows = _fuzzy_visible_rows(console.height or 24)
|
|
417
|
+
visible_matches = matched[fuzzy_scroll:fuzzy_scroll + visible_rows]
|
|
418
|
+
for idx, match_item in enumerate(visible_matches, start=fuzzy_scroll):
|
|
325
419
|
is_selected = idx == hover
|
|
326
420
|
prefix = " ▸ " if is_selected else " "
|
|
327
421
|
if is_selected:
|
|
328
422
|
console.print(Text(f"{prefix}{match_item}", style="bold cyan"), end="\033[K\n")
|
|
329
423
|
else:
|
|
330
424
|
console.print(Text(f"{prefix}{match_item}", style="dim"), end="\033[K\n")
|
|
425
|
+
visible_count = len(visible_matches)
|
|
426
|
+
visible_rows = _fuzzy_visible_rows(console.height or 24)
|
|
427
|
+
for _ in range(visible_rows - visible_count):
|
|
428
|
+
console.print(end="\033[K\n")
|
|
331
429
|
elif view == "lists_menu":
|
|
332
430
|
lists = db.get_all_lists()
|
|
333
431
|
term_height = console.height or 24
|
|
334
|
-
max_visible =
|
|
432
|
+
max_visible = _task_visible_rows(mode, status_msg, term_height)
|
|
433
|
+
list_slots = max(0, max_visible - (1 if mode == "new_list" else 0))
|
|
335
434
|
start = lists_scroll
|
|
336
|
-
end = min(start +
|
|
435
|
+
end = min(start + list_slots, len(lists))
|
|
337
436
|
|
|
338
437
|
lines = []
|
|
339
438
|
for i in range(start, end):
|
|
@@ -379,8 +478,11 @@ def _render_main(
|
|
|
379
478
|
if not lists and mode != "new_list":
|
|
380
479
|
lines.append(Text(" No lists. Press a to add one.", style="dim"))
|
|
381
480
|
|
|
382
|
-
|
|
481
|
+
visible_lines = lines[:max_visible]
|
|
482
|
+
for line in visible_lines:
|
|
383
483
|
console.print(line, end="\033[K\n")
|
|
484
|
+
for _ in range(max_visible - len(visible_lines)):
|
|
485
|
+
console.print(end="\033[K\n")
|
|
384
486
|
else:
|
|
385
487
|
lines = []
|
|
386
488
|
prev_starred = False
|
|
@@ -482,8 +584,12 @@ def _render_main(
|
|
|
482
584
|
if not tasks:
|
|
483
585
|
lines.append(Text(" No items. Press a for a task or A for a note.", style="dim"))
|
|
484
586
|
|
|
485
|
-
|
|
587
|
+
visible_rows = _task_visible_rows(mode, status_msg, console.height or 24)
|
|
588
|
+
visible_lines = lines[tasks_scroll:tasks_scroll + visible_rows]
|
|
589
|
+
for line in visible_lines:
|
|
486
590
|
console.print(line, end="\033[K\n")
|
|
591
|
+
for _ in range(visible_rows - len(visible_lines)):
|
|
592
|
+
console.print(end="\033[K\n")
|
|
487
593
|
|
|
488
594
|
if mode == "new_note":
|
|
489
595
|
console.print(end="\033[K\n")
|
|
@@ -504,9 +610,12 @@ def _render_main(
|
|
|
504
610
|
|
|
505
611
|
console.print(end="\033[K\n")
|
|
506
612
|
console.print(Text("─" * divider_width, style="dim"), end="\033[K\n")
|
|
507
|
-
console.print(
|
|
508
|
-
|
|
509
|
-
|
|
613
|
+
console.print(
|
|
614
|
+
Text(hint_text, style="dim"),
|
|
615
|
+
end="\033[K\n",
|
|
616
|
+
overflow="crop",
|
|
617
|
+
no_wrap=True,
|
|
618
|
+
)
|
|
510
619
|
|
|
511
620
|
|
|
512
621
|
def _fmt_timestamp(iso: str | None) -> str:
|
|
@@ -645,6 +754,46 @@ def _note_editor_visual_rows(
|
|
|
645
754
|
return visual_rows, cursor_visual_row
|
|
646
755
|
|
|
647
756
|
|
|
757
|
+
def _page_note_cursor(
|
|
758
|
+
lines: list[str],
|
|
759
|
+
row: int,
|
|
760
|
+
column: int,
|
|
761
|
+
content_width: int,
|
|
762
|
+
visible_rows: int,
|
|
763
|
+
direction: int,
|
|
764
|
+
) -> tuple[int, int]:
|
|
765
|
+
"""Move the editor cursor by one viewport of wrapped visual rows."""
|
|
766
|
+
visual_rows, cursor_visual_row = _note_editor_visual_rows(
|
|
767
|
+
lines, row, column, content_width
|
|
768
|
+
)
|
|
769
|
+
if not visual_rows:
|
|
770
|
+
return row, column
|
|
771
|
+
|
|
772
|
+
target_visual_row = max(
|
|
773
|
+
0,
|
|
774
|
+
min(
|
|
775
|
+
len(visual_rows) - 1,
|
|
776
|
+
cursor_visual_row + direction * max(1, visible_rows),
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
target_row, target_segment, _ = visual_rows[target_visual_row]
|
|
780
|
+
|
|
781
|
+
if target_row != row:
|
|
782
|
+
return target_row, min(column, len(lines[target_row]))
|
|
783
|
+
|
|
784
|
+
active_segments = [
|
|
785
|
+
segment
|
|
786
|
+
for line_index, _, segment in visual_rows
|
|
787
|
+
if line_index == row
|
|
788
|
+
]
|
|
789
|
+
current_segment = visual_rows[cursor_visual_row][1]
|
|
790
|
+
current_start = sum(len(segment.plain) for segment in active_segments[:current_segment])
|
|
791
|
+
target_start = sum(len(segment.plain) for segment in active_segments[:target_segment])
|
|
792
|
+
horizontal_offset = max(0, column - current_start)
|
|
793
|
+
return row, min(len(lines[row]), target_start + horizontal_offset)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@_buffered_frame
|
|
648
797
|
def _render_note_editor(
|
|
649
798
|
title: str,
|
|
650
799
|
lines: list[str],
|
|
@@ -653,7 +802,6 @@ def _render_note_editor(
|
|
|
653
802
|
scroll: int,
|
|
654
803
|
status_msg: str = "",
|
|
655
804
|
) -> None:
|
|
656
|
-
term.reset_cursor()
|
|
657
805
|
width = max(30, console.width or 80)
|
|
658
806
|
height = max(10, console.height or 24)
|
|
659
807
|
|
|
@@ -681,7 +829,7 @@ def _render_note_editor(
|
|
|
681
829
|
for _ in range(visible_rows - len(visible)):
|
|
682
830
|
console.print(end="\033[K\n")
|
|
683
831
|
|
|
684
|
-
footer = " Esc:save & close │ Ctrl+S:save │ Enter:new line │ arrows:move"
|
|
832
|
+
footer = " Esc:save & close │ Ctrl+S:save │ Enter:new line │ arrows:move │ PgUp/PgDn:page"
|
|
685
833
|
if status_msg:
|
|
686
834
|
footer = f" {status_msg} │ " + footer.strip()
|
|
687
835
|
clear_line_key = "Cmd+⌫" if sys.platform == "darwin" else "Ctrl+⌫"
|
|
@@ -692,8 +840,6 @@ def _render_note_editor(
|
|
|
692
840
|
console.print(Text("─" * width, style="dim"), end="\033[K\n")
|
|
693
841
|
console.print(Text(footer, style="dim"), end="\033[K\n", overflow="crop", no_wrap=True)
|
|
694
842
|
console.print(Text(editor_hint, style="dim"), end="\033[K\n", overflow="crop", no_wrap=True)
|
|
695
|
-
sys.stdout.write("\033[J")
|
|
696
|
-
sys.stdout.flush()
|
|
697
843
|
|
|
698
844
|
|
|
699
845
|
def _run_note_editor(note_id: int) -> None:
|
|
@@ -757,6 +903,26 @@ def _run_note_editor(note_id: int) -> None:
|
|
|
757
903
|
elif key in (term.KEY_CMD_BACKSPACE, term.KEY_CTRL_BACKSPACE):
|
|
758
904
|
lines[row] = ""
|
|
759
905
|
column = 0
|
|
906
|
+
elif key == term.KEY_PAGE_UP:
|
|
907
|
+
row, column = _page_note_cursor(
|
|
908
|
+
lines, row, column, content_width, visible_rows, -1
|
|
909
|
+
)
|
|
910
|
+
visual_rows, cursor_visual_row = _note_editor_visual_rows(
|
|
911
|
+
lines, row, column, content_width
|
|
912
|
+
)
|
|
913
|
+
scroll = min(
|
|
914
|
+
cursor_visual_row, max(0, len(visual_rows) - visible_rows)
|
|
915
|
+
)
|
|
916
|
+
elif key == term.KEY_PAGE_DOWN:
|
|
917
|
+
row, column = _page_note_cursor(
|
|
918
|
+
lines, row, column, content_width, visible_rows, 1
|
|
919
|
+
)
|
|
920
|
+
visual_rows, cursor_visual_row = _note_editor_visual_rows(
|
|
921
|
+
lines, row, column, content_width
|
|
922
|
+
)
|
|
923
|
+
scroll = min(
|
|
924
|
+
cursor_visual_row, max(0, len(visual_rows) - visible_rows)
|
|
925
|
+
)
|
|
760
926
|
elif key == term.KEY_ARROW_UP:
|
|
761
927
|
if row > 0:
|
|
762
928
|
row -= 1
|
|
@@ -832,6 +998,7 @@ def _run_note_editor(note_id: int) -> None:
|
|
|
832
998
|
column += 1
|
|
833
999
|
|
|
834
1000
|
|
|
1001
|
+
@_buffered_frame
|
|
835
1002
|
def _render_archive(
|
|
836
1003
|
tasks: list[dict],
|
|
837
1004
|
hover: int,
|
|
@@ -841,8 +1008,6 @@ def _render_archive(
|
|
|
841
1008
|
confirm_msg: str = "",
|
|
842
1009
|
list_name: str = "main",
|
|
843
1010
|
) -> None:
|
|
844
|
-
term.reset_cursor()
|
|
845
|
-
|
|
846
1011
|
header = Text(f"archive • {list_name} • ", style="bold")
|
|
847
1012
|
header.append(Text(f"{len(tasks)} items", style="dim"))
|
|
848
1013
|
console.print(header)
|
|
@@ -850,16 +1015,18 @@ def _render_archive(
|
|
|
850
1015
|
if mode == "confirm":
|
|
851
1016
|
hint_text = " " + " │ ".join(["Enter:confirm", "Esc:cancel"])
|
|
852
1017
|
else:
|
|
853
|
-
hint_text = " " + " │ ".join(["↑/k ↓/j:navigate", "d:delete", "r:restore", "c:clear", "q:return"])
|
|
1018
|
+
hint_text = " " + " │ ".join(["↑/k ↓/j:navigate", "PgUp/PgDn:page", "d:delete", "r:restore", "c:clear", "q:return"])
|
|
854
1019
|
|
|
855
1020
|
divider_width = min(len(_normal_hint_text()), console.width or 80)
|
|
856
1021
|
console.print(Text("─" * divider_width, style="dim"))
|
|
857
1022
|
console.print()
|
|
858
1023
|
|
|
1024
|
+
max_lines = max(1, term_height - 6 - (2 if mode == "confirm" else 0))
|
|
1025
|
+
rendered_lines = 0
|
|
859
1026
|
if not tasks:
|
|
860
1027
|
console.print(Text(" No archived items.", style="dim"))
|
|
1028
|
+
rendered_lines = 1
|
|
861
1029
|
else:
|
|
862
|
-
max_lines = term_height - 6 # header(2) + blank + bottom blank + divider + hints
|
|
863
1030
|
start = scroll
|
|
864
1031
|
end = min(start + max_lines, len(tasks))
|
|
865
1032
|
|
|
@@ -887,6 +1054,10 @@ def _render_archive(
|
|
|
887
1054
|
line.append(Text(task["text"], style=name_style))
|
|
888
1055
|
line.append(Text(f" {ts_text}", style="strike dim"))
|
|
889
1056
|
console.print(line)
|
|
1057
|
+
rendered_lines += 1
|
|
1058
|
+
|
|
1059
|
+
for _ in range(max_lines - rendered_lines):
|
|
1060
|
+
console.print(end="\033[K\n")
|
|
890
1061
|
|
|
891
1062
|
if mode == "confirm":
|
|
892
1063
|
console.print()
|
|
@@ -894,18 +1065,17 @@ def _render_archive(
|
|
|
894
1065
|
|
|
895
1066
|
console.print()
|
|
896
1067
|
console.print(Text("─" * divider_width, style="dim"))
|
|
897
|
-
console.print(Text(hint_text, style="dim"))
|
|
898
|
-
sys.stdout.write("\033[J")
|
|
899
|
-
sys.stdout.flush()
|
|
1068
|
+
console.print(Text(hint_text, style="dim"), overflow="crop", no_wrap=True)
|
|
900
1069
|
|
|
901
1070
|
|
|
1071
|
+
@_buffered_frame
|
|
902
1072
|
def _render_archived_lists(
|
|
903
1073
|
lists: list[dict],
|
|
904
1074
|
hover: int,
|
|
1075
|
+
scroll: int = 0,
|
|
905
1076
|
mode: str = "normal",
|
|
906
1077
|
confirm_msg: str = "",
|
|
907
1078
|
) -> None:
|
|
908
|
-
term.reset_cursor()
|
|
909
1079
|
width = min(console.width or 80, 100)
|
|
910
1080
|
|
|
911
1081
|
header = Text("lists archive • ", style="bold")
|
|
@@ -914,11 +1084,13 @@ def _render_archived_lists(
|
|
|
914
1084
|
console.print(Text("─" * width, style="dim"), end="\033[K\n")
|
|
915
1085
|
console.print(end="\033[K\n")
|
|
916
1086
|
|
|
1087
|
+
visible_rows = max(1, (console.height or 24) - 6 - (2 if mode == "confirm" else 0))
|
|
1088
|
+
rendered_rows = 0
|
|
917
1089
|
if not lists:
|
|
918
1090
|
console.print(Text(" No archived lists.", style="dim"), end="\033[K\n")
|
|
1091
|
+
rendered_rows = 1
|
|
919
1092
|
else:
|
|
920
|
-
|
|
921
|
-
start = max(0, hover - visible_rows + 1)
|
|
1093
|
+
start = scroll
|
|
922
1094
|
end = min(len(lists), start + visible_rows)
|
|
923
1095
|
for index in range(start, end):
|
|
924
1096
|
archived_list = lists[index]
|
|
@@ -932,6 +1104,10 @@ def _render_archived_lists(
|
|
|
932
1104
|
archived_at = _fmt_timestamp(archived_list["archived_at"])
|
|
933
1105
|
line.append(Text(f" archived {archived_at}", style="dim"))
|
|
934
1106
|
console.print(line, end="\033[K\n")
|
|
1107
|
+
rendered_rows += 1
|
|
1108
|
+
|
|
1109
|
+
for _ in range(visible_rows - rendered_rows):
|
|
1110
|
+
console.print(end="\033[K\n")
|
|
935
1111
|
|
|
936
1112
|
if mode == "confirm":
|
|
937
1113
|
console.print(end="\033[K\n")
|
|
@@ -942,14 +1118,18 @@ def _render_archived_lists(
|
|
|
942
1118
|
if mode == "confirm":
|
|
943
1119
|
hint = " Enter:confirm permanent delete │ Esc:cancel"
|
|
944
1120
|
else:
|
|
945
|
-
hint = " Enter/r:restore │ d:delete permanently │ ↑/↓:navigate │ q:return"
|
|
946
|
-
console.print(
|
|
947
|
-
|
|
948
|
-
|
|
1121
|
+
hint = " Enter/r:restore │ d:delete permanently │ ↑/↓:navigate │ PgUp/PgDn:page │ q:return"
|
|
1122
|
+
console.print(
|
|
1123
|
+
Text(hint, style="dim"),
|
|
1124
|
+
end="\033[K\n",
|
|
1125
|
+
overflow="crop",
|
|
1126
|
+
no_wrap=True,
|
|
1127
|
+
)
|
|
949
1128
|
|
|
950
1129
|
|
|
951
1130
|
def _run_archived_lists_loop() -> None:
|
|
952
1131
|
hover = 0
|
|
1132
|
+
scroll = 0
|
|
953
1133
|
mode = "normal"
|
|
954
1134
|
confirm_name = ""
|
|
955
1135
|
|
|
@@ -957,14 +1137,23 @@ def _run_archived_lists_loop() -> None:
|
|
|
957
1137
|
lists = db.get_archived_lists()
|
|
958
1138
|
if lists:
|
|
959
1139
|
hover = min(hover, len(lists) - 1)
|
|
1140
|
+
visible_rows = max(
|
|
1141
|
+
1, (console.height or 24) - 6 - (2 if mode == "confirm" else 0)
|
|
1142
|
+
)
|
|
1143
|
+
if hover < scroll:
|
|
1144
|
+
scroll = hover
|
|
1145
|
+
elif hover >= scroll + visible_rows:
|
|
1146
|
+
scroll = hover - visible_rows + 1
|
|
1147
|
+
scroll = max(0, min(scroll, max(0, len(lists) - visible_rows)))
|
|
960
1148
|
else:
|
|
961
1149
|
hover = 0
|
|
1150
|
+
scroll = 0
|
|
962
1151
|
confirm_msg = (
|
|
963
1152
|
f'Delete archived list "{confirm_name}" and all its items permanently?'
|
|
964
1153
|
if mode == "confirm"
|
|
965
1154
|
else ""
|
|
966
1155
|
)
|
|
967
|
-
_render_archived_lists(lists, hover, mode, confirm_msg)
|
|
1156
|
+
_render_archived_lists(lists, hover, scroll, mode, confirm_msg)
|
|
968
1157
|
key = term.read_key()
|
|
969
1158
|
|
|
970
1159
|
if mode == "confirm":
|
|
@@ -981,6 +1170,16 @@ def _run_archived_lists_loop() -> None:
|
|
|
981
1170
|
elif key in (term.KEY_ARROW_DOWN, "j"):
|
|
982
1171
|
if hover < len(lists) - 1:
|
|
983
1172
|
hover += 1
|
|
1173
|
+
elif key == term.KEY_PAGE_UP:
|
|
1174
|
+
hover = _page_index(
|
|
1175
|
+
hover, len(lists), max(1, (console.height or 24) - 6), -1
|
|
1176
|
+
)
|
|
1177
|
+
scroll = hover
|
|
1178
|
+
elif key == term.KEY_PAGE_DOWN:
|
|
1179
|
+
hover = _page_index(
|
|
1180
|
+
hover, len(lists), max(1, (console.height or 24) - 6), 1
|
|
1181
|
+
)
|
|
1182
|
+
scroll = hover
|
|
984
1183
|
elif key in (term.KEY_ENTER, "r"):
|
|
985
1184
|
if lists:
|
|
986
1185
|
db.restore_list(lists[hover]["name"])
|
|
@@ -1004,6 +1203,8 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1004
1203
|
confirm_list_name = ""
|
|
1005
1204
|
status_msg = ""
|
|
1006
1205
|
lists_scroll = 0
|
|
1206
|
+
tasks_scroll = 0
|
|
1207
|
+
fuzzy_scroll = 0
|
|
1007
1208
|
|
|
1008
1209
|
current_list = list_name
|
|
1009
1210
|
if not lock_list and active_lists and current_list not in active_lists:
|
|
@@ -1051,13 +1252,23 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1051
1252
|
hover = len(matched) - 1
|
|
1052
1253
|
if not matched:
|
|
1053
1254
|
hover = 0
|
|
1255
|
+
fuzzy_scroll = 0
|
|
1256
|
+
else:
|
|
1257
|
+
fuzzy_rows = _fuzzy_visible_rows(console.height or 24)
|
|
1258
|
+
if hover < fuzzy_scroll:
|
|
1259
|
+
fuzzy_scroll = hover
|
|
1260
|
+
elif hover >= fuzzy_scroll + fuzzy_rows:
|
|
1261
|
+
fuzzy_scroll = hover - fuzzy_rows + 1
|
|
1262
|
+
fuzzy_scroll = max(
|
|
1263
|
+
0, min(fuzzy_scroll, max(0, len(matched) - fuzzy_rows))
|
|
1264
|
+
)
|
|
1054
1265
|
elif view == "lists_menu":
|
|
1055
1266
|
lists = db.get_all_lists()
|
|
1056
1267
|
if lists and hover >= len(lists):
|
|
1057
1268
|
hover = len(lists) - 1
|
|
1058
1269
|
if not lists:
|
|
1059
1270
|
hover = 0
|
|
1060
|
-
max_vis =
|
|
1271
|
+
max_vis = _task_visible_rows(mode, status_msg, console.height or 24)
|
|
1061
1272
|
if hover < lists_scroll:
|
|
1062
1273
|
lists_scroll = hover
|
|
1063
1274
|
elif hover >= lists_scroll + max_vis:
|
|
@@ -1067,6 +1278,18 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1067
1278
|
hover = len(tasks) - 1
|
|
1068
1279
|
if not tasks:
|
|
1069
1280
|
hover = 0
|
|
1281
|
+
tasks_scroll = 0
|
|
1282
|
+
else:
|
|
1283
|
+
divider_width = min(len(_normal_hint_text(lock_list)), console.width or 80)
|
|
1284
|
+
starts, ends, total_rows = _task_visual_row_ranges(
|
|
1285
|
+
tasks, hover, divider_width, mode
|
|
1286
|
+
)
|
|
1287
|
+
visible_rows = _task_visible_rows(mode, status_msg, console.height or 24)
|
|
1288
|
+
if starts[hover] < tasks_scroll:
|
|
1289
|
+
tasks_scroll = starts[hover]
|
|
1290
|
+
if ends[hover] > tasks_scroll + visible_rows:
|
|
1291
|
+
tasks_scroll = ends[hover] - visible_rows
|
|
1292
|
+
tasks_scroll = max(0, min(tasks_scroll, max(0, total_rows - visible_rows)))
|
|
1070
1293
|
|
|
1071
1294
|
# Construct confirmation message
|
|
1072
1295
|
if mode == "confirm" and confirm_action == "archive":
|
|
@@ -1081,7 +1304,21 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1081
1304
|
else:
|
|
1082
1305
|
confirm_msg = ""
|
|
1083
1306
|
|
|
1084
|
-
_render_main(
|
|
1307
|
+
_render_main(
|
|
1308
|
+
tasks,
|
|
1309
|
+
hover,
|
|
1310
|
+
mode,
|
|
1311
|
+
edit_text,
|
|
1312
|
+
edit_cursor,
|
|
1313
|
+
confirm_msg,
|
|
1314
|
+
current_list,
|
|
1315
|
+
lock_list,
|
|
1316
|
+
view,
|
|
1317
|
+
status_msg,
|
|
1318
|
+
lists_scroll,
|
|
1319
|
+
tasks_scroll,
|
|
1320
|
+
fuzzy_scroll,
|
|
1321
|
+
)
|
|
1085
1322
|
status_msg = ""
|
|
1086
1323
|
|
|
1087
1324
|
key = term.read_key()
|
|
@@ -1093,12 +1330,23 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1093
1330
|
edit_text = ""
|
|
1094
1331
|
edit_cursor = 0
|
|
1095
1332
|
hover = 0
|
|
1333
|
+
fuzzy_scroll = 0
|
|
1096
1334
|
elif key == term.KEY_ARROW_UP:
|
|
1097
1335
|
if hover > 0:
|
|
1098
1336
|
hover -= 1
|
|
1099
1337
|
elif key == term.KEY_ARROW_DOWN:
|
|
1100
1338
|
if matched and hover < len(matched) - 1:
|
|
1101
1339
|
hover += 1
|
|
1340
|
+
elif key == term.KEY_PAGE_UP:
|
|
1341
|
+
hover = _page_index(
|
|
1342
|
+
hover, len(matched), _fuzzy_visible_rows(console.height or 24), -1
|
|
1343
|
+
)
|
|
1344
|
+
fuzzy_scroll = hover
|
|
1345
|
+
elif key == term.KEY_PAGE_DOWN:
|
|
1346
|
+
hover = _page_index(
|
|
1347
|
+
hover, len(matched), _fuzzy_visible_rows(console.height or 24), 1
|
|
1348
|
+
)
|
|
1349
|
+
fuzzy_scroll = hover
|
|
1102
1350
|
elif key == term.KEY_BACKSPACE:
|
|
1103
1351
|
if edit_cursor > 0:
|
|
1104
1352
|
edit_text = edit_text[:edit_cursor - 1] + edit_text[edit_cursor:]
|
|
@@ -1123,6 +1371,8 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1123
1371
|
edit_text = ""
|
|
1124
1372
|
edit_cursor = 0
|
|
1125
1373
|
hover = 0
|
|
1374
|
+
tasks_scroll = 0
|
|
1375
|
+
fuzzy_scroll = 0
|
|
1126
1376
|
term.clear_screen()
|
|
1127
1377
|
elif len(key) == 1 and ord(key) >= 32:
|
|
1128
1378
|
edit_text = edit_text[:edit_cursor] + key + edit_text[edit_cursor:]
|
|
@@ -1139,12 +1389,29 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1139
1389
|
lists = db.get_all_lists()
|
|
1140
1390
|
if hover < len(lists) - 1:
|
|
1141
1391
|
hover += 1
|
|
1392
|
+
elif key == term.KEY_PAGE_UP:
|
|
1393
|
+
hover = _page_index(
|
|
1394
|
+
hover,
|
|
1395
|
+
len(db.get_all_lists()),
|
|
1396
|
+
max(1, (console.height or 24) - 6),
|
|
1397
|
+
-1,
|
|
1398
|
+
)
|
|
1399
|
+
lists_scroll = hover
|
|
1400
|
+
elif key == term.KEY_PAGE_DOWN:
|
|
1401
|
+
hover = _page_index(
|
|
1402
|
+
hover,
|
|
1403
|
+
len(db.get_all_lists()),
|
|
1404
|
+
max(1, (console.height or 24) - 6),
|
|
1405
|
+
1,
|
|
1406
|
+
)
|
|
1407
|
+
lists_scroll = hover
|
|
1142
1408
|
elif key == term.KEY_ENTER:
|
|
1143
1409
|
lists = db.get_all_lists()
|
|
1144
1410
|
if lists:
|
|
1145
1411
|
current_list = lists[hover]
|
|
1146
1412
|
view = "tasks"
|
|
1147
1413
|
hover = 0
|
|
1414
|
+
tasks_scroll = 0
|
|
1148
1415
|
term.clear_screen()
|
|
1149
1416
|
elif key == "a":
|
|
1150
1417
|
mode = "new_list"
|
|
@@ -1216,6 +1483,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1216
1483
|
edit_text = ""
|
|
1217
1484
|
edit_cursor = 0
|
|
1218
1485
|
hover = 0
|
|
1486
|
+
fuzzy_scroll = 0
|
|
1219
1487
|
continue
|
|
1220
1488
|
elif key in (term.KEY_ARROW_UP, "k"):
|
|
1221
1489
|
if hover > 0:
|
|
@@ -1223,6 +1491,44 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1223
1491
|
elif key in (term.KEY_ARROW_DOWN, "j"):
|
|
1224
1492
|
if tasks and hover < len(tasks) - 1:
|
|
1225
1493
|
hover += 1
|
|
1494
|
+
elif key == term.KEY_PAGE_UP:
|
|
1495
|
+
hover = _task_page_hover(
|
|
1496
|
+
tasks,
|
|
1497
|
+
hover,
|
|
1498
|
+
min(len(_normal_hint_text(lock_list)), console.width or 80),
|
|
1499
|
+
_task_visible_rows(mode, status_msg, console.height or 24),
|
|
1500
|
+
-1,
|
|
1501
|
+
)
|
|
1502
|
+
starts, _, total_rows = _task_visual_row_ranges(
|
|
1503
|
+
tasks,
|
|
1504
|
+
hover,
|
|
1505
|
+
min(len(_normal_hint_text(lock_list)), console.width or 80),
|
|
1506
|
+
)
|
|
1507
|
+
visible_rows = _task_visible_rows(
|
|
1508
|
+
mode, status_msg, console.height or 24
|
|
1509
|
+
)
|
|
1510
|
+
tasks_scroll = min(
|
|
1511
|
+
starts[hover], max(0, total_rows - visible_rows)
|
|
1512
|
+
) if tasks else 0
|
|
1513
|
+
elif key == term.KEY_PAGE_DOWN:
|
|
1514
|
+
hover = _task_page_hover(
|
|
1515
|
+
tasks,
|
|
1516
|
+
hover,
|
|
1517
|
+
min(len(_normal_hint_text(lock_list)), console.width or 80),
|
|
1518
|
+
_task_visible_rows(mode, status_msg, console.height or 24),
|
|
1519
|
+
1,
|
|
1520
|
+
)
|
|
1521
|
+
starts, _, total_rows = _task_visual_row_ranges(
|
|
1522
|
+
tasks,
|
|
1523
|
+
hover,
|
|
1524
|
+
min(len(_normal_hint_text(lock_list)), console.width or 80),
|
|
1525
|
+
)
|
|
1526
|
+
visible_rows = _task_visible_rows(
|
|
1527
|
+
mode, status_msg, console.height or 24
|
|
1528
|
+
)
|
|
1529
|
+
tasks_scroll = min(
|
|
1530
|
+
starts[hover], max(0, total_rows - visible_rows)
|
|
1531
|
+
) if tasks else 0
|
|
1226
1532
|
elif key == term.KEY_ARROW_LEFT and not lock_list:
|
|
1227
1533
|
lists = db.get_all_lists()
|
|
1228
1534
|
if current_list in lists:
|
|
@@ -1230,6 +1536,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1230
1536
|
next_idx = max(0, curr_idx - 1)
|
|
1231
1537
|
current_list = lists[next_idx]
|
|
1232
1538
|
hover = 0
|
|
1539
|
+
tasks_scroll = 0
|
|
1233
1540
|
elif key == term.KEY_ARROW_RIGHT and not lock_list:
|
|
1234
1541
|
lists = db.get_all_lists()
|
|
1235
1542
|
if current_list in lists:
|
|
@@ -1237,6 +1544,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1237
1544
|
next_idx = min(len(lists) - 1, curr_idx + 1)
|
|
1238
1545
|
current_list = lists[next_idx]
|
|
1239
1546
|
hover = 0
|
|
1547
|
+
tasks_scroll = 0
|
|
1240
1548
|
elif key in (term.KEY_SHIFT_ARROW_UP, term.KEY_CTRL_ARROW_UP):
|
|
1241
1549
|
if tasks and hover > 0:
|
|
1242
1550
|
db.move_task(tasks[hover]["id"], -1)
|
|
@@ -1469,6 +1777,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1469
1777
|
current_list = cleaned_name
|
|
1470
1778
|
hover = 0
|
|
1471
1779
|
view = "tasks"
|
|
1780
|
+
tasks_scroll = 0
|
|
1472
1781
|
mode = "normal"
|
|
1473
1782
|
edit_text = ""
|
|
1474
1783
|
edit_cursor = 0
|
|
@@ -1501,6 +1810,7 @@ def _run_archive_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1501
1810
|
confirm_task_id: int | None = None
|
|
1502
1811
|
|
|
1503
1812
|
while True:
|
|
1813
|
+
term_height = console.height or 24
|
|
1504
1814
|
tasks = db.get_archived_tasks(list_name)
|
|
1505
1815
|
if not tasks:
|
|
1506
1816
|
hover = 0
|
|
@@ -1509,7 +1819,7 @@ def _run_archive_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1509
1819
|
if hover >= len(tasks):
|
|
1510
1820
|
hover = len(tasks) - 1
|
|
1511
1821
|
# Auto-scroll to keep hover visible
|
|
1512
|
-
max_lines = term_height - 6
|
|
1822
|
+
max_lines = max(1, term_height - 6 - (2 if mode == "confirm" else 0))
|
|
1513
1823
|
if hover < scroll:
|
|
1514
1824
|
scroll = hover
|
|
1515
1825
|
elif hover >= scroll + max_lines:
|
|
@@ -1536,6 +1846,16 @@ def _run_archive_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1536
1846
|
elif key in (term.KEY_ARROW_DOWN, "j"):
|
|
1537
1847
|
if tasks and hover < len(tasks) - 1:
|
|
1538
1848
|
hover += 1
|
|
1849
|
+
elif key == term.KEY_PAGE_UP:
|
|
1850
|
+
hover = _page_index(
|
|
1851
|
+
hover, len(tasks), max(1, term_height - 6), -1
|
|
1852
|
+
)
|
|
1853
|
+
scroll = hover
|
|
1854
|
+
elif key == term.KEY_PAGE_DOWN:
|
|
1855
|
+
hover = _page_index(
|
|
1856
|
+
hover, len(tasks), max(1, term_height - 6), 1
|
|
1857
|
+
)
|
|
1858
|
+
scroll = hover
|
|
1539
1859
|
elif key == "d":
|
|
1540
1860
|
if tasks:
|
|
1541
1861
|
confirm_action = "delete"
|
|
@@ -1574,6 +1894,7 @@ def _run_archive_loop(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
1574
1894
|
confirm_task_id = None
|
|
1575
1895
|
|
|
1576
1896
|
|
|
1897
|
+
@_buffered_frame
|
|
1577
1898
|
def _render_settings(
|
|
1578
1899
|
list_name: str,
|
|
1579
1900
|
hover: int,
|
|
@@ -1582,8 +1903,6 @@ def _render_settings(
|
|
|
1582
1903
|
edit_cursor: int = 0,
|
|
1583
1904
|
status_msg: str = "",
|
|
1584
1905
|
) -> None:
|
|
1585
|
-
term.reset_cursor()
|
|
1586
|
-
|
|
1587
1906
|
max_tasks = db.get_max_tasks(list_name)
|
|
1588
1907
|
max_starred = db.get_max_starred_tasks()
|
|
1589
1908
|
|
|
@@ -1597,7 +1916,7 @@ def _render_settings(
|
|
|
1597
1916
|
else:
|
|
1598
1917
|
hint_text = " " + " │ ".join(["Esc:cancel", "Enter:confirm"])
|
|
1599
1918
|
else:
|
|
1600
|
-
hint_text = " " + " │ ".join(["↑/k ↓/j:navigate", "e:edit", "Enter:select", "q:return"])
|
|
1919
|
+
hint_text = " " + " │ ".join(["↑/k ↓/j:navigate", "PgUp/PgDn:page", "e:edit", "Enter:select", "q:return"])
|
|
1601
1920
|
|
|
1602
1921
|
divider_width = min(len(_normal_hint_text(False)), console.width or 80)
|
|
1603
1922
|
console.print(Text("─" * divider_width, style="dim"), end="\033[K\n")
|
|
@@ -1722,9 +2041,12 @@ def _render_settings(
|
|
|
1722
2041
|
|
|
1723
2042
|
console.print(end="\033[K\n")
|
|
1724
2043
|
console.print(Text("─" * divider_width, style="dim"), end="\033[K\n")
|
|
1725
|
-
console.print(
|
|
1726
|
-
|
|
1727
|
-
|
|
2044
|
+
console.print(
|
|
2045
|
+
_footer_with_version(hint_text, divider_width),
|
|
2046
|
+
end="\033[K\n",
|
|
2047
|
+
overflow="crop",
|
|
2048
|
+
no_wrap=True,
|
|
2049
|
+
)
|
|
1728
2050
|
|
|
1729
2051
|
|
|
1730
2052
|
def _run_settings_loop(list_name: str) -> None:
|
|
@@ -1749,6 +2071,14 @@ def _run_settings_loop(list_name: str) -> None:
|
|
|
1749
2071
|
elif key in (term.KEY_ARROW_DOWN, "j"):
|
|
1750
2072
|
if hover < num_items - 1:
|
|
1751
2073
|
hover += 1
|
|
2074
|
+
elif key == term.KEY_PAGE_UP:
|
|
2075
|
+
hover = _page_index(
|
|
2076
|
+
hover, num_items, max(1, (console.height or 24) - 6), -1
|
|
2077
|
+
)
|
|
2078
|
+
elif key == term.KEY_PAGE_DOWN:
|
|
2079
|
+
hover = _page_index(
|
|
2080
|
+
hover, num_items, max(1, (console.height or 24) - 6), 1
|
|
2081
|
+
)
|
|
1752
2082
|
elif key in (term.KEY_ENTER, "e"):
|
|
1753
2083
|
if hover == 0:
|
|
1754
2084
|
mode = "edit"
|
|
@@ -148,6 +148,110 @@ class NoteTuiTests(unittest.TestCase):
|
|
|
148
148
|
self.assertEqual(scroll_positions[0], 0)
|
|
149
149
|
self.assertGreater(scroll_positions[-1], 0)
|
|
150
150
|
|
|
151
|
+
def test_task_visual_rows_include_wrapping_and_starred_separator(self) -> None:
|
|
152
|
+
tasks = [
|
|
153
|
+
{"text": "x" * 21, "starred": 1},
|
|
154
|
+
{"text": "next", "starred": 0},
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
starts, ends, total = tui._task_visual_row_ranges(tasks, 0, 24)
|
|
158
|
+
|
|
159
|
+
self.assertEqual(starts, [0, 3])
|
|
160
|
+
self.assertEqual(ends, [2, 4])
|
|
161
|
+
self.assertEqual(total, 4)
|
|
162
|
+
|
|
163
|
+
def test_main_loop_scrolls_large_task_lists(self) -> None:
|
|
164
|
+
db.create_list("main")
|
|
165
|
+
db.set_max_tasks(40, "main")
|
|
166
|
+
for index in range(20):
|
|
167
|
+
db.add_task(f"Task {index}", "main")
|
|
168
|
+
|
|
169
|
+
scroll_positions: list[int] = []
|
|
170
|
+
keys = iter([terminal.KEY_ARROW_DOWN] * 19 + ["q"])
|
|
171
|
+
test_console = Console(file=io.StringIO(), width=80, height=10, force_terminal=False)
|
|
172
|
+
|
|
173
|
+
with (
|
|
174
|
+
patch.object(tui, "console", test_console),
|
|
175
|
+
patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
|
|
176
|
+
patch.object(
|
|
177
|
+
tui,
|
|
178
|
+
"_render_main",
|
|
179
|
+
side_effect=lambda *args: scroll_positions.append(args[-2]),
|
|
180
|
+
),
|
|
181
|
+
):
|
|
182
|
+
tui._run_main_loop("main", lock_list=True)
|
|
183
|
+
|
|
184
|
+
self.assertEqual(scroll_positions[0], 0)
|
|
185
|
+
self.assertGreater(scroll_positions[-1], 0)
|
|
186
|
+
|
|
187
|
+
def test_page_down_moves_task_selection_by_one_viewport(self) -> None:
|
|
188
|
+
db.create_list("main")
|
|
189
|
+
db.set_max_tasks(40, "main")
|
|
190
|
+
for index in range(20):
|
|
191
|
+
db.add_task(f"Task {index}", "main")
|
|
192
|
+
|
|
193
|
+
hover_positions: list[int] = []
|
|
194
|
+
keys = iter([terminal.KEY_PAGE_DOWN, "q"])
|
|
195
|
+
test_console = Console(file=io.StringIO(), width=80, height=10, force_terminal=False)
|
|
196
|
+
|
|
197
|
+
with (
|
|
198
|
+
patch.object(tui, "console", test_console),
|
|
199
|
+
patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
|
|
200
|
+
patch.object(
|
|
201
|
+
tui,
|
|
202
|
+
"_render_main",
|
|
203
|
+
side_effect=lambda *args: hover_positions.append(args[1]),
|
|
204
|
+
),
|
|
205
|
+
):
|
|
206
|
+
tui._run_main_loop("main", lock_list=True)
|
|
207
|
+
|
|
208
|
+
self.assertEqual(hover_positions, [0, 4])
|
|
209
|
+
|
|
210
|
+
def test_page_down_moves_within_a_wrapped_editor_line(self) -> None:
|
|
211
|
+
lines = ["word " * 40]
|
|
212
|
+
|
|
213
|
+
row, column = tui._page_note_cursor(lines, 0, 0, 10, 3, 1)
|
|
214
|
+
|
|
215
|
+
self.assertEqual(row, 0)
|
|
216
|
+
self.assertGreater(column, 0)
|
|
217
|
+
|
|
218
|
+
def test_main_header_and_footer_stay_at_fixed_rows(self) -> None:
|
|
219
|
+
class CountingOutput(io.StringIO):
|
|
220
|
+
def __init__(self) -> None:
|
|
221
|
+
super().__init__()
|
|
222
|
+
self.write_count = 0
|
|
223
|
+
self.writes: list[str] = []
|
|
224
|
+
|
|
225
|
+
def write(self, value: str) -> int:
|
|
226
|
+
if value:
|
|
227
|
+
self.write_count += 1
|
|
228
|
+
self.writes.append(value)
|
|
229
|
+
return super().write(value)
|
|
230
|
+
|
|
231
|
+
db.create_list("main")
|
|
232
|
+
tasks = [
|
|
233
|
+
{"text": f"Task {index}", "status": "active", "starred": 0}
|
|
234
|
+
for index in range(20)
|
|
235
|
+
]
|
|
236
|
+
output = CountingOutput()
|
|
237
|
+
test_console = Console(file=output, width=80, height=10, force_terminal=False)
|
|
238
|
+
output.write_count = 0
|
|
239
|
+
|
|
240
|
+
with (
|
|
241
|
+
patch.object(tui, "console", test_console),
|
|
242
|
+
patch.object(terminal, "reset_cursor"),
|
|
243
|
+
patch.object(tui.sys.stdout, "write"),
|
|
244
|
+
patch.object(tui.sys.stdout, "flush"),
|
|
245
|
+
):
|
|
246
|
+
tui._render_main(tasks, hover=7, list_name="main", tasks_scroll=4)
|
|
247
|
+
|
|
248
|
+
frame = output.getvalue()
|
|
249
|
+
self.assertEqual(frame.count("\n"), 9)
|
|
250
|
+
self.assertTrue(frame.startswith("\033[Htd • main"))
|
|
251
|
+
self.assertIn("PgUp/PgDn:page", frame)
|
|
252
|
+
self.assertFalse(frame.endswith("\n\033[J"))
|
|
253
|
+
self.assertEqual(output.write_count, 1, [repr(value) for value in output.writes])
|
|
254
|
+
|
|
151
255
|
def test_alt_arrows_move_editor_lines(self) -> None:
|
|
152
256
|
note = db.add_note("Movement")
|
|
153
257
|
assert note is not None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|