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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: td-task
3
- Version: 0.2.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,6 +1,6 @@
1
1
  [project]
2
2
  name = "td-task"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Fast TUI and CLI manager for tasks and Markdown notes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -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 matches"]
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
- for idx, match_item in enumerate(matched):
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 = max(3, term_height - 6)
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 + max_visible, len(lists))
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
- for line in lines:
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
- for line in lines:
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(Text(hint_text, style="dim"), end="\033[K\n")
508
- sys.stdout.write("\033[J")
509
- sys.stdout.flush()
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
- visible_rows = max(3, (console.height or 24) - 7)
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(Text(hint, style="dim"), end="\033[K\n")
947
- sys.stdout.write("\033[J")
948
- sys.stdout.flush()
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 = max(3, (console.height or 24) - 6)
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(tasks, hover, mode, edit_text, edit_cursor, confirm_msg, current_list, lock_list, view, status_msg, lists_scroll)
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(_footer_with_version(hint_text, divider_width), end="\033[K\n")
1726
- sys.stdout.write("\033[J")
1727
- sys.stdout.flush()
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
@@ -142,7 +142,7 @@ wheels = [
142
142
 
143
143
  [[package]]
144
144
  name = "td-task"
145
- version = "0.2.1"
145
+ version = "0.2.3"
146
146
  source = { editable = "." }
147
147
  dependencies = [
148
148
  { name = "cryptography" },
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