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/__init__.py +0 -0
- tklr/cli/main.py +253 -0
- tklr/cli/migrate_etm_to_tklr.py +764 -0
- tklr/common.py +1296 -0
- tklr/controller.py +2602 -0
- tklr/item.py +3765 -0
- tklr/list_colors.py +234 -0
- tklr/model.py +3973 -0
- tklr/shared.py +654 -0
- tklr/sounds/alert.mp3 +0 -0
- tklr/tklr_env.py +461 -0
- tklr/use_system.py +64 -0
- tklr/versioning.py +21 -0
- tklr/view.py +2912 -0
- tklr/view_agenda.py +236 -0
- tklr/view_textual.css +296 -0
- tklr_dgraham-0.0.0rc11.dist-info/METADATA +699 -0
- tklr_dgraham-0.0.0rc11.dist-info/RECORD +21 -0
- tklr_dgraham-0.0.0rc11.dist-info/WHEEL +5 -0
- tklr_dgraham-0.0.0rc11.dist-info/entry_points.txt +2 -0
- tklr_dgraham-0.0.0rc11.dist-info/top_level.txt +1 -0
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
|