reqdeck 0.1.0__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.
Files changed (44) hide show
  1. reqdeck/__init__.py +3 -0
  2. reqdeck/__main__.py +7 -0
  3. reqdeck/cli.py +706 -0
  4. reqdeck/config.py +185 -0
  5. reqdeck/editor.py +51 -0
  6. reqdeck/init.py +317 -0
  7. reqdeck/markers.py +65 -0
  8. reqdeck/model.py +393 -0
  9. reqdeck/ops.py +124 -0
  10. reqdeck/questions.py +62 -0
  11. reqdeck/render.py +140 -0
  12. reqdeck/skills/reqdeck-cli/SKILL.md +166 -0
  13. reqdeck/skills/reqdeck-method/SKILL.md +166 -0
  14. reqdeck/skills/reqdeck-method/references/markers.md +92 -0
  15. reqdeck/skills/reqdeck-method/references/writing-requirements.md +125 -0
  16. reqdeck/store.py +186 -0
  17. reqdeck/templates/OPEN-QUESTIONS.md +23 -0
  18. reqdeck/themes/README.md +31 -0
  19. reqdeck/themes/__init__.py +73 -0
  20. reqdeck/themes/atom-one-dark.toml +27 -0
  21. reqdeck/themes/atom-one-light.toml +27 -0
  22. reqdeck/themes/catppuccin-frappe.toml +27 -0
  23. reqdeck/themes/catppuccin-latte.toml +27 -0
  24. reqdeck/themes/catppuccin-macchiato.toml +27 -0
  25. reqdeck/themes/catppuccin-mocha.toml +27 -0
  26. reqdeck/themes/dracula.toml +27 -0
  27. reqdeck/themes/flexoki.toml +27 -0
  28. reqdeck/themes/gruvbox.toml +27 -0
  29. reqdeck/themes/monokai.toml +27 -0
  30. reqdeck/themes/nord.toml +27 -0
  31. reqdeck/themes/rose-pine-dawn.toml +27 -0
  32. reqdeck/themes/rose-pine-moon.toml +27 -0
  33. reqdeck/themes/rose-pine.toml +27 -0
  34. reqdeck/themes/solarized-dark.toml +27 -0
  35. reqdeck/themes/solarized-light.toml +27 -0
  36. reqdeck/themes/textual-dark.toml +27 -0
  37. reqdeck/themes/textual-light.toml +27 -0
  38. reqdeck/themes/tokyo-night.toml +27 -0
  39. reqdeck/viewer.py +1452 -0
  40. reqdeck-0.1.0.dist-info/METADATA +305 -0
  41. reqdeck-0.1.0.dist-info/RECORD +44 -0
  42. reqdeck-0.1.0.dist-info/WHEEL +4 -0
  43. reqdeck-0.1.0.dist-info/entry_points.txt +2 -0
  44. reqdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
reqdeck/viewer.py ADDED
@@ -0,0 +1,1452 @@
1
+ """The reqdeck TUI: requirements as documents, with their sections, and the open
2
+ questions beside them. Reads through the same model as the CLI and writes
3
+ through the same operations.
4
+
5
+ Needs `textual`, which the CLI does not. The chosen theme and sidebar width
6
+ are remembered per user in `$XDG_CONFIG_HOME/reqdeck/tui.json`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from rich.text import Text
20
+ from textual import events, on
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+ from textual.containers import Horizontal, Vertical, VerticalScroll
24
+ from textual.content import Content
25
+ from textual.screen import ModalScreen
26
+ from textual.widget import Widget
27
+ from textual.widgets import Input, Label, Markdown, OptionList, Static, Tree
28
+ from textual.widgets.option_list import Option
29
+ from textual.widgets.tree import TreeNode
30
+
31
+ from . import editor, ops, render, themes
32
+ from .cli import load_model
33
+ from .model import Model, Req
34
+
35
+ ID_RE = re.compile(r"\b(?:OQ-\d{4}|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*[_-]\d+)\b")
36
+ TARGET_RE = re.compile(r"(?:goto|link)\('(?:reqdeck:)?([^']+)'\)")
37
+
38
+ #: Status letter in the tree, and the theme role that colours it.
39
+ STATUS_MARK = {"Accepted": ("A", "accepted"), "Proposed": ("P", "proposed"), "Reopened": ("R", "reopened")}
40
+
41
+ HELP = """\
42
+ # Keys
43
+
44
+ The tree always has the cursor; the reading pane shows what it highlights.
45
+
46
+ | | |
47
+ |---|---|
48
+ | `j` `k` | Down, up |
49
+ | `h` `l` | Collapse or go to the parent, expand or go in |
50
+ | `gg` `G` | First, last |
51
+ | `n` `N` | Next, previous requirement or question — the search results, while searching |
52
+ | `tab` `shift+tab` | Next, previous link in the reading pane, previewed beside it |
53
+ | `enter` `gd` | Follow the selected link — the tree moves there too. A code or test reference opens in the editor. With no link selected, `enter` opens or closes the node |
54
+ | `escape` | Drop the selected link and its preview; again, clear the search |
55
+ | `K` | Show the selected link's preview again |
56
+ | `ctrl+o` `[` | Back to where you jumped from |
57
+ | `]` | Forward again |
58
+ | `ctrl+d` `ctrl+u` | Scroll the reading pane half a page; `ctrl+e` `ctrl+y` a line |
59
+ | `/` | Search — a regex over id, title, statement, detail. `enter` returns to the tree |
60
+ | `o` | Only open questions |
61
+ | `a` | Accept the highlighted requirement |
62
+ | `<` `>` | Narrow or widen the sidebar — or drag the divider |
63
+ | `t` | Theme — previewed as you move, `enter` keeps it |
64
+ | `r` | Reload from disk |
65
+ | `q` | Quit |
66
+
67
+ Every requirement and question ID in the reading pane is a link, and so is every code
68
+ and test reference. Hover one for a preview, click it to go there.
69
+ """
70
+
71
+
72
+ # --------------------------------------------------------------------- prefs
73
+
74
+ def _prefs_path() -> Path:
75
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
76
+ return Path(base) / "reqdeck" / "tui.json"
77
+
78
+
79
+ def load_prefs() -> dict:
80
+ try:
81
+ return json.loads(_prefs_path().read_text())
82
+ except (OSError, ValueError):
83
+ return {}
84
+
85
+
86
+ def save_prefs(prefs: dict) -> None:
87
+ try:
88
+ p = _prefs_path()
89
+ p.parent.mkdir(parents=True, exist_ok=True)
90
+ p.write_text(json.dumps(prefs, indent=2))
91
+ except OSError:
92
+ pass
93
+
94
+
95
+ # ------------------------------------------------------------------- widgets
96
+
97
+ class GPrefix:
98
+ """`gg` as two presses of `g` within a second."""
99
+
100
+ def __init__(self):
101
+ self._last = 0.0
102
+
103
+ def second(self) -> bool:
104
+ now = time.monotonic()
105
+ hit = now - self._last < 1.0
106
+ self._last = 0.0 if hit else now
107
+ return hit
108
+
109
+ def pending(self) -> bool:
110
+ """A `g` pressed within the last second, consumed."""
111
+ hit = time.monotonic() - self._last < 1.0
112
+ self._last = 0.0
113
+ return hit
114
+
115
+
116
+ class VimTree(Tree):
117
+ BINDINGS = [
118
+ Binding("j", "cursor_down", show=False),
119
+ Binding("k", "cursor_up", show=False),
120
+ Binding("h", "vim_left", show=False),
121
+ Binding("l", "vim_right", show=False),
122
+ Binding("G", "last", show=False),
123
+ Binding("g", "g", show=False),
124
+ Binding("d", "d", show=False),
125
+ # The reading pane is never focused: the tree drives it.
126
+ Binding("tab", "app.cycle_link(1)", show=False),
127
+ Binding("shift+tab", "app.cycle_link(-1)", show=False),
128
+ Binding("enter", "app.follow_or_toggle", show=False),
129
+ Binding("K", "app.peek_link", show=False),
130
+ Binding("escape", "app.escape", show=False),
131
+ Binding("ctrl+d", "app.scroll_view(0.5)", show=False),
132
+ Binding("ctrl+u", "app.scroll_view(-0.5)", show=False),
133
+ Binding("ctrl+e", "app.scroll_view(1)", show=False),
134
+ Binding("ctrl+y", "app.scroll_view(-1)", show=False),
135
+ ]
136
+
137
+ def __init__(self, *a, **kw):
138
+ super().__init__(*a, **kw)
139
+ self._g = GPrefix()
140
+
141
+ def action_vim_left(self) -> None:
142
+ node = self.cursor_node
143
+ if node is None:
144
+ return
145
+ if node.allow_expand and node.is_expanded:
146
+ node.collapse()
147
+ elif node.parent is not None and node.parent is not self.root:
148
+ self.action_cursor_parent()
149
+
150
+ def action_vim_right(self) -> None:
151
+ node = self.cursor_node
152
+ if node is None:
153
+ return
154
+ if node.allow_expand and not node.is_expanded:
155
+ node.expand()
156
+ elif node.children:
157
+ self.action_cursor_down() # an expanded node's first child is the next line
158
+
159
+ def action_last(self) -> None:
160
+ self.cursor_line = self.last_line
161
+
162
+ def action_g(self) -> None:
163
+ if self._g.second():
164
+ self.cursor_line = 0
165
+
166
+ def action_d(self) -> None:
167
+ """`gd` follows the selected link, as go-to-definition does in nvim."""
168
+ if self._g.pending():
169
+ self.app.action_follow_link()
170
+
171
+
172
+ class ReadingPane(VerticalScroll):
173
+ """Shows what the tree has highlighted. Never focused — the tree drives it,
174
+ and its links are stepped through from there."""
175
+
176
+ can_focus = False
177
+
178
+
179
+ class Divider(Widget):
180
+ """The bar between sidebar and reading pane. Drag it to resize."""
181
+
182
+ DEFAULT_CSS = """
183
+ Divider { width: 1; height: 1fr; color: $rk-border-active; text-opacity: 0%; }
184
+ Divider:hover, Divider.-dragging { text-opacity: 100%; }
185
+ """
186
+
187
+ def render(self) -> str:
188
+ return "\n".join("┃" * 1 for _ in range(max(1, self.size.height)))
189
+
190
+ def on_mouse_down(self, event: events.MouseDown) -> None:
191
+ self.capture_mouse()
192
+ self.add_class("-dragging")
193
+ event.stop()
194
+
195
+ def on_mouse_move(self, event: events.MouseMove) -> None:
196
+ if self.has_class("-dragging"):
197
+ self.app.set_sidebar(event.screen_x)
198
+
199
+ def on_mouse_up(self, event: events.MouseUp) -> None:
200
+ if self.has_class("-dragging"):
201
+ self.release_mouse()
202
+ self.remove_class("-dragging")
203
+ self.app.remember()
204
+
205
+
206
+ class Confirm(ModalScreen[bool]):
207
+ DEFAULT_CSS = """
208
+ Confirm { align: center middle; background: transparent; }
209
+ Confirm > Vertical { width: 72; height: auto; padding: 1 2; border: round $rk-border-active;
210
+ border-title-color: $rk-title; background: transparent; }
211
+ Confirm Label { color: $rk-foreground; }
212
+ Confirm .hint { margin-top: 1; color: $rk-muted; }
213
+ """
214
+ BINDINGS = [Binding("escape,n", "dismiss(False)", "Cancel"), Binding("y,enter", "dismiss(True)", "Yes")]
215
+
216
+ def __init__(self, message: str):
217
+ super().__init__()
218
+ self.message = message
219
+
220
+ def compose(self) -> ComposeResult:
221
+ with Vertical() as box:
222
+ box.border_title = "Accept"
223
+ yield Label(self.message)
224
+ yield Static(Content.assemble(("Accept: ", "$rk-muted"), ("y", "bold $rk-keys"),
225
+ (" | Cancel: ", "$rk-muted"), ("n", "bold $rk-keys")),
226
+ classes="hint")
227
+
228
+
229
+ class ThemePicker(ModalScreen[str | None]):
230
+ """Every theme with a colour suite, applied live as the cursor moves."""
231
+
232
+ DEFAULT_CSS = """
233
+ ThemePicker { align: center middle; background: transparent; }
234
+ ThemePicker OptionList { width: 44; height: auto; max-height: 80%; padding: 0 1;
235
+ border: round $rk-border-active; border-title-color: $rk-title;
236
+ background: transparent; color: $rk-foreground; }
237
+ ThemePicker OptionList > .option-list--option-highlighted {
238
+ background: $rk-selection; color: $rk-foreground; text-style: bold; }
239
+ """
240
+ BINDINGS = [Binding("escape,q", "cancel", show=False), Binding("j", "down", show=False),
241
+ Binding("k", "up", show=False)]
242
+
243
+ def __init__(self, palettes: dict, current: str):
244
+ super().__init__()
245
+ self.palettes = palettes
246
+ self.original = current
247
+
248
+ def compose(self) -> ComposeResult:
249
+ names = sorted(self.palettes, key=lambda n: self.palettes[n].title.lower())
250
+ opts = OptionList(*[Option(self.palettes[n].title, id=n) for n in names])
251
+ opts.border_title = "Theme"
252
+ yield opts
253
+
254
+ def on_mount(self) -> None:
255
+ opts = self.query_one(OptionList)
256
+ opts.highlighted = opts.get_option_index(self.original) if self.original in self.palettes else 0
257
+ opts.focus()
258
+
259
+ @on(OptionList.OptionHighlighted)
260
+ def _preview(self, event: OptionList.OptionHighlighted) -> None:
261
+ self.app.theme = event.option.id
262
+
263
+ @on(OptionList.OptionSelected)
264
+ def _chosen(self, event: OptionList.OptionSelected) -> None:
265
+ self.dismiss(event.option.id)
266
+
267
+ def action_down(self) -> None:
268
+ self.query_one(OptionList).action_cursor_down()
269
+
270
+ def action_up(self) -> None:
271
+ self.query_one(OptionList).action_cursor_up()
272
+
273
+ def action_cancel(self) -> None:
274
+ self.app.theme = self.original
275
+ self.dismiss(None)
276
+
277
+
278
+ # --------------------------------------------------------------------- cards
279
+
280
+ def _status_chip(status: str) -> Content:
281
+ _, role = STATUS_MARK.get(status, ("?", "muted"))
282
+ return Content.assemble((f"● {status}", f"bold $rk-{role}"))
283
+
284
+
285
+ def _link(rid: str, sel: str = "") -> tuple[str, str]:
286
+ """A clickable ID; the one under the keyboard link cursor is drawn reversed."""
287
+ style = "bold $rk-link-active on $rk-selection" if rid == sel else "$rk-link underline"
288
+ return rid, f"{style} @click=app.goto('{rid}')"
289
+
290
+
291
+ def _ref_link(file: str, line: int, sel: str = "") -> tuple[str, str]:
292
+ """A code or test reference, which opens in the editor when followed."""
293
+ target = f"ref:{file}:{line}"
294
+ style = "bold $rk-link-active on $rk-selection" if target == sel else "$rk-link underline"
295
+ shown = f"{Path(file).name}:{line}" # the full path is in the preview
296
+ if "'" in file:
297
+ return shown, "$rk-link"
298
+ return shown, f"{style} @click=app.goto('{target}')"
299
+
300
+
301
+ def _field(label: str, *value) -> Horizontal:
302
+ """A label and its value side by side, the value wrapping under itself."""
303
+ return Horizontal(Static(label, classes="flabel"), Static(Content.assemble(*value), classes="fvalue"),
304
+ classes="frow")
305
+
306
+
307
+ INLINE = re.compile(r"\*\*(.+?)\*\*|(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])|`([^`]+)`")
308
+
309
+
310
+ def _inline(text: str, style: str) -> list[tuple[str, str]]:
311
+ """Inline markdown — bold, italic, code — as styled segments."""
312
+ out, pos = [], 0
313
+ for mm in INLINE.finditer(text):
314
+ if mm.start() > pos:
315
+ out.append((text[pos:mm.start()], style))
316
+ bold, italic, code = mm.groups()
317
+ if bold is not None:
318
+ out.append((bold, f"{style} bold".strip()))
319
+ elif italic is not None:
320
+ out.append((italic, f"{style} italic".strip()))
321
+ else:
322
+ out.append((code, f"{style} $rk-code".strip()))
323
+ pos = mm.end()
324
+ if pos < len(text):
325
+ out.append((text[pos:], style))
326
+ return out
327
+
328
+
329
+ def linked(m: Model, text: str, style: str = "", sel: str = "") -> list:
330
+ """`text` as Content parts: inline markdown rendered, every known ID a link."""
331
+ parts: list = []
332
+ for seg, st in _inline(text, style):
333
+ pos = 0
334
+ for mm in ID_RE.finditer(seg):
335
+ if not m.knows(mm.group(0)):
336
+ continue
337
+ if mm.start() > pos:
338
+ parts.append((seg[pos:mm.start()], st))
339
+ parts.append(_link(mm.group(0), sel))
340
+ pos = mm.end()
341
+ if pos < len(seg):
342
+ parts.append((seg[pos:], st))
343
+ return parts
344
+
345
+
346
+ def linked_md(m: Model, text: str, sel: str = "") -> str:
347
+ """Markdown with every known ID outside code spans turned into a link."""
348
+ def link(mm: re.Match) -> str:
349
+ rid = mm.group(0)
350
+ if not m.knows(rid):
351
+ return rid
352
+ return f"[**▸ {rid}**](reqdeck:{rid})" if rid == sel else f"[{rid}](reqdeck:{rid})"
353
+
354
+ out = []
355
+ for i, chunk in enumerate(text.split("`")):
356
+ if i % 2 == 0:
357
+ chunk = ID_RE.sub(link, chunk)
358
+ out.append(chunk)
359
+ return "`".join(out)
360
+
361
+
362
+ def md(text: str, classes: str) -> Markdown:
363
+ return Markdown(text, classes=classes, open_links=False)
364
+
365
+
366
+ def requirement_card(m: Model, r: Req, full: bool, sel: str = "") -> Vertical:
367
+ """One requirement, formatted. `full` adds the chain and references."""
368
+ parts: list[Widget] = []
369
+ head = [(r.id, f"bold $rk-id @click=app.goto('{r.id}')") if not full else (r.id, "bold $rk-id")]
370
+ if r.title:
371
+ head += [" ", (r.title, "bold $rk-foreground")]
372
+ if m.reviewed(r) and r.status:
373
+ head += [" ", _status_chip(r.status)]
374
+ if r.security:
375
+ head += [" ", ("◆ security", "bold $rk-security")]
376
+ parts.append(Static(Content.assemble(*head), classes="card-head"))
377
+ parts.append(Static(Content.assemble(*linked(m, r.statement, "bold", sel)), classes="statement"))
378
+ if r.detail:
379
+ parts.append(md(linked_md(m, r.detail, sel), "detail"))
380
+
381
+ rows: list[Widget] = []
382
+ if r.rationale:
383
+ rows.append(_field("Rationale", *linked(m, r.rationale, "$rk-muted italic", sel)))
384
+ if r.satisfies and not full:
385
+ links: list = []
386
+ for i, t in enumerate(r.satisfies):
387
+ links += ([", "] if i else []) + [_link(t, sel)]
388
+ rows.append(_field("Satisfies", *links))
389
+ if r.topics:
390
+ rows.append(_field("Topic", ", ".join(f"{t} · {m.topics.get(t, '?')}" for t in r.topics)))
391
+ if r.source:
392
+ rows.append(_field("Source", *linked(m, r.source, sel=sel)))
393
+ if r.verification:
394
+ v = m.cfg.verification.get(r.verification)
395
+ rows.append(_field("Verification", (r.verification, "bold $rk-heading"),
396
+ f" — {v.name}" if v else ""))
397
+ if r.section and full:
398
+ u = m.unit(r.unit)
399
+ s = u.section(r.section) if u else None
400
+ if s:
401
+ rows.append(_field("Section", f"{u.numbering()[s.key]} {s.title}"))
402
+ for k, v in r.extra.items():
403
+ rows.append(_field(k.capitalize(), *linked(m, v, sel=sel)))
404
+ if rows:
405
+ parts.append(Vertical(*rows, classes="fields"))
406
+
407
+ if full:
408
+ def chain(title: str, ids: list[str], empty: str) -> Static:
409
+ lines = [Content.assemble((title, "bold $rk-heading"))]
410
+ for t in ids:
411
+ label = m.reqs[t].label if t in m.reqs else "(undefined)"
412
+ lines.append(Content.assemble(" ", _link(t, sel), " ", (label, "$rk-foreground")))
413
+ if not ids:
414
+ lines.append(Content.assemble((" " + empty, "$rk-muted italic")))
415
+ return Static(Content("\n").join(lines), classes="chain")
416
+
417
+ parts.append(chain("Satisfies ↑", r.satisfies, "nothing — top level or orphan"))
418
+ parts.append(chain("Satisfied by ↓", m.satisfied_by.get(r.id, []), "nothing yet"))
419
+ refs = m.refs_for(r.id)
420
+ lines = [Content.assemble(("References", "bold $rk-heading"))]
421
+ for x in refs:
422
+ kind = ("test", "bold $rk-accepted") if x.is_test else ("code", "bold $rk-id")
423
+ lines.append(Content.assemble(" ", kind, " ", _ref_link(x.file, x.line, sel),
424
+ " ", (render.origin(m.cfg.root, x.file, x.line), "$rk-muted")))
425
+ if not refs:
426
+ lines.append(Content.assemble((" none", "$rk-muted italic")))
427
+ parts.append(Static(Content("\n").join(lines), classes="chain"))
428
+ parts.append(Static(Content.assemble((f"{r.file}:{r.line}", "$rk-muted")), classes="where"))
429
+ return Vertical(*parts, classes="card full" if full else "card", id=f"card-{r.id}")
430
+
431
+
432
+ def heading(text: str, level: int = 1) -> Static:
433
+ return Static(Content.assemble((text, "bold")), classes=f"heading h{min(level, 3)}")
434
+
435
+
436
+ # ----------------------------------------------------------------------- app
437
+
438
+ class ReqdeckApp(App):
439
+ TITLE = "reqdeck"
440
+ CSS = """
441
+ Screen { background: transparent; layers: default overlay; }
442
+ * { scrollbar-background: transparent; scrollbar-background-hover: transparent;
443
+ scrollbar-background-active: transparent; scrollbar-color: $rk-selection;
444
+ scrollbar-color-hover: $rk-muted; scrollbar-color-active: $rk-border-active;
445
+ scrollbar-corner-color: transparent; scrollbar-size-vertical: 1; }
446
+ #main { height: 1fr; background: transparent; }
447
+
448
+ .panel { border: round $rk-border; border-title-color: $rk-border; border-title-style: bold;
449
+ border-subtitle-color: $rk-muted; background: transparent; }
450
+ #left { width: 60; padding: 0; }
451
+ #left.-searching { border: round $rk-border-search; }
452
+ #view { width: 1fr; padding: 0 1; }
453
+
454
+ #search { height: 1; border: none; padding: 0 1; margin: 0 0 1 0; background: transparent;
455
+ color: $rk-foreground; }
456
+ #search:focus { background-tint: transparent; }
457
+ #search > .input--placeholder { color: $rk-muted; }
458
+ #search > .input--cursor { background: $rk-foreground; color: $rk-selection; }
459
+
460
+ Tree { background: transparent; color: $rk-foreground; overflow-x: hidden; }
461
+ Tree:focus { background-tint: transparent; }
462
+ Tree > .tree--guides, Tree > .tree--guides-hover { color: $rk-border; }
463
+ Tree > .tree--guides-selected { color: $rk-border-active; }
464
+ Tree > .tree--cursor { background: transparent; text-style: bold; }
465
+ Tree:focus > .tree--cursor { background: $rk-selection; color: $rk-foreground; text-style: none; }
466
+ Tree:focus > .tree--guides { color: $rk-border; }
467
+ Tree:focus > .tree--guides-selected { color: $rk-border-active; }
468
+ Tree > .tree--highlight-line { background: transparent; }
469
+
470
+ .heading { margin: 1 0; color: $rk-heading; }
471
+ .heading.h1 { color: $rk-title; border-bottom: solid $rk-border; }
472
+ .heading.h3 { margin: 0 0 1 0; }
473
+
474
+ .card { height: auto; margin: 0 0 1 0; padding: 0 1; background: transparent;
475
+ border: round $rk-border; }
476
+ .card.full { border: none; padding: 0; margin-top: 1; }
477
+ .card.-selected { border: round $rk-link-active; }
478
+ .card-head { height: auto; }
479
+ .card .statement { margin: 1 0 0 0; padding: 0 1; border-left: outer $rk-statement-bar;
480
+ text-style: bold; }
481
+ .skeleton { margin: 1 0 0 0; }
482
+ .card .detail { margin: 1 0 0 0; }
483
+ .card .fields { margin: 1 0 0 0; height: auto; }
484
+ .frow { height: auto; }
485
+ .flabel { width: 14; color: $rk-muted; }
486
+ .fvalue { width: 1fr; color: $rk-foreground; }
487
+ .card .chain { margin: 1 0 0 0; height: auto; }
488
+ .card .where { margin: 1 0 0 0; }
489
+ .prose { margin: 0 0 1 0; }
490
+ .page { height: auto; }
491
+
492
+ Markdown { margin: 0; padding: 0; background: transparent; color: $rk-foreground;
493
+ link-color: $rk-link; link-background: transparent; link-style: underline;
494
+ link-color-hover: $rk-link-active; link-background-hover: transparent;
495
+ link-style-hover: bold underline; }
496
+ Markdown MarkdownBlock > .code_inline { background: transparent !important; color: $rk-code !important; }
497
+ Markdown MarkdownBlock > .strong { color: $rk-foreground; }
498
+ MarkdownTable { background: transparent !important; }
499
+ MarkdownTableContent > .header { color: $rk-table-header; text-style: bold; }
500
+ MarkdownTableContent > .markdown-table--lines { color: $rk-border; }
501
+ MarkdownBullet { color: $rk-muted; }
502
+ MarkdownFence { background: transparent !important; color: $rk-code; }
503
+ MarkdownBlockQuote { background: transparent; border-left: outer $rk-border; }
504
+ MarkdownH1, MarkdownH2, MarkdownH3 { color: $rk-heading; background: transparent; }
505
+
506
+ #peek { layer: overlay; position: absolute; display: none; width: 66; height: auto;
507
+ max-height: 18; padding: 0 1; background: transparent; color: $rk-foreground;
508
+ border: round $rk-link-active; border-title-color: $rk-link-active; }
509
+ #keys { dock: bottom; height: 1; padding: 0 1; background: transparent; }
510
+
511
+ Toast { background: transparent; border: round $rk-border-active; color: $rk-foreground; }
512
+ .toast--title { color: $rk-title; }
513
+ """
514
+ BINDINGS = [
515
+ Binding("slash", "search", "Search"),
516
+ Binding("escape", "clear", "Clear search", show=False),
517
+ Binding("a", "accept", "Accept"),
518
+ Binding("o", "toggle_open", "Open questions"),
519
+ Binding("t", "change_theme", "Theme"),
520
+ Binding("question_mark", "help", "Keys"),
521
+ Binding("r", "reload", "Reload", show=False),
522
+ Binding("n", "next_match(1)", show=False),
523
+ Binding("N", "next_match(-1)", show=False),
524
+ Binding("ctrl+o,left_square_bracket", "history_back", show=False),
525
+ Binding("right_square_bracket", "history_forward", show=False),
526
+ Binding("less_than_sign", "resize(-4)", show=False),
527
+ Binding("greater_than_sign", "resize(4)", show=False),
528
+ Binding("q", "quit", "Quit"),
529
+ ]
530
+
531
+ def __init__(self):
532
+ # ansi_color leaves the terminal's own background unpainted: with it
533
+ # off Textual fills every cell.
534
+ super().__init__(ansi_color=True)
535
+ self.m: Model = load_model()
536
+ self.query_text = ""
537
+ self.open_only = False
538
+ self.nodes: dict[str, TreeNode] = {}
539
+ self.links: list[str] | None = []
540
+ self.shown: tuple | None = None
541
+ #: Bumped by every new view, so a fill still running for an old one stops.
542
+ self._token = 0
543
+ #: The reading pane holds the real view, not its skeleton.
544
+ self._filled = False
545
+ self.link_index = -1
546
+ self.hovering = ""
547
+ self.back: list[tuple] = []
548
+ self.forward: list[tuple] = []
549
+ self.prefs = load_prefs()
550
+ self.palettes = themes.load_all()
551
+ for p in self.palettes.values():
552
+ self.register_theme(p.theme())
553
+ wanted = self.prefs.get("theme", themes.DEFAULT)
554
+ self.theme = wanted if wanted in self.palettes else themes.DEFAULT
555
+
556
+ def get_theme_variable_defaults(self) -> dict[str, str]:
557
+ """Every role, so the stylesheet parses before a theme is applied."""
558
+ palettes = getattr(self, "palettes", None) or themes.load_all()
559
+ fallback = palettes.get(themes.DEFAULT) or next(iter(palettes.values()))
560
+ return {f"rk-{k.replace('_', '-')}": v for k, v in fallback.colors.items()}
561
+
562
+ def compose(self) -> ComposeResult:
563
+ with Horizontal(id="main"):
564
+ with Vertical(id="left", classes="panel") as left:
565
+ left.border_title = "Requirements"
566
+ left.border_subtitle = self.m.cfg.root.name
567
+ yield Input(placeholder="/ search — regex over id, title, statement, detail", id="search")
568
+ yield VimTree("reqdeck", id="nav")
569
+ yield Divider()
570
+ view = ReadingPane(id="view", classes="panel")
571
+ view.border_title = "Keys"
572
+ yield view
573
+ yield Static(id="keys")
574
+ yield Static(id="peek")
575
+
576
+ def on_mount(self) -> None:
577
+ if "sidebar" in self.prefs:
578
+ self.query_one("#left").styles.width = int(self.prefs["sidebar"])
579
+ self.theme_changed_signal.subscribe(self, self._theme_changed)
580
+ self.rebuild()
581
+ tree = self.query_one("#nav", Tree)
582
+ tree.focus()
583
+ tree.cursor_line = 0
584
+ self.show(("help", None))
585
+ self.refresh_keys()
586
+ self.run_worker(self._warm_origins, thread=True, group="warm")
587
+
588
+ def _warm_origins(self) -> None:
589
+ """What every reference is attached to, read in a thread at start, so a
590
+ requirement with references is as quick to show as one without."""
591
+ root = self.m.cfg.root
592
+ for ref in list(self.m.refs):
593
+ render.origin(root, ref.file, ref.line)
594
+
595
+ # ------------------------------------------------------------- layout and prefs
596
+
597
+ def set_sidebar(self, width: int) -> None:
598
+ width = max(24, min(width, self.size.width - 30))
599
+ self.query_one("#left").styles.width = width
600
+
601
+ def action_resize(self, delta: int) -> None:
602
+ self.set_sidebar(self.query_one("#left").outer_size.width + delta)
603
+ self.remember()
604
+
605
+ def remember(self) -> None:
606
+ self.prefs["sidebar"] = self.query_one("#left").outer_size.width
607
+ self.prefs["theme"] = self.theme
608
+ save_prefs(self.prefs)
609
+
610
+ def _theme_changed(self, _theme) -> None:
611
+ self.relabel()
612
+ self.refresh_keys()
613
+
614
+ def colour(self, role: str) -> str:
615
+ """A role's colour in the current theme, for Rich text that cannot name a variable."""
616
+ p = self.palettes.get(self.theme) or self.palettes[themes.DEFAULT]
617
+ return p.colors[role]
618
+
619
+ def action_change_theme(self) -> None:
620
+ def done(name: str | None) -> None:
621
+ if name:
622
+ self.theme = name
623
+ self.remember()
624
+ self.push_screen(ThemePicker(self.palettes, self.theme), done)
625
+
626
+ def on_descendant_focus(self, event: events.DescendantFocus) -> None:
627
+ self.refresh_keys()
628
+
629
+ def refresh_keys(self) -> None:
630
+ """The one line of `Action: key` hints — only those that do something here."""
631
+ try:
632
+ bar = self.query_one("#keys", Static)
633
+ tree = self.query_one("#nav", Tree)
634
+ view = self.query_one("#view", ReadingPane)
635
+ peek = self.query_one("#peek", Static)
636
+ except Exception:
637
+ return
638
+ node = tree.cursor_node
639
+ data = node.data if node is not None else None
640
+ in_questions = False
641
+ while node is not None:
642
+ if node.data and node.data[0] == "qroot":
643
+ in_questions = True
644
+ node = node.parent
645
+ pairs = [("Search", "/")]
646
+ if self._ensure_links():
647
+ pairs.append(("Next link", "tab"))
648
+ if peek.display and 0 <= self.link_index < len(self.links or []):
649
+ ref = self.links[self.link_index].startswith("ref:")
650
+ pairs.append(("Open in editor" if ref else "Follow", "enter"))
651
+ if self.back:
652
+ pairs.append(("Back", "ctrl+o"))
653
+ if self.forward:
654
+ pairs.append(("Forward", "]"))
655
+ if view.max_scroll_y > 0:
656
+ pairs.append(("Scroll", "ctrl+d/u"))
657
+ if in_questions:
658
+ pairs.append(("All questions" if self.open_only else "Only open questions", "o"))
659
+ if data and data[0] == "req" and data[1] in self.m.reqs:
660
+ r = self.m.reqs[data[1]]
661
+ if self.m.reviewed(r) and r.status != "Accepted":
662
+ pairs.append(("Accept", "a"))
663
+ pairs += [("Theme", "t"), ("Keys", "?"), ("Quit", "q")]
664
+ parts: list = []
665
+ for i, (action, key) in enumerate(pairs):
666
+ if i:
667
+ parts.append((" | ", "$rk-muted"))
668
+ parts += [(f"{action}: ", "$rk-muted"), (key, "bold $rk-keys")]
669
+ line = Content.assemble(*parts)
670
+ if str(line) != str(bar.content): # a repaint only when it changed
671
+ bar.update(line)
672
+
673
+ # ------------------------------------------------------------- the tree
674
+
675
+ def _matches(self, *texts: str) -> bool:
676
+ if not self.query_text:
677
+ return True
678
+ try:
679
+ pat = re.compile(self.query_text, re.I)
680
+ except re.error:
681
+ return any(self.query_text.lower() in t.lower() for t in texts)
682
+ return any(pat.search(t) for t in texts)
683
+
684
+ def _req_label(self, r: Req) -> Text:
685
+ t = Text()
686
+ if self.m.reviewed(r):
687
+ mark, role = STATUS_MARK.get(r.status, ("?", "muted"))
688
+ t.append(f"{mark} ", style=f"bold {self.colour(role)}")
689
+ t.append(r.id, style=self.colour("id"))
690
+ t.append(f" {r.label}")
691
+ return t
692
+
693
+ def _topic_label(self, t: str, title: str, n_open: int) -> Text:
694
+ return Text.assemble((t, f"bold {self.colour('heading')}"), f" {title} ",
695
+ (f"{n_open} open", self.colour("open") if n_open else self.colour("muted")))
696
+
697
+ def _question_label(self, q) -> Text:
698
+ mark = ("open ", self.colour("open")) if q.open else ("done ", self.colour("muted"))
699
+ return Text.assemble(mark, (q.id, self.colour("id")), f" {q.title}")
700
+
701
+ def relabel(self) -> None:
702
+ """Recolour every label for the current theme, keeping the cursor where it is."""
703
+ tree = self.query_one("#nav", Tree)
704
+ here = tree.cursor_node.data if tree.cursor_node else None
705
+ self.rebuild()
706
+ if here:
707
+ node = self._node_for(here)
708
+ if node is not None:
709
+ parent = node.parent
710
+ while parent is not None:
711
+ parent.expand()
712
+ parent = parent.parent
713
+ self.call_after_refresh(tree.move_cursor, node)
714
+
715
+ def rebuild(self) -> None:
716
+ m = self.m
717
+ tree = self.query_one("#nav", Tree)
718
+ tree.clear()
719
+ tree.show_root = False
720
+ self.nodes = {}
721
+ searching = bool(self.query_text)
722
+
723
+ reqs_root = tree.root.add("Requirements", data=("root", None), expand=True)
724
+ for lv in m.cfg.levels:
725
+ units = [u for u in m.units if u.level == lv.kind]
726
+ if not units:
727
+ continue
728
+ lv_node = reqs_root.add(Text.assemble((lv.kind, f"bold {self.colour('title')}"), f" {lv.title}"),
729
+ data=("level", lv.kind), expand=searching)
730
+ for u in units:
731
+ u_node = lv_node.add(Text.assemble((u.title, "bold"),
732
+ (f" {', '.join(u.prefixes)}", self.colour("muted"))),
733
+ data=("unit", u.stem), expand=searching)
734
+ self._add_document(u_node, u)
735
+ if searching and not u_node.children:
736
+ u_node.remove()
737
+ if searching and not lv_node.children:
738
+ lv_node.remove()
739
+
740
+ q_root = tree.root.add("Open questions", data=("qroot", None), expand=searching or self.open_only)
741
+ by_topic: dict[str, list] = {}
742
+ for q in m.questions:
743
+ by_topic.setdefault(q.topic, []).append(q)
744
+ for t, title in sorted(m.topics.items()):
745
+ qs = [q for q in by_topic.get(t, [])
746
+ if (q.open or not self.open_only) and self._matches(q.id, q.title, q.question, q.answer)]
747
+ if not qs and (searching or self.open_only):
748
+ continue
749
+ n_open = sum(1 for q in by_topic.get(t, []) if q.open)
750
+ t_node = q_root.add(self._topic_label(t, title, n_open), data=("topic", t),
751
+ expand=searching or self.open_only)
752
+ for q in qs:
753
+ self.nodes[q.id] = t_node.add_leaf(self._question_label(q), data=("question", q.id))
754
+
755
+ def _add_document(self, u_node: TreeNode, unit) -> None:
756
+ stack: list[tuple[int, TreeNode]] = [(0, u_node)]
757
+ searching = bool(self.query_text)
758
+ for e in self.m.document(unit):
759
+ if e["type"] == "section":
760
+ while stack and stack[-1][0] >= e["depth"]:
761
+ stack.pop()
762
+ s = e["section"]
763
+ node = stack[-1][1].add(
764
+ Text.assemble((e["number"], f"bold {self.colour('heading')}"), f" {s.title}"),
765
+ data=("section", (unit.stem, s.key)), expand=searching)
766
+ stack.append((e["depth"], node))
767
+ else:
768
+ r: Req = e["req"]
769
+ if self._matches(r.id, r.title, r.statement, r.rationale, r.detail):
770
+ self.nodes[r.id] = stack[-1][1].add_leaf(self._req_label(r), data=("req", r.id))
771
+ if searching:
772
+ self._drop_empty_sections(u_node)
773
+
774
+ def _drop_empty_sections(self, node: TreeNode) -> None:
775
+ for child in list(node.children):
776
+ if child.data and child.data[0] == "section":
777
+ self._drop_empty_sections(child)
778
+ if not child.children:
779
+ child.remove()
780
+
781
+ # ------------------------------------------------------------- the reading pane
782
+
783
+ @on(Tree.NodeHighlighted)
784
+ def _highlighted(self, event: Tree.NodeHighlighted) -> None:
785
+ self.show(event.node.data)
786
+
787
+ def _widgets_for(self, data, sel: str = "") -> list[Widget]:
788
+ m = self.m
789
+ kind, key = data
790
+ if kind == "help":
791
+ return [Markdown(HELP, classes="prose")]
792
+ if kind == "req" and key in m.reqs:
793
+ return [requirement_card(m, m.reqs[key], full=True, sel=sel)]
794
+ if kind == "unit":
795
+ u = m.unit(key)
796
+ out: list[Widget] = [heading(u.title, 1)]
797
+ if u.description:
798
+ out.append(Markdown(u.description, classes="prose"))
799
+ for e in m.document(u):
800
+ if e["type"] == "section":
801
+ s = e["section"]
802
+ out.append(heading(f"{e['number']} {s.title}", e["depth"] + 1))
803
+ if s.description:
804
+ out.append(Markdown(s.description, classes="prose"))
805
+ else:
806
+ out.append(requirement_card(m, e["req"], full=False))
807
+ return out
808
+ if kind == "section":
809
+ stem, skey = key
810
+ u = m.unit(stem)
811
+ s = u.section(skey)
812
+ out = [heading(f"{u.numbering()[skey]} {s.title}", 1)]
813
+ if s.description:
814
+ out.append(Markdown(s.description, classes="prose"))
815
+ out += [requirement_card(m, r, full=False)
816
+ for r in sorted(m.reqs.values(), key=m.sort_key)
817
+ if r.unit == stem and (r.section == skey or r.section.startswith(skey + "."))]
818
+ return out
819
+ if kind == "level":
820
+ lv = m.cfg.level(key)
821
+ rows = "\n".join(
822
+ f"| {u.title} | `{', '.join(u.prefixes)}` | {sum(1 for r in m.reqs.values() if r.unit == u.stem)} "
823
+ f"| `{m.cfg.rel(u.csv_path)}` |" for u in m.units if u.level == key)
824
+ return [heading(f"{lv.kind} — {lv.title}", 1),
825
+ Markdown(f"Satisfies: **{', '.join(lv.satisfies) or 'top level'}**\n\n"
826
+ f"| Unit | Prefix | Requirements | File |\n|---|---|---|---|\n{rows}\n",
827
+ classes="prose")]
828
+ if kind == "topic":
829
+ qs = [q for q in m.questions if q.topic == key]
830
+ rows = "\n".join(f"| {linked_md(m, q.id, sel)} | {'**open**' if q.open else 'answered'} | {q.title} |"
831
+ for q in qs)
832
+ n_reqs = sum(1 for r in m.reqs.values() if key in r.topics)
833
+ return [heading(f"Topic {key} — {m.topics.get(key, '')}", 1),
834
+ md(f"{n_reqs} requirement(s) name this topic.\n\n"
835
+ f"| Question | | |\n|---|---|---|\n{rows}\n", "prose")]
836
+ if kind == "question":
837
+ q = next(q for q in m.questions if q.id == key)
838
+ chip = ("● open", "bold $rk-open") if q.open else ("● answered", "bold $rk-answered")
839
+ head = Static(Content.assemble((q.id, "bold $rk-id"), " ", (q.title, "bold"), " ", chip,
840
+ "\n", (f"Topic {q.topic} — {m.topics.get(q.topic, '')}",
841
+ "$rk-muted")), classes="card-head")
842
+ body = render.question(q, m.topics.get(q.topic, "")).split("\n", 4)[-1]
843
+ return [Vertical(head, md(linked_md(m, body, sel), "detail"), classes="card full")]
844
+ return []
845
+
846
+ def _title_for(self, data) -> str:
847
+ m = self.m
848
+ kind, key = data
849
+ if kind == "req" and key in m.reqs:
850
+ return f"{key} — {m.reqs[key].label}"
851
+ if kind == "unit":
852
+ return m.unit(key).title
853
+ if kind == "section":
854
+ u = m.unit(key[0])
855
+ return f"{u.title} · {u.numbering()[key[1]]} {u.section(key[1]).title}"
856
+ if kind == "level":
857
+ return f"{key} — {m.cfg.level(key).title}"
858
+ if kind == "topic":
859
+ return f"Topic {key} — {m.topics.get(key, '')}"
860
+ if kind == "question":
861
+ return key
862
+ return "Keys"
863
+
864
+ CONTENT_KINDS = ("req", "unit", "section", "level", "topic", "question", "help")
865
+ #: How long the cursor must rest before a view is filled.
866
+ FILL_DELAY = 0.08
867
+
868
+ def show(self, data, sel: str = "", keep: bool = False) -> None:
869
+ """Show `data` in the reading pane.
870
+
871
+ A new view appears as a skeleton at once and is filled in a worker once
872
+ the cursor rests, so moving quickly through the tree draws only skeletons.
873
+ With `sel` or `keep` it is the same view again — `sel` the link under the
874
+ cursor — rebuilt at once, scrolled where it was."""
875
+ if not data or data[0] not in self.CONTENT_KINDS:
876
+ return
877
+ view = self.query_one("#view", ReadingPane)
878
+ title = self._title_for(data)
879
+ if view.border_title != title:
880
+ view.border_title = title
881
+ if sel or keep:
882
+ self._replace(self._widgets_for(data, sel))
883
+ if sel:
884
+ # The link preview restores this, in order with revealing the link.
885
+ self._restore_scroll = view.scroll_y
886
+ else:
887
+ self.call_after_refresh(view.scroll_to, y=view.scroll_y, animate=False)
888
+ return
889
+ self.hide_peek()
890
+ self.shown = data
891
+ self.links = None
892
+ self.link_index = -1
893
+ self._filled = False
894
+ self._token += 1
895
+ if self._cheap(data):
896
+ # Nothing slow to draw: no Markdown, no document. Shown at once.
897
+ self.workers.cancel_group(self, "render")
898
+ self._replace(self._widgets_for(data))
899
+ view.scroll_home(animate=False)
900
+ # Its links are read off the page, which is mounted by the next frame.
901
+ self.call_after_refresh(self._mark_filled, self._token)
902
+ return
903
+ top, bars = self._skeleton(data)
904
+ current = view.query(".page")
905
+ if current and current.last().has_class("skeleton-page") and len(current.last().children) == 2:
906
+ # Moving on from a skeleton: rewrite its header in place, nothing to lay out.
907
+ current.last().children[0].update(top.content)
908
+ else:
909
+ self._replace([top, bars]).add_class("skeleton-page")
910
+ view.scroll_home(animate=False)
911
+ self.run_worker(self._fill(data, self._token), exclusive=True, group="render")
912
+ self.refresh_keys()
913
+
914
+ def _mark_filled(self, token: int) -> None:
915
+ if token == self._token:
916
+ self._filled = True
917
+ self.refresh_keys()
918
+
919
+ def _cheap(self, data) -> bool:
920
+ """A view with nothing slow to lay out: a requirement without a detail table,
921
+ whose references' origins are already known."""
922
+ kind, key = data
923
+ if kind != "req" or key not in self.m.reqs or self.m.reqs[key].detail:
924
+ return False
925
+ root = self.m.cfg.root
926
+ return all(render.origin_cached(root, x.file, x.line) for x in self.m.refs_for(key))
927
+
928
+ def _replace(self, widgets: list[Widget]) -> Widget:
929
+ """A fresh page holding `widgets`: the old one is removed asynchronously,
930
+ and its cards share IDs with the new one's."""
931
+ view = self.query_one("#view", ReadingPane)
932
+ page = Vertical(*widgets, classes="page")
933
+ with self.batch_update():
934
+ for old in list(view.query(".page")):
935
+ old.remove()
936
+ view.mount(page)
937
+ return page
938
+
939
+ def _skeleton(self, data) -> list[Widget]:
940
+ """What a view looks like before it is filled: its header, and grey bars."""
941
+ m = self.m
942
+ kind, key = data
943
+ if kind == "req" and key in m.reqs:
944
+ r = m.reqs[key]
945
+ head = [(r.id, "bold $rk-id")] + ([" ", (r.title, "bold $rk-foreground")] if r.title else [])
946
+ if m.reviewed(r) and r.status:
947
+ head += [" ", _status_chip(r.status)]
948
+ else:
949
+ head = [(self._title_for(data), "bold $rk-heading")]
950
+ top = Static(Content.assemble(*head), classes="card-head")
951
+ bars = [58, 44, 0, 70, 66, 52, 0, 24, 30, 28, 0, 36, 40]
952
+ lines = Content("\n").join(Content.assemble(("▆" * w, "$rk-selection")) for w in bars)
953
+ return [top, Static(lines, classes="skeleton")]
954
+
955
+ async def _fill(self, data, token: int) -> None:
956
+ await asyncio.sleep(self.FILL_DELAY) # only once the cursor rests
957
+ if token != self._token:
958
+ return
959
+ if data[0] == "req" and data[1] in self.m.reqs:
960
+ # File reads — what each reference is attached to — off the main thread.
961
+ refs = self.m.refs_for(data[1])
962
+ await asyncio.to_thread(lambda: [render.origin(self.m.cfg.root, x.file, x.line) for x in refs])
963
+ widgets = self._widgets_for(data)
964
+ if token != self._token or not widgets:
965
+ return
966
+ page = self._replace([])
967
+ # A screenful at once, then the rest a batch a frame.
968
+ batches = [widgets[:6]] + [widgets[i:i + 12] for i in range(6, len(widgets), 12)]
969
+ for batch in batches:
970
+ await page.mount_all(batch)
971
+ await asyncio.sleep(0)
972
+ if token != self._token:
973
+ return
974
+ if data[0] in ("unit", "section"):
975
+ self.links = self._links_for(data)
976
+ self._filled = True
977
+ self.refresh_keys()
978
+ self.call_after_refresh(self.refresh_keys) # again once laid out: whether it scrolls
979
+
980
+ # ------------------------------------------------------------- actions
981
+
982
+ @on(Input.Changed, "#search")
983
+ def _search(self, event: Input.Changed) -> None:
984
+ self.query_text = event.value.strip()
985
+ self.query_one("#left").set_class(bool(self.query_text), "-searching")
986
+ self.rebuild()
987
+
988
+ @on(Input.Submitted, "#search")
989
+ def _submitted(self, event: Input.Submitted) -> None:
990
+ self.query_one("#nav", Tree).focus()
991
+ self.action_next_match(1)
992
+
993
+ def action_search(self) -> None:
994
+ self.query_one("#search", Input).focus()
995
+
996
+ def action_clear(self) -> None:
997
+ self.query_one("#search", Input).value = ""
998
+ self.query_one("#nav", Tree).focus()
999
+
1000
+ def action_toggle_open(self) -> None:
1001
+ self.open_only = not self.open_only
1002
+ self.rebuild()
1003
+ self.refresh_keys()
1004
+
1005
+ def action_help(self) -> None:
1006
+ self.show(("help", None))
1007
+
1008
+ def action_reload(self, quiet: bool = False) -> None:
1009
+ self.m = load_model()
1010
+ self.relabel() # rebuilds the tree, keeping the cursor where it was
1011
+ if not quiet:
1012
+ self.notify("Reloaded")
1013
+
1014
+ def action_next_match(self, direction: int) -> None:
1015
+ tree = self.query_one("#nav", Tree)
1016
+ line = tree.cursor_line
1017
+ while 0 <= (line := line + direction) <= tree.last_line:
1018
+ node = tree.get_node_at_line(line)
1019
+ if node is not None and node.data and node.data[0] in ("req", "question"):
1020
+ tree.cursor_line = line
1021
+ return
1022
+
1023
+ # ------------------------------------------------------------- links
1024
+
1025
+ def _links_for(self, data) -> list[str]:
1026
+ """A document's cards, in order: the link cursor steps through whole requirements."""
1027
+ m = self.m
1028
+ kind, key = data
1029
+ stem = key if kind == "unit" else key[0]
1030
+ return [r.id for r in sorted(m.reqs.values(), key=m.sort_key)
1031
+ if r.unit == stem and (kind == "unit" or r.section == key[1]
1032
+ or r.section.startswith(key[1] + "."))]
1033
+
1034
+ def known(self, target: str) -> bool:
1035
+ """A requirement, a question, or a source line that exists."""
1036
+ if target.startswith("ref:"):
1037
+ file, _, _line = target[4:].rpartition(":")
1038
+ return (self.m.cfg.root / file).is_file()
1039
+ return self.m.knows(target)
1040
+
1041
+ @staticmethod
1042
+ def split_ref(target: str) -> tuple[str, int]:
1043
+ file, _, line = target[4:].rpartition(":")
1044
+ return file, int(line)
1045
+
1046
+ def _ref_preview(self, target: str) -> Content:
1047
+ from rich.style import Style
1048
+ from rich.syntax import Syntax
1049
+ from rich.text import Span
1050
+
1051
+ file, line = self.split_ref(target)
1052
+ rows = render.excerpt(self.m.cfg.root, file, line)
1053
+ ref = next((x for x in self.m.refs if x.file == file and x.line == line), None)
1054
+ kind = ("test", "bold $rk-accepted") if ref and ref.is_test else ("code", "bold $rk-id")
1055
+ head = [Content.assemble(kind, " ", (f"{file}:{line}", "bold $rk-link"), " ",
1056
+ (render.origin(self.m.cfg.root, file, line), "$rk-muted")),
1057
+ Content("")]
1058
+ if not rows:
1059
+ return Content("\n").join(head + [Content.assemble(("unreadable", "$rk-reopened"))])
1060
+ style = self.theme if self.theme in ("dracula", "nord", "monokai", "gruvbox-dark",
1061
+ "solarized-dark", "solarized-light", "one-dark") else \
1062
+ ("monokai" if self.current_theme.dark else "default")
1063
+ code = Syntax("\n".join(text for _, text in rows), Syntax.guess_lexer(file),
1064
+ theme=style).highlight("\n".join(text for _, text in rows))
1065
+ # The theme's token colours, without its background: nothing here paints one.
1066
+ code.style = ""
1067
+ code.spans[:] = [Span(sp.start, sp.end, Style(color=sp.style.color, bold=sp.style.bold,
1068
+ italic=sp.style.italic))
1069
+ for sp in code.spans if isinstance(sp.style, Style)]
1070
+ body = []
1071
+ for (n, _), text in zip(rows, code.split("\n", allow_blank=True)):
1072
+ text.truncate(66 - 4 - 8, overflow="ellipsis") # the gutter stays a gutter
1073
+ mark = (f"{n:>5} ▶ ", "bold $rk-link-active") if n == line else (f"{n:>5} │ ", "$rk-muted")
1074
+ body.append(Content.assemble(mark, Content.from_rich_text(text)))
1075
+ return Content("\n").join(head + body)
1076
+
1077
+ def preview(self, target: str) -> Content:
1078
+ if target.startswith("ref:"):
1079
+ return self._ref_preview(target)
1080
+ m = self.m
1081
+ if target in m.reqs:
1082
+ r = m.reqs[target]
1083
+ head = [(r.id, "bold $rk-id")]
1084
+ if r.title:
1085
+ head += [" ", (r.title, "bold")]
1086
+ if m.reviewed(r) and r.status:
1087
+ head += [" ", _status_chip(r.status)]
1088
+ lines = [Content.assemble(*head), Content(""), Content(r.statement)]
1089
+ if r.satisfies:
1090
+ lines.append(Content.assemble(("Satisfies ", "$rk-muted"), ", ".join(r.satisfies)))
1091
+ if r.verification:
1092
+ lines.append(Content.assemble(("Verified ", "$rk-muted"), r.verification))
1093
+ return Content("\n").join(lines)
1094
+ q = m.question(target)
1095
+ if q:
1096
+ chip = ("● open", "bold $rk-open") if q.open else ("● answered", "bold $rk-answered")
1097
+ answer = q.answer if len(q.answer) < 400 else q.answer[:399] + "…"
1098
+ lines = [Content.assemble((q.id, "bold $rk-id"), " ", (q.title, "bold"), " ", chip),
1099
+ Content(""), Content(q.question)]
1100
+ if answer:
1101
+ lines += [Content(""), Content.assemble((answer, "$rk-muted"))]
1102
+ return Content("\n").join(lines)
1103
+ return Content(target)
1104
+
1105
+ def show_peek(self, target: str, cell: tuple[int, int] | None, title: str = "") -> None:
1106
+ """The preview of `target`, beside the cell its link is drawn in.
1107
+
1108
+ Below the link where it fits, above where it does not — never over it,
1109
+ as nvim places a hover. With no cell, in the reading pane's corner."""
1110
+ peek = self.query_one("#peek", Static)
1111
+ content = self.preview(target)
1112
+ peek.update(content)
1113
+ peek.border_title = title
1114
+ width = 66
1115
+ # The height it will be drawn at: border 2, padding 2, at most 18 rows.
1116
+ height = min(18, peek.get_content_height(self.size, self.size, width - 4) + 2)
1117
+ bottom = self.size.height - 1 # the hint line
1118
+ if cell is None:
1119
+ view = self.query_one("#view").region
1120
+ x, y = view.right - width - 2, view.bottom - height - 1
1121
+ else:
1122
+ x = cell[0] + 2
1123
+ y = cell[1] + 1 if cell[1] + 1 + height <= bottom else cell[1] - height
1124
+ peek.styles.offset = (max(0, min(x, self.size.width - width - 1)), max(0, y))
1125
+ peek.display = True
1126
+ self.refresh_keys()
1127
+
1128
+ def hide_peek(self) -> bool:
1129
+ # A preview still being placed would reappear after this.
1130
+ if any(w.group == "peek" and w.is_running for w in self.workers):
1131
+ self.workers.cancel_group(self, "peek")
1132
+ peek = self.query_one("#peek", Static)
1133
+ shown = bool(peek.display)
1134
+ peek.display = False
1135
+ self.hovering = ""
1136
+ if shown:
1137
+ self.refresh_keys()
1138
+ return shown
1139
+
1140
+ def on_mouse_move(self, event: events.MouseMove) -> None:
1141
+ action = event.style.meta.get("@click", "") if event.style else ""
1142
+ mm = TARGET_RE.search(action) if action else None
1143
+ target = mm.group(1) if mm else ""
1144
+ if target and self.known(target):
1145
+ if target != self.hovering:
1146
+ self.show_peek(target, (event.screen_x, event.screen_y))
1147
+ self.hovering = target
1148
+ elif self.hovering:
1149
+ self.hide_peek()
1150
+
1151
+ @on(Markdown.LinkClicked)
1152
+ def _markdown_link(self, event: Markdown.LinkClicked) -> None:
1153
+ if event.href.startswith("reqdeck:"):
1154
+ event.stop()
1155
+ self.action_goto(event.href.removeprefix("reqdeck:"))
1156
+
1157
+ def _page(self) -> Widget | None:
1158
+ pages = list(self.query_one("#view").query(".page"))
1159
+ return pages[-1] if pages else None
1160
+
1161
+ @staticmethod
1162
+ def _targets_in(w: Widget) -> list[str]:
1163
+ """Link targets a widget draws, in order."""
1164
+ if isinstance(w, Markdown):
1165
+ return re.findall(r"\(reqdeck:([^)\s]+)\)", w.source)
1166
+ if isinstance(w, Static) and isinstance(w.content, Content):
1167
+ spans = sorted(w.content.spans, key=lambda sp: sp.start)
1168
+ return [mm.group(1) for sp in spans if (mm := TARGET_RE.search(str(sp.style)))]
1169
+ return []
1170
+
1171
+ def _ensure_links(self) -> list[str]:
1172
+ if not self._filled:
1173
+ return []
1174
+ if self.links is None:
1175
+ page = self._page()
1176
+ own = self.shown[1] if self.shown else None
1177
+ found = [t for w in (page.query("*") if page else []) for t in self._targets_in(w)]
1178
+ self.links = [t for t in dict.fromkeys(found) if t != own and self.known(t)]
1179
+ return self.links
1180
+
1181
+ def _holder(self, target: str) -> Widget | None:
1182
+ """The first widget on the page that draws a link to `target`."""
1183
+ page = self._page()
1184
+ for w in page.query("*") if page else []:
1185
+ if target in self._targets_in(w):
1186
+ return w
1187
+ return None
1188
+
1189
+ def _locate(self, target: str) -> tuple[int, int] | None:
1190
+ """The screen cell the link to `target` is drawn in, if it is in view."""
1191
+ view = self.query_one("#view")
1192
+ w = self._holder(target)
1193
+ if w is None:
1194
+ return None
1195
+ area = w.region.intersection(view.region.shrink((1, 1, 1, 1)))
1196
+ for y in range(area.y, area.bottom):
1197
+ for x in range(area.x, area.right):
1198
+ mm = TARGET_RE.search(str(self.screen.get_style_at(x, y).meta.get("@click", "")))
1199
+ if mm and mm.group(1) == target:
1200
+ return x, y
1201
+ return None
1202
+
1203
+ def action_cycle_link(self, direction: int) -> None:
1204
+ links = self._ensure_links()
1205
+ if not links:
1206
+ self.notify("Nothing here links anywhere.")
1207
+ return
1208
+ self.link_index = (self.link_index + direction) % len(links)
1209
+ target = links[self.link_index]
1210
+ # The last preview may cover the next link, which then cannot be found on screen.
1211
+ self.hide_peek()
1212
+ self.query_one("#view").add_class("-linking")
1213
+ kind = self.shown[0] if self.shown else ""
1214
+ if kind in ("unit", "section"):
1215
+ # A document: the cursor is on whole cards, so no re-render is needed.
1216
+ for card in self.query(".card.-selected"):
1217
+ card.remove_class("-selected")
1218
+ try:
1219
+ card = self.query(f"#card-{target}").last()
1220
+ card.add_class("-selected")
1221
+ card.scroll_visible(animate=False, top=True)
1222
+ self.query_one("#view").add_class("-linking")
1223
+ except Exception:
1224
+ pass
1225
+ self.action_peek_link()
1226
+ else:
1227
+ self.show(self.shown, sel=target)
1228
+ self.action_peek_link(reveal=True)
1229
+
1230
+ def action_peek_link(self, reveal: bool = False) -> None:
1231
+ """Preview the selected link beside it — in a worker, since the page may
1232
+ not be drawn yet and the link may first have to be scrolled to. A newer
1233
+ `tab` cancels an older one still looking."""
1234
+ links = self._ensure_links()
1235
+ if not links:
1236
+ return
1237
+ if self.link_index < 0:
1238
+ self.link_index = 0
1239
+ self.run_worker(self._peek(links[self.link_index], reveal), exclusive=True, group="peek")
1240
+
1241
+ async def _frame(self) -> None:
1242
+ await asyncio.sleep(0.02)
1243
+
1244
+ async def _still(self, view: Widget) -> None:
1245
+ """Until the pane has stopped scrolling: scroll_visible lands a frame or two late."""
1246
+ last = None
1247
+ for _ in range(10):
1248
+ await self._frame()
1249
+ if view.scroll_y == last:
1250
+ return
1251
+ last = view.scroll_y
1252
+
1253
+ async def _peek(self, target: str, reveal: bool) -> None:
1254
+ links = self.links or []
1255
+ verb = f"enter opens in {editor.command(self.m.cfg.editor, 'x', 1)[0][0].rsplit('/', 1)[-1]}" \
1256
+ if target.startswith("ref:") else "enter to follow"
1257
+ title = f" link {self.link_index + 1}/{len(links)} · {verb} "
1258
+ view = self.query_one("#view", ReadingPane)
1259
+ spot = None
1260
+ if self.shown and self.shown[0] in ("unit", "section"):
1261
+ await self._frame()
1262
+ try:
1263
+ card = self.query(f"#card-{target}").last().region
1264
+ spot = (card.x + 2, card.y + 1) # the ID in the card's head, as a hover sees it
1265
+ except Exception:
1266
+ pass
1267
+ else:
1268
+ holder = None
1269
+ for _ in range(15): # the page mounted, laid out and drawn
1270
+ holder = self._holder(target)
1271
+ if holder is not None and holder.region.height:
1272
+ break
1273
+ await self._frame()
1274
+ if holder is not None:
1275
+ if reveal:
1276
+ view.scroll_to(y=getattr(self, "_restore_scroll", 0), animate=False)
1277
+ await self._still(view)
1278
+ holder.scroll_visible(animate=False)
1279
+ await self._still(view)
1280
+ for _ in range(40): # down through a tall widget, a page at a time
1281
+ spot = self._locate(target)
1282
+ if spot is not None:
1283
+ break
1284
+ await self._frame()
1285
+ spot = self._locate(target)
1286
+ if spot is not None or not reveal:
1287
+ break
1288
+ inside = holder.region.bottom > view.region.bottom - 1
1289
+ if not inside or view.scroll_y >= view.max_scroll_y:
1290
+ break
1291
+ view.scroll_relative(y=max(1, view.size.height - 2), animate=False)
1292
+ await self._still(view)
1293
+ self.show_peek(target, spot, title)
1294
+
1295
+ def open_in_editor(self, file: str, line: int) -> None:
1296
+ """`file` at `line` in the configured editor. A terminal editor gets the
1297
+ terminal until it exits; a windowed one is launched and left running."""
1298
+ argv, terminal = editor.command(self.m.cfg.editor, file, line)
1299
+ self.hide_peek()
1300
+ try:
1301
+ if terminal:
1302
+ with self.suspend():
1303
+ subprocess.run(argv, cwd=self.m.cfg.root)
1304
+ self.action_reload(quiet=True)
1305
+ else:
1306
+ subprocess.Popen(argv, cwd=self.m.cfg.root, stdin=subprocess.DEVNULL,
1307
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
1308
+ start_new_session=True)
1309
+ except Exception as e: # a missing editor, or no terminal to hand over
1310
+ self.notify(f"Could not run {argv[0]}: {e}", severity="error")
1311
+
1312
+ def action_follow_or_toggle(self) -> None:
1313
+ """`enter`: follow the selected link, or with none selected open or close the node."""
1314
+ if self.link_index >= 0:
1315
+ self.action_follow_link()
1316
+ return
1317
+ tree = self.query_one("#nav", Tree)
1318
+ node = tree.cursor_node
1319
+ if node is not None and node.allow_expand:
1320
+ node.toggle()
1321
+
1322
+ def drop_link(self) -> bool:
1323
+ """Clear the link cursor and its preview. Whether there was one."""
1324
+ if self.link_index < 0 and not self.hide_peek():
1325
+ return False
1326
+ self.hide_peek()
1327
+ had = self.link_index >= 0
1328
+ self.link_index = -1
1329
+ view = self.query_one("#view")
1330
+ view.remove_class("-linking")
1331
+ for card in self.query(".card.-selected"):
1332
+ card.remove_class("-selected")
1333
+ if had and self.shown and self.shown[0] not in ("unit", "section"):
1334
+ self.show(self.shown, keep=True)
1335
+ return True
1336
+
1337
+ def action_escape(self) -> None:
1338
+ if not self.drop_link():
1339
+ self.action_clear()
1340
+
1341
+ def action_scroll_view(self, pages: float) -> None:
1342
+ view = self.query_one("#view")
1343
+ lines = int(pages * max(1, view.size.height)) if abs(pages) < 1 else int(pages)
1344
+ view.scroll_relative(y=lines, animate=False)
1345
+
1346
+ def action_follow_link(self) -> None:
1347
+ self._ensure_links()
1348
+ if 0 <= self.link_index < len(self.links):
1349
+ self.action_goto(self.links[self.link_index])
1350
+ elif self.links:
1351
+ self.action_cycle_link(1)
1352
+
1353
+ # ------------------------------------------------------------- jumping and history
1354
+
1355
+ def _node_for(self, data) -> TreeNode | None:
1356
+ kind, key = data
1357
+ if kind in ("req", "question"):
1358
+ return self.nodes.get(key)
1359
+ stack = [self.query_one("#nav", Tree).root]
1360
+ while stack:
1361
+ node = stack.pop()
1362
+ if node.data == data:
1363
+ return node
1364
+ stack.extend(node.children)
1365
+ return None
1366
+
1367
+ def _move_to(self, data) -> bool:
1368
+ node = self._node_for(data)
1369
+ if node is None and (self.query_text or self.open_only):
1370
+ self.query_one("#search", Input).value = ""
1371
+ self.query_text = ""
1372
+ self.open_only = False
1373
+ self.rebuild()
1374
+ node = self._node_for(data)
1375
+ if node is None:
1376
+ return False
1377
+ parent = node.parent
1378
+ while parent is not None:
1379
+ parent.expand()
1380
+ parent = parent.parent
1381
+ tree = self.query_one("#nav", Tree)
1382
+ self.call_after_refresh(tree.move_cursor, node)
1383
+ return True
1384
+
1385
+ def action_goto(self, target: str) -> None:
1386
+ if target.startswith("ref:"):
1387
+ self.open_in_editor(*self.split_ref(target))
1388
+ return
1389
+ kind = "req" if target in self.m.reqs else "question"
1390
+ here = self.query_one("#nav", Tree).cursor_node
1391
+ if self._move_to((kind, target)):
1392
+ if here is not None and here.data and here.data != (kind, target):
1393
+ self.back.append(here.data)
1394
+ self.forward.clear()
1395
+ else:
1396
+ self.notify(f"{target} is not in the tree.", severity="warning")
1397
+
1398
+ def _step(self, source: list, dest: list) -> None:
1399
+ here = self.query_one("#nav", Tree).cursor_node
1400
+ while source:
1401
+ data = source.pop()
1402
+ if self._move_to(data):
1403
+ if here is not None and here.data:
1404
+ dest.append(here.data)
1405
+ return
1406
+ self.notify("Nothing further back." if source is self.back else "Nothing further forward.")
1407
+
1408
+ def action_history_back(self) -> None:
1409
+ if not self.back:
1410
+ self.notify("Nothing further back.")
1411
+ return
1412
+ self._step(self.back, self.forward)
1413
+
1414
+ def action_history_forward(self) -> None:
1415
+ if not self.forward:
1416
+ self.notify("Nothing further forward.")
1417
+ return
1418
+ self._step(self.forward, self.back)
1419
+
1420
+ def action_accept(self) -> None:
1421
+ node = self.query_one("#nav", Tree).cursor_node
1422
+ if not node or not node.data or node.data[0] != "req":
1423
+ self.notify("Highlight a requirement to accept it.", severity="warning")
1424
+ return
1425
+ r = self.m.reqs[node.data[1]]
1426
+ if not self.m.reviewed(r):
1427
+ self.notify(f"{r.id} carries no review status.", severity="warning")
1428
+ return
1429
+ if r.status == "Accepted":
1430
+ self.notify(f"{r.id} is already accepted.")
1431
+ return
1432
+
1433
+ def done(yes: bool | None) -> None:
1434
+ if not yes:
1435
+ return
1436
+ try:
1437
+ ops.accept(self.m, r.id)
1438
+ except ops.OpError as e:
1439
+ self.notify(str(e), severity="error")
1440
+ return
1441
+ r.status = "Accepted"
1442
+ node.set_label(self._req_label(r))
1443
+ self.show(node.data)
1444
+ self.refresh_keys()
1445
+ self.notify(f"{r.id} accepted")
1446
+
1447
+ self.push_screen(Confirm(f"Accept {r.id} — {r.label}?\n\nYou have read it and agree with it."), done)
1448
+
1449
+
1450
+ def main() -> int:
1451
+ ReqdeckApp().run()
1452
+ return 0