td-task 0.2.3__tar.gz → 0.2.4__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.3
3
+ Version: 0.2.4
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
@@ -123,6 +123,7 @@ td notes delete 12 --json
123
123
  | `s` | Star task (pin to top, bold yellow) |
124
124
  | `c` | Archive all completed tasks |
125
125
  | `y` | Copy task list to clipboard |
126
+ | `f` | Filter notes by title in current list; submit empty query to clear |
126
127
  | `↑` / `k`   `↓` / `j` | Navigate tasks |
127
128
  | `PageUp` / `PageDown` | Move one viewport |
128
129
  | `Ctrl+↑` / `Ctrl+↓` | Reorder task position |
@@ -112,6 +112,7 @@ td notes delete 12 --json
112
112
  | `s` | Star task (pin to top, bold yellow) |
113
113
  | `c` | Archive all completed tasks |
114
114
  | `y` | Copy task list to clipboard |
115
+ | `f` | Filter notes by title in current list; submit empty query to clear |
115
116
  | `↑` / `k`   `↓` / `j` | Navigate tasks |
116
117
  | `PageUp` / `PageDown` | Move one viewport |
117
118
  | `Ctrl+↑` / `Ctrl+↓` | Reorder task position |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "td-task"
3
- version = "0.2.3"
3
+ version = "0.2.4"
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,8 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.4] - 2026-08-05
4
+ - Add live, case-insensitive note-title filtering with `f` in the current list.
5
+
3
6
  ## [0.2.3] - 2026-08-05
4
7
  - Scroll large task lists inside a fixed header and footer viewport.
5
8
  - Buffer complete TUI frames to prevent flicker and partial header rendering.
@@ -155,7 +155,7 @@ def _ensure_list_unlocked(list_name: str) -> bool:
155
155
 
156
156
 
157
157
  def _normal_hint_text(lock_list: bool = False) -> str:
158
- parts = ["a:task", "A:note", "e/Enter:edit", "E:rename note", "d:delete", "Space:done", "s:star", "c:clear"]
158
+ parts = ["f:filter", "a:task", "A:note", "e/Enter:edit", "E:rename note", "d:delete", "Space:done", "s:star", "c:clear"]
159
159
  if not lock_list:
160
160
  parts.append("l:view lists")
161
161
  parts.append("q:quit")
@@ -185,6 +185,7 @@ def _render_help_screen(lock_list: bool = False) -> None:
185
185
  console.print(" s Toggle star/priority (pin to top)")
186
186
  console.print(" c Archive all completed tasks")
187
187
  console.print(" y Copy active tasks in list to clipboard")
188
+ console.print(" f Filter notes by title in current list")
188
189
  console.print()
189
190
 
190
191
  console.print(Text("Note Editor:", style="bold yellow"))
@@ -237,7 +238,7 @@ def _render_help_screen(lock_list: bool = False) -> None:
237
238
 
238
239
  def _task_visible_rows(mode: str, status_msg: str, term_height: int) -> int:
239
240
  """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
+ reserved_rows = 2 if mode in ("new_note", "note_filter", "confirm") or status_msg else 0
241
242
  return max(1, term_height - 6 - reserved_rows)
242
243
 
243
244
 
@@ -308,6 +309,19 @@ def _task_page_hover(
308
309
  return 0
309
310
 
310
311
 
312
+ def _filter_notes(tasks: list[dict], query: str) -> list[dict]:
313
+ """Return notes whose titles contain the case-insensitive query."""
314
+ normalized_query = query.strip().casefold()
315
+ if not normalized_query:
316
+ return tasks
317
+ return [
318
+ task
319
+ for task in tasks
320
+ if task.get("is_note")
321
+ and normalized_query in task.get("text", "").casefold()
322
+ ]
323
+
324
+
311
325
  @_buffered_frame
312
326
  def _render_main(
313
327
  tasks: list[dict],
@@ -323,6 +337,7 @@ def _render_main(
323
337
  lists_scroll: int = 0,
324
338
  tasks_scroll: int = 0,
325
339
  fuzzy_scroll: int = 0,
340
+ note_filter: str = "",
326
341
  ) -> None:
327
342
  divider_width = min(len(_normal_hint_text(lock_list)), console.width or 80)
328
343
 
@@ -331,7 +346,7 @@ def _render_main(
331
346
  header = Text("td • ", style="bold")
332
347
  header.append(Text("lists menu", style="bold cyan"))
333
348
  header.append(Text(f" • {len(lists)} lists", style="dim"))
334
- console.print(header, end="\033[K\n")
349
+ console.print(header, end="\033[K\n", overflow="crop", no_wrap=True)
335
350
  else:
336
351
  open_count = sum(1 for t in tasks if t["status"] == "active")
337
352
  completed_count = db.get_completed_count(list_name)
@@ -341,7 +356,10 @@ def _render_main(
341
356
  header.append(Text(f"{open_count} open", style="dim"))
342
357
  header.append(Text(" / ", style="dim"))
343
358
  header.append(Text(f"{completed_count} completed", style="dim"))
344
- console.print(header, end="\033[K\n")
359
+ if note_filter:
360
+ header.append(Text(" • ", style="dim"))
361
+ header.append(Text(f'filter: "{note_filter}"', style="bold yellow"))
362
+ console.print(header, end="\033[K\n", overflow="crop", no_wrap=True)
345
363
 
346
364
  # Determine hints
347
365
  if mode == "edit":
@@ -357,6 +375,8 @@ def _render_main(
357
375
  hint_parts = ["Enter:confirm", "Esc:cancel"]
358
376
  elif mode == "fuzzy_list":
359
377
  hint_parts = ["Esc:cancel", "Enter:go to list", "↑/↓:navigate", "PgUp/PgDn:page"]
378
+ elif mode == "note_filter":
379
+ hint_parts = ["Esc:cancel", "Enter:apply", "type:filter note titles", "empty:clear"]
360
380
  else:
361
381
  # mode == "normal"
362
382
  if view == "lists_menu":
@@ -365,7 +385,7 @@ def _render_main(
365
385
  "Enter:open", "q:quit",
366
386
  ]
367
387
  else:
368
- hint_parts = ["PgUp/PgDn:page", "a:task", "A:note", "e/Enter:edit", "E:rename note", "d:delete", "Space:done", "s:star", "c:clear"]
388
+ hint_parts = ["PgUp/PgDn:page", "f:filter", "a:task", "A:note", "e/Enter:edit", "E:rename note", "d:delete", "Space:done", "s:star", "c:clear"]
369
389
  if not lock_list:
370
390
  hint_parts.append("l:view lists")
371
391
  hint_parts.append("q:quit")
@@ -582,7 +602,12 @@ def _render_main(
582
602
  prev_starred = is_starred
583
603
 
584
604
  if not tasks:
585
- lines.append(Text(" No items. Press a for a task or A for a note.", style="dim"))
605
+ empty_message = (
606
+ " No matching notes."
607
+ if note_filter
608
+ else " No items. Press a for a task or A for a note."
609
+ )
610
+ lines.append(Text(empty_message, style="dim"))
586
611
 
587
612
  visible_rows = _task_visible_rows(mode, status_msg, console.height or 24)
588
613
  visible_lines = lines[tasks_scroll:tasks_scroll + visible_rows]
@@ -591,7 +616,16 @@ def _render_main(
591
616
  for _ in range(visible_rows - len(visible_lines)):
592
617
  console.print(end="\033[K\n")
593
618
 
594
- if mode == "new_note":
619
+ if mode == "note_filter":
620
+ console.print(end="\033[K\n")
621
+ input_line = Text(" Filter notes > ", style="yellow bold")
622
+ input_line.append(Text(edit_text[:edit_cursor], style="yellow bold"))
623
+ char_under = edit_text[edit_cursor] if edit_cursor < len(edit_text) else " "
624
+ input_line.append(Text(char_under, style="reverse yellow bold"))
625
+ if edit_cursor < len(edit_text):
626
+ input_line.append(Text(edit_text[edit_cursor + 1:], style="yellow bold"))
627
+ console.print(input_line, end="\033[K\n", overflow="crop", no_wrap=True)
628
+ elif mode == "new_note":
595
629
  console.print(end="\033[K\n")
596
630
  console.print(Text(" Note name", style="bold yellow"), end="\033[K\n")
597
631
  input_line = Text(" > ", style="yellow bold")
@@ -1205,6 +1239,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1205
1239
  lists_scroll = 0
1206
1240
  tasks_scroll = 0
1207
1241
  fuzzy_scroll = 0
1242
+ note_filter = ""
1208
1243
 
1209
1244
  current_list = list_name
1210
1245
  if not lock_list and active_lists and current_list not in active_lists:
@@ -1226,7 +1261,9 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1226
1261
  hover = 0
1227
1262
  continue
1228
1263
 
1229
- tasks = db.get_active_tasks(current_list) if view == "tasks" else []
1264
+ all_tasks = db.get_active_tasks(current_list) if view == "tasks" else []
1265
+ displayed_filter = edit_text if mode == "note_filter" else note_filter
1266
+ tasks = _filter_notes(all_tasks, displayed_filter)
1230
1267
 
1231
1268
  # Determine and clamp hovers
1232
1269
  if mode == "fuzzy_list":
@@ -1318,6 +1355,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1318
1355
  lists_scroll,
1319
1356
  tasks_scroll,
1320
1357
  fuzzy_scroll,
1358
+ displayed_filter,
1321
1359
  )
1322
1360
  status_msg = ""
1323
1361
 
@@ -1360,12 +1398,16 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1360
1398
  edit_cursor = min(len(edit_text), edit_cursor + 1)
1361
1399
  elif key == term.KEY_ENTER:
1362
1400
  if matched:
1363
- current_list = matched[hover]
1401
+ selected_list = matched[hover]
1402
+ if selected_list != current_list:
1403
+ note_filter = ""
1404
+ current_list = selected_list
1364
1405
  else:
1365
1406
  cleaned_name = edit_text.strip()
1366
1407
  if cleaned_name:
1367
1408
  db.create_list(cleaned_name)
1368
1409
  current_list = cleaned_name
1410
+ note_filter = ""
1369
1411
  mode = "normal"
1370
1412
  view = "tasks"
1371
1413
  edit_text = ""
@@ -1378,6 +1420,70 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1378
1420
  edit_text = edit_text[:edit_cursor] + key + edit_text[edit_cursor:]
1379
1421
  edit_cursor += 1
1380
1422
 
1423
+ elif mode == "note_filter":
1424
+ if key == term.KEY_ESC:
1425
+ mode = "normal"
1426
+ edit_text = ""
1427
+ edit_cursor = 0
1428
+ hover = 0
1429
+ tasks_scroll = 0
1430
+ elif key == term.KEY_ENTER:
1431
+ note_filter = edit_text.strip()
1432
+ mode = "normal"
1433
+ edit_text = ""
1434
+ edit_cursor = 0
1435
+ hover = 0
1436
+ tasks_scroll = 0
1437
+ elif key == term.KEY_ARROW_UP:
1438
+ hover = max(0, hover - 1)
1439
+ elif key == term.KEY_ARROW_DOWN:
1440
+ hover = min(max(0, len(tasks) - 1), hover + 1)
1441
+ elif key == term.KEY_PAGE_UP:
1442
+ hover = _task_page_hover(
1443
+ tasks,
1444
+ hover,
1445
+ min(len(_normal_hint_text(lock_list)), console.width or 80),
1446
+ _task_visible_rows(mode, status_msg, console.height or 24),
1447
+ -1,
1448
+ )
1449
+ elif key == term.KEY_PAGE_DOWN:
1450
+ hover = _task_page_hover(
1451
+ tasks,
1452
+ hover,
1453
+ min(len(_normal_hint_text(lock_list)), console.width or 80),
1454
+ _task_visible_rows(mode, status_msg, console.height or 24),
1455
+ 1,
1456
+ )
1457
+ elif key in (term.KEY_CMD_BACKSPACE, term.KEY_CTRL_BACKSPACE):
1458
+ edit_text = ""
1459
+ edit_cursor = 0
1460
+ hover = 0
1461
+ tasks_scroll = 0
1462
+ elif key == term.KEY_BACKSPACE:
1463
+ if edit_cursor > 0:
1464
+ edit_text = edit_text[:edit_cursor - 1] + edit_text[edit_cursor:]
1465
+ edit_cursor -= 1
1466
+ hover = 0
1467
+ tasks_scroll = 0
1468
+ elif key == term.KEY_DELETE:
1469
+ if edit_cursor < len(edit_text):
1470
+ edit_text = edit_text[:edit_cursor] + edit_text[edit_cursor + 1:]
1471
+ hover = 0
1472
+ tasks_scroll = 0
1473
+ elif key == term.KEY_ARROW_LEFT:
1474
+ edit_cursor = max(0, edit_cursor - 1)
1475
+ elif key == term.KEY_ARROW_RIGHT:
1476
+ edit_cursor = min(len(edit_text), edit_cursor + 1)
1477
+ elif key == term.KEY_HOME:
1478
+ edit_cursor = 0
1479
+ elif key == term.KEY_END:
1480
+ edit_cursor = len(edit_text)
1481
+ elif len(key) == 1 and ord(key) >= 32:
1482
+ edit_text = edit_text[:edit_cursor] + key + edit_text[edit_cursor:]
1483
+ edit_cursor += 1
1484
+ hover = 0
1485
+ tasks_scroll = 0
1486
+
1381
1487
  elif mode == "normal":
1382
1488
  if view == "lists_menu":
1383
1489
  if key in ("q", term.KEY_ESC):
@@ -1408,7 +1514,10 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1408
1514
  elif key == term.KEY_ENTER:
1409
1515
  lists = db.get_all_lists()
1410
1516
  if lists:
1411
- current_list = lists[hover]
1517
+ selected_list = lists[hover]
1518
+ if selected_list != current_list:
1519
+ note_filter = ""
1520
+ current_list = selected_list
1412
1521
  view = "tasks"
1413
1522
  hover = 0
1414
1523
  tasks_scroll = 0
@@ -1485,6 +1594,12 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1485
1594
  hover = 0
1486
1595
  fuzzy_scroll = 0
1487
1596
  continue
1597
+ elif key == "f":
1598
+ mode = "note_filter"
1599
+ edit_text = note_filter
1600
+ edit_cursor = len(edit_text)
1601
+ hover = 0
1602
+ tasks_scroll = 0
1488
1603
  elif key in (term.KEY_ARROW_UP, "k"):
1489
1604
  if hover > 0:
1490
1605
  hover -= 1
@@ -1534,6 +1649,8 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1534
1649
  if current_list in lists:
1535
1650
  curr_idx = lists.index(current_list)
1536
1651
  next_idx = max(0, curr_idx - 1)
1652
+ if next_idx != curr_idx:
1653
+ note_filter = ""
1537
1654
  current_list = lists[next_idx]
1538
1655
  hover = 0
1539
1656
  tasks_scroll = 0
@@ -1542,6 +1659,8 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1542
1659
  if current_list in lists:
1543
1660
  curr_idx = lists.index(current_list)
1544
1661
  next_idx = min(len(lists) - 1, curr_idx + 1)
1662
+ if next_idx != curr_idx:
1663
+ note_filter = ""
1545
1664
  current_list = lists[next_idx]
1546
1665
  hover = 0
1547
1666
  tasks_scroll = 0
@@ -1555,15 +1674,15 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1555
1674
  hover += 1
1556
1675
  elif key == term.KEY_ALT_ARROW_UP:
1557
1676
  limit = db.get_max_tasks(current_list)
1558
- if tasks and hover > 0 and len(tasks) < limit:
1677
+ if tasks and hover > 0 and len(all_tasks) < limit:
1559
1678
  db.duplicate_task(tasks[hover]["id"], -1)
1560
1679
  tasks = db.get_active_tasks(current_list)
1561
1680
  hover -= 1
1562
- elif tasks and len(tasks) >= limit:
1681
+ elif tasks and len(all_tasks) >= limit:
1563
1682
  status_msg = f"max tasks reached ({limit})"
1564
1683
  elif key == term.KEY_ALT_ARROW_DOWN:
1565
1684
  limit = db.get_max_tasks(current_list)
1566
- if tasks and len(tasks) < limit:
1685
+ if tasks and len(all_tasks) < limit:
1567
1686
  db.duplicate_task(tasks[hover]["id"], 1)
1568
1687
  tasks = db.get_active_tasks(current_list)
1569
1688
  hover += 1
@@ -1587,7 +1706,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1587
1706
  edit_cursor = len(edit_text)
1588
1707
  elif key == "a":
1589
1708
  limit = db.get_max_tasks(current_list)
1590
- if len(tasks) < limit:
1709
+ if len(all_tasks) < limit:
1591
1710
  new_task = db.add_task("", current_list)
1592
1711
  if new_task:
1593
1712
  tasks = db.get_active_tasks(current_list)
@@ -1600,7 +1719,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1600
1719
  status_msg = f"max tasks reached ({limit})"
1601
1720
  elif key == "A":
1602
1721
  limit = db.get_max_tasks(current_list)
1603
- if len(tasks) < limit:
1722
+ if len(all_tasks) < limit:
1604
1723
  edit_text = _default_note_title()
1605
1724
  edit_cursor = len(edit_text)
1606
1725
  mode = "new_note"
@@ -1625,10 +1744,9 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1625
1744
  db.toggle_starred(tasks[hover]["id"])
1626
1745
  elif key == "y":
1627
1746
  # Yank copy
1628
- active_tasks = db.get_active_tasks(current_list)
1629
- if active_tasks:
1747
+ if tasks:
1630
1748
  lines = []
1631
- for t in active_tasks:
1749
+ for t in tasks:
1632
1750
  marker = "★" if t.get("starred", 0) == 1 else "○"
1633
1751
  lines.append(f"{marker} {t['text']}")
1634
1752
  content = "\n".join(lines)
@@ -1660,6 +1778,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1660
1778
  lists = db.get_all_lists()
1661
1779
  if current_list == confirm_list_name:
1662
1780
  current_list = lists[0] if lists else "main"
1781
+ note_filter = ""
1663
1782
  hover = 0
1664
1783
  view = "lists_menu"
1665
1784
  elif confirm_action == "archive_list":
@@ -1667,6 +1786,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1667
1786
  lists = db.get_all_lists()
1668
1787
  if current_list == confirm_list_name:
1669
1788
  current_list = lists[0] if lists else "main"
1789
+ note_filter = ""
1670
1790
  hover = min(hover, max(0, len(lists) - 1))
1671
1791
  view = "lists_menu"
1672
1792
  mode = "normal"
@@ -1775,6 +1895,7 @@ def _run_main_loop(list_name: str = "main", lock_list: bool = False) -> None:
1775
1895
  if cleaned_name:
1776
1896
  db.create_list(cleaned_name)
1777
1897
  current_list = cleaned_name
1898
+ note_filter = ""
1778
1899
  hover = 0
1779
1900
  view = "tasks"
1780
1901
  tasks_scroll = 0
@@ -160,6 +160,88 @@ class NoteTuiTests(unittest.TestCase):
160
160
  self.assertEqual(ends, [2, 4])
161
161
  self.assertEqual(total, 4)
162
162
 
163
+ def test_note_filter_matches_note_titles_only(self) -> None:
164
+ tasks = [
165
+ {"text": "Alpha task", "is_note": False},
166
+ {"text": "Alpha Journal", "is_note": True},
167
+ {"text": "Beta Journal", "is_note": True},
168
+ ]
169
+
170
+ self.assertEqual(tui._filter_notes(tasks, "alpha"), [tasks[1]])
171
+ self.assertEqual(tui._filter_notes(tasks, " JOURNAL "), tasks[1:])
172
+ self.assertEqual(tui._filter_notes(tasks, ""), tasks)
173
+
174
+ def test_f_filters_notes_in_current_list(self) -> None:
175
+ db.create_list("main")
176
+ db.add_task("Beta task", "main")
177
+ db.add_note("Alpha Journal", "main")
178
+ db.add_note("Beta Journal", "main")
179
+ frames: list[tuple[str, list[str], str]] = []
180
+ keys = iter(["f", "B", "E", "T", "A", terminal.KEY_ENTER, "q"])
181
+
182
+ with (
183
+ patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
184
+ patch.object(
185
+ tui,
186
+ "_render_main",
187
+ side_effect=lambda *args: frames.append(
188
+ (args[2], [task["text"] for task in args[0]], args[-1])
189
+ ),
190
+ ),
191
+ ):
192
+ tui._run_main_loop("main", lock_list=True)
193
+
194
+ self.assertEqual(frames[-1], ("normal", ["Beta Journal"], "BETA"))
195
+
196
+ def test_empty_note_filter_clears_active_filter(self) -> None:
197
+ db.create_list("main")
198
+ db.add_task("Task", "main")
199
+ db.add_note("Alpha Journal", "main")
200
+ frames: list[tuple[list[str], str]] = []
201
+ keys = iter([
202
+ "f", "a", "l", "p", "h", "a", terminal.KEY_ENTER,
203
+ "f", terminal.KEY_CMD_BACKSPACE, terminal.KEY_ENTER, "q",
204
+ ])
205
+
206
+ with (
207
+ patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
208
+ patch.object(
209
+ tui,
210
+ "_render_main",
211
+ side_effect=lambda *args: frames.append(
212
+ ([task["text"] for task in args[0]], args[-1])
213
+ ),
214
+ ),
215
+ ):
216
+ tui._run_main_loop("main", lock_list=True)
217
+
218
+ self.assertEqual(frames[-1], (["Task", "Alpha Journal"], ""))
219
+
220
+ def test_escape_cancels_note_filter_edits(self) -> None:
221
+ db.create_list("main")
222
+ db.add_note("Alpha Journal", "main")
223
+ db.add_note("Beta Journal", "main")
224
+ frames: list[tuple[list[str], str]] = []
225
+ keys = iter([
226
+ "f", "a", "l", "p", "h", "a", terminal.KEY_ENTER,
227
+ "f", terminal.KEY_CMD_BACKSPACE, "b", "e", "t", "a",
228
+ terminal.KEY_ESC, "q",
229
+ ])
230
+
231
+ with (
232
+ patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
233
+ patch.object(
234
+ tui,
235
+ "_render_main",
236
+ side_effect=lambda *args: frames.append(
237
+ ([task["text"] for task in args[0]], args[-1])
238
+ ),
239
+ ),
240
+ ):
241
+ tui._run_main_loop("main", lock_list=True)
242
+
243
+ self.assertEqual(frames[-1], (["Alpha Journal"], "alpha"))
244
+
163
245
  def test_main_loop_scrolls_large_task_lists(self) -> None:
164
246
  db.create_list("main")
165
247
  db.set_max_tasks(40, "main")
@@ -176,7 +258,7 @@ class NoteTuiTests(unittest.TestCase):
176
258
  patch.object(
177
259
  tui,
178
260
  "_render_main",
179
- side_effect=lambda *args: scroll_positions.append(args[-2]),
261
+ side_effect=lambda *args: scroll_positions.append(args[-3]),
180
262
  ),
181
263
  ):
182
264
  tui._run_main_loop("main", lock_list=True)
@@ -252,6 +334,32 @@ class NoteTuiTests(unittest.TestCase):
252
334
  self.assertFalse(frame.endswith("\n\033[J"))
253
335
  self.assertEqual(output.write_count, 1, [repr(value) for value in output.writes])
254
336
 
337
+ output.seek(0)
338
+ output.truncate(0)
339
+ output.write_count = 0
340
+ output.writes.clear()
341
+
342
+ with (
343
+ patch.object(tui, "console", test_console),
344
+ patch.object(terminal, "reset_cursor"),
345
+ patch.object(tui.sys.stdout, "write"),
346
+ patch.object(tui.sys.stdout, "flush"),
347
+ ):
348
+ tui._render_main(
349
+ tasks[:1],
350
+ hover=0,
351
+ mode="note_filter",
352
+ edit_text="Task" * 40,
353
+ edit_cursor=160,
354
+ list_name="main",
355
+ note_filter="Task" * 40,
356
+ )
357
+
358
+ filter_frame = output.getvalue()
359
+ self.assertEqual(filter_frame.count("\n"), 9)
360
+ self.assertTrue(filter_frame.startswith("\033[Htd • main"))
361
+ self.assertEqual(output.write_count, 1, [repr(value) for value in output.writes])
362
+
255
363
  def test_alt_arrows_move_editor_lines(self) -> None:
256
364
  note = db.add_note("Movement")
257
365
  assert note is not None
@@ -142,7 +142,7 @@ wheels = [
142
142
 
143
143
  [[package]]
144
144
  name = "td-task"
145
- version = "0.2.3"
145
+ version = "0.2.4"
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
File without changes