tklr-dgraham 0.0.0rc22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tklr/view.py ADDED
@@ -0,0 +1,3503 @@
1
+ from __future__ import annotations
2
+
3
+ # import tklr
4
+ import os
5
+ import time
6
+
7
+ from asyncio import create_task
8
+
9
+ from .shared import log_msg, bug_msg, display_messages, parse
10
+ from datetime import datetime, timedelta, date
11
+
12
+ # from logging import log
13
+ from packaging.version import parse as parse_version
14
+
15
+ # from rich import box
16
+ from rich.console import Console
17
+ from rich.segment import Segment
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+ from rich.rule import Rule
21
+ from rich.style import Style
22
+ from textual.app import App, ComposeResult
23
+ from textual.containers import Horizontal, Vertical, Grid
24
+ from textual.geometry import Size
25
+ from textual.reactive import reactive
26
+ from textual.screen import ModalScreen
27
+ from textual.screen import Screen, NoMatches
28
+ from textual.scroll_view import ScrollView
29
+ from textual.strip import Strip
30
+ from textual.widget import Widget
31
+ from textual.widgets import Input
32
+ from textual.widgets import Label
33
+ from textual.widgets import Markdown, Static, Footer, Button, Header, Tree
34
+ from textual.widgets import Placeholder
35
+ from textual.widgets import TextArea
36
+ from textual.widgets import OptionList
37
+ from textual import on
38
+ import string
39
+ import shutil
40
+ import asyncio
41
+ from .shared import fmt_user
42
+ from typing import Dict, Tuple
43
+ import pyperclip
44
+ from .item import Item
45
+ from .use_system import open_with_default, play_alert_sound
46
+
47
+ import re
48
+
49
+ from rich.panel import Panel
50
+ from textual.containers import Container
51
+
52
+ from typing import List, Callable, Optional, Any, Iterable, Tuple
53
+
54
+ # details_drawer.py
55
+ from textual import events
56
+
57
+ from textual.events import Key
58
+ from .versioning import get_version
59
+ from pathlib import Path
60
+
61
+ from dataclasses import dataclass
62
+ import json
63
+
64
+ from .shared import (
65
+ TYPE_TO_COLOR,
66
+ )
67
+
68
+
69
+ tklr_version = get_version()
70
+
71
+ # Color hex values for readability (formerly from prompt_toolkit.styles.named_colors)
72
+ LEMON_CHIFFON = "#FFFACD"
73
+ KHAKI = "#F0E68C"
74
+ LIGHT_SKY_BLUE = "#87CEFA"
75
+ DARK_GRAY = "#A9A9A9"
76
+ LIME_GREEN = "#32CD32"
77
+ SLATE_GREY = "#708090"
78
+ DARK_GREY = "#A9A9A9" # same as DARK_GRAY
79
+ GOLDENROD = "#DAA520"
80
+ DARK_ORANGE = "#FF8C00"
81
+ GOLD = "#FFD700"
82
+ ORANGE_RED = "#FF4500"
83
+ TOMATO = "#FF6347"
84
+ CORNSILK = "#FFF8DC"
85
+ FOOTER = "#FF8C00"
86
+ DARK_SALMON = "#E9967A"
87
+
88
+ # App version
89
+ VERSION = parse_version(tklr_version)
90
+
91
+ # Colors for UI elements
92
+ DAY_COLOR = LEMON_CHIFFON
93
+ FRAME_COLOR = KHAKI
94
+ HEADER_COLOR = LIGHT_SKY_BLUE
95
+ DIM_COLOR = DARK_GRAY
96
+ EVENT_COLOR = LIME_GREEN
97
+ AVAILABLE_COLOR = LIGHT_SKY_BLUE
98
+ WAITING_COLOR = SLATE_GREY
99
+ FINISHED_COLOR = DARK_GREY
100
+ GOAL_COLOR = GOLDENROD
101
+ CHORE_COLOR = KHAKI
102
+ PASTDUE_COLOR = DARK_ORANGE
103
+ BEGIN_COLOR = GOLD
104
+ DRAFT_COLOR = ORANGE_RED
105
+ TODAY_COLOR = TOMATO
106
+ # SELECTED_BACKGROUND = "#566573"
107
+ SELECTED_BACKGROUND = "#dcdcdc"
108
+ MATCH_COLOR = GOLD
109
+ TITLE_COLOR = CORNSILK
110
+ BIN_COLOR = TOMATO
111
+ NOTE_COLOR = DARK_SALMON
112
+ NOTICE_COLOR = GOLD
113
+
114
+ # This one appears to be a Rich/Textual style string
115
+ SELECTED_COLOR = "bold yellow"
116
+
117
+ ONEDAY = timedelta(days=1)
118
+ ONEWK = 7 * ONEDAY
119
+ alpha = [x for x in string.ascii_lowercase]
120
+
121
+
122
+ # TYPE_TO_COLOR - moved to shared.py
123
+
124
+
125
+ def base26_to_decimal(tag: str) -> int:
126
+ """Decode 'a'..'z' (a=0) for any length."""
127
+ total = 0
128
+ for ch in tag:
129
+ total = total * 26 + (ord(ch) - ord("a"))
130
+ return total
131
+
132
+
133
+ def indx_to_tag(indx: int, fill: int = 1):
134
+ """
135
+ Convert an index to a base-26 tag.
136
+ """
137
+ return decimal_to_base26(indx).rjust(fill, "a")
138
+
139
+
140
+ def build_details_help(meta: dict) -> list[str]:
141
+ # bug_msg(f"{meta = }")
142
+ is_task = meta.get("itemtype") == "~"
143
+ is_event = meta.get("itemtype") == "*"
144
+ is_goal = meta.get("itemtype") == "+"
145
+ is_recurring = bool(meta.get("rruleset"))
146
+ is_pinned = bool(meta.get("pinned")) if is_task else False
147
+ subject = meta.get("subject")
148
+
149
+ left, rght = [], []
150
+ left.append("[bold],e[/bold] Edit ")
151
+ left.append("[bold],c[/bold] Copy ")
152
+ left.append("[bold],d[/bold] Delete ")
153
+ rght.append("[bold],r[/bold] Reschedule ")
154
+ rght.append("[bold],n[/bold] Schedule New ")
155
+ rght.append("[bold],t[/bold] Touch ")
156
+
157
+ if is_task:
158
+ left.append("[bold],f[/bold] Finish ")
159
+ rght.append("[bold],p[/bold] Toggle Pinned ")
160
+ if is_recurring:
161
+ left.append("[bold]Ctrl+R[/bold] Show Repetitions ")
162
+
163
+ m = max(len(left), len(rght))
164
+ left += [""] * (m - len(left))
165
+ rght += [""] * (m - len(rght))
166
+
167
+ lines = [
168
+ f"[bold {TITLE_COLOR}]{meta.get('subject', '- Details -')}[/bold {TITLE_COLOR}]",
169
+ "",
170
+ ]
171
+ for l, r in zip(left, rght):
172
+ lines.append(f"{l} {r}" if r else l)
173
+ return lines
174
+
175
+
176
+ def meta_times(meta: dict) -> dict:
177
+ return {x: meta.get(x, "") for x in ["subject", "first", "second", "instance_ts"]}
178
+ # return "\n".join(
179
+ # [
180
+ # f"{x}: {meta.get(x, '')}"
181
+ # for x in ["subject", "first", "second", "instance_ts"]
182
+ # ]
183
+ # )
184
+
185
+
186
+ def _measure_rows(lines: list[str]) -> int:
187
+ """
188
+ Count how many display rows are implied by explicit newlines.
189
+ Does NOT try to wrap, so markup stays safe.
190
+ """
191
+ total = 0
192
+ for block in lines:
193
+ # each newline adds a line visually
194
+ total += len(block.splitlines()) or 1
195
+ return total
196
+
197
+
198
+ def _make_rows(lines: list[str]) -> list[str]:
199
+ new_lines = []
200
+ for block in lines:
201
+ new_lines.extend(block.splitlines())
202
+ return new_lines
203
+
204
+
205
+ def format_date_range(start_dt: datetime, end_dt: datetime):
206
+ """
207
+ Format a datetime object as a week string, taking not to repeat the month name unless the week spans two months.
208
+ """
209
+ same_year = start_dt.year == end_dt.year
210
+ same_month = start_dt.month == end_dt.month
211
+ if same_year and same_month:
212
+ return f"{start_dt.strftime('%B %-d')} - {end_dt.strftime('%-d, %Y')}"
213
+ elif same_year and not same_month:
214
+ return f"{start_dt.strftime('%B %-d')} - {end_dt.strftime('%B %-d, %Y')}"
215
+ else:
216
+ return f"{start_dt.strftime('%B %-d, %Y')} - {end_dt.strftime('%B %-d, %Y')}"
217
+
218
+
219
+ def decimal_to_base26(decimal_num):
220
+ """
221
+ Convert a decimal number to its equivalent base-26 string.
222
+
223
+ Args:
224
+ decimal_num (int): The decimal number to convert.
225
+
226
+ Returns:
227
+ str: The base-26 representation where 'a' = 0, 'b' = 1, ..., 'z' = 25.
228
+ """
229
+ if decimal_num < 0:
230
+ raise ValueError("Decimal number must be non-negative.")
231
+
232
+ if decimal_num == 0:
233
+ return "a" # Special case for zero
234
+
235
+ base26 = ""
236
+ while decimal_num > 0:
237
+ digit = decimal_num % 26
238
+ base26 = chr(digit + ord("a")) + base26 # Map digit to 'a'-'z'
239
+ decimal_num //= 26
240
+
241
+ return base26
242
+
243
+
244
+ def get_previous_yrwk(year, week):
245
+ """
246
+ Get the previous (year, week) from an ISO calendar (year, week).
247
+ """
248
+ # Convert the ISO year and week to a Monday date
249
+ monday_date = datetime.strptime(f"{year} {week} 1", "%G %V %u")
250
+ # Subtract 1 week
251
+ previous_monday = monday_date - timedelta(weeks=1)
252
+ # Get the ISO year and week of the new date
253
+ return previous_monday.isocalendar()[:2]
254
+
255
+
256
+ def get_next_yrwk(year, week):
257
+ """
258
+ Get the next (year, week) from an ISO calendar (year, week).
259
+ """
260
+ # Convert the ISO year and week to a Monday date
261
+ monday_date = datetime.strptime(f"{year} {week} 1", "%G %V %u")
262
+ # Add 1 week
263
+ next_monday = monday_date + timedelta(weeks=1)
264
+ # Get the ISO year and week of the new date
265
+ return next_monday.isocalendar()[:2]
266
+
267
+
268
+ def calculate_4_week_start():
269
+ """
270
+ Calculate the starting date of the 4-week period, starting on a Monday.
271
+ """
272
+ today = datetime.now()
273
+ iso_year, iso_week, iso_weekday = today.isocalendar()
274
+ start_of_week = today - timedelta(days=iso_weekday - 1)
275
+ weeks_into_cycle = (iso_week - 1) % 4
276
+ return start_of_week - timedelta(weeks=weeks_into_cycle)
277
+
278
+
279
+ HelpText = f"""\
280
+ [bold][{TITLE_COLOR}]TKLR {VERSION}[/{TITLE_COLOR}][/bold]
281
+ [bold][{HEADER_COLOR}]Key Bindings[/{HEADER_COLOR}][/bold]
282
+ [bold]^Q[/bold] Quit [bold]^S[/bold] Screenshot
283
+ [bold][{HEADER_COLOR}]Views[/{HEADER_COLOR}][/bold]
284
+ [bold]A[/bold] Agenda [bold]F[/bold] Find
285
+ [bold]B[/bold] Bins [bold]L[/bold] Last
286
+ [bold]C[/bold] Completed [bold]N[/bold] Next
287
+ [bold]W[/bold] Weeks [bold]T[/bold] Tags
288
+ [bold]R[/bold] Reminders
289
+ [bold][{HEADER_COLOR}]Search[/{HEADER_COLOR}][/bold]
290
+ [bold]/[/bold] Set search empty search clears
291
+ [bold]>[/bold] Next match [bold]<[/bold] Previous match
292
+ [bold][{HEADER_COLOR}]Weeks Navigation [/{HEADER_COLOR}][/bold]
293
+ [bold]Left[/bold] previous week [bold]Up[/bold] up in the list
294
+ [bold]Right[/bold] next week [bold]Down[/bold] down in the list
295
+ [bold]S+Left[/bold] 4 weeks back [bold]" "[/bold] current week
296
+ [bold]S+Right[/bold] 4 weeks forward [bold]"J"[/bold] ?jump to date?
297
+ [bold][{HEADER_COLOR}]Tags and Item Details[/{HEADER_COLOR}][/bold]
298
+ Each of the views listed above displays a list
299
+ of items. In these listings, each item begins
300
+ with a tag sequentially generated from 'a', 'b',
301
+ ..., 'z'. When more than 26 tags are required,
302
+ additional pages are appended with left and right
303
+ cursor keys used to move between pages. Press the
304
+ key of the tag on your keyboard to see the
305
+ details of the item and access related commands
306
+ to edit, reschedule, finish and so forth. To see
307
+ the complete list of available commands press ?
308
+ when the details pane is open.
309
+ """.splitlines()
310
+ #
311
+ # tklr/clipboard.py
312
+
313
+
314
+ def timestamped_screenshot_path(
315
+ view: str, directory: str = "screenshots_tmp", ext: str = "svg"
316
+ ) -> Path:
317
+ Path(directory).mkdir(parents=True, exist_ok=True)
318
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
319
+ return Path(directory) / f"{view}_screenshot-{ts}.{ext}"
320
+
321
+
322
+ class ClipboardUnavailable(RuntimeError):
323
+ """Raised when no system clipboard backend is available for pyperclip."""
324
+
325
+
326
+ def copy_to_clipboard(text: str) -> None:
327
+ """
328
+ Copy text to the system clipboard using pyperclip.
329
+
330
+ Raises ClipboardUnavailable if pyperclip cannot access a clipboard backend.
331
+ """
332
+ try:
333
+ pyperclip.copy(text)
334
+ except pyperclip.PyperclipException as e:
335
+ # Give the user an actionable message rather than silently failing.
336
+ raise ClipboardUnavailable(
337
+ "Clipboard operation failed: no system clipboard backend available. "
338
+ "On Linux you may need to install 'xclip', 'xsel' or 'wl-clipboard' "
339
+ "(e.g. 'sudo apt install xclip' or 'sudo pacman -S wl-clipboard'). "
340
+ "If you're running headless (CI/container/SSH) a desktop clipboard may not be present."
341
+ ) from e
342
+
343
+
344
+ def paste_from_clipboard() -> Optional[str]:
345
+ """
346
+ Return clipboard contents, or None if not available.
347
+
348
+ Raises ClipboardUnavailable on failure.
349
+ """
350
+ try:
351
+ return pyperclip.paste()
352
+ except pyperclip.PyperclipException as e:
353
+ raise ClipboardUnavailable(
354
+ "Paste failed: no system clipboard backend available. "
355
+ "On Linux you may need to install 'xclip', 'xsel' or 'wl-clipboard'."
356
+ ) from e
357
+
358
+
359
+ class BusyWeekBar(Widget):
360
+ """Renders a 7×5 weekly busy bar with aligned day labels."""
361
+
362
+ day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
363
+ colors = {0: "grey35", 1: "yellow", 2: "red"}
364
+
365
+ def __init__(self, segments: list[int]):
366
+ assert len(segments) == 35, "Expected 35 slots (7×5)"
367
+ super().__init__()
368
+ self.segments = segments
369
+
370
+ def render(self) -> Text:
371
+ # Row 1: labels
372
+ text = Text()
373
+ for d, lbl in enumerate(self.day_labels):
374
+ text.append(f"| {lbl} |", style="bold cyan")
375
+ if d < 6:
376
+ text.append(" ") # space between columns
377
+ text.append("\n")
378
+
379
+ # Row 2: busy/conflict visualization
380
+ for d in range(7):
381
+ day_bits = self.segments[d * 5 : (d + 1) * 5]
382
+ for val in day_bits:
383
+ ch = "█" if val else "░"
384
+ text.append(ch, style=self.colors.get(val, "grey35"))
385
+ if d < 6:
386
+ text.append(" ") # one space between columns
387
+
388
+ return text
389
+
390
+
391
+ class SafeScreen(Screen):
392
+ """Base class that runs post-mount setup safely (after layout is complete)."""
393
+
394
+ async def on_mount(self) -> None:
395
+ # Automatically schedule the post-mount hook if defined
396
+ if hasattr(self, "after_mount"):
397
+ # Run a tiny delay to ensure all widgets are fully realized
398
+ self.set_timer(0.01, self.after_mount)
399
+
400
+
401
+ class ListWithDetails(Container):
402
+ """Container with a main ScrollableList and a bottom details ScrollableList."""
403
+
404
+ def __init__(self, *args, match_color: str = "#ffd75f", **kwargs):
405
+ super().__init__(*args, **kwargs)
406
+ self._main: ScrollableList | None = None
407
+ self._details: ScrollableList | None = None
408
+ self.match_color = match_color
409
+ self._detail_key_handler: callable | None = None # ← inject this
410
+ self._details_active = False
411
+ self.details_meta: dict = {} # ← you already have this
412
+
413
+ def on_mount(self):
414
+ # 1) Set the widget backgrounds (outer)
415
+ for w in (self._main, self._details):
416
+ if w and hasattr(w, "styles"):
417
+ w.styles.background = "#373737"
418
+
419
+ # 2) Try to set the internal viewport background too (inner)
420
+ def force_scroller_bg(scroller, color: str):
421
+ if not scroller:
422
+ return
423
+ # Newer Textual names
424
+ for attr in ("_viewport", "_window", "_scroll_view", "_view", "_content"):
425
+ vp = getattr(scroller, attr, None)
426
+ if vp and hasattr(vp, "styles"):
427
+ vp.styles.background = color
428
+ try:
429
+ vp.refresh()
430
+ except Exception:
431
+ pass
432
+
433
+ force_scroller_bg(self._main, "#373737")
434
+ force_scroller_bg(self._details, "#373737")
435
+
436
+ # 3) (Optional) make the container itself non-transparent
437
+ if hasattr(self, "styles"):
438
+ self.styles.background = "#373737"
439
+
440
+ def _dump_chain(widget):
441
+ w = widget
442
+ depth = 0
443
+ while w is not None:
444
+ try:
445
+ bg = w.styles.background
446
+ except Exception:
447
+ bg = "<no styles.background>"
448
+ w = getattr(w, "parent", None)
449
+ depth += 1
450
+
451
+ try:
452
+ m = self.query_one("#main-list")
453
+ # bug_msg("=== debug: main-list styles ===")
454
+ # bug_msg(repr(m.styles))
455
+ _dump_chain(m)
456
+ except Exception as e:
457
+ log_msg(f"debug: couldn't find #main-list: {e}")
458
+
459
+ def compose(self):
460
+ # Background filler behind the lists
461
+ # yield Static("", id="list-bg")
462
+ self._main = ScrollableList([], id="main-list")
463
+ self._details = ScrollableList([], id="details-list")
464
+ self._details.add_class("hidden")
465
+ yield self._main
466
+ yield self._details
467
+
468
+ def update_list(
469
+ self, lines: list[str], meta_map: dict[str, dict] | None = None
470
+ ) -> None:
471
+ """
472
+ Replace the main list content and (optionally) update the tag→meta mapping.
473
+ `meta_map` is typically controller.list_tag_to_id[view] (or week_tag_to_id[week]).
474
+ """
475
+ self._main.update_list(lines)
476
+ if meta_map is not None:
477
+ self._meta_map = meta_map
478
+
479
+ def set_search_term(self, term: str | None) -> None:
480
+ self._main.set_search_term(term)
481
+
482
+ def clear_search(self) -> None:
483
+ self._main.clear_search()
484
+
485
+ def jump_next_match(self) -> None:
486
+ self._main.jump_next_match()
487
+
488
+ def jump_prev_match(self) -> None:
489
+ self._main.jump_prev_match()
490
+
491
+ # ---- details control ----
492
+
493
+ def show_details(
494
+ self, title: str, lines: list[str], meta: dict | None = None
495
+ ) -> None:
496
+ self.details_meta = meta or {} # <- keep meta for key actions
497
+ body = [title] + _make_rows(lines)
498
+ bug_msg(f"{meta_times(self.details_meta)}")
499
+ # bug_msg(f"{meta['instance_ts'] = }, {title = }, {lines = }")
500
+ self._details.update_list(body)
501
+ self._details.remove_class("hidden")
502
+ self._details_active = True
503
+ self._details.focus()
504
+
505
+ def hide_details(self) -> None:
506
+ self.details_meta = {} # clear meta on close
507
+ if not self._details.has_class("hidden"):
508
+ self._details_active = False
509
+ self._details.add_class("hidden")
510
+ self._main.focus()
511
+
512
+ def has_details_open(self) -> bool:
513
+ return not self._details.has_class("hidden")
514
+
515
+ def focus_main(self) -> None:
516
+ self._main.focus()
517
+
518
+ def set_meta_map(self, meta_map: dict[str, dict]) -> None:
519
+ self._meta_map = meta_map
520
+
521
+ def get_meta_for_tag(self, tag: str) -> dict | None:
522
+ return self._meta_map.get(tag)
523
+
524
+ def set_detail_key_handler(self, handler: callable) -> None:
525
+ """handler(key: str, meta: dict) -> None"""
526
+ self._detail_key_handler = handler
527
+
528
+ def on_key(self, event) -> None:
529
+ """Only handle detail commands; let lowercase tag keys bubble up."""
530
+ if not self.has_details_open():
531
+ return
532
+
533
+ k = event.key or ""
534
+
535
+ # 1) Let lowercase a–z pass through (tag selection)
536
+ if len(k) == 1 and "a" <= k <= "z":
537
+ # do NOT stop the event; DynamicViewApp will collect the tag chars
538
+ return
539
+
540
+ # 2) Close details with Escape (but not 'q')
541
+ if k == "escape":
542
+ if self.has_details_open():
543
+ self.hide_details()
544
+ event.stop()
545
+ return
546
+
547
+ # 3) Route only your command keys to the injected handler
548
+ if not self._detail_key_handler:
549
+ return
550
+
551
+ # Normalize keys: we want uppercase single-letter commands + 'ctrl+r'
552
+ if k == "ctrl+r":
553
+ cmd = "CTRL+R"
554
+ elif len(k) == 1:
555
+ cmd = k.upper()
556
+ else:
557
+ cmd = k # leave other keys as-is (unlikely used)
558
+
559
+ # Allow only the detail commands you use (uppercase)
560
+ ALLOWED = {"E", "D", "F", "P", "N", "R", "T", "CTRL+R"}
561
+ if cmd in ALLOWED:
562
+ try:
563
+ self._detail_key_handler(cmd, self.details_meta or {})
564
+ finally:
565
+ event.stop()
566
+
567
+
568
+ class DetailsHelpScreen(ModalScreen[None]):
569
+ BINDINGS = [
570
+ ("escape", "app.pop_screen", "Close"),
571
+ ("ctrl+q", "app.quit", "Quit"),
572
+ ]
573
+
574
+ def __init__(self, text: str, title: str = "Item Commands"):
575
+ super().__init__()
576
+ self._title = title
577
+ self._text = text
578
+
579
+ def compose(self) -> ComposeResult:
580
+ yield Vertical(
581
+ Static(self._title, id="details_title", classes="title-class"),
582
+ Static(self._text, expand=True, id="details_text"),
583
+ )
584
+ yield Footer()
585
+
586
+
587
+ class HelpModal(ModalScreen[None]):
588
+ """Scrollable help overlay."""
589
+
590
+ BINDINGS = [
591
+ ("escape", "dismiss", "Close"),
592
+ ("q", "dismiss", "Close"),
593
+ ]
594
+
595
+ def __init__(self, title: str, lines: list[str] | str):
596
+ super().__init__()
597
+ self._title = title
598
+ self._body = lines if isinstance(lines, str) else "\n".join(lines)
599
+
600
+ def compose(self) -> ComposeResult:
601
+ yield Vertical(
602
+ Static(self._title, id="details_title", classes="title-class"),
603
+ ScrollView(
604
+ Static(Text.from_markup(self._body), id="help_body"), id="help_scroll"
605
+ ),
606
+ Footer(), # your normal footer style
607
+ id="help_layout",
608
+ )
609
+
610
+ def on_mount(self) -> None:
611
+ self.set_focus(self.query_one("#help_scroll", ScrollView))
612
+
613
+ def action_dismiss(self) -> None:
614
+ self.app.pop_screen()
615
+
616
+
617
+ # class ChoicePrompt(ModalScreen[str | None]):
618
+ # """
619
+ # Simple multi-choice prompt.
620
+ #
621
+ # Shows a message + numbered options, user types 1/2/3... and presses Enter.
622
+ # Returns the chosen *value* (from choices list), or None on ESC/invalid.
623
+ # """
624
+ #
625
+ # def __init__(self, message: str, choices: list[str]):
626
+ # super().__init__()
627
+ # self.title_text = " Choose an option"
628
+ # self.message = message.strip()
629
+ # self.choices = choices
630
+ # self.input: Input | None = None
631
+ # self.feedback: Static | None = None
632
+ # self.instructions: Static | None = None
633
+ #
634
+ # def compose(self) -> "ComposeResult":
635
+ # from rich.text import Text # if you like; or just use strings
636
+ #
637
+ # with Vertical(id="choice_prompt"):
638
+ # self.instructions = Static(
639
+ # "Type the number of your choice, then press "
640
+ # "[bold yellow]ENTER[/bold yellow]. "
641
+ # "Press [bold yellow]ESC[/bold yellow] to cancel.",
642
+ # id="choice_instructions",
643
+ # )
644
+ #
645
+ # # Build a numbered list of options
646
+ # lines = []
647
+ # for i, choice in enumerate(self.choices, start=1):
648
+ # lines.append(f"{i}) {choice}")
649
+ # choices_text = "\n".join(lines)
650
+ #
651
+ # self.feedback = Static("", id="choice_feedback")
652
+ # self.input = Input(id="choice_entry")
653
+ #
654
+ # yield Static(self.title_text, classes="title-class", id="choice_title")
655
+ #
656
+ # if self.message:
657
+ # yield Static(self.message.strip(), id="choice_message")
658
+ #
659
+ # yield self.instructions
660
+ # yield Static(choices_text, id="choice_options")
661
+ # yield self.input
662
+ # yield self.feedback
663
+ #
664
+ # def on_mount(self) -> None:
665
+ # self.query_one("#choice_entry", Input).focus()
666
+ # self.feedback.update("")
667
+ #
668
+ # def on_key(self, event) -> None:
669
+ # if event.key == "escape":
670
+ # self.dismiss(None)
671
+ # elif event.key == "enter":
672
+ # value = (self.input.value or "").strip()
673
+ # try:
674
+ # idx = int(value)
675
+ # except ValueError:
676
+ # self.feedback.update("[red]Invalid choice[/red]")
677
+ # return
678
+ #
679
+ # if 1 <= idx <= len(self.choices):
680
+ # self.dismiss(self.choices[idx - 1])
681
+ # else:
682
+ # self.feedback.update("[red]Invalid choice[/red]")
683
+
684
+
685
+ class OptionPrompt(ModalScreen[Optional[str]]):
686
+ """
687
+ Modal screen offering a message + choice list using OptionList.
688
+
689
+ Returns the chosen option label (string) or None on cancel (ESC).
690
+ """
691
+
692
+ def __init__(self, message: str, options: List[str]):
693
+ super().__init__()
694
+ self.message = message.strip()
695
+ self.options = options
696
+ self._olist: OptionList | None = None
697
+
698
+ def compose(self):
699
+ with Vertical(id="option_prompt"):
700
+ yield Static(" Select an option ", classes="title-class", id="option_title")
701
+
702
+ if self.message:
703
+ yield Static(self.message, id="option_message")
704
+
705
+ yield Static(
706
+ "Use ↑/↓ to move, [bold yellow]Enter[/bold yellow] to select, "
707
+ "[bold yellow]ESC[/bold yellow] to cancel.",
708
+ id="option_instructions",
709
+ )
710
+
711
+ self._olist = OptionList(*self.options, id="option_list")
712
+ yield self._olist
713
+
714
+ def on_mount(self) -> None:
715
+ # Make sure the list actually has focus
716
+ self._olist = self.query_one("#option_list", OptionList)
717
+ self._olist.focus()
718
+
719
+ @on(OptionList.OptionSelected)
720
+ def _on_option_selected(self, event: OptionList.OptionSelected) -> None:
721
+ """Called when the user hits Enter on an option."""
722
+ # event.option.prompt is the label we passed in
723
+ label = str(event.option.prompt)
724
+ # You can log here to prove it fires:
725
+ # bug_msg(f"OptionPrompt: selected {label!r}")
726
+ self.dismiss(label)
727
+ event.stop()
728
+
729
+ def on_key(self, event: events.Key) -> None:
730
+ """Only handle ESC here; OptionList handles Enter itself."""
731
+ if event.key == "escape":
732
+ event.stop()
733
+ self.dismiss(None)
734
+
735
+
736
+ class ChoicePrompt(ModalScreen[Optional[str]]):
737
+ """
738
+ Simple multi-choice prompt.
739
+
740
+ Shows a message + numbered options.
741
+ User presses 1/2/3... to select, or ESC to cancel.
742
+
743
+ Returns the chosen *string* from `choices`, or None.
744
+ """
745
+
746
+ def __init__(self, message: str, choices: List[str]):
747
+ super().__init__()
748
+ self.title_text = " Choose an option"
749
+ self.message = message.strip()
750
+ self.choices = choices
751
+
752
+ def compose(self) -> "ComposeResult":
753
+ with Vertical(id="choice_prompt"):
754
+ yield Static(self.title_text, classes="title-class", id="choice_title")
755
+
756
+ if self.message:
757
+ yield Static(self.message, id="choice_message")
758
+
759
+ # Build numbered list of options
760
+ lines = []
761
+ for i, choice in enumerate(self.choices, start=1):
762
+ lines.append(f"{i}) {choice}")
763
+ choices_text = "\n".join(lines)
764
+
765
+ yield Static(
766
+ "Press the number of your choice, or ESC to cancel.",
767
+ id="choice_instructions",
768
+ )
769
+ # yield Static(choices_text, id="choice_options")
770
+
771
+ def on_key(self, event) -> None:
772
+ key = event.key
773
+
774
+ if key == "escape":
775
+ self.dismiss(None)
776
+ return
777
+
778
+ # Numeric keys '1', '2', ... map directly to choices
779
+ if key.isdigit():
780
+ idx = int(key)
781
+ if 1 <= idx <= len(self.choices):
782
+ self.dismiss(self.choices[idx - 1])
783
+
784
+
785
+ class ConfirmPrompt(ModalScreen[Optional[bool]]):
786
+ """
787
+ Simple yes/no/escape confirm dialog.
788
+
789
+ Returns:
790
+ True -> user confirmed ("yes")
791
+ False -> user explicitly said "no"
792
+ None -> user cancelled with ESC
793
+ """
794
+
795
+ def __init__(self, message: str):
796
+ super().__init__()
797
+ self.title_text = " Confirm"
798
+ self.message = message.strip()
799
+
800
+ def compose(self) -> "ComposeResult":
801
+ with Vertical(id="confirm_prompt"):
802
+ yield Static(self.title_text, classes="title-class", id="confirm_title")
803
+
804
+ if self.message:
805
+ yield Static(self.message, id="confirm_message")
806
+
807
+ yield Static(
808
+ "Press [bold yellow]Y[/bold yellow] for yes, "
809
+ "[bold yellow]N[/bold yellow] for no, or [bold yellow]ESC[/bold yellow] to cancel.",
810
+ id="confirm_instructions",
811
+ )
812
+
813
+ def on_key(self, event) -> None:
814
+ key = event.key.lower()
815
+
816
+ if key == "escape":
817
+ self.dismiss(None)
818
+ elif key == "y":
819
+ self.dismiss(True)
820
+ elif key == "n":
821
+ self.dismiss(False)
822
+
823
+
824
+ # class DatetimePrompt(ModalScreen[datetime | None]):
825
+ # """
826
+ # Prompt for a datetime, live-parsed with dateutil.parser.parse.
827
+ # """
828
+ #
829
+ # def __init__(
830
+ # self,
831
+ # message: str, # top custom lines before fixed footer
832
+ # subject: str | None = None,
833
+ # due: str | None = None,
834
+ # default: datetime | None = None,
835
+ # ):
836
+ # super().__init__()
837
+ # self.title_text = " Datetime Entry"
838
+ # self.message = message.strip()
839
+ # # self.subject = subject
840
+ # # self.due = due
841
+ # self.default = default or datetime.now()
842
+ #
843
+ # # assigned later
844
+ # self.input: Input | None = None
845
+ # self.feedback: Static | None = None
846
+ # self.instructions: Static | None = None
847
+ #
848
+ # def compose(self) -> ComposeResult:
849
+ # """Build prompt layout."""
850
+ # # ARROW = "↳"
851
+ # default_str = self.default.strftime("%Y-%m-%d %H:%M")
852
+ #
853
+ # def rule():
854
+ # return Static("─" * 60, classes="dim-rule")
855
+ #
856
+ # with Vertical(id="dt_prompt"):
857
+ # instructions = [
858
+ # "Modify the datetime belew if necessary, then press",
859
+ # "[bold yellow]ENTER[/bold yellow] to submit or [bold yellow]ESC[/bold yellow] to cancel.",
860
+ # ]
861
+ # self.instructions = Static("\n".join(instructions), id="dt_instructions")
862
+ # self.feedback = Static(f"️↳ {default_str}", id="dt_feedback")
863
+ # self.input = Input(value=default_str, id="dt_entry")
864
+ # #
865
+ # # Title
866
+ # yield Static(self.title_text, classes="title-class", id="dt_title")
867
+ # # yield rule()
868
+ #
869
+ # # Message (custom, may include subject/due or other contextual info)
870
+ # if self.message:
871
+ # yield Static(self.message.strip(), id="dt_message")
872
+ # # yield rule()
873
+ #
874
+ # yield self.instructions
875
+ #
876
+ # yield self.input
877
+ #
878
+ # yield self.feedback
879
+ #
880
+ # # yield rule()
881
+ #
882
+ # def on_mount(self) -> None:
883
+ # """Focus the input and show feedback for the initial value."""
884
+ # self.query_one("#dt_entry", Input).focus()
885
+ # self._update_feedback(self.input.value)
886
+ #
887
+ # def _update_feedback(self, text: str) -> None:
888
+ # try:
889
+ # parsed = parse(text)
890
+ # if isinstance(parsed, date) and not isinstance(parsed, datetime):
891
+ # self.feedback.update(f"datetime: {parsed.strftime('%Y-%m-%d')}")
892
+ # else:
893
+ # self.feedback.update(f"datetime: {parsed.strftime('%Y-%m-%d %H:%M')}")
894
+ # except Exception:
895
+ # _t = f": {text} " if text else ""
896
+ # self.feedback.update(f"[{ORANGE_RED}] invalid{_t}[/{ORANGE_RED}] ")
897
+ #
898
+ # def on_key(self, event) -> None:
899
+ # """Handle Enter and Escape."""
900
+ # if event.key == "escape":
901
+ # self.dismiss(None)
902
+ # elif event.key == "enter":
903
+ # try:
904
+ # value = self.input.value.strip()
905
+ # parsed = parse(value) if value else self.default
906
+ # self.dismiss(parsed)
907
+ # except Exception:
908
+ # self.dismiss(None)
909
+
910
+
911
+ class DatetimePrompt(ModalScreen[datetime | None]):
912
+ """
913
+ Prompt for a datetime, live-parsed with dateutil.parser.parse.
914
+ """
915
+
916
+ def __init__(
917
+ self,
918
+ message: str,
919
+ subject: str | None = None,
920
+ due: str | None = None,
921
+ default: datetime | None = None,
922
+ ):
923
+ super().__init__()
924
+ self.title_text = " Datetime Entry"
925
+ self.message = message.strip()
926
+ self.default = default or datetime.now()
927
+
928
+ self.input: Input | None = None
929
+ self.feedback: Static | None = None
930
+ self.instructions: Static | None = None
931
+
932
+ def compose(self) -> ComposeResult:
933
+ default_str = self.default.strftime("%Y-%m-%d %H:%M")
934
+
935
+ with Vertical(id="dt_prompt"):
936
+ instructions = [
937
+ "Modify the datetime below if necessary, then press",
938
+ "[bold yellow]ENTER[/bold yellow] to submit or [bold yellow]ESC[/bold yellow] to cancel.",
939
+ ]
940
+ self.instructions = Static("\n".join(instructions), id="dt_instructions")
941
+ self.feedback = Static(f"️↳ {default_str}", id="dt_feedback")
942
+ self.input = Input(value=default_str, id="dt_entry")
943
+
944
+ yield Static(self.title_text, classes="title-class", id="dt_title")
945
+
946
+ if self.message:
947
+ yield Static(self.message.strip(), id="dt_message")
948
+
949
+ yield self.instructions
950
+ yield self.input
951
+ yield self.feedback
952
+
953
+ def on_mount(self) -> None:
954
+ """Focus the input and show feedback for the initial value."""
955
+ self.query_one("#dt_entry", Input).focus()
956
+ self._update_feedback(self.input.value)
957
+
958
+ def _update_feedback(self, text: str) -> None:
959
+ try:
960
+ parsed = parse(text)
961
+ if isinstance(parsed, date) and not isinstance(parsed, datetime):
962
+ self.feedback.update(f"datetime: {parsed.strftime('%Y-%m-%d')}")
963
+ else:
964
+ self.feedback.update(f"datetime: {parsed.strftime('%Y-%m-%d %H:%M')}")
965
+ except Exception:
966
+ _t = f": {text} " if text else ""
967
+ self.feedback.update(f"[{ORANGE_RED}] invalid{_t}[/{ORANGE_RED}] ")
968
+
969
+ def on_input_changed(self, event: Input.Changed) -> None:
970
+ """Live-parse as the user types."""
971
+ if event.input.id == "dt_entry":
972
+ self._update_feedback(event.value)
973
+
974
+ # def on_key(self, event) -> None:
975
+ # """Handle Enter and Escape."""
976
+ # if event.key == "escape":
977
+ # self.dismiss(None)
978
+ # elif event.key == "enter":
979
+ # try:
980
+ # value = self.input.value.strip()
981
+ # parsed = parse(value) if value else self.default
982
+ # log_msg(f"returning {parsed =} from submitted {value = }")
983
+ # self.dismiss(parsed)
984
+ # except Exception:
985
+ # self.dismiss(None)
986
+
987
+ def on_key(self, event) -> None:
988
+ """Handle Enter and Escape."""
989
+ if event.key == "escape":
990
+ self.dismiss(None)
991
+ event.stop()
992
+ elif event.key == "enter":
993
+ try:
994
+ value = self.input.value.strip()
995
+ parsed = parse(value) if value else self.default
996
+ self.dismiss(parsed)
997
+ except Exception:
998
+ self.dismiss(None)
999
+ finally:
1000
+ event.stop()
1001
+
1002
+
1003
+ ORANGE_RED = "red3"
1004
+ FOOTER = "yellow"
1005
+
1006
+
1007
+ class EditorScreen(Screen):
1008
+ """
1009
+ Single-Item editor with live, token-aware feedback.
1010
+
1011
+ Behavior:
1012
+ - Keeps one Item instance (self.item).
1013
+ - On text change: item.final = False; item.parse_input(text)
1014
+ - Feedback shows status for the token under the cursor (if any).
1015
+ - Save / Commit:
1016
+ item.final = True; item.parse_input(text) # finalize rrules/jobs/etc
1017
+ persist only if parse_ok, else warn.
1018
+ """
1019
+
1020
+ # BINDINGS = [
1021
+ # ("shift+enter", "commit", "Commit"),
1022
+ # ("ctrl+s", "save", "Save"),
1023
+ # ("escape", "close", "Back"),
1024
+ # ]
1025
+ BINDINGS = [
1026
+ ("ctrl+s", "save_and_close", "Save"),
1027
+ ("shift+enter", "save_and_close", "Commit"),
1028
+ ("escape", "close", "Back"),
1029
+ ]
1030
+
1031
+ def __init__(
1032
+ self, controller, record_id: int | None = None, *, seed_text: str = ""
1033
+ ):
1034
+ super().__init__()
1035
+ self.controller = controller
1036
+ self.record_id = record_id
1037
+ self.entry_text = seed_text
1038
+
1039
+ # one persistent Item
1040
+ from tklr.item import Item # adjust import to your layout if needed
1041
+
1042
+ self.ItemCls = Item
1043
+ self.item = self.ItemCls(
1044
+ seed_text, controller=self.controller
1045
+ ) # initialize with existing text
1046
+ self._feedback_lines: list[str] = []
1047
+
1048
+ # widgets
1049
+ self._title: Static | None = None
1050
+ self._message: Static | None = None
1051
+ self._text: TextArea | None = None
1052
+ self._feedback: Static | None = None
1053
+ self._instructions: Static | None = None
1054
+
1055
+ # ---------- Layout like DatetimePrompt ----------
1056
+ def compose(self) -> ComposeResult:
1057
+ # title_text = " Editor"
1058
+ title_text = self._build_context()
1059
+
1060
+ with Vertical(id="ed_prompt"):
1061
+ instructions = [
1062
+ "Edit the entry below as desired, then press",
1063
+ f"[bold {FOOTER}]Ctrl+S[/bold {FOOTER}] to save or [bold {FOOTER}]Esc[/bold {FOOTER}] to cancel",
1064
+ ]
1065
+ self._instructions = Static("\n".join(instructions), id="ed_instructions")
1066
+ self._feedback = Static("", id="ed_feedback")
1067
+ self._text = TextArea(self.entry_text, id="ed_entry")
1068
+
1069
+ yield Static(title_text, classes="title-class", id="ed_title")
1070
+
1071
+ # yield Static(ctx_line, id="ed_message")
1072
+
1073
+ yield self._instructions
1074
+ yield self._text
1075
+ yield self._feedback
1076
+
1077
+ def on_mount(self) -> None:
1078
+ # focus editor and run initial parse (non-final)
1079
+ if self._text:
1080
+ self._text.focus()
1081
+ self._render_feedback()
1082
+ self._live_parse_and_feedback(final=False)
1083
+
1084
+ # ---------- Text change -> live parse ----------
1085
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
1086
+ """Re-parse using the actual TextArea content, not the event payload."""
1087
+ # Make sure we have a handle to the TextArea
1088
+ # if not getattr(self, "_text", None):
1089
+ # self._text = self.query_one("#ed_entry", TextArea)
1090
+
1091
+ # Source of truth: the widget's text property
1092
+ self.entry_text = self._text.text or ""
1093
+ self._live_parse_and_feedback(final=False)
1094
+
1095
+ # Optional: stop propagation so nothing else double-handles it
1096
+ event.stop()
1097
+
1098
+ def on_text_area_selection_changed(self, event: TextArea.SelectionChanged) -> None:
1099
+ # Don't re-parse—just re-render feedback for the new caret position
1100
+ self._render_feedback()
1101
+
1102
+ # ---------- Actions ----------
1103
+ # def action_close(self) -> None:
1104
+ # self.app.pop_screen()
1105
+ #
1106
+ # def action_save(self) -> None:
1107
+ # """Finalize, re-parse, and persist if valid (no partial saves)."""
1108
+ # ok = self._finalize_and_validate()
1109
+ # if not ok:
1110
+ # self.app.notify("Cannot save: fix errors first.", severity="warning")
1111
+ # return
1112
+ # self._persist(self.item)
1113
+ # self.app.notify("Saved.", timeout=1.0)
1114
+ #
1115
+ #
1116
+ # def action_commit(self) -> None:
1117
+ # """Same semantics as save; you can keep separate if you want a different UX."""
1118
+ # ok = self._finalize_and_validate()
1119
+ # if not ok:
1120
+ # self.app.notify("Cannot commit: fix errors first.", severity="warning")
1121
+ # return
1122
+ # self._persist(self.item)
1123
+ # self.app.notify("Committed.", timeout=1.0)
1124
+ # self._try_refresh_calling_view()
1125
+ # self.app.pop_screen()
1126
+
1127
+ def action_save_and_close(self) -> None:
1128
+ ok = self._finalize_and_validate()
1129
+ if not ok:
1130
+ self.app.notify("Cannot save: fix errors first.", severity="warning")
1131
+ return
1132
+ self._persist(self.item)
1133
+ self.app.notify("Saved.", timeout=0.8)
1134
+ self.dismiss({"changed": True, "record_id": self.record_id})
1135
+
1136
+ # def action_save(self) -> None: # optional shim
1137
+ # self.action_save_and_close()
1138
+ #
1139
+ # def action_commit(self) -> None: # optional shim
1140
+ # self.action_save_and_close()
1141
+
1142
+ def action_close(self) -> None:
1143
+ self.dismiss(None) # close without saving
1144
+
1145
+ # ---------- Internals ----------
1146
+ def _build_context(self) -> str:
1147
+ if self.record_id is None:
1148
+ return "New item"
1149
+ row = self.controller.db_manager.get_record(self.record_id)
1150
+ # subj = row[2] or "(untitled)"
1151
+ return f"Editing Record {self.record_id}"
1152
+
1153
+ def _finalize_and_validate(self) -> bool:
1154
+ """
1155
+ Finalize the entry (rrules/jobs/etc) and validate.
1156
+ Returns True iff parse_ok after a finalizing parse.
1157
+ """
1158
+ if not getattr(self.item, "parse_ok", False):
1159
+ self._render_feedback()
1160
+ return False
1161
+
1162
+ self.item.final = True
1163
+ self.item.parse_input(self.entry_text)
1164
+ self.item.finalize_record()
1165
+ return self.item.parse_ok
1166
+
1167
+ def _live_parse_and_feedback(self, *, final: bool) -> None:
1168
+ """Non-throwing live parse + feedback for current cursor token."""
1169
+ self.item.final = bool(final)
1170
+ self.item.parse_input(self.entry_text)
1171
+ self._render_feedback()
1172
+
1173
+ def _token_at(self, idx: int) -> Optional[Dict[str, Any]]:
1174
+ """Find the token whose [s,e) spans idx; fallback to first incomplete after idx."""
1175
+ toks: List[Dict[str, Any]] = getattr(self.item, "relative_tokens", []) or []
1176
+ for t in toks:
1177
+ s, e = t.get("s", -1), t.get("e", -1)
1178
+ if s <= idx < e:
1179
+ return t
1180
+ for t in toks:
1181
+ if t.get("incomplete") and t.get("s", 1 << 30) >= idx:
1182
+ return t
1183
+ return None
1184
+
1185
+ def _cursor_abs_index(self) -> int:
1186
+ """Map TextArea (row, col) to absolute index in self.entry_text."""
1187
+ try:
1188
+ ta = self.query_one("#ed_entry", TextArea)
1189
+ except NoMatches:
1190
+ return len(self.entry_text or "")
1191
+ loc = getattr(ta, "cursor_location", None)
1192
+ if not loc:
1193
+ return len(self.entry_text or "")
1194
+ row, col = loc
1195
+ lines = (self.entry_text or "").splitlines(True) # keep \n
1196
+ if row >= len(lines):
1197
+ return len(self.entry_text or "")
1198
+ return sum(len(l) for l in lines[:row]) + min(col, len(lines[row]))
1199
+
1200
+ def _render_feedback(self) -> None:
1201
+ """Update the feedback panel using only screen state."""
1202
+ _AT_DESC = {
1203
+ "#": "Ref / id",
1204
+ "+": "Include datetimes",
1205
+ "-": "Exclued datetimes",
1206
+ "a": "Alert",
1207
+ "b": "Bin",
1208
+ "c": "Context",
1209
+ "d": "Description",
1210
+ "e": "Extent",
1211
+ "g": "Goal",
1212
+ "k": "Keyword",
1213
+ "l": "Location",
1214
+ "m": "Mask",
1215
+ "n": "Notice",
1216
+ "o": "Offset",
1217
+ "p": "Priority 1 - 5 (low - high)",
1218
+ "r": "Repetition frequency",
1219
+ "s": "Scheduled datetime",
1220
+ "t": "Tags",
1221
+ "u": "URL",
1222
+ "w": "Wrap",
1223
+ "x": "Exclude dates",
1224
+ "z": "Timezone",
1225
+ }
1226
+ _AMP_DESC = {
1227
+ "r": "Repetiton frequency",
1228
+ "c": "Count",
1229
+ "d": "By month day",
1230
+ "m": "By month",
1231
+ "H": "By hour",
1232
+ "M": "By minute",
1233
+ "E": "By-second",
1234
+ "i": "Interval",
1235
+ "s": "Schedule offset",
1236
+ "u": "Until",
1237
+ "W": "ISO week",
1238
+ "w": "Weekday modifier",
1239
+ }
1240
+
1241
+ # bug_msg(f"{self.entry_text = }, {self._text.text = }")
1242
+ panel = self.query_one("#ed_feedback", Static) # <— direct, no fallback
1243
+
1244
+ item = getattr(self, "item", None)
1245
+ # bug_msg(f"{item = }")
1246
+ if not item:
1247
+ panel.update("")
1248
+ return
1249
+
1250
+ # 1) Show validate messages if any.
1251
+ if self.item.validate_messages:
1252
+ # log_msg(f"{self.item.validate_messages = }")
1253
+ panel.update("\n".join(self.item.validate_messages))
1254
+ return
1255
+
1256
+ msgs = getattr(item, "messages", None) or []
1257
+ bug_msg(f"{msgs = }")
1258
+ if msgs:
1259
+ l = []
1260
+ if isinstance(msgs, list):
1261
+ for msg in msgs:
1262
+ if isinstance(msg, tuple):
1263
+ l.append(msg[1])
1264
+ else:
1265
+ l.append(msg)
1266
+
1267
+ s = "\n".join(l)
1268
+ # log_msg(f"{s = }")
1269
+ # panel.update("\n".join(msgs))
1270
+ panel.update(s)
1271
+ return
1272
+
1273
+ last = getattr(item, "last_result", None)
1274
+ # bug_msg(f"{last = }")
1275
+ if last and last[1]:
1276
+ panel.update(str(last[1]))
1277
+ # return
1278
+
1279
+ # 2) No errors: describe token at cursor (with normalized preview if available).
1280
+ idx = self._cursor_abs_index()
1281
+ tok = self._token_at(idx)
1282
+ # bug_msg(f"{idx = } {tok = }")
1283
+
1284
+ if not tok:
1285
+ # panel.update("")
1286
+ return
1287
+
1288
+ ttype = tok.get("t", "")
1289
+ raw = tok.get("token", "").strip()
1290
+ # bug_msg(f"{raw = }")
1291
+ k = tok.get("k", "")
1292
+
1293
+ preview = ""
1294
+ last = getattr(item, "last_result", None)
1295
+ # bug_msg(f"{last = }")
1296
+ # if isinstance(last, tuple) and len(last) >= 3 and last[0] is True:
1297
+ if isinstance(last, tuple) and len(last) >= 3:
1298
+ meta = last[2] or {}
1299
+ if meta.get("s") == tok.get("s") and meta.get("e") == tok.get("e"):
1300
+ norm_val = last[1]
1301
+ if isinstance(norm_val, str) and norm_val:
1302
+ preview = f"{meta.get('t')}{meta.get('k')} {norm_val}"
1303
+
1304
+ if ttype == "itemtype":
1305
+ panel.update(f"itemtype: {self.item.itemtype}")
1306
+ elif ttype == "subject":
1307
+ panel.update(f"subject: {self.item.subject}")
1308
+ elif ttype == "@":
1309
+ # panel.update(f"↳ @{k or '?'} {preview or raw}")
1310
+ key = tok.get("k", None)
1311
+ description = f"{_AT_DESC.get(key, '')}:" if key else "↳"
1312
+ panel.update(f"{description} {preview or raw}")
1313
+ elif ttype == "&":
1314
+ key = tok.get("k", None)
1315
+ description = f"{_AMP_DESC.get(key, '')}:" if key else "↳"
1316
+ panel.update(f"{description} {preview or raw}")
1317
+ else:
1318
+ panel.update(f"↳ {raw}{preview}")
1319
+
1320
+ def _persist(self, item) -> None:
1321
+ rid = self.controller.db_manager.save_record(item, record_id=self.record_id)
1322
+ self.record_id = rid
1323
+ completion = getattr(item, "completion", None)
1324
+ bug_msg(f"{completion = }")
1325
+ if completion:
1326
+ self.controller.db_manager.add_completion(self.record_id, completion)
1327
+
1328
+
1329
+ class DetailsScreen(ModalScreen[None]):
1330
+ # BINDINGS = [
1331
+ # ("escape", "close", "Back"),
1332
+ # ("?", "show_help", "Help"),
1333
+ # ("ctrl+q", "quit", "Quit"),
1334
+ # ("alt+e", "edit_item", "Edit"),
1335
+ # ("alt+c", "copy_item", "Copy"),
1336
+ # ("alt+d", "delete_item", "Delete"),
1337
+ # ("alt+f", "finish_task", "Finish"), # tasks only
1338
+ # ("alt+p", "toggle_pinned", "Pin/Unpin"), # tasks only
1339
+ # ("alt+n", "schedule_new", "Schedule"),
1340
+ # ("alt+r", "reschedule", "Reschedule"),
1341
+ # ("alt+t", "touch_item", "Touch"),
1342
+ # ("ctrl+r", "show_repetitions", "Show Repetitions"),
1343
+ # ]
1344
+
1345
+ # Actions mapped to bindings
1346
+ # def action_edit_item(self) -> None:
1347
+ # self._edit_item()
1348
+ #
1349
+ # def action_copy_item(self) -> None:
1350
+ # self._copy_item()
1351
+ #
1352
+ # def action_delete_item(self) -> None:
1353
+ # self._delete_item()
1354
+ #
1355
+ # def action_finish_task(self) -> None:
1356
+ # if self.is_task:
1357
+ # self._finish_task()
1358
+ #
1359
+ def action_toggle_pinned(self) -> None:
1360
+ if self.is_task:
1361
+ self._toggle_pinned()
1362
+
1363
+ def action_schedule_new(self) -> None:
1364
+ self._schedule_new()
1365
+
1366
+ def action_reschedule(self) -> None:
1367
+ self._reschedule()
1368
+
1369
+ def action_touch_item(self) -> None:
1370
+ self._touch_item()
1371
+
1372
+ def __init__(self, details: Iterable[str], showing_help: bool = False):
1373
+ super().__init__()
1374
+ dl = list(details)
1375
+ self.title_text: str = dl[0] if dl else "<Details>"
1376
+ self.lines: list[str] = dl[1:] if len(dl) > 1 else []
1377
+ if showing_help:
1378
+ self.footer_content = f"[bold {FOOTER}]esc[/bold {FOOTER}] Back"
1379
+ else:
1380
+ self.footer_content = f"[bold {FOOTER}]esc[/bold {FOOTER}] Back [bold {FOOTER}]?[/bold {FOOTER}] Item Commands"
1381
+
1382
+ # meta / flags (populated on_mount)
1383
+ self.record_id: Optional[int] = None
1384
+ self.itemtype: str = "" # "~" task, "*" event, etc.
1385
+ self.is_task: bool = False
1386
+ self.is_event: bool = False
1387
+ self.is_goal: bool = False
1388
+ self.is_recurring: bool = False # from rruleset truthiness
1389
+ self.is_pinned: bool = False # task-only
1390
+ self.record: Any = None # original tuple if you need it
1391
+
1392
+ # ---------- helpers ---------
1393
+ def _base_title(self) -> str:
1394
+ # Strip any existing pin and return the plain title
1395
+ return self.title_text.removeprefix("📌 ").strip()
1396
+
1397
+ def _apply_pin_glyph(self) -> None:
1398
+ base = self._base_title()
1399
+ if self.is_task and self.is_pinned:
1400
+ self.title_text = f"📌 {base}"
1401
+ else:
1402
+ self.title_text = base
1403
+ self.query_one("#details_title", Static).update(self.title_text)
1404
+
1405
+ # ---------- layout ----------
1406
+ def compose(self) -> ComposeResult:
1407
+ yield Vertical(
1408
+ Static(self.title_text, id="details_title", classes="title-class"),
1409
+ Static("\n".join(self.lines), expand=True, id="details_text"),
1410
+ # Static(self.footer_content),
1411
+ )
1412
+ yield (Static(self.footer_content))
1413
+ # yield Footer()
1414
+
1415
+ # ---------- lifecycle ----------
1416
+ def on_mount(self) -> None:
1417
+ meta = self.app.controller.get_last_details_meta() or {}
1418
+ # bug_msg(f"{meta = }")
1419
+ self.set_focus(self) # 👈 this makes sure the modal is active for bindings
1420
+ self.record_id = meta.get("record_id")
1421
+ self.itemtype = meta.get("itemtype") or ""
1422
+ self.is_task = self.itemtype == "~"
1423
+ self.is_event = self.itemtype == "*"
1424
+ self.is_goal = self.itemtype == "+"
1425
+ self.is_recurring = bool(meta.get("rruleset"))
1426
+ self.is_pinned = bool(meta.get("pinned")) if self.is_task else False
1427
+ self.record = meta.get("record")
1428
+ self._apply_pin_glyph() # ← show 📌 if needed
1429
+
1430
+ # ---------- actions (footer bindings) ----------
1431
+ def action_quit(self) -> None:
1432
+ self.app.action_quit()
1433
+
1434
+ def action_close(self) -> None:
1435
+ self.app.pop_screen()
1436
+
1437
+ def action_show_repetitions(self) -> None:
1438
+ if self.is_recurring:
1439
+ self._show_repetitions()
1440
+
1441
+ # def action_show_help(self) -> None:
1442
+ # self.app.push_screen(DetailsHelpScreen(self._build_help_text()))
1443
+
1444
+ def action_show_help(self) -> None:
1445
+ # Build the specialized details help
1446
+ lines = self._build_help_text().splitlines()
1447
+ self.app.push_screen(HelpScreen(lines))
1448
+
1449
+ # ---------- wire these to your controller ----------
1450
+ # def _edit_item(self) -> None:
1451
+ # # e.g. self.app.controller.edit_record(self.record_id)
1452
+ # log_msg("edit_item")
1453
+ #
1454
+ # def _copy_item(self) -> None:
1455
+ # # e.g. self.app.controller.copy_record(self.record_id)
1456
+ # log_msg("copy_item")
1457
+ #
1458
+ # def _delete_item(self) -> None:
1459
+ # # e.g. self.app.controller.delete_record(self.record_id, scope=...)
1460
+ # log_msg("delete_item")
1461
+
1462
+ def _prompt_finish_datetime(self) -> datetime | None:
1463
+ """
1464
+ Tiny blocking prompt:
1465
+ - Enter -> accept default (now)
1466
+ - Esc/empty -> cancel
1467
+ - Otherwise parse with dateutil
1468
+ Replace with your real prompt widget if you have one.
1469
+ """
1470
+ default = datetime.utcnow()
1471
+ default_str = default.strftime("%Y-%m-%d %H:%M")
1472
+ try:
1473
+ # If you have a modal/prompt helper, use it; otherwise, Python input() works in a pinch.
1474
+ user = self.app.prompt( # <— replace with your TUI prompt helper if you have one
1475
+ f"Finish when? (Enter = {default_str}, Esc = cancel): "
1476
+ )
1477
+ except Exception:
1478
+ # Fallback to stdin
1479
+ user = input(
1480
+ f"Finish when? (Enter = {default_str}, type 'esc' to cancel): "
1481
+ ).strip()
1482
+
1483
+ if user is None:
1484
+ return None
1485
+ s = str(user).strip()
1486
+ if not s:
1487
+ return default
1488
+ if s.lower() in {"esc", "cancel", "c"}:
1489
+ return None
1490
+ try:
1491
+ return parse_dt(s)
1492
+ except Exception as e:
1493
+ self.app.notify(f"Couldn’t parse that date/time ({e.__class__.__name__}).")
1494
+ return None
1495
+
1496
+ def _finish_task(self) -> None:
1497
+ """
1498
+ Called on 'f' from DetailsScreen.
1499
+ Gathers record/job context, prompts for completion time, calls controller.
1500
+ """
1501
+ log_msg("finish_task")
1502
+ return
1503
+
1504
+ meta = self.app.controller.get_last_details_meta() or {}
1505
+ record_id = meta.get("record_id")
1506
+ job_id = meta.get("job_id") # may be None for non-project tasks
1507
+
1508
+ if not record_id:
1509
+ self.app.notify("No record selected.")
1510
+ return
1511
+
1512
+ # dt = datetime.now()
1513
+ dt = self._prompt_finish_datetime()
1514
+ if dt is None:
1515
+ self.app.notify("Finish cancelled.")
1516
+ return
1517
+
1518
+ try:
1519
+ res = self.app.controller.finish_from_details(record_id, job_id, dt)
1520
+ # res is a dict: {record_id, final, due_ts, completed_ts, new_rruleset}
1521
+ if res.get("final"):
1522
+ self.app.notify("Finished ✅ (no more occurrences).")
1523
+ else:
1524
+ self.app.notify("Finished this occurrence ✅.")
1525
+ # refresh the list(s) so the item disappears/moves immediately
1526
+ if hasattr(self.app.controller, "populate_dependent_tables"):
1527
+ self.app.controller.populate_dependent_tables()
1528
+ if hasattr(self.app, "refresh_current_view"):
1529
+ self.app.refresh_current_view()
1530
+ elif hasattr(self.app, "switch_to_same_view"):
1531
+ self.app.switch_to_same_view()
1532
+ except Exception as e:
1533
+ self.app.notify(f"Finish failed: {e}")
1534
+
1535
+ def _toggle_pinned(self) -> None:
1536
+ log_msg("toggle_pin")
1537
+ return
1538
+
1539
+ if not self.is_task or self.record_id is None:
1540
+ return
1541
+ new_state = self.app.controller.toggle_pin(self.record_id)
1542
+ self.is_pinned = bool(new_state)
1543
+ self.app.notify("Pinned" if self.is_pinned else "Unpinned", timeout=1.2)
1544
+
1545
+ self._apply_pin_glyph() # ← update title immediately
1546
+
1547
+ # Optional: refresh Agenda if present so list order updates
1548
+ for scr in getattr(self.app, "screen_stack", []):
1549
+ if scr.__class__.__name__ == "AgendaScreen" and hasattr(
1550
+ scr, "refresh_data"
1551
+ ):
1552
+ scr.refresh_data()
1553
+ break
1554
+
1555
+ def _schedule_new(self) -> None:
1556
+ # e.g. self.app.controller.schedule_new(self.record_id)
1557
+ log_msg("schedule_new")
1558
+
1559
+ def _reschedule(self) -> None:
1560
+ # e.g. self.app.controller.reschedule(self.record_id)
1561
+ log_msg("reschedule")
1562
+
1563
+ def _touch_item(self) -> None:
1564
+ # e.g. self.app.controller.touch_record(self.record_id)
1565
+ log_msg("touch")
1566
+
1567
+ def _show_repetitions(self) -> None:
1568
+ log_msg("show_repetitions")
1569
+ if not self.is_recurring or self.record_id is None:
1570
+ return
1571
+ # e.g. rows = self.app.controller.list_repetitions(self.record_id)
1572
+ pass
1573
+
1574
+ def _show_completions(self) -> None:
1575
+ log_msg("show_completions")
1576
+ if not self.is_task or self.record_id is None:
1577
+ return
1578
+ # e.g. rows = self.app.controller.list_completions(self.record_id)
1579
+ pass
1580
+
1581
+
1582
+ class HelpScreen(Screen):
1583
+ BINDINGS = [("escape", "app.pop_screen", "Back")]
1584
+
1585
+ def __init__(self, lines: list[str], footer: str = ""):
1586
+ super().__init__()
1587
+ self._title = lines[0]
1588
+ self._lines = lines[1:]
1589
+ self._footer = footer or f"[bold {FOOTER}]esc[/bold {FOOTER}] Back"
1590
+ self.add_class("panel-bg-help") # HelpScreen
1591
+
1592
+ def compose(self):
1593
+ yield Vertical(
1594
+ Static(self._title, id="details_title", classes="title-class"),
1595
+ ScrollableList(self._lines, id="help_list"),
1596
+ Static(self._footer, id="custom_footer"),
1597
+ id="help_layout",
1598
+ )
1599
+
1600
+ def on_mount(self):
1601
+ self.styles.width = "100%"
1602
+ self.styles.height = "100%"
1603
+ self.query_one("#help_layout").styles.height = "100%"
1604
+
1605
+ help_list = self.query_one("#help_list", ScrollableList)
1606
+ for attr in ("_viewport", "_window", "_scroll_view", "_view", "_content"):
1607
+ vp = getattr(help_list, attr, None)
1608
+ if vp and hasattr(vp, "styles"):
1609
+ vp.styles.background = "#373737"
1610
+ try:
1611
+ vp.refresh()
1612
+ except Exception:
1613
+ pass
1614
+ log_msg(
1615
+ f"help_layout children: {[(i, child.__class__.__name__, child.id, child.styles.background) for i, child in enumerate(self.query_one('#help_layout').children)]}"
1616
+ ) # Make sure it fills the screen; no popup sizing/margins.
1617
+
1618
+
1619
+ class ScrollableList(ScrollView):
1620
+ """A scrollable list widget with title-friendly rendering and search.
1621
+
1622
+ Features:
1623
+ - Efficient virtualized rendering (line-by-line).
1624
+ - Simple search with highlight.
1625
+ - Jump to next/previous match.
1626
+ - Easy list updating via `update_list`.
1627
+ """
1628
+
1629
+ DEFAULT_CSS = """
1630
+ ScrollableList {
1631
+ background: #373737 90%;
1632
+ }
1633
+ """
1634
+
1635
+ def __init__(self, lines: List[str], *, match_color: str = MATCH_COLOR, **kwargs):
1636
+ super().__init__(**kwargs)
1637
+ self.console = Console()
1638
+ self.match_color = match_color
1639
+ self.row_bg = Style(bgcolor="#373737") # ← row background color
1640
+
1641
+ self.lines: List[Text] = [Text.from_markup(line) for line in lines]
1642
+ width = shutil.get_terminal_size().columns - 3
1643
+ self.virtual_size = Size(width, len(self.lines))
1644
+
1645
+ self.search_term: Optional[str] = None
1646
+ self.matches: List[int] = []
1647
+ self.current_match_idx: int = -1
1648
+
1649
+ # ... update_list / search methods unchanged ...
1650
+
1651
+ def update_list(self, new_lines: List[str]) -> None:
1652
+ """Replace the list content and refresh."""
1653
+ # log_msg(f"{new_lines = }")
1654
+ self.lines = [Text.from_markup(line) for line in new_lines if line]
1655
+ # log_msg(f"{self.lines = }")
1656
+ width = shutil.get_terminal_size().columns - 3
1657
+ self.virtual_size = Size(width, len(self.lines))
1658
+ # Clear any existing search (content likely changed)
1659
+ self.clear_search()
1660
+ self.refresh()
1661
+
1662
+ def set_search_term(self, search_term: Optional[str]) -> None:
1663
+ """Apply a new search term, highlight all matches, and jump to the first."""
1664
+ self.clear_search() # resets matches and index
1665
+ term = (search_term or "").strip().lower()
1666
+ if not term:
1667
+ self.refresh()
1668
+ return
1669
+
1670
+ self.search_term = term
1671
+ self.matches = [
1672
+ i for i, line in enumerate(self.lines) if term in line.plain.lower()
1673
+ ]
1674
+ if self.matches:
1675
+ self.current_match_idx = 0
1676
+ self.scroll_to(0, self.matches[0])
1677
+ self.refresh()
1678
+
1679
+ def clear_search(self) -> None:
1680
+ """Clear current search term and highlights."""
1681
+ self.search_term = None
1682
+ self.matches = []
1683
+ self.current_match_idx = -1
1684
+ self.refresh()
1685
+
1686
+ def jump_next_match(self) -> None:
1687
+ """Jump to the next match (wraps)."""
1688
+ if not self.matches:
1689
+ return
1690
+ self.current_match_idx = (self.current_match_idx + 1) % len(self.matches)
1691
+ self.scroll_to(0, self.matches[self.current_match_idx])
1692
+ self.refresh()
1693
+
1694
+ def jump_prev_match(self) -> None:
1695
+ """Jump to the previous match (wraps)."""
1696
+ if not self.matches:
1697
+ return
1698
+ self.current_match_idx = (self.current_match_idx - 1) % len(self.matches)
1699
+ self.scroll_to(0, self.matches[self.current_match_idx])
1700
+ self.refresh()
1701
+
1702
+ def render_line(self, y: int) -> Strip:
1703
+ """Render a single virtual line at viewport row y with full-row background."""
1704
+ scroll_x, scroll_y = self.scroll_offset
1705
+ y += scroll_y
1706
+
1707
+ if y < 0 or y >= len(self.lines):
1708
+ # pad a blank row with background so empty area is painted too
1709
+ return Strip(
1710
+ Segment.adjust_line_length([], self.size.width, style=self.row_bg),
1711
+ self.size.width,
1712
+ )
1713
+
1714
+ # copy so we can stylize safely
1715
+ line_text = self.lines[y].copy()
1716
+
1717
+ # search highlight (doesn't touch background)
1718
+ if self.search_term and y in self.matches:
1719
+ line_text.stylize(f"bold {self.match_color}")
1720
+
1721
+ # ensure everything drawn has background
1722
+ line_text.stylize(self.row_bg)
1723
+
1724
+ # render → crop/pad to width; pad uses our background style
1725
+ segments = list(line_text.render(self.console))
1726
+ segments = Segment.adjust_line_length(
1727
+ segments, self.size.width, style=self.row_bg
1728
+ )
1729
+
1730
+ return Strip(segments, self.size.width)
1731
+
1732
+
1733
+ class SearchableScreen(Screen):
1734
+ """Base class for screens that support search on a list widget."""
1735
+
1736
+ def get_search_target(self):
1737
+ """Return the ScrollableList that should receive search/scroll commands.
1738
+
1739
+ If details pane is open, target the details list, otherwise the main list.
1740
+ """
1741
+ if not self.list_with_details:
1742
+ return None
1743
+
1744
+ # if details is open, search/scroll that; otherwise main list
1745
+ return (
1746
+ self.list_with_details._details
1747
+ if self.list_with_details.has_details_open()
1748
+ else self.list_with_details._main
1749
+ )
1750
+
1751
+ def perform_search(self, term: str):
1752
+ try:
1753
+ target = self.get_search_target()
1754
+ target.set_search_term(term)
1755
+ target.refresh()
1756
+ except NoMatches:
1757
+ pass
1758
+
1759
+ def clear_search(self):
1760
+ try:
1761
+ target = self.get_search_target()
1762
+ target.clear_search()
1763
+ target.refresh()
1764
+ except NoMatches:
1765
+ pass
1766
+
1767
+ def scroll_to_next_match(self):
1768
+ try:
1769
+ target = self.get_search_target()
1770
+ y = target.scroll_offset.y
1771
+ nxt = next((i for i in target.matches if i > y), None)
1772
+ if nxt is not None:
1773
+ target.scroll_to(0, nxt)
1774
+ target.refresh()
1775
+ except NoMatches:
1776
+ pass
1777
+
1778
+ def scroll_to_previous_match(self):
1779
+ try:
1780
+ target = self.get_search_target()
1781
+ y = target.scroll_offset.y
1782
+ prv = next((i for i in reversed(target.matches) if i < y), None)
1783
+ if prv is not None:
1784
+ target.scroll_to(0, prv)
1785
+ target.refresh()
1786
+ except NoMatches:
1787
+ pass
1788
+
1789
+ def get_search_term(self) -> str:
1790
+ """
1791
+ Return the current search string for this screen.
1792
+
1793
+ Priority:
1794
+ 1. If the screen exposes a search input widget (self.search_input),
1795
+ return its current value (.value or .text).
1796
+ 2. If this screen wants to store the term elsewhere, override this method.
1797
+ 3. Fallback to the app-wide reactive `self.app.search_term`.
1798
+ """
1799
+ # 1) common pattern: a Textual Input-like widget called `search_input`
1800
+ si = getattr(self, "search_input", None)
1801
+ if si is not None:
1802
+ # support common widget APIs
1803
+ if hasattr(si, "value"):
1804
+ return si.value or ""
1805
+ if hasattr(si, "text"):
1806
+ return si.text or ""
1807
+ # fallback convert to str
1808
+ try:
1809
+ return str(si)
1810
+ except Exception:
1811
+ return ""
1812
+
1813
+ # 2) some screens may keep the term on the screen in another attribute;
1814
+ # override get_search_term in those screens if needed.
1815
+
1816
+ # 3) fallback app-wide value
1817
+ return getattr(self.app, "search_term", "") or ""
1818
+
1819
+
1820
+ # type aliases for clarity
1821
+ PageRows = List[str]
1822
+ PageTagMap = Dict[str, Tuple[int, Optional[int]]] # tag -> (record_id, job_id|None)
1823
+ Page = Tuple[PageRows, PageTagMap]
1824
+
1825
+
1826
+ class WeeksScreen(SearchableScreen, SafeScreen):
1827
+ """
1828
+ 1-week grid with a bottom details panel, powered by ListWithDetails.
1829
+
1830
+ `details` is expected to be a list of pages:
1831
+ pages = [ (rows_for_page0, tag_map0), (rows_for_page1, tag_map1), ... ]
1832
+ where rows_for_pageX is a list[str] (includes header rows and record rows)
1833
+ and tag_mapX maps single-letter tags 'a'..'z' to (record_id, job_id|None).
1834
+ """
1835
+
1836
+ def __init__(
1837
+ self,
1838
+ title: str,
1839
+ table: str,
1840
+ details: Optional[List[Page]],
1841
+ footer_content: str,
1842
+ ):
1843
+ super().__init__()
1844
+ log_msg(f"{self.app = }, {self.app.controller = }")
1845
+ self.add_class("panel-bg-weeks") # WeeksScreen
1846
+ self.table_title = title
1847
+ self.table = table # busy bar / calendar mini-grid content (string)
1848
+ # pages: list of (rows, tag_map). Accept None or [].
1849
+ self.pages: List[Page] = details or []
1850
+ self.current_page: int = 0
1851
+
1852
+ # footer string (unchanged)
1853
+ self.footer_content = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
1854
+ self.list_with_details: Optional[ListWithDetails] = None
1855
+
1856
+ # Let global search target the currently-focused list
1857
+ def get_search_target(self):
1858
+ if not self.list_with_details:
1859
+ return None
1860
+ return (
1861
+ self.list_with_details._details
1862
+ if self.list_with_details.has_details_open()
1863
+ else self.list_with_details._main
1864
+ )
1865
+
1866
+ # --- Compose/layout -------------------------------------------------
1867
+ def compose(self) -> ComposeResult:
1868
+ yield Static(
1869
+ self.table_title or "Untitled",
1870
+ id="table_title",
1871
+ classes="title-class",
1872
+ )
1873
+
1874
+ yield Static(
1875
+ self.table or "[i]No data[/i]",
1876
+ id="table",
1877
+ classes="busy-bar",
1878
+ markup=True,
1879
+ )
1880
+
1881
+ # Single list (no separate list title)
1882
+ self.list_with_details = ListWithDetails(id="list")
1883
+ # keep the same handler wiring as before (detail opens a record details)
1884
+ self.list_with_details.set_detail_key_handler(
1885
+ self.app.make_detail_key_handler(
1886
+ view_name="weeks",
1887
+ week_provider=lambda: self.app.selected_week,
1888
+ )
1889
+ )
1890
+ self.app.detail_handler = self.list_with_details._detail_key_handler
1891
+ yield self.list_with_details
1892
+
1893
+ yield Static(self.footer_content, id="custom_footer")
1894
+
1895
+ # Called once layout is up
1896
+ def after_mount(self) -> None:
1897
+ """Populate the list with the current page once layout is ready."""
1898
+ if self.list_with_details:
1899
+ self.refresh_page()
1900
+
1901
+ # --- Page management API (used by DynamicViewApp) -------------------
1902
+ def has_next_page(self) -> bool:
1903
+ return self.current_page < (len(self.pages) - 1)
1904
+
1905
+ def has_prev_page(self) -> bool:
1906
+ return self.current_page > 0
1907
+
1908
+ def next_page(self) -> None:
1909
+ if self.has_next_page():
1910
+ self.current_page += 1
1911
+ self.refresh_page()
1912
+
1913
+ def previous_page(self) -> None:
1914
+ if self.has_prev_page():
1915
+ self.current_page -= 1
1916
+ self.refresh_page()
1917
+
1918
+ def reset_to_first_page(self) -> None:
1919
+ if self.pages:
1920
+ self.current_page = 0
1921
+ self.refresh_page()
1922
+
1923
+ def get_record_for_tag(self, tag: str) -> Optional[Tuple[int, Optional[int]]]:
1924
+ """Return (record_id, job_id) for a tag on the current page or None."""
1925
+ if not self.pages:
1926
+ return None
1927
+ _, tag_map = self.pages[self.current_page]
1928
+ return tag_map.get(tag)
1929
+
1930
+ # --- UI refresh helpers ---------------------------------------------
1931
+
1932
+ def refresh_page(self) -> None:
1933
+ """Update the ListWithDetails widget to reflect the current page (with debug)."""
1934
+ log_msg(
1935
+ f"[WeeksScreen.refresh_page] current_page={self.current_page}, total_pages={len(self.pages)}"
1936
+ )
1937
+ if not self.list_with_details:
1938
+ log_msg("[WeeksScreen.refresh_page] no list_with_details widget")
1939
+ return
1940
+
1941
+ if not self.pages:
1942
+ log_msg("[WeeksScreen.refresh_page] no pages -> clearing list")
1943
+ self.list_with_details.update_list([])
1944
+ if self.list_with_details.has_details_open():
1945
+ self.list_with_details.hide_details()
1946
+ # ensure controller expects single-letter tags for weeks
1947
+ # self.app.controller.afill_by_view["weeks"] = 1
1948
+ # ensure title shows base title (no indicator)
1949
+ self.query_one("#table_title", Static).update(self.table_title)
1950
+ return
1951
+
1952
+ # defensive: check page index bounds
1953
+ if self.current_page < 0 or self.current_page >= len(self.pages):
1954
+ log_msg(
1955
+ f"[WeeksScreen.refresh_page] current_page out of bounds, resetting to 0"
1956
+ )
1957
+ self.current_page = 0
1958
+
1959
+ page = self.pages[self.current_page]
1960
+ # validate page tuple shape
1961
+ if not (isinstance(page, (list, tuple)) and len(page) == 2):
1962
+ log_msg(
1963
+ f"[WeeksScreen.refresh_page] BAD PAGE SHAPE at index {self.current_page}: {type(page)} {page!r}"
1964
+ )
1965
+ # try to fall back: if pages is a list of rows (no tag maps), display as-is
1966
+ if isinstance(self.pages, list) and all(
1967
+ isinstance(p, str) for p in self.pages
1968
+ ):
1969
+ self.list_with_details.update_list(self.pages)
1970
+ # update title without indicator
1971
+ self.query_one("#table_title", Static).update(self.table_title)
1972
+ return
1973
+ # otherwise clear to avoid crash
1974
+ self.list_with_details.update_list([])
1975
+ self.query_one("#table_title", Static).update(self.table_title)
1976
+ return
1977
+
1978
+ rows, tag_map = page
1979
+ log_msg(
1980
+ f"[WeeksScreen.refresh_page] page {self.current_page} rows={len(rows)} tags={len(tag_map)}"
1981
+ )
1982
+ # update list contents
1983
+ self.list_with_details.update_list(rows)
1984
+ # reset controller afill for week -> single-letter tags (page_tagger guarantees this)
1985
+ # self.app.controller.afill_by_view["weeks"] = 1
1986
+
1987
+ if self.list_with_details.has_details_open():
1988
+ # close stale details when page changes (optional)
1989
+ self.list_with_details.hide_details()
1990
+
1991
+ # --- update table title to include page indicator when needed ---
1992
+ if len(self.pages) > 1:
1993
+ indicator = f" ({self.current_page + 1}/{len(self.pages)})"
1994
+ else:
1995
+ indicator = ""
1996
+ self.query_one("#table_title", Static).update(f"{self.table_title}{indicator}")
1997
+
1998
+ # --- Called from app when the underlying week data has changed ----------
1999
+
2000
+ def update_table_and_list(self):
2001
+ """
2002
+ Called by app after the controller recomputes the table + list pages
2003
+ for the currently-selected week.
2004
+ Controller.get_table_and_list must now return: (title, busy_bar, pages)
2005
+ where pages is a list[Page].
2006
+ """
2007
+ title, busy_bar, pages = self.app.controller.get_table_and_list(
2008
+ self.app.current_start_date, self.app.selected_week
2009
+ )
2010
+
2011
+ log_msg(
2012
+ f"[WeeksScreen.update_table_and_list] controller returned title={title!r} busy_bar_len={len(busy_bar) if busy_bar else 0} pages_type={type(pages)}"
2013
+ )
2014
+
2015
+ # some controllers might mistakenly return (pages, header) tuple; normalize:
2016
+ normalized_pages = pages
2017
+ # If it's a tuple (pages, header) — detect and unwrap
2018
+ if isinstance(pages, tuple) and len(pages) == 2 and isinstance(pages[0], list):
2019
+ log_msg(
2020
+ "[WeeksScreen.update_table_and_list] Detected (pages, header) tuple; unwrapping first element as pages."
2021
+ )
2022
+ normalized_pages = pages[0]
2023
+
2024
+ # final validation: normalized_pages should be list of (rows, tag_map)
2025
+ if not isinstance(normalized_pages, list):
2026
+ log_msg(
2027
+ f"[WeeksScreen.update_table_and_list] WARNING: pages is not a list: {type(normalized_pages)} -> treating as empty"
2028
+ )
2029
+ normalized_pages = []
2030
+
2031
+ # optionally, do a quick contents-sanity check
2032
+ page_cnt = len(normalized_pages)
2033
+ sample_info = []
2034
+ for i, p in enumerate(normalized_pages[:3]):
2035
+ if isinstance(p, (list, tuple)) and len(p) == 2:
2036
+ sample_info.append((i, len(p[0]), len(p[1])))
2037
+ else:
2038
+ sample_info.append((i, "BAD_PAGE_SHAPE", type(p)))
2039
+ log_msg(
2040
+ f"[WeeksScreen.update_table_and_list] pages_count={page_cnt} sample={sample_info}"
2041
+ )
2042
+
2043
+ # adopt new pages and reset page index
2044
+ self.pages = normalized_pages
2045
+ self.current_page = 0
2046
+
2047
+ # Save base title so refresh_page can add indicator consistently
2048
+ self.table_title = title
2049
+
2050
+ # update busy-bar immediately
2051
+ self.query_one("#table", Static).update(busy_bar)
2052
+
2053
+ # update the title now including an indicator if appropriate
2054
+ if len(self.pages) > 1:
2055
+ title_with_indicator = (
2056
+ f"{self.table_title}\n({self.current_page + 1}/{len(self.pages)})"
2057
+ )
2058
+ else:
2059
+ title_with_indicator = self.table_title
2060
+ self.query_one("#table_title", Static).update(title_with_indicator)
2061
+
2062
+ # refresh the visible page (calls update_list and will also update title)
2063
+ if self.list_with_details:
2064
+ self.refresh_page()
2065
+
2066
+ # --- Tag activation -> show details ----------------------------------
2067
+ def show_details_for_tag(self, tag: str) -> None:
2068
+ """
2069
+ Called by DynamicViewApp when a tag is completed.
2070
+ We look up the record_id/job_id for this tag on the current page and then
2071
+ ask the controller for details and show them in the lower panel.
2072
+ """
2073
+ rec = self.get_record_for_tag(tag)
2074
+ if not rec:
2075
+ return
2076
+ record_id, job_id, datetime_id, instance_ts = rec
2077
+
2078
+ # Controller helper returns title, list-of-lines (fields), and meta
2079
+ title, lines, meta = self.app.controller.get_details_for_record(
2080
+ record_id, job_id, datetime_id, instance_ts
2081
+ )
2082
+ if self.list_with_details:
2083
+ self.list_with_details.show_details(title, lines, meta)
2084
+
2085
+
2086
+ class FullScreenList(SearchableScreen):
2087
+ """Full-screen list view with paged navigation and tag support."""
2088
+
2089
+ def __init__(self, pages, title, header="", footer_content="..."):
2090
+ super().__init__()
2091
+ self.pages = pages # list of (rows, tag_map)
2092
+ self.title = title
2093
+ self.header = header
2094
+ self.footer_content = footer_content
2095
+ # self.footer_content = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
2096
+ self.current_page = 0
2097
+ self.lines = []
2098
+ self.tag_map = {}
2099
+ if self.pages:
2100
+ self.lines, self.tag_map = self.pages[0]
2101
+ self.list_with_details: ListWithDetails | None = None
2102
+ self.add_class("panel-bg-list") # FullScreenList
2103
+
2104
+ # --- Page Navigation ----------------------------------------------------
2105
+ def next_page(self):
2106
+ if self.current_page < len(self.pages) - 1:
2107
+ self.current_page += 1
2108
+ self.refresh_list()
2109
+
2110
+ def previous_page(self):
2111
+ if self.current_page > 0:
2112
+ self.current_page -= 1
2113
+ self.refresh_list()
2114
+
2115
+ # --- Tag Lookup ---------------------------------------------------------
2116
+ def get_record_for_tag(self, tag: str):
2117
+ """Return the record_id corresponding to a tag on the current page."""
2118
+ _, tag_map = self.pages[self.current_page]
2119
+ log_msg(f"{tag_map = }")
2120
+ return tag_map.get(tag)
2121
+
2122
+ def show_details_for_tag(self, tag: str) -> None:
2123
+ app = self.app # DynamicViewApp
2124
+ record = self.get_record_for_tag(tag)
2125
+ log_msg(f"{record = }")
2126
+ if record:
2127
+ record_id, job_id, datetime_id, instance_ts = record
2128
+
2129
+ title, lines, meta = app.controller.get_details_for_record(
2130
+ record_id, job_id, datetime_id, instance_ts
2131
+ )
2132
+ log_msg(f"{title = }, {lines = }, {meta = }")
2133
+ if self.list_with_details:
2134
+ self.list_with_details.show_details(title, lines, meta)
2135
+
2136
+ def _render_page_indicator(self) -> str:
2137
+ total_pages = len(self.pages)
2138
+ if total_pages <= 1:
2139
+ return ""
2140
+ return f" ({self.current_page + 1}/{total_pages})"
2141
+
2142
+ # --- Refresh Display ----------------------------------------------------
2143
+ def refresh_list(self):
2144
+ page_rows, tag_map = self.pages[self.current_page]
2145
+ self.lines = page_rows
2146
+ self.tag_map = tag_map
2147
+ if self.list_with_details:
2148
+ self.list_with_details.update_list(self.lines)
2149
+ # Update header/title with bullet indicator
2150
+ header_text = f"{self.title}{self._render_page_indicator()}"
2151
+ self.query_one("#scroll_title", Static).update(header_text)
2152
+
2153
+ # --- Compose ------------------------------------------------------------
2154
+ def compose(self) -> ComposeResult:
2155
+ yield Static(self.title, id="scroll_title", expand=True, classes="title-class")
2156
+ if self.header:
2157
+ yield Static(
2158
+ self.header, id="scroll_header", expand=True, classes="header-class"
2159
+ )
2160
+ self.list_with_details = ListWithDetails(id="list")
2161
+ self.list_with_details.set_detail_key_handler(
2162
+ self.app.make_detail_key_handler(view_name="next")
2163
+ )
2164
+ yield self.list_with_details
2165
+ yield Static(self.footer_content, id="custom_footer")
2166
+
2167
+ def on_mount(self) -> None:
2168
+ if self.list_with_details:
2169
+ self.list_with_details.update_list(self.lines)
2170
+ # Add the initial page indicator after mount
2171
+ self.query_one("#scroll_title", Static).update(
2172
+ f"{self.title}{self._render_page_indicator()}"
2173
+ )
2174
+
2175
+
2176
+ Page = Tuple[List[str], Dict[str, Tuple[str, object]]]
2177
+
2178
+
2179
+ ###VVV new for tagged bin screen
2180
+ # --- Row types expected from the controller ---
2181
+ @dataclass
2182
+ class ChildBinRow:
2183
+ bin_id: int
2184
+ name: str
2185
+ child_ct: int
2186
+ rem_ct: int
2187
+
2188
+
2189
+ @dataclass
2190
+ class ReminderRow:
2191
+ record_id: int
2192
+ subject: str
2193
+ itemtype: str
2194
+
2195
+
2196
+ # --- Constants ---
2197
+ TAGS = [chr(ord("a") + i) for i in range(26)] # single-letter tags per page
2198
+
2199
+
2200
+ class TaggedHierarchyScreen(SearchableScreen):
2201
+ """
2202
+ Tagged hierarchy browser that mirrors BinView’s behavior:
2203
+
2204
+ • Uses SearchableScreen + ListWithDetails.
2205
+ • Shows the *entire subtree of bins* under the current bin (bins only; no reminders below).
2206
+ • Only the current bin's *immediate children* + its *reminders* are taggable:
2207
+ - children appear in the tree with inline tags (a..z) on depth-1 rows;
2208
+ - reminders appear at the bottom with their tags.
2209
+ • Tags are paged 26-per-page (children first, then reminders).
2210
+ • a–z tags are handled by DynamicViewApp via show_details_for_tag(), exactly like BinView.
2211
+ • / search highlights within the list.
2212
+ • Digits 0..9 jump to breadcrumb ancestors (0=root, 1=child, etc.), current bin unnumbered.
2213
+ • Left/Right change pages; ESC jumps to root.
2214
+ """
2215
+
2216
+ def __init__(self, controller, bin_id: int, footer_content: str = ""):
2217
+ super().__init__()
2218
+ self.controller = controller
2219
+ self.bin_id = bin_id
2220
+
2221
+ # pages: list[(rows, tag_map)], where:
2222
+ # rows -> list[str] rendered in ListWithDetails
2223
+ # tag_map -> {tag: ("bin", bin_id) | ("rem", (record_id, job_id))}
2224
+ self.pages: list[tuple[list[str], dict[str, tuple[str, object]]]] = []
2225
+ self.current_page: int = 0
2226
+ self.title: str = ""
2227
+ self.footer_content = (
2228
+ footer_content
2229
+ or f"[bold {FOOTER}]?[/bold {FOOTER}] Help "
2230
+ f" [bold {FOOTER}]/[/bold {FOOTER}] Search "
2231
+ )
2232
+ self.list_with_details: Optional[ListWithDetails] = None
2233
+ self.tag_map: dict[str, tuple[str, object]] = {}
2234
+ self.crumb: list[tuple[int, str]] = [] # [(id, name), ...]
2235
+ self.descendants: list[tuple[int, str, int]] = [] # (bin_id, name, depth)
2236
+
2237
+ # ----- Compose -----
2238
+ def compose(self) -> ComposeResult:
2239
+ # Title: breadcrumb + optional page indicator
2240
+ yield Static("", id="scroll_title", classes="title-class", expand=True)
2241
+
2242
+ self.list_with_details = ListWithDetails(id="list")
2243
+ # Details handler is the same pattern as other views
2244
+ self.list_with_details.set_detail_key_handler(
2245
+ self.app.make_detail_key_handler(view_name="bins")
2246
+ )
2247
+ yield self.list_with_details
2248
+
2249
+ yield Static(self.footer_content, id="custom_footer")
2250
+
2251
+ # ----- Lifecycle -----
2252
+ def on_mount(self) -> None:
2253
+ self.refresh_hierarchy()
2254
+
2255
+ # ----- Public mini-API (called by app’s on_key for tags) -----
2256
+ def next_page(self) -> None:
2257
+ if self.current_page < len(self.pages) - 1:
2258
+ self.current_page += 1
2259
+ self._refresh_page()
2260
+
2261
+ def previous_page(self) -> None:
2262
+ if self.current_page > 0:
2263
+ self.current_page -= 1
2264
+ self._refresh_page()
2265
+
2266
+ def has_next_page(self) -> bool:
2267
+ return self.current_page < len(self.pages) - 1
2268
+
2269
+ def has_prev_page(self) -> bool:
2270
+ return self.current_page > 0
2271
+
2272
+ def show_details_for_tag(self, tag: str) -> None:
2273
+ """Called by DynamicViewApp for tag keys a–z."""
2274
+ if not self.pages:
2275
+ return
2276
+ _, tag_map = self.pages[self.current_page]
2277
+ payload = tag_map.get(tag)
2278
+ if not payload:
2279
+ return
2280
+
2281
+ kind, data = payload
2282
+ if kind == "bin":
2283
+ # navigate into that bin
2284
+ self.bin_id = int(data)
2285
+ self.refresh_hierarchy()
2286
+ return
2287
+
2288
+ # "rem" -> open details
2289
+ record_id, job_id = data
2290
+ title, lines, meta = self.controller.get_details_for_record(record_id, job_id)
2291
+ if self.list_with_details:
2292
+ self.list_with_details.show_details(title, lines, meta)
2293
+
2294
+ # ----- Local key handling -----
2295
+ def on_key(self, event) -> None:
2296
+ k = event.key
2297
+
2298
+ # ESC -> jump to root (same behavior as BinView)
2299
+ if k == "escape":
2300
+ root_id = getattr(self.controller, "root_id", None)
2301
+ if root_id is not None:
2302
+ self.bin_id = root_id
2303
+ self.refresh_hierarchy()
2304
+ event.stop()
2305
+ return
2306
+
2307
+ # digits -> breadcrumb jump (ancestors only, last crumb is current bin)
2308
+ if k.isdigit():
2309
+ i = int(k)
2310
+ if 0 <= i < len(self.crumb) - 1:
2311
+ self.bin_id = self.crumb[i][0]
2312
+ self.refresh_hierarchy()
2313
+ event.stop()
2314
+ return
2315
+
2316
+ # Left/Right for paging
2317
+ if k == "left":
2318
+ if self.has_prev_page():
2319
+ self.previous_page()
2320
+ event.stop()
2321
+ elif k == "right":
2322
+ if self.has_next_page():
2323
+ self.next_page()
2324
+ event.stop()
2325
+ # All other keys (including /, a–z) bubble up to SearchableScreen / app
2326
+
2327
+ # ----- Internal helpers -----
2328
+ def refresh_hierarchy(self) -> None:
2329
+ """Rebuild pages and redraw from the current bin."""
2330
+ self.pages, self.title = self._build_pages_and_title()
2331
+ self.current_page = 0
2332
+ self._refresh_page()
2333
+
2334
+ def _refresh_page(self) -> None:
2335
+ rows, tag_map = self.pages[self.current_page] if self.pages else ([], {})
2336
+ self.tag_map = tag_map
2337
+
2338
+ if self.list_with_details:
2339
+ self.list_with_details.update_list(rows)
2340
+ if self.list_with_details.has_details_open():
2341
+ self.list_with_details.hide_details()
2342
+
2343
+ self._refresh_header()
2344
+
2345
+ def _refresh_header(self) -> None:
2346
+ bullets = self._page_bullets() # "1/3" or ""
2347
+ if bullets:
2348
+ header = f"Bins ({bullets})"
2349
+ else:
2350
+ header = "Bins"
2351
+ self.query_one("#scroll_title", Static).update(header)
2352
+
2353
+ def _page_bullets(self) -> str:
2354
+ n = len(self.pages)
2355
+ if n <= 1:
2356
+ return ""
2357
+ return f"{self.current_page + 1}/{n}"
2358
+
2359
+ def _build_pages_and_title(
2360
+ self,
2361
+ ) -> tuple[list[tuple[list[str], dict[str, tuple[str, object]]]], str]:
2362
+ """
2363
+ Build pages:
2364
+ - rows: breadcrumb line + tree (bins only) + reminders for that page
2365
+ - tag_map: tags -> ("bin", bin_id) or ("rem", (record_id, job_id))
2366
+ Returns (pages, title), where title is just the breadcrumb text (no page indicator).
2367
+ """
2368
+ # 1) Summary + breadcrumb
2369
+ children, reminders, crumb = self.controller.get_bin_summary(
2370
+ self.bin_id, filter_text=None
2371
+ )
2372
+ self.crumb = crumb
2373
+
2374
+ # 2) Full subtree (bins only)
2375
+ self.descendants = self.controller.get_descendant_tree(self.bin_id)
2376
+ log_msg(f"{self.descendants = }, {children = }")
2377
+
2378
+ # 3) Crumb text: ancestors numbered, last (current) unnumbered
2379
+ if crumb:
2380
+ parts: list[str] = []
2381
+ for i, (_bid, name) in enumerate(crumb):
2382
+ if i < len(crumb) - 1:
2383
+ parts.append(
2384
+ f"[dim]{i}[/dim] [{TYPE_TO_COLOR['b']}]{name}[/{TYPE_TO_COLOR['b']}]"
2385
+ )
2386
+ else:
2387
+ parts.append(
2388
+ f"[bold {TYPE_TO_COLOR['B']}]{name}[/bold {TYPE_TO_COLOR['B']}]"
2389
+ )
2390
+ # parts.append(f"[bold red]{name}[/bold red]")
2391
+ crumb_txt = " / ".join(parts)
2392
+ else:
2393
+ crumb_txt = "root"
2394
+
2395
+ # 4) Build taggable items: children first, then reminders
2396
+ taggable: list[tuple[str, object]] = []
2397
+ for ch in children:
2398
+ taggable.append(("bin", ch.bin_id))
2399
+ for r in reminders:
2400
+ taggable.append(("rem", (r.record_id, None))) # job_id=None for now
2401
+
2402
+ # Map reminders by ID for label rendering
2403
+ rem_by_id: dict[int, ReminderRow] = {r.record_id: r for r in reminders}
2404
+
2405
+ pages: list[tuple[list[str], dict[str, tuple[str, object]]]] = []
2406
+
2407
+ if not taggable:
2408
+ # No taggable items; show breadcrumb + tree as a single page
2409
+ tree_rows = self._render_tree_rows(self.descendants, child_tags={})
2410
+ rows = [crumb_txt] + tree_rows
2411
+ pages.append((rows, {}))
2412
+ return pages, crumb_txt # title = crumb_txt
2413
+
2414
+ total = len(taggable)
2415
+ num_pages = (total + 25) // 26 # 26 tags per page
2416
+
2417
+ for page_index in range(num_pages):
2418
+ start = page_index * 26
2419
+ end = min(start + 26, total)
2420
+ page_items = taggable[start:end]
2421
+
2422
+ page_tag_map: dict[str, tuple[str, object]] = {}
2423
+ child_tags: dict[int, str] = {}
2424
+
2425
+ # Assign tags to taggable items for this page
2426
+ for i, (kind, data) in enumerate(page_items):
2427
+ tag = TAGS[i]
2428
+ if kind == "bin":
2429
+ bin_id = int(data)
2430
+ page_tag_map[tag] = ("bin", bin_id)
2431
+ child_tags[bin_id] = tag
2432
+ else: # "rem"
2433
+ record_id, job_id = data
2434
+ page_tag_map[tag] = ("rem", (record_id, job_id))
2435
+
2436
+ # Tree rows (bins only) with inline tags on depth-1 nodes
2437
+ rows: list[str] = self._render_tree_rows(self.descendants, child_tags)
2438
+
2439
+ # Reminders for this page, appended below the tree
2440
+ for i, (kind, data) in enumerate(page_items):
2441
+ tag = TAGS[i]
2442
+ if kind != "rem":
2443
+ continue
2444
+ record_id, job_id = data
2445
+ r = rem_by_id.get(record_id)
2446
+ if not r:
2447
+ continue
2448
+ log_msg(f"view bins {r = }")
2449
+ label = self._render_reminder_label(r)
2450
+ rows.append(
2451
+ f" [dim]{tag}[/dim] {label}"
2452
+ ) # 4-space indent to align with depth-1
2453
+
2454
+ # Insert breadcrumb as FIRST row in the list (no page indicator here)
2455
+ rows.insert(0, crumb_txt)
2456
+
2457
+ pages.append((rows, page_tag_map))
2458
+
2459
+ # Title is just the crumb text; page indicator is added in _refresh_header
2460
+ return pages, crumb_txt
2461
+
2462
+ def _render_tree_rows(
2463
+ self,
2464
+ flat_nodes: list[tuple[int, str, int]],
2465
+ child_tags: dict[int, str],
2466
+ ) -> list[str]:
2467
+ """
2468
+ Render the pre-ordered subtree as simple indented lines.
2469
+
2470
+ • No box/branch glyphs.
2471
+ • Skip the root row (depth==0) so the current bin name is not repeated.
2472
+ • Insert inline tags for depth-1 nodes that are on the current page.
2473
+ """
2474
+ rows: list[str] = []
2475
+ for bid, name, depth in flat_nodes:
2476
+ if depth == 0:
2477
+ continue # skip the current bin itself
2478
+ indent = " " * depth
2479
+ tag_prefix = (
2480
+ f"[dim]{child_tags[bid]}[/dim] "
2481
+ if (depth == 1 and bid in child_tags)
2482
+ else ""
2483
+ )
2484
+ rows.append(
2485
+ f"{indent}{tag_prefix}[{TYPE_TO_COLOR['b']}]{name}[/ {TYPE_TO_COLOR['b']}]"
2486
+ )
2487
+ return rows
2488
+
2489
+ def _render_tree_rows(
2490
+ self,
2491
+ flat_nodes: list[tuple[int, str, int]],
2492
+ child_tags: dict[int, str],
2493
+ ) -> list[str]:
2494
+ """
2495
+ Render the pre-ordered subtree as simple indented lines.
2496
+
2497
+ • No box/branch glyphs.
2498
+ • Skip the root row (depth==0) so the current bin name is not repeated.
2499
+ • Insert inline tags for depth-1 nodes that are on the current page.
2500
+ • If a child's name looks like "PARENT:SUFFIX" where PARENT == parent's name,
2501
+ display only "SUFFIX".
2502
+ """
2503
+ rows: list[str] = []
2504
+ # Track last seen name at each depth to know the parent name
2505
+ last_name_at_depth: dict[int, str] = {}
2506
+
2507
+ for bid, name, depth in flat_nodes:
2508
+ if depth == 0:
2509
+ # Root row: remember its name but don't render it here
2510
+ last_name_at_depth[0] = name
2511
+ continue
2512
+
2513
+ # Remember this bin's name at its depth
2514
+ last_name_at_depth[depth] = name
2515
+
2516
+ # Determine parent name (if any)
2517
+ parent_name = last_name_at_depth.get(depth - 1, "")
2518
+
2519
+ # Default display name is the full name
2520
+ display_name = name
2521
+
2522
+ # If "PARENT:rest" and PARENT matches parent_name, show only "rest"
2523
+ if parent_name and ":" in name:
2524
+ prefix, suffix = name.split(":", 1)
2525
+ if prefix == parent_name:
2526
+ display_name = suffix
2527
+
2528
+ indent = " " * depth
2529
+ tag_prefix = (
2530
+ f"[dim]{child_tags[bid]}[/dim] "
2531
+ if (depth == 1 and bid in child_tags)
2532
+ else ""
2533
+ )
2534
+ rows.append(
2535
+ f"{indent}{tag_prefix}[{TYPE_TO_COLOR['b']}]{display_name}[/ {TYPE_TO_COLOR['b']}]"
2536
+ )
2537
+
2538
+ return rows
2539
+
2540
+ def _render_reminder_label(self, r: ReminderRow) -> str:
2541
+ # Example: "Fix itinerary [dim]task[/dim]"
2542
+ log_msg(f"view bins {r = }")
2543
+ tclr = TYPE_TO_COLOR[r.itemtype]
2544
+ return f"[{tclr}]{r.itemtype} {r.subject}[/ {tclr}]"
2545
+
2546
+
2547
+ ###^^^ new for tagged bin screen
2548
+
2549
+
2550
+ class DynamicViewApp(App):
2551
+ """A dynamic app that supports temporary and permanent view changes."""
2552
+
2553
+ CSS_PATH = "view_textual.css"
2554
+ VIEW_REFRESHERS = {
2555
+ "weeks": "action_show_weeks",
2556
+ "agenda": "action_show_agenda",
2557
+ # ...
2558
+ }
2559
+
2560
+ digit_buffer = reactive([])
2561
+ # afill = 1
2562
+ search_term = reactive("")
2563
+
2564
+ BINDINGS = [
2565
+ # global
2566
+ # (".", "center_week", ""),
2567
+ ("space", "current_period", ""),
2568
+ ("shift+left", "previous_period", ""),
2569
+ ("shift+right", "next_period", ""),
2570
+ ("ctrl+s", "take_screenshot", "Take Screenshot"),
2571
+ ("escape", "close_details", "Close details"),
2572
+ ("R", "show_alerts", "Show Alerts"),
2573
+ ("A", "show_agenda", "Show Agenda"),
2574
+ ("B", "show_bins", "Bins"),
2575
+ ("C", "show_completions", "Completions"),
2576
+ ("L", "show_last", "Show Last"),
2577
+ ("N", "show_next", "Show Next"),
2578
+ ("T", "show_tags", "Show Tags"),
2579
+ ("F", "show_find", "Find"),
2580
+ ("W", "show_weeks", "Weeks"),
2581
+ ("?", "show_help", "Help"),
2582
+ ("ctrl+q", "quit", "Quit"),
2583
+ ("ctrl+n", "new_reminder", "Add new reminder"),
2584
+ ("ctrl+r", "detail_repetitions", "Show Repetitions"),
2585
+ ("/", "start_search", "Search"),
2586
+ (">", "next_match", "Next Match"),
2587
+ ("<", "previous_match", "Previous Match"),
2588
+ ("ctrl+z", "copy_search", "Copy Search"),
2589
+ ("ctrl-b", "show_bin", "Bin"),
2590
+ ]
2591
+
2592
+ def __init__(self, controller) -> None:
2593
+ super().__init__()
2594
+ self.controller = controller
2595
+ self.current_start_date = calculate_4_week_start()
2596
+ self.selected_week = tuple(datetime.now().isocalendar()[:2])
2597
+ self.title = ""
2598
+ self.view_mode = "list"
2599
+ self.view = "weeks"
2600
+ self.saved_lines = []
2601
+ self.afill = 1
2602
+ self.leader_mode = False
2603
+ self.details_drawer: DetailsDrawer | None = None
2604
+ self.run_daily_tasks()
2605
+
2606
+ async def on_mount(self):
2607
+ self.styles.background = "#373737"
2608
+ try:
2609
+ screen = self.screen
2610
+ # screen.styles.background = "#2e2e2e"
2611
+ screen.styles.background = "#373737 100%"
2612
+ screen.styles.opacity = "100%"
2613
+ # optional: explicitly make some known child widgets transparent
2614
+ for sel in ("#list", "#main-list", "#details-list", "#table"):
2615
+ try:
2616
+ w = screen.query_one(sel)
2617
+ w.styles.background = "#373737 100%"
2618
+ w.styles.opacity = "100%"
2619
+ except Exception:
2620
+ pass
2621
+ except Exception:
2622
+ pass
2623
+ # open default screen
2624
+ self.action_show_weeks()
2625
+
2626
+ # your alert timers as-is
2627
+ now = datetime.now()
2628
+ seconds_to_next = (6 - (now.second % 6)) % 6
2629
+ await asyncio.sleep(seconds_to_next)
2630
+ self.set_interval(6, self.check_alerts)
2631
+
2632
+ def _return_focus_to_active_screen(self) -> None:
2633
+ screen = self.screen
2634
+ # if screen exposes a search target (your panes do), focus it; otherwise noop
2635
+ try:
2636
+ if hasattr(screen, "get_search_target"):
2637
+ self.set_focus(screen.get_search_target())
2638
+ except Exception:
2639
+ pass
2640
+
2641
+ def _resolve_tag_to_record(self, tag: str) -> tuple[int | None, int | None]:
2642
+ """
2643
+ Return (record_id, job_id) for the current view + tag, or (None, None).
2644
+ NOTE: uses week_tag_to_id for 'week' view, list_tag_to_id otherwise.
2645
+ """
2646
+ if self.view == "weeks":
2647
+ mapping = self.controller.week_tag_to_id.get(self.selected_week, {})
2648
+ else:
2649
+ mapping = self.controller.list_tag_to_id.get(self.view, {})
2650
+
2651
+ meta = mapping.get(tag)
2652
+ if not meta:
2653
+ return None, None
2654
+ if isinstance(meta, dict):
2655
+ return meta.get("record_id"), meta.get("job_id")
2656
+ # backward compatibility (old mapping was tag -> record_id)
2657
+ return meta, None
2658
+
2659
+ def action_close_details(self):
2660
+ screen = self.screen
2661
+ drawer = getattr(screen, "details_drawer", None)
2662
+ if drawer and not drawer.has_class("hidden"):
2663
+ drawer.close()
2664
+
2665
+ def _screen_show_details(self, title: str, lines: list[str]) -> None:
2666
+ screen = self.screen
2667
+ log_msg("showing details")
2668
+ if hasattr(screen, "show_details"):
2669
+ # DetailsPaneMixin: show inline at bottom
2670
+ screen.show_details(title, lines)
2671
+ else:
2672
+ # from tklr.view import DetailsScreen
2673
+
2674
+ self.push_screen(DetailsScreen([title] + lines))
2675
+
2676
+ def open_delete_prompt(
2677
+ self,
2678
+ *,
2679
+ record_id: int,
2680
+ job_id: int | None,
2681
+ subject: str,
2682
+ itemtype: str,
2683
+ instance_ts: str | None,
2684
+ is_repeating: bool,
2685
+ ) -> None:
2686
+ """Open an OptionPrompt to choose how/what to delete for a record."""
2687
+ ctrl = self.controller
2688
+
2689
+ # Build options + message depending on whether this is a repeating item
2690
+ if is_repeating and instance_ts and itemtype in "~*":
2691
+ options = [
2692
+ "Just this instance",
2693
+ "This and all subsequent instances",
2694
+ "The reminder itself",
2695
+ "Cancel",
2696
+ ]
2697
+ msg = (
2698
+ f"Delete [{LIGHT_SKY_BLUE}]{subject}[/{LIGHT_SKY_BLUE}]?\n\n"
2699
+ "Choose what to delete:"
2700
+ )
2701
+ else:
2702
+ options = ["Delete record", "Cancel"]
2703
+ msg = (
2704
+ f"Delete [{LIGHT_SKY_BLUE}]{subject}[/{LIGHT_SKY_BLUE}]?\n\n"
2705
+ "This cannot be undone."
2706
+ )
2707
+
2708
+ def _after_choice(choice: str | None) -> None:
2709
+ log_msg(f"delete prompt returned {choice = }")
2710
+
2711
+ if not choice or choice == "Cancel":
2712
+ return
2713
+
2714
+ changed = False
2715
+
2716
+ if choice == "Delete record":
2717
+ # Entire reminder (and all jobs, if any)
2718
+ ctrl.delete_record(record_id)
2719
+ changed = True
2720
+
2721
+ elif choice == "Just this instance":
2722
+ if instance_ts:
2723
+ ctrl.delete_instance(
2724
+ record_id,
2725
+ instance_text=instance_ts,
2726
+ )
2727
+ changed = True
2728
+
2729
+ elif choice == "This and all subsequent instances":
2730
+ if instance_ts:
2731
+ ctrl.delete_this_and_future(
2732
+ record_id,
2733
+ instance_text=instance_ts,
2734
+ )
2735
+ changed = True
2736
+
2737
+ elif choice == "The reminder itself":
2738
+ ctrl.delete_record(record_id)
2739
+ changed = True
2740
+
2741
+ if changed:
2742
+ # 1) Close details so there’s no stale panel
2743
+ if hasattr(self, "_close_details_if_open"):
2744
+ self._close_details_if_open()
2745
+
2746
+ # 2) Refresh the current view (weeks/agenda/next/etc.)
2747
+ if hasattr(self, "refresh_view"):
2748
+ self.refresh_view()
2749
+
2750
+ # Push the modal with a callback; no async/await here
2751
+ self.push_screen(OptionPrompt(msg, options), callback=_after_choice)
2752
+
2753
+ def make_detail_key_handler(self, *, view_name: str, week_provider=None):
2754
+ ctrl = self.controller
2755
+ app = self
2756
+ # bug_msg(f"{ctrl = }, {app = }")
2757
+
2758
+ def handler(key: str, meta: dict) -> None: # chord-aware, sync
2759
+ record_id = meta.get("record_id")
2760
+ # bug_msg(f"in handler with {key = }, {meta = }, {record_id = }")
2761
+ job_id = meta.get("job_id")
2762
+ first = meta.get("first")
2763
+ second = meta.get("second")
2764
+ itemtype = meta.get("itemtype")
2765
+ subject = meta.get("subject")
2766
+ # instance-aware info
2767
+ instance_ts = meta.get("instance_ts")
2768
+ datetime_id = meta.get("datetime_id")
2769
+
2770
+ if not record_id:
2771
+ return
2772
+
2773
+ # ---------- ,f : FINISH ----------
2774
+ if key == "comma,f" and itemtype in "~^":
2775
+ # bug_msg(f"{record_id = }, {job_id = }, {first = }")
2776
+ job = f" {job_id}" if job_id else ""
2777
+ id_part = f"({record_id}{job})"
2778
+ due = (
2779
+ f"\nDue: [{LIGHT_SKY_BLUE}]{fmt_user(first)}[/{LIGHT_SKY_BLUE}]"
2780
+ if first
2781
+ else ""
2782
+ )
2783
+ msg = (
2784
+ f"Finished datetime\n"
2785
+ f"For: [{LIGHT_SKY_BLUE}]{subject} {id_part}[/{LIGHT_SKY_BLUE}]{due}"
2786
+ )
2787
+
2788
+ def _after_dt(dt: datetime | None) -> None:
2789
+ # bug_msg(f"finish, got {dt = }")
2790
+ if dt:
2791
+ ctrl.finish_task(record_id, job_id=job_id, when=dt)
2792
+ if hasattr(app, "refresh_view"):
2793
+ app.refresh_view()
2794
+
2795
+ app.prompt_datetime_with_callback(msg, _after_dt)
2796
+
2797
+ # ---------- ,e : EDIT ----------
2798
+ elif key == "comma,e":
2799
+ # bug_msg("got comma,e")
2800
+ seed_text = ctrl.get_entry_from_record(record_id)
2801
+ # bug_msg(f"{seed_text = }")
2802
+
2803
+ # Close/hide details before opening the editor
2804
+ try:
2805
+ scr = app.screen
2806
+ if (
2807
+ hasattr(scr, "list_with_details")
2808
+ and scr.list_with_details.has_details_open()
2809
+ ):
2810
+ if hasattr(scr.list_with_details, "hide_details"):
2811
+ scr.list_with_details.hide_details()
2812
+ except Exception as e:
2813
+ log_msg(f"Error while hiding details before edit: {e}")
2814
+
2815
+ app.push_screen(
2816
+ EditorScreen(ctrl, record_id, seed_text=seed_text),
2817
+ callback=self._after_edit,
2818
+ )
2819
+
2820
+ # ---------- ,c : CLONE ----------
2821
+ elif key == "comma,c":
2822
+ seed_text = ctrl.get_entry_from_record(record_id)
2823
+ app.push_screen(
2824
+ EditorScreen(ctrl, None, seed_text=seed_text),
2825
+ callback=self._after_edit,
2826
+ )
2827
+
2828
+ # ---------- ,d : DELETE ----------
2829
+ elif key == "comma,d":
2830
+ # bug_msg(f"in delete {second = }, {instance_ts = }, {itemtype = }")
2831
+ is_repeating = second is not None
2832
+ app.open_delete_prompt(
2833
+ record_id=record_id,
2834
+ job_id=job_id,
2835
+ subject=subject,
2836
+ itemtype=itemtype,
2837
+ instance_ts=instance_ts,
2838
+ is_repeating=is_repeating,
2839
+ )
2840
+
2841
+ # ---------- ,n : SCHEDULE NEW INSTANCE ----------
2842
+ elif key == "comma,n":
2843
+
2844
+ def _after_dt(dt: datetime | None) -> None:
2845
+ # bug_msg(f"schedule_new, got {dt = }")
2846
+ if dt:
2847
+ ctrl.schedule_new(record_id, job_id=job_id, when=dt)
2848
+ if hasattr(app, "refresh_view"):
2849
+ app.refresh_view()
2850
+
2851
+ app.prompt_datetime_with_callback("Schedule when?", _after_dt)
2852
+
2853
+ # ---------- ,r : RESCHEDULE INSTANCE ----------
2854
+ elif key == "comma,r":
2855
+ if instance_ts:
2856
+ msg = (
2857
+ f"Reschedule instance for "
2858
+ f"[{LIGHT_SKY_BLUE}]{subject}[/{LIGHT_SKY_BLUE}] "
2859
+ f"from {instance_ts} to?"
2860
+ )
2861
+
2862
+ def _after_dt(dt: datetime | None) -> None:
2863
+ # bug_msg(f"reschedule instance, got {dt = }")
2864
+ if dt:
2865
+ ctrl.reschedule_instance(
2866
+ record_id,
2867
+ job_id=job_id,
2868
+ old_instance_text=instance_ts,
2869
+ new_when=dt,
2870
+ )
2871
+ if hasattr(app, "refresh_view"):
2872
+ app.refresh_view()
2873
+
2874
+ app.prompt_datetime_with_callback(msg, _after_dt)
2875
+
2876
+ else:
2877
+ # fallback: older coarse reschedule
2878
+ def _after_dt(dt: datetime | None) -> None:
2879
+ # bug_msg(f"reschedule coarse, got {dt = }")
2880
+ if dt:
2881
+ yrwk = week_provider() if week_provider else None
2882
+ ctrl.reschedule(
2883
+ record_id, when=dt, context=view_name, yrwk=yrwk
2884
+ )
2885
+
2886
+ app.prompt_datetime_with_callback("Reschedule to?", _after_dt)
2887
+
2888
+ # ---------- ,g : GOTO (open_with_default) ----------
2889
+ elif key == "comma,g":
2890
+ self.action_open_with_default(record_id)
2891
+
2892
+ # ---------- ,t : TOUCH ----------
2893
+ elif key == "comma,t":
2894
+ ctrl.touch_item(record_id)
2895
+
2896
+ # ---------- ,p : PIN / UNPIN ----------
2897
+ elif key == "comma,p" and itemtype == "~":
2898
+ ctrl.toggle_pinned(record_id)
2899
+ if hasattr(app, "_reopen_details"):
2900
+ app._reopen_details(tag_meta=meta)
2901
+
2902
+ # keep ctrl+r for repetitions
2903
+ elif key == "ctrl+r" and itemtype == "~":
2904
+ ctrl.show_repetitions(record_id)
2905
+
2906
+ return handler
2907
+
2908
+ def on_key(self, event: events.Key) -> None:
2909
+ """Handle global key events (tags, escape, etc.)."""
2910
+ # bug_msg(f"before: {event.key = }, {self.leader_mode = }")
2911
+
2912
+ # --- View-specific setup ---
2913
+ # bug_msg(f"{self.view = }")
2914
+ # ------------------ improved left/right handling ------------------
2915
+ # if event.key == "ctrl+b":
2916
+ # self.action_show_bins()
2917
+
2918
+ if event.key in ("left", "right"):
2919
+ if self.view == "weeks":
2920
+ screen = getattr(self, "screen", None)
2921
+ # log_msg(
2922
+ # f"[LEFT/RIGHT] screen={type(screen).__name__ if screen else None}"
2923
+ # )
2924
+
2925
+ if not screen:
2926
+ log_msg("[LEFT/RIGHT] no screen -> fallback week nav")
2927
+ if event.key == "left":
2928
+ self.action_previous_week()
2929
+ else:
2930
+ self.action_next_week()
2931
+ return
2932
+
2933
+ # check both "has method" and the result of calling it (if callable)
2934
+ has_prev_method = getattr(screen, "has_prev_page", None)
2935
+ has_next_method = getattr(screen, "has_next_page", None)
2936
+ do_prev = callable(getattr(screen, "previous_page", None))
2937
+ do_next = callable(getattr(screen, "next_page", None))
2938
+
2939
+ has_prev_callable = callable(has_prev_method)
2940
+ has_next_callable = callable(has_next_method)
2941
+
2942
+ # call them (safely) to get boolean availability
2943
+ try:
2944
+ has_prev_available = (
2945
+ has_prev_method() if has_prev_callable else False
2946
+ )
2947
+ except Exception as e:
2948
+ log_msg(f"[LEFT/RIGHT] has_prev_page() raised: {e}")
2949
+ has_prev_available = False
2950
+
2951
+ try:
2952
+ has_next_available = (
2953
+ has_next_method() if has_next_callable else False
2954
+ )
2955
+ except Exception as e:
2956
+ log_msg(f"[LEFT/RIGHT] has_next_page() raised: {e}")
2957
+ has_next_available = False
2958
+
2959
+ # Prefer page navigation when page available; otherwise fallback to week nav.
2960
+ if event.key == "left":
2961
+ if has_prev_available and do_prev:
2962
+ # bug_msg("[LEFT/RIGHT] -> screen.previous_page()")
2963
+ screen.previous_page()
2964
+ else:
2965
+ # bug_msg("[LEFT/RIGHT] -> no prev page -> previous week")
2966
+ self.action_previous_week()
2967
+ return
2968
+
2969
+ else: # right
2970
+ if has_next_available and do_next:
2971
+ # bug_msg("[LEFT/RIGHT] -> screen.next_page()")
2972
+ screen.next_page()
2973
+ else:
2974
+ # bug_msg("[LEFT/RIGHT] -> no next page -> next week")
2975
+ self.action_next_week()
2976
+ return
2977
+ # else: not week view -> let other code handle left/right
2978
+ if event.key == "full_stop" and self.view == "weeks":
2979
+ # call the existing "center_week" or "go to today" action
2980
+ try:
2981
+ self.action_center_week() # adjust name if different
2982
+ except Exception:
2983
+ pass
2984
+ # reset pages if screen supports it
2985
+ if hasattr(self.screen, "reset_to_first_page"):
2986
+ self.screen.reset_to_first_page()
2987
+ return
2988
+
2989
+ if event.key == "escape":
2990
+ if self.leader_mode:
2991
+ self.leader_mode = False
2992
+ return
2993
+ if self.view == "bin":
2994
+ self.pop_screen()
2995
+ self.view = "bintree"
2996
+ return
2997
+
2998
+ # --- Leader (comma) mode ---
2999
+ if event.key == "comma":
3000
+ self.leader_mode = True
3001
+ # bug_msg(f"set {self.leader_mode = }")
3002
+ return
3003
+
3004
+ if self.leader_mode:
3005
+ self.leader_mode = False
3006
+ meta = self.controller.get_last_details_meta() or {}
3007
+ handler = getattr(self, "detail_handler", None)
3008
+ # bug_msg(f"got {event.key = }, {handler = }")
3009
+ if handler:
3010
+ # bug_msg(f"dispatching detail handler for {event.key = }, {meta = }")
3011
+ # 🔹 handler is now sync; just call it
3012
+ handler(f"comma,{event.key}", meta)
3013
+ return
3014
+
3015
+ # inside DynamicViewApp.on_key, after handling leader/escape etc.
3016
+ screen = self.screen # current active Screen (FullScreenList, WeeksScreen, ...)
3017
+ key = event.key
3018
+
3019
+ # --- Page navigation (left / right) for any view that provides it ----------
3020
+ if key in ("right",): # pick whichever keys you bind for next page
3021
+ if hasattr(screen, "next_page"):
3022
+ try:
3023
+ screen.next_page()
3024
+ return
3025
+ except Exception as e:
3026
+ log_msg(f"next_page error: {e}")
3027
+ # previous page
3028
+ if key in ("left",): # your left binding(s)
3029
+ if hasattr(screen, "previous_page"):
3030
+ try:
3031
+ screen.previous_page()
3032
+ return
3033
+ except Exception as e:
3034
+ log_msg(f"previous_page error: {e}")
3035
+
3036
+ # --- Single-letter tag press handling for paged views ----------------------
3037
+ # (Note: we assume tags are exactly one lower-case ASCII letter 'a'..'z')
3038
+ if key in "abcdefghijklmnopqrstuvwxyz":
3039
+ # If the view supplies a show_details_for_tag method, use it
3040
+ if hasattr(screen, "show_details_for_tag"):
3041
+ screen.show_details_for_tag(key)
3042
+ return
3043
+
3044
+ def action_take_screenshot(self):
3045
+ path = timestamped_screenshot_path(self.view)
3046
+ self.save_screenshot(str(path))
3047
+ self.notify(f"Screenshot saved to: {path}", severity="info", timeout=3)
3048
+
3049
+ def run_daily_tasks(self):
3050
+ created, kept, removed = self.controller.rotate_daily_backups()
3051
+ if created:
3052
+ log_msg(f"✅ Backup created: {created}")
3053
+ else:
3054
+ log_msg("ℹ️ No backup created (DB unchanged since last snapshot).")
3055
+ if removed:
3056
+ log_msg("🧹 Pruned: " + ", ".join(p.name for p in removed))
3057
+
3058
+ self.controller.populate_alerts()
3059
+ self.controller.populate_notice()
3060
+
3061
+ def play_bells(self) -> None:
3062
+ """An action to ring the bell."""
3063
+ delay = [0.6, 0.4, 0.2]
3064
+ for d in delay:
3065
+ time.sleep(d) # ~400 ms gap helps trigger distinct alerts
3066
+ self.app.bell()
3067
+
3068
+ async def check_alerts(self):
3069
+ # called every 6 seconds
3070
+ now = datetime.now()
3071
+ if now.hour == 0 and now.minute == 0 and 0 <= now.second < 6:
3072
+ self.run_daily_tasks()
3073
+ if now.minute % 10 == 0 and now.second == 0:
3074
+ # check alerts every 10 minutes
3075
+ self.notify(
3076
+ "Checking for scheduled alerts...", severity="info", timeout=1.2
3077
+ )
3078
+ # execute due alerts
3079
+ due = self.controller.get_due_alerts(now) # list of [alert_id, alert_commands]
3080
+ if not due:
3081
+ return
3082
+ for alert_id, alert_name, alert_command in due:
3083
+ if alert_name == "n":
3084
+ self.notify(f"{alert_command}", timeout=60)
3085
+ play_alert_sound("alert.mp3")
3086
+ else:
3087
+ os.system(alert_command)
3088
+ self.controller.db_manager.mark_alert_executed(alert_id)
3089
+
3090
+ # def action_new_reminder(self):
3091
+ # self.push_screen(EditorScreen(self.controller, None, seed_text=""))
3092
+
3093
+ def action_new_reminder(self) -> None:
3094
+ # Use whatever seed you like (empty, template, clipboard, etc.)
3095
+ self.open_editor_for(seed_text="")
3096
+
3097
+ def show_screen(self, screen: Screen) -> None:
3098
+ """Use switch_screen when possible; fall back to push_screen initially."""
3099
+ try:
3100
+ self.switch_screen(screen)
3101
+ except IndexError:
3102
+ # No screen to switch from yet; first main screen
3103
+ self.push_screen(screen)
3104
+
3105
+ def refresh_view(self) -> None:
3106
+ view_name = getattr(self, "view", None)
3107
+ if not view_name:
3108
+ return
3109
+
3110
+ method_name = self.VIEW_REFRESHERS.get(view_name)
3111
+ log_msg(f"{view_name = }, {method_name = }")
3112
+ if not method_name:
3113
+ return
3114
+
3115
+ method = getattr(self, method_name, None)
3116
+ if callable(method):
3117
+ method()
3118
+
3119
+ def action_show_weeks(self):
3120
+ self.view = "weeks"
3121
+ log_msg(f"{self.selected_week = }")
3122
+ title, table, details = self.controller.get_table_and_list(
3123
+ self.current_start_date, self.selected_week
3124
+ )
3125
+ footer = "[bold yellow]?[/bold yellow] Help [bold yellow]/[/bold yellow] Search"
3126
+ # self.set_afill("weeks")
3127
+
3128
+ screen = WeeksScreen(title, table, details, footer)
3129
+ self.show_screen(screen)
3130
+
3131
+ def action_show_agenda(self):
3132
+ self.view = "agenda"
3133
+ details, title = self.controller.get_agenda()
3134
+ # footer = "[bold yellow]?[/bold yellow] Help [bold yellow]/[/bold yellow] Search"
3135
+ footer = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
3136
+ self.show_screen(FullScreenList(details, title, "", footer))
3137
+
3138
+ return
3139
+
3140
+ def action_show_bin(self, bin_id: Optional[int] = None):
3141
+ self.view = "bin"
3142
+ if bin_id is None:
3143
+ bin_id = self.controller.root_id
3144
+ self.show_screen(BinView(controller=self.controller, bin_id=bin_id))
3145
+
3146
+ def action_show_last(self):
3147
+ self.view = "last"
3148
+ details, title = self.controller.get_last()
3149
+ footer = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
3150
+ self.show_screen(FullScreenList(details, title, "", footer))
3151
+
3152
+ def action_show_next(self):
3153
+ self.view = "next"
3154
+ details, title = self.controller.get_next()
3155
+ log_msg(f"{details = }, {title = }")
3156
+
3157
+ footer = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
3158
+ self.show_screen(FullScreenList(details, title, "", footer))
3159
+
3160
+ def action_show_tags(self):
3161
+ self.view = "tags"
3162
+ details, title = self.controller.get_tag_view()
3163
+ log_msg(f"{details = }, {title = }")
3164
+
3165
+ footer = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
3166
+ self.show_screen(FullScreenList(details, title, "", footer))
3167
+
3168
+ def action_show_find(self):
3169
+ self.view = "find"
3170
+ search_input = Input(placeholder="Enter search term...", id="find_input")
3171
+ self.mount(search_input)
3172
+ self.set_focus(search_input)
3173
+
3174
+ def action_show_completions(self):
3175
+ self.view = "completions"
3176
+ details, title = self.controller.get_completions()
3177
+
3178
+ footer = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
3179
+ self.show_screen(FullScreenList(details, title, "", footer))
3180
+
3181
+ def action_show_alerts(self):
3182
+ self.view = "alerts"
3183
+ pages, header = self.controller.get_active_alerts()
3184
+ log_msg(f"{pages = }, {header = }")
3185
+
3186
+ footer = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
3187
+
3188
+ self.push_screen(FullScreenList(pages, "Reminders for Today", header, footer))
3189
+
3190
+ def _close_details_if_open(self) -> None:
3191
+ # If your details is a modal screen, pop it; if it's a panel, hide it.
3192
+ try:
3193
+ scr = self.screen
3194
+ if (
3195
+ hasattr(scr, "list_with_details")
3196
+ and scr.list_with_details.has_details_open()
3197
+ ):
3198
+ scr.list_with_details.hide_details() # or details.visible = False / self.app.pop_screen()
3199
+ except Exception:
3200
+ pass
3201
+
3202
+ def _after_edit(self, result: dict | None) -> None:
3203
+ if not result or not result.get("changed"):
3204
+ return
3205
+
3206
+ self.refresh_view()
3207
+
3208
+ def open_editor_for(
3209
+ self, *, record_id: int | None = None, seed_text: str = ""
3210
+ ) -> None:
3211
+ self._close_details_if_open()
3212
+ self.app.push_screen(
3213
+ EditorScreen(self.controller, record_id=record_id, seed_text=seed_text),
3214
+ callback=self._after_edit,
3215
+ )
3216
+
3217
+ def on_input_submitted(self, event: Input.Submitted):
3218
+ search_term = event.value
3219
+ event.input.remove()
3220
+
3221
+ if event.input.id == "find_input":
3222
+ self.view = "find"
3223
+ results, title = self.controller.find_records(search_term)
3224
+ footer = f"[bold {FOOTER}]?[/bold {FOOTER}] Help [bold {FOOTER}]/[/bold {FOOTER}] Search"
3225
+ self.show_screen(FullScreenList(results, title, "", footer))
3226
+
3227
+ elif event.input.id == "search":
3228
+ self.perform_search(search_term)
3229
+
3230
+ def action_start_search(self):
3231
+ search_input = Input(placeholder="Search...", id="search")
3232
+ self.mount(search_input)
3233
+ self.set_focus(search_input)
3234
+
3235
+ def action_clear_search(self):
3236
+ self.search_term = ""
3237
+ screen = self.screen
3238
+ if isinstance(screen, SearchableScreen):
3239
+ screen.clear_search()
3240
+ self.update_footer(search_active=False)
3241
+
3242
+ def action_next_match(self):
3243
+ if isinstance(self.screen, SearchableScreen):
3244
+ try:
3245
+ self.screen.scroll_to_next_match()
3246
+ except Exception as e:
3247
+ log_msg(f"[Search] Error in next_match: {e}")
3248
+ else:
3249
+ log_msg("[Search] Current screen does not support search.")
3250
+
3251
+ def action_previous_match(self):
3252
+ if isinstance(self.screen, SearchableScreen):
3253
+ try:
3254
+ self.screen.scroll_to_previous_match()
3255
+ except Exception as e:
3256
+ log_msg(f"[Search] Error in previous_match: {e}")
3257
+ else:
3258
+ log_msg("[Search] Current screen does not support search.")
3259
+
3260
+ def perform_search(self, term: str):
3261
+ self.search_term = term
3262
+ screen = self.screen
3263
+ if isinstance(screen, SearchableScreen):
3264
+ screen.perform_search(term)
3265
+ else:
3266
+ log_msg("[App] Current screen does not support search.")
3267
+
3268
+ def action_copy_search(self) -> None:
3269
+ screen = getattr(self, "screen", None)
3270
+ term = ""
3271
+ if screen is not None and hasattr(screen, "get_search_term"):
3272
+ try:
3273
+ term = screen.get_search_term() or ""
3274
+ except Exception:
3275
+ term = ""
3276
+ else:
3277
+ term = getattr(self, "search_term", "") or ""
3278
+
3279
+ if not term:
3280
+ self.notify("Nothing to copy", severity="info", timeout=1.2)
3281
+ return
3282
+
3283
+ try:
3284
+ copy_to_clipboard(term)
3285
+ self.notify("Copied search to clipboard ✓", severity="info", timeout=1.2)
3286
+ except ClipboardUnavailable as e:
3287
+ self.notify(f"{str(e)}", severity="error", timeout=1.2)
3288
+
3289
+ def update_table_and_list(self):
3290
+ screen = self.screen
3291
+ if isinstance(screen, WeeksScreen):
3292
+ screen.update_table_and_list()
3293
+
3294
+ def action_current_period(self):
3295
+ self.current_start_date = calculate_4_week_start()
3296
+ self.selected_week = tuple(datetime.now().isocalendar()[:2])
3297
+ # self.set_afill("weeks")
3298
+ self.update_table_and_list()
3299
+
3300
+ def action_next_period(self):
3301
+ self.current_start_date += timedelta(weeks=4)
3302
+ self.selected_week = tuple(self.current_start_date.isocalendar()[:2])
3303
+ # self.set_afill("weeks")
3304
+ self.update_table_and_list()
3305
+
3306
+ def action_previous_period(self):
3307
+ self.current_start_date -= timedelta(weeks=4)
3308
+ self.selected_week = tuple(self.current_start_date.isocalendar()[:2])
3309
+ # self.set_afill("weeks")
3310
+ self.update_table_and_list()
3311
+
3312
+ def action_next_week(self):
3313
+ self.selected_week = get_next_yrwk(*self.selected_week)
3314
+ if self.selected_week > tuple(
3315
+ (self.current_start_date + timedelta(weeks=4) - ONEDAY).isocalendar()[:2]
3316
+ ):
3317
+ self.current_start_date += timedelta(weeks=1)
3318
+ # self.set_afill("weeks")
3319
+ self.update_table_and_list()
3320
+
3321
+ def action_previous_week(self):
3322
+ self.selected_week = get_previous_yrwk(*self.selected_week)
3323
+ if self.selected_week < tuple((self.current_start_date).isocalendar()[:2]):
3324
+ self.current_start_date -= timedelta(weeks=1)
3325
+ # self.set_afill("weeks")
3326
+ self.update_table_and_list()
3327
+
3328
+ def action_center_week(self):
3329
+ self.current_start_date = datetime.strptime(
3330
+ f"{self.selected_week[0]} {self.selected_week[1]} 1", "%G %V %u"
3331
+ ) - timedelta(weeks=1)
3332
+ self.update_table_and_list()
3333
+
3334
+ def action_quit(self):
3335
+ self.exit()
3336
+
3337
+ # def action_show_help(self):
3338
+ # self.push_screen(HelpScreen(HelpText))
3339
+
3340
+ def action_show_help(self):
3341
+ scr = self.screen
3342
+ bug_msg(
3343
+ f"{scr = }, {self.controller.get_last_details_meta() = }, {hasattr(scr, 'list_with_details') = }"
3344
+ )
3345
+ if (
3346
+ hasattr(scr, "list_with_details")
3347
+ and scr.list_with_details.has_details_open()
3348
+ ):
3349
+ meta = self.controller.get_last_details_meta() or {}
3350
+ lines = build_details_help(meta)
3351
+ self.push_screen(HelpScreen(lines))
3352
+ else:
3353
+ self.push_screen(HelpScreen(HelpText))
3354
+
3355
+ def action_detail_edit(self):
3356
+ self._dispatch_detail_key("/e")
3357
+
3358
+ def action_detail_copy(self):
3359
+ self._dispatch_detail_key("/c")
3360
+
3361
+ def action_detail_delete(self):
3362
+ self._dispatch_detail_key("/d")
3363
+
3364
+ def action_detail_finish(self):
3365
+ self._dispatch_detail_key("/f")
3366
+
3367
+ def action_detail_goto(self):
3368
+ self._dispatch_detail_key("/g")
3369
+
3370
+ def action_detail_pin(self):
3371
+ self._dispatch_detail_key("/p")
3372
+
3373
+ def action_detail_schedule(self):
3374
+ self._dispatch_detail_key("/s")
3375
+
3376
+ def action_detail_reschedule(self):
3377
+ self._dispatch_detail_key("/r")
3378
+
3379
+ def action_detail_touch(self):
3380
+ self._dispatch_detail_key("/t")
3381
+
3382
+ def action_detail_repetitions(self):
3383
+ self._dispatch_detail_key("ctrl+r")
3384
+
3385
+ def _dispatch_detail_key(self, key: str) -> None:
3386
+ # Look at the current screen and meta
3387
+ scr = self.screen
3388
+ if (
3389
+ hasattr(scr, "list_with_details")
3390
+ and scr.list_with_details.has_details_open()
3391
+ ):
3392
+ meta = self.controller.get_last_details_meta() or {}
3393
+ handler = self.make_detail_key_handler(view_name=self.view)
3394
+ bug_msg(f"got {self.view = }, {key = }, {meta = }, {handler = }")
3395
+ handler(key, meta)
3396
+
3397
+ # async def prompt_datetime(
3398
+ # self, message: str, default: datetime | None = None
3399
+ # ) -> datetime | None:
3400
+ # """Show DatetimePrompt and return parsed datetime or None."""
3401
+ # return await self.push_screen_wait(DatetimePrompt(message, default))
3402
+
3403
+ def prompt_datetime_with_callback(
3404
+ self,
3405
+ message: str,
3406
+ callback: Callable[[Optional[datetime]], None],
3407
+ default: datetime | None = None,
3408
+ ) -> None:
3409
+ """
3410
+ Show DatetimePrompt and call `callback(parsed_dt_or_None)` when done.
3411
+ No async/await required in the caller.
3412
+ """
3413
+
3414
+ def _after(result: datetime | None) -> None:
3415
+ # `result` is whatever DatetimePrompt.dismiss(...) gave us
3416
+ callback(result)
3417
+
3418
+ self.push_screen(
3419
+ DatetimePrompt(message=message, default=default),
3420
+ callback=_after,
3421
+ )
3422
+
3423
+ async def prompt_options(self, message: str, options: list[str]) -> str | None:
3424
+ """Show OptionPrompt and return the chosen label, or None."""
3425
+ return await self.push_screen_wait(OptionPrompt(message, options))
3426
+
3427
+ async def prompt_choice(self, message: str, choices: list[str]) -> str | None:
3428
+ """Show ChoicePrompt and return one of `choices` or None."""
3429
+ return await self.push_screen_wait(ChoicePrompt(message, choices))
3430
+
3431
+ async def prompt_confirm(self, message: str) -> bool | None:
3432
+ """
3433
+ Show a Yes/No/Esc confirm dialog.
3434
+
3435
+ Returns:
3436
+ True -> Yes
3437
+ False -> No
3438
+ None -> Esc/cancel
3439
+ """
3440
+ return await self.push_screen_wait(ConfirmPrompt(message))
3441
+
3442
+ def action_show_bins(self, start_bin_id: int | None = None):
3443
+ root_id = start_bin_id or self.controller.get_root_bin_id()
3444
+ self.push_screen(TaggedHierarchyScreen(self.controller, root_id))
3445
+
3446
+ def action_open_with_default(self, record_id: int) -> None:
3447
+ """
3448
+ Open the @g target for this record using the OS default handler.
3449
+ """
3450
+ # Get the record row + tokens JSON
3451
+ row = self.controller.db_manager.get_record_as_dictionary(record_id)
3452
+ if not row:
3453
+ self.notify(f"Record {record_id} not found.", severity="warning")
3454
+ return
3455
+
3456
+ tokens_json = row.get("tokens")
3457
+ if not tokens_json:
3458
+ self.notify("This record has no tokens (no @g).", severity="warning")
3459
+ return
3460
+
3461
+ try:
3462
+ tokens = json.loads(tokens_json) or []
3463
+ except Exception as e:
3464
+ self.notify(
3465
+ f"Cannot parse tokens for record {record_id}: {e}", severity="warning"
3466
+ )
3467
+ return
3468
+
3469
+ goto_value: str | None = None
3470
+
3471
+ for tok in tokens:
3472
+ if tok.get("t") != "@":
3473
+ continue
3474
+ if tok.get("k") != "g":
3475
+ continue
3476
+
3477
+ # Prefer the parsed value if do_g stored it
3478
+ if "goto" in tok:
3479
+ goto_value = tok["goto"]
3480
+ else:
3481
+ raw = (tok.get("token") or "").strip()
3482
+ if raw.startswith("@g"):
3483
+ goto_value = raw[2:].strip()
3484
+ else:
3485
+ parts = raw.split(None, 1)
3486
+ goto_value = parts[1].strip() if len(parts) > 1 else ""
3487
+
3488
+ if goto_value:
3489
+ break
3490
+
3491
+ if not goto_value:
3492
+ self.notify("This record has no @g link.", severity="warning")
3493
+ return
3494
+
3495
+ # Finally, delegate to your OS opener
3496
+ try:
3497
+ open_with_default(goto_value)
3498
+ except Exception as e:
3499
+ self.notify(f"Failed to open {goto_value!r}: {e}", severity="error")
3500
+
3501
+
3502
+ if __name__ == "__main__":
3503
+ pass