tklr-dgraham 0.0.0rc11__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.

Potentially problematic release.


This version of tklr-dgraham might be problematic. Click here for more details.

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