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.
- reqdeck/__init__.py +3 -0
- reqdeck/__main__.py +7 -0
- reqdeck/cli.py +706 -0
- reqdeck/config.py +185 -0
- reqdeck/editor.py +51 -0
- reqdeck/init.py +317 -0
- reqdeck/markers.py +65 -0
- reqdeck/model.py +393 -0
- reqdeck/ops.py +124 -0
- reqdeck/questions.py +62 -0
- reqdeck/render.py +140 -0
- reqdeck/skills/reqdeck-cli/SKILL.md +166 -0
- reqdeck/skills/reqdeck-method/SKILL.md +166 -0
- reqdeck/skills/reqdeck-method/references/markers.md +92 -0
- reqdeck/skills/reqdeck-method/references/writing-requirements.md +125 -0
- reqdeck/store.py +186 -0
- reqdeck/templates/OPEN-QUESTIONS.md +23 -0
- reqdeck/themes/README.md +31 -0
- reqdeck/themes/__init__.py +73 -0
- reqdeck/themes/atom-one-dark.toml +27 -0
- reqdeck/themes/atom-one-light.toml +27 -0
- reqdeck/themes/catppuccin-frappe.toml +27 -0
- reqdeck/themes/catppuccin-latte.toml +27 -0
- reqdeck/themes/catppuccin-macchiato.toml +27 -0
- reqdeck/themes/catppuccin-mocha.toml +27 -0
- reqdeck/themes/dracula.toml +27 -0
- reqdeck/themes/flexoki.toml +27 -0
- reqdeck/themes/gruvbox.toml +27 -0
- reqdeck/themes/monokai.toml +27 -0
- reqdeck/themes/nord.toml +27 -0
- reqdeck/themes/rose-pine-dawn.toml +27 -0
- reqdeck/themes/rose-pine-moon.toml +27 -0
- reqdeck/themes/rose-pine.toml +27 -0
- reqdeck/themes/solarized-dark.toml +27 -0
- reqdeck/themes/solarized-light.toml +27 -0
- reqdeck/themes/textual-dark.toml +27 -0
- reqdeck/themes/textual-light.toml +27 -0
- reqdeck/themes/tokyo-night.toml +27 -0
- reqdeck/viewer.py +1452 -0
- reqdeck-0.1.0.dist-info/METADATA +305 -0
- reqdeck-0.1.0.dist-info/RECORD +44 -0
- reqdeck-0.1.0.dist-info/WHEEL +4 -0
- reqdeck-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|