simdref 0.0.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.
simdref/tui.py ADDED
@@ -0,0 +1,2366 @@
1
+ """Interactive Textual-based terminal UI for simdref.
2
+
3
+ Provides fuzzy search across intrinsics and instructions with
4
+ collapsible detail sections, keyboard navigation, and Rich rendering.
5
+
6
+ Uses SQLite FTS for fast search instead of loading the full catalog.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import sqlite3
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from typing import TYPE_CHECKING
18
+
19
+ _PROFILE = os.environ.get("SIMDREF_PROFILE") == "1"
20
+
21
+
22
+ def _profile_log(tag: str, elapsed_ms: float, **extras: object) -> None:
23
+ if not _PROFILE:
24
+ return
25
+ extras_str = " ".join(f"{k}={v}" for k, v in extras.items())
26
+ sys.stderr.write(f"[simdref-profile] {tag} {elapsed_ms:.1f}ms {extras_str}\n")
27
+
28
+ from rich.panel import Panel
29
+ from rich.syntax import Syntax
30
+ from rich.table import Table
31
+ try:
32
+ from textual import events, on, work
33
+ from textual.app import App, ComposeResult
34
+ from textual.message import Message
35
+ from textual.binding import Binding
36
+ from textual.containers import Horizontal, Vertical, VerticalScroll
37
+ from textual.screen import ModalScreen
38
+ from textual.widgets import (
39
+ Button,
40
+ Checkbox,
41
+ Collapsible,
42
+ ContentSwitcher,
43
+ Footer,
44
+ Header,
45
+ Input,
46
+ Label,
47
+ ListItem,
48
+ ListView,
49
+ Select,
50
+ Static,
51
+ TextArea,
52
+ )
53
+ except ImportError: # pragma: no cover - allows non-TUI test environments
54
+ class _TextualStub:
55
+ def __init__(self, *args, **kwargs):
56
+ pass
57
+
58
+ class _EventStub:
59
+ pass
60
+
61
+ def on(*args, **kwargs):
62
+ def _decorator(fn):
63
+ return fn
64
+ return _decorator
65
+
66
+ def work(*args, **kwargs):
67
+ def _decorator(fn):
68
+ return fn
69
+ return _decorator
70
+
71
+ class App: # type: ignore[override]
72
+ pass
73
+
74
+ class ModalScreen(_TextualStub): # type: ignore[override]
75
+ pass
76
+
77
+ ComposeResult = object
78
+ Message = _TextualStub
79
+ Binding = _TextualStub
80
+ Horizontal = _TextualStub
81
+ Vertical = _TextualStub
82
+ VerticalScroll = _TextualStub
83
+ Button = _TextualStub
84
+ Button.Pressed = _EventStub
85
+ Checkbox = _TextualStub
86
+ Checkbox.Changed = _EventStub
87
+ Collapsible = _TextualStub
88
+ ContentSwitcher = _TextualStub
89
+ Footer = _TextualStub
90
+ Header = _TextualStub
91
+ Input = _TextualStub
92
+ Input.Changed = _EventStub
93
+ Input.Submitted = _EventStub
94
+ Label = _TextualStub
95
+ ListItem = _TextualStub
96
+ ListView = _TextualStub
97
+ ListView.Selected = _EventStub
98
+ ListView.Highlighted = _EventStub
99
+ Select = _TextualStub
100
+ Select.Changed = _EventStub
101
+ Static = _TextualStub
102
+ TextArea = _TextualStub
103
+ events = _TextualStub()
104
+
105
+ from simdref.display import (
106
+ _CODE_SECTION_LANG,
107
+ _DESCRIPTION_ORDER,
108
+ _EXPANDED_SECTIONS,
109
+ _MEASUREMENT_EXCLUDE_KEYS,
110
+ _MEASUREMENT_PREFERRED_ORDER,
111
+ DEFAULT_ENABLED_ISAS,
112
+ DEFAULT_SUBS,
113
+ FAMILY_SUB_ORDER,
114
+ ISA_FAMILY_ORDER,
115
+ canonical_url,
116
+ display_isa,
117
+ display_instruction_form,
118
+ display_uarch,
119
+ isa_family,
120
+ perf_panel_border,
121
+ perf_panel_title,
122
+ split_perf_rows,
123
+ isa_to_sub_isa,
124
+ instruction_metadata_rows,
125
+ normalize_isa_token,
126
+ strip_instruction_decorators,
127
+ measurement_rows,
128
+ uarch_sort_key,
129
+ )
130
+ from simdref.perf import variant_perf_summary
131
+ from simdref.queries import linked_instruction_records
132
+ from simdref.search import SearchResult
133
+ from simdref.storage import (
134
+ SQLITE_PATH,
135
+ load_instruction_from_db,
136
+ load_intrinsic_from_db,
137
+ open_db,
138
+ sqlite_schema_is_current,
139
+ )
140
+
141
+ from simdref.models import InstructionRecord, IntrinsicRecord
142
+
143
+ _INITIAL_RESULT_BATCH = 50
144
+ _RESULT_BATCH_SIZE = 10
145
+ _RESULT_PREFETCH_THRESHOLD = 5
146
+
147
+ _ISA_FAMILIES: list[str] = [name for name, _ in sorted(ISA_FAMILY_ORDER.items(), key=lambda item: item[1])]
148
+
149
+
150
+ def _isa_matches_sub(isa: str, sub_isa: str) -> bool:
151
+ """Return whether a raw ISA token belongs to the given sub-ISA."""
152
+ normalized_isa = normalize_isa_token(isa)
153
+ normalized_sub = normalize_isa_token(sub_isa)
154
+ return normalized_isa == normalized_sub or (
155
+ normalized_isa.startswith(normalized_sub) and len(normalized_isa) > len(normalized_sub)
156
+ )
157
+
158
+
159
+ def _normalize_sub_isa_selection(
160
+ enabled_families: set[str],
161
+ enabled_sub_isas: set[str] | None,
162
+ family_subs: dict[str, list[str]],
163
+ ) -> set[str] | None:
164
+ """Keep only sub-ISAs that belong to enabled families.
165
+
166
+ Returns ``None`` when all visible sub-ISAs are effectively enabled.
167
+ """
168
+ visible_subs: set[str] = set()
169
+ for family in enabled_families:
170
+ visible_subs.update(family_subs.get(family, []))
171
+ if not visible_subs:
172
+ return None
173
+ if enabled_sub_isas is None:
174
+ return None
175
+ normalized = {sub for sub in enabled_sub_isas if sub in visible_subs}
176
+ if not normalized or normalized == visible_subs:
177
+ return None
178
+ return normalized
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # FTS search
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ def _name_match_score(name: str, terms: list[str]) -> int:
187
+ """Score how well query terms match a name. Higher = better match.
188
+
189
+ Rewards: all terms present in name, contiguous terms, exact substrings.
190
+ """
191
+ name_lower = name.lower().replace("_", " ")
192
+ score = 0
193
+ # Count how many terms appear in the name
194
+ matched = sum(1 for t in terms if t in name_lower)
195
+ score += matched * 10
196
+ # Bonus if ALL terms are in the name
197
+ if matched == len(terms):
198
+ score += 50
199
+ # Bonus for contiguous substring: "256 add ps" in "mm256 add ps"
200
+ query_joined = " ".join(terms)
201
+ if query_joined in name_lower:
202
+ score += 100
203
+ # Bonus for underscore-joined match: "256_add_ps" in name
204
+ query_underscored = "_".join(terms)
205
+ if query_underscored in name.lower():
206
+ score += 100
207
+ # Penalty for longer names (prefer more specific matches)
208
+ score -= len(name) // 10
209
+ return score
210
+
211
+
212
+ def _fts_search(
213
+ conn: sqlite3.Connection,
214
+ query: str,
215
+ enabled_families: set[str],
216
+ enabled_sub_isas: set[str] | None = None,
217
+ offset: int = 0,
218
+ limit: int = 30,
219
+ ) -> list[SearchResult]:
220
+ """Search using SQLite FTS5 with ISA family and sub-ISA filtering."""
221
+ _t_start = time.perf_counter() if _PROFILE else 0.0
222
+ results: list[SearchResult] = []
223
+ fts_query = query.replace('"', '""').replace("*", "")
224
+ if not fts_query.strip():
225
+ return results
226
+ terms = [t.lower() for t in fts_query.replace("_", " ").strip().split() if t]
227
+ if not terms:
228
+ return results
229
+ term_expr = " ".join(f'"{t}"*' for t in terms)
230
+ # Search name/name_tokens first (precise), fall back to all columns (broad)
231
+ fts_expr = f"{{name name_tokens}} : {term_expr}"
232
+ fts_expr_broad = term_expr
233
+
234
+ # Build an ISA LIKE-clause fragment so SQLite can drop non-matching rows
235
+ # before we ever hand them to Python. Sub-ISA tokens (e.g. "SSE2") and
236
+ # family names (e.g. "Arm") are substring-matched against the space-joined
237
+ # isa column. Families without a sub-ISA breakdown (RISC-V "x86", etc.)
238
+ # get their family name added so a plain family-level toggle still matches.
239
+ isa_like_tokens: list[str] = []
240
+ if enabled_families:
241
+ for fam in enabled_families:
242
+ subs = FAMILY_SUB_ORDER.get(fam)
243
+ if not subs:
244
+ isa_like_tokens.append(fam)
245
+ if enabled_sub_isas is not None:
246
+ isa_like_tokens.extend(enabled_sub_isas)
247
+ # Normalise the isa column the same way _isa_matches_sub does: strip
248
+ # hyphens so "AVX-512F" matches a pushed-down "AVX512F" pattern. REPLACE
249
+ # isn't indexed, but it runs only over FTS-matching rows.
250
+ if isa_like_tokens:
251
+ normalized_tokens = [tok.replace("-", "") for tok in isa_like_tokens]
252
+ isa_like_sql = "(" + " OR ".join("REPLACE(isa, '-', '') LIKE ?" for _ in normalized_tokens) + ")"
253
+ isa_like_params = [f"%{tok}%" for tok in normalized_tokens]
254
+ else:
255
+ isa_like_sql = ""
256
+ isa_like_params = []
257
+
258
+ def _isa_visible(isa_str: str) -> bool:
259
+ parts = [v.strip() for v in isa_str.replace(",", " ").split() if v.strip()]
260
+ families = {isa_family(v) for v in parts}
261
+ if enabled_families and not families & enabled_families:
262
+ return False
263
+ if enabled_sub_isas is not None:
264
+ for p in parts:
265
+ for sub in enabled_sub_isas:
266
+ if _isa_matches_sub(p, sub):
267
+ return True
268
+ if isa_family(p) not in FAMILY_SUB_ORDER:
269
+ return True
270
+ return False
271
+ return True
272
+
273
+ # Collect candidates and re-rank by name match quality
274
+ candidates: list[tuple[int, SearchResult]] = []
275
+
276
+ seen: set[str] = set()
277
+
278
+ def _target_candidate_count() -> int:
279
+ return offset + limit + max(limit, 20)
280
+
281
+ # With the ISA LIKE filter in SQL, fetch_limit can stay modest — the
282
+ # filter drops non-matching rows before they reach Python.
283
+ isa_filter_clause = f" AND {isa_like_sql}" if isa_like_sql else ""
284
+
285
+ def _query_intrinsic_rows(expr: str) -> list[sqlite3.Row]:
286
+ rows: list[sqlite3.Row] = []
287
+ fetch_limit = max((offset + limit) * 2, 60)
288
+ while fetch_limit <= 5000:
289
+ for sql in (
290
+ f"SELECT name, summary, description, isa FROM intrinsics_fts WHERE intrinsics_fts MATCH ?{isa_filter_clause} ORDER BY rank LIMIT ?",
291
+ f"SELECT name, description, isa FROM intrinsics_fts WHERE intrinsics_fts MATCH ?{isa_filter_clause} ORDER BY rank LIMIT ?",
292
+ ):
293
+ try:
294
+ rows = conn.execute(sql, (expr, *isa_like_params, fetch_limit)).fetchall()
295
+ break
296
+ except sqlite3.OperationalError:
297
+ continue
298
+ visible = sum(1 for row in rows if row["name"] not in seen and _isa_visible(row["isa"] or ""))
299
+ if visible >= _target_candidate_count() or len(rows) < fetch_limit:
300
+ return rows
301
+ fetch_limit *= 2
302
+ return rows
303
+
304
+ def _query_instruction_rows(expr: str) -> list[sqlite3.Row]:
305
+ rows: list[sqlite3.Row] = []
306
+ fetch_limit = max((offset + limit) * 2, 60)
307
+ while fetch_limit <= 5000:
308
+ try:
309
+ rows = conn.execute(
310
+ f"""
311
+ SELECT instructions_data.db_key, instructions_fts.key, instructions_fts.summary, instructions_fts.isa
312
+ FROM instructions_fts
313
+ JOIN instructions_data ON instructions_data.rowid = instructions_fts.rowid
314
+ WHERE instructions_fts MATCH ?{isa_filter_clause.replace('REPLACE(isa,', 'REPLACE(instructions_fts.isa,')}
315
+ ORDER BY rank
316
+ LIMIT ?
317
+ """,
318
+ (expr, *isa_like_params, fetch_limit),
319
+ ).fetchall()
320
+ except sqlite3.OperationalError:
321
+ return rows
322
+ visible = sum(1 for row in rows if row["db_key"] not in seen and _isa_visible(row["isa"] or ""))
323
+ if visible >= _target_candidate_count() or len(rows) < fetch_limit:
324
+ return rows
325
+ fetch_limit *= 2
326
+ return rows
327
+
328
+ # Search name columns first (precise), then broaden to all columns.
329
+ # The name-only query uses {name name_tokens} column filter which
330
+ # requires schema v7+; fall back to broad search on OperationalError.
331
+ search_exprs = [fts_expr, fts_expr_broad]
332
+ for expr in search_exprs:
333
+ rows = _query_intrinsic_rows(expr)
334
+ for row in rows:
335
+ if row["name"] in seen or not _isa_visible(row["isa"] or ""):
336
+ continue
337
+ seen.add(row["name"])
338
+ subtitle = (row["summary"] if "summary" in row.keys() else "") or row["description"] or ""
339
+ result = SearchResult(
340
+ kind="intrinsic",
341
+ key=row["name"],
342
+ title=row["name"],
343
+ subtitle=subtitle,
344
+ score=100,
345
+ )
346
+ candidates.append((_name_match_score(row["name"], terms), result))
347
+
348
+ irows = _query_instruction_rows(expr)
349
+ for row in irows:
350
+ if row["db_key"] in seen or not _isa_visible(row["isa"] or ""):
351
+ continue
352
+ seen.add(row["db_key"])
353
+ result = SearchResult(
354
+ kind="instruction",
355
+ key=row["db_key"],
356
+ title=strip_instruction_decorators(row["key"]),
357
+ subtitle=row["summary"] or "",
358
+ score=90,
359
+ )
360
+ candidates.append((_name_match_score(row["key"], terms), result))
361
+
362
+ # Sort by match score descending, then by kind (intrinsics first)
363
+ candidates.sort(key=lambda c: (-c[0], 0 if c[1].kind == "intrinsic" else 1))
364
+ results = [r for _, r in candidates[offset:offset + limit]]
365
+ if _PROFILE:
366
+ _profile_log("_fts_search", (time.perf_counter() - _t_start) * 1000, q=repr(query), n=len(results))
367
+ return results
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Widgets
372
+ # ---------------------------------------------------------------------------
373
+
374
+
375
+ class _AnnSplitter(Static):
376
+ """Draggable vertical splitter between the annotate input and output.
377
+
378
+ Mouse drag adjusts the split ratio; arrow keys also work when focused.
379
+ Emits a custom message so the App can re-flow the panes."""
380
+
381
+ DEFAULT_CSS = "" # styled from the App's global CSS block
382
+
383
+ can_focus = True
384
+
385
+ class Moved(Message):
386
+ def __init__(self, ratio: float) -> None:
387
+ super().__init__()
388
+ self.ratio = max(0.1, min(0.9, ratio))
389
+
390
+ def __init__(self, *, glyph: str = "║", id: str | None = None) -> None:
391
+ super().__init__(glyph, id=id, classes="ann-splitter")
392
+ self._dragging = False
393
+
394
+ def on_mouse_down(self, event: events.MouseDown) -> None: # pragma: no cover - UI wiring
395
+ self._dragging = True
396
+ self.add_class("-dragging")
397
+ self.capture_mouse(True)
398
+ event.stop()
399
+
400
+ def on_mouse_up(self, event: events.MouseUp) -> None: # pragma: no cover - UI wiring
401
+ if not self._dragging:
402
+ return
403
+ self._dragging = False
404
+ self.remove_class("-dragging")
405
+ self.capture_mouse(False)
406
+ event.stop()
407
+
408
+ def on_mouse_move(self, event: events.MouseMove) -> None: # pragma: no cover - UI wiring
409
+ if not self._dragging:
410
+ return
411
+ parent = self.parent
412
+ if parent is None:
413
+ return
414
+ parent_region = parent.region
415
+ if parent_region.width <= 0:
416
+ return
417
+ # event.screen_x is relative to the screen origin; shift into parent coords.
418
+ x_in_parent = event.screen_x - parent_region.x
419
+ ratio = x_in_parent / parent_region.width
420
+ self.post_message(self.Moved(ratio))
421
+
422
+ def on_key(self, event: events.Key) -> None: # pragma: no cover - UI wiring
423
+ if event.key in ("left", "right"):
424
+ delta = -0.05 if event.key == "left" else 0.05
425
+ parent = self.parent
426
+ parent_region = getattr(parent, "region", None) if parent is not None else None
427
+ width = parent_region.width if parent_region else 60
428
+ if width <= 0:
429
+ return
430
+ # Nudge by a % step, independent of current position — the App
431
+ # clamps the new ratio.
432
+ current_fraction = None
433
+ try:
434
+ sibling = parent.query_one("#ann-input") if parent else None
435
+ cw = sibling.region.width if sibling and sibling.region else None
436
+ if cw is not None and width > 0:
437
+ current_fraction = cw / width
438
+ except Exception:
439
+ current_fraction = None
440
+ new_ratio = (current_fraction if current_fraction is not None else 0.33) + delta
441
+ self.post_message(self.Moved(new_ratio))
442
+ event.stop()
443
+
444
+
445
+ class _ToggleChip(Static):
446
+ """Base clickable toggle chip for ISA/category filters."""
447
+
448
+ DEFAULT_CSS = """
449
+ _ToggleChip {
450
+ width: auto;
451
+ height: 1;
452
+ padding: 0 1;
453
+ margin: 0 0 0 1;
454
+ }
455
+ _ToggleChip.enabled {
456
+ background: $accent;
457
+ color: $text;
458
+ }
459
+ _ToggleChip.disabled {
460
+ background: $surface;
461
+ color: $text-muted;
462
+ }
463
+ """
464
+
465
+ class Toggled(Message):
466
+ """Posted when a toggle chip is clicked."""
467
+
468
+ def __init__(self, toggle: _ToggleChip) -> None:
469
+ super().__init__()
470
+ self.toggle = toggle
471
+
472
+ def __init__(self, label: str, enabled: bool = False) -> None:
473
+ super().__init__(label)
474
+ self.label_text = label
475
+ self.enabled = enabled
476
+ self.add_class("enabled" if enabled else "disabled")
477
+
478
+ def on_click(self) -> None:
479
+ self.enabled = not self.enabled
480
+ self.remove_class("enabled" if not self.enabled else "disabled")
481
+ self.add_class("enabled" if self.enabled else "disabled")
482
+ self.post_message(self.Toggled(self))
483
+
484
+ def set_enabled(self, enabled: bool) -> None:
485
+ """Update the enabled state without firing a Toggled event."""
486
+ if self.enabled == enabled:
487
+ return
488
+ self.enabled = enabled
489
+ self.remove_class("enabled" if not enabled else "disabled")
490
+ self.add_class("enabled" if enabled else "disabled")
491
+
492
+
493
+ class ToggleAllLabel(Static):
494
+ """Clickable label that toggles all sibling chips on/off."""
495
+
496
+ class Clicked(Message):
497
+ def __init__(self, label: ToggleAllLabel) -> None:
498
+ super().__init__()
499
+ self.label = label
500
+
501
+ def on_click(self) -> None:
502
+ self.post_message(self.Clicked(self))
503
+
504
+
505
+ class PresetButton(Static):
506
+ """Clickable button that applies a named preset."""
507
+
508
+ class Clicked(Message):
509
+ def __init__(self, mode: str) -> None:
510
+ super().__init__()
511
+ self.mode = mode
512
+
513
+ def __init__(self, label: str, mode: str, **kwargs) -> None:
514
+ super().__init__(label, **kwargs)
515
+ self.mode = mode
516
+
517
+ def on_click(self) -> None:
518
+ self.post_message(self.Clicked(self.mode))
519
+
520
+
521
+ class IsaToggle(_ToggleChip):
522
+ """A clickable ISA family toggle chip."""
523
+
524
+ def __init__(self, family: str, enabled: bool = False) -> None:
525
+ super().__init__(family, enabled)
526
+ self.family = family
527
+
528
+
529
+ class SubIsaToggle(_ToggleChip):
530
+ """A clickable sub-ISA extension toggle chip."""
531
+
532
+ def __init__(self, isa: str, enabled: bool = True) -> None:
533
+ super().__init__(isa, enabled)
534
+ self.isa = isa
535
+
536
+
537
+ class KindToggle(_ToggleChip):
538
+ """Toggle chip for result kind (intrinsic | instruction)."""
539
+
540
+ def __init__(self, kind: str, label: str, enabled: bool = True) -> None:
541
+ super().__init__(label, enabled)
542
+ self.kind = kind
543
+
544
+
545
+ class SearchInput(Input):
546
+ """Search box with result-navigation arrow key behavior."""
547
+
548
+ def key_down(self) -> None:
549
+ app = self.app
550
+ if hasattr(app, "_move_result_focus"):
551
+ app._move_result_focus(1)
552
+
553
+ def key_up(self) -> None:
554
+ app = self.app
555
+ if hasattr(app, "_move_result_focus"):
556
+ app._move_result_focus(-1)
557
+
558
+ async def _on_key(self, event: events.Key) -> None: # type: ignore[override]
559
+ # '?' always opens the help modal; if we let the Input consume it we
560
+ # would instead insert a literal '?' into the search value.
561
+ if event.character == "?":
562
+ event.prevent_default()
563
+ event.stop()
564
+ action = getattr(self.app, "action_show_help", None)
565
+ if action is not None:
566
+ action()
567
+ return
568
+ await super()._on_key(event)
569
+
570
+
571
+ class ResultItem(ListItem):
572
+ """A search result list item carrying the underlying SearchResult."""
573
+
574
+ def __init__(self, result: SearchResult, index: int) -> None:
575
+ self.result = result
576
+ title_color = "cyan" if result.kind == "intrinsic" else "magenta"
577
+ subtitle = (result.subtitle or "").split("\n")[0]
578
+ label = f"[dim]{index:>2}[/] [{title_color} bold]{result.title}[/] [dim italic]{subtitle}[/]"
579
+ super().__init__(Static(label, markup=True))
580
+
581
+
582
+ class _AnnInput(TextArea):
583
+ """Annotate input TextArea using Textual's native editing actions."""
584
+
585
+ def action_paste(self) -> None:
586
+ app = self.app
587
+ text = None
588
+ prepare = getattr(app, "_prepare_annotate_clipboard_for_paste", None)
589
+ if prepare is not None:
590
+ text = prepare()
591
+ if text is None:
592
+ return
593
+ else:
594
+ text = getattr(app, "clipboard", "") or ""
595
+ if not text:
596
+ return
597
+ super().action_paste()
598
+ try:
599
+ app.query_one("#ann-status", Label).update(f"Pasted {len(text)} chars")
600
+ except Exception:
601
+ pass
602
+ schedule = getattr(app, "_schedule_annotate", None)
603
+ if schedule is not None:
604
+ schedule(delay=0.75 if len(text) > 5000 else 0.35)
605
+
606
+
607
+ class _AnnOutput(Static):
608
+ """Annotate output pane — focusable, copies its full rendered text."""
609
+
610
+ can_focus = True
611
+
612
+ BINDINGS = [
613
+ Binding("ctrl+c", "copy_all", "Copy all", show=False),
614
+ ]
615
+
616
+ def action_copy_all(self) -> None:
617
+ app = self.app
618
+ fn = getattr(app, "_ann_copy_output", None)
619
+ if fn is not None:
620
+ fn()
621
+
622
+
623
+ class AnnContextMenu(ModalScreen):
624
+ """Right-click context menu for the annotate input/output panes."""
625
+
626
+ BINDINGS = [
627
+ Binding("escape", "dismiss", "Close", show=False),
628
+ Binding("q", "dismiss", "Close", show=False),
629
+ ]
630
+
631
+ DEFAULT_CSS = """
632
+ AnnContextMenu {
633
+ align: center middle;
634
+ background: rgba(0, 0, 0, 0.35);
635
+ }
636
+ AnnContextMenu > Vertical {
637
+ width: 28;
638
+ height: auto;
639
+ border: thick $primary;
640
+ background: $surface;
641
+ padding: 0;
642
+ }
643
+ AnnContextMenu Button {
644
+ width: 100%;
645
+ border: none;
646
+ background: $surface;
647
+ color: $text;
648
+ content-align: left middle;
649
+ padding: 0 2;
650
+ height: 1;
651
+ }
652
+ AnnContextMenu Button:hover,
653
+ AnnContextMenu Button:focus {
654
+ background: $accent 25%;
655
+ color: $accent;
656
+ text-style: bold;
657
+ }
658
+ """
659
+
660
+ def __init__(self, target_id: str, has_selection: bool) -> None:
661
+ super().__init__()
662
+ self._target_id = target_id
663
+ self._has_selection = has_selection
664
+
665
+ def compose(self) -> ComposeResult:
666
+ with Vertical():
667
+ yield Button("Cut", id="ctx-cut", disabled=not self._has_selection)
668
+ yield Button("Copy", id="ctx-copy", disabled=not self._has_selection and self._target_id == "ann-input")
669
+ yield Button("Paste", id="ctx-paste")
670
+ yield Button("Select All", id="ctx-select-all")
671
+ yield Button("Clear", id="ctx-clear")
672
+
673
+ def on_button_pressed(self, event: Button.Pressed) -> None:
674
+ bid = getattr(event.button, "id", "") or ""
675
+ app = self.app
676
+ fn = getattr(app, "_ctx_action", None)
677
+ # Dispatch via a helper on the app so the action runs in the
678
+ # right context after the modal pops.
679
+ self.dismiss()
680
+ if fn is not None:
681
+ fn(bid, self._target_id)
682
+
683
+ def action_dismiss(self) -> None: # type: ignore[override]
684
+ self.app.pop_screen()
685
+
686
+
687
+ class HelpScreen(ModalScreen):
688
+ """Modal overlay listing active keybindings."""
689
+
690
+ BINDINGS = [
691
+ Binding("escape", "dismiss", "Close", show=True),
692
+ Binding("q", "dismiss", "Close", show=False),
693
+ Binding("?", "dismiss", "Close", show=False),
694
+ ]
695
+
696
+ DEFAULT_CSS = """
697
+ HelpScreen {
698
+ align: center middle;
699
+ }
700
+ HelpScreen > Static {
701
+ width: 64;
702
+ max-height: 24;
703
+ border: thick $primary;
704
+ background: $surface;
705
+ padding: 1 2;
706
+ }
707
+ """
708
+
709
+ def __init__(self, bindings) -> None:
710
+ super().__init__()
711
+ self._bindings_source = bindings
712
+
713
+ def compose(self) -> ComposeResult:
714
+ rows: list[str] = ["[b]simdref — keybindings[/b]", ""]
715
+ seen: set[tuple[str, str]] = set()
716
+ for binding in self._bindings_source:
717
+ key = getattr(binding, "key_display", None) or getattr(binding, "key", "")
718
+ action = getattr(binding, "action", "")
719
+ description = getattr(binding, "description", "") or action
720
+ if not key or (key, action) in seen:
721
+ continue
722
+ seen.add((key, action))
723
+ rows.append(f"[cyan]{key:<10}[/] {description}")
724
+ rows.append("")
725
+ rows.append("[dim]esc / q / ? to close[/]")
726
+ yield Static("\n".join(rows), markup=True)
727
+
728
+ def action_dismiss(self) -> None: # type: ignore[override]
729
+ self.app.pop_screen()
730
+
731
+
732
+ class SimdrefApp(App):
733
+ """Interactive SIMD reference browser."""
734
+
735
+ TITLE = "simdref"
736
+
737
+ CSS = """
738
+ #tabs-bar {
739
+ dock: top;
740
+ height: 1;
741
+ margin: 0 1;
742
+ }
743
+ #tabs-bar .tab-btn {
744
+ width: auto;
745
+ color: $text-muted;
746
+ padding: 0 2;
747
+ margin: 0 1 0 0;
748
+ text-style: none;
749
+ background: transparent;
750
+ border: none;
751
+ }
752
+ #tabs-bar .tab-btn:hover { color: $text; }
753
+ #tabs-bar .tab-btn.active {
754
+ color: $accent;
755
+ text-style: bold underline;
756
+ }
757
+ #view-switcher { height: 1fr; }
758
+ #annotate-view { height: 1fr; layout: vertical; }
759
+ #ann-toolbar {
760
+ height: auto;
761
+ padding: 0 1;
762
+ }
763
+ #ann-toolbar Checkbox { margin: 0 2 0 0; width: auto; border: none; padding: 0; background: transparent; }
764
+ #ann-toolbar Select { width: 22; margin: 0 1 0 0; }
765
+ #ann-toolbar Label { width: auto; margin: 0 1 0 0; color: $text-muted; }
766
+ #ann-panes {
767
+ height: 1fr;
768
+ margin: 0 1;
769
+ }
770
+ #ann-input, #ann-output-wrap {
771
+ border: solid $primary;
772
+ height: 100%;
773
+ }
774
+ #ann-input { width: 33fr; }
775
+ #ann-output-wrap { width: 67fr; }
776
+ #ann-output {
777
+ height: auto;
778
+ min-height: 100%;
779
+ padding: 0 1;
780
+ }
781
+ .ann-splitter {
782
+ width: 1;
783
+ height: 100%;
784
+ background: $primary 20%;
785
+ color: $accent;
786
+ content-align: center middle;
787
+ }
788
+ .ann-splitter:hover, .ann-splitter.-dragging {
789
+ background: $accent 50%;
790
+ }
791
+ #ann-status {
792
+ dock: bottom;
793
+ height: 1;
794
+ margin: 0 1;
795
+ color: $text-muted;
796
+ }
797
+ #search-input {
798
+ dock: top;
799
+ margin: 0 1;
800
+ }
801
+ #isa-bar {
802
+ height: 1;
803
+ margin: 0 1;
804
+ overflow-x: auto;
805
+ }
806
+ #kind-bar {
807
+ height: 1;
808
+ margin: 0 1;
809
+ overflow-x: auto;
810
+ }
811
+ #kind-bar .isa-label {
812
+ width: auto;
813
+ color: $text-muted;
814
+ padding: 0 1 0 0;
815
+ text-style: underline;
816
+ }
817
+ #sub-isa-container {
818
+ height: auto;
819
+ max-height: 10;
820
+ margin: 0 1;
821
+ overflow-y: auto;
822
+ }
823
+ .sub-isa-row {
824
+ height: 1;
825
+ }
826
+ .sub-isa-row .sub-fam-label {
827
+ width: 10;
828
+ color: $text-muted;
829
+ text-style: underline;
830
+ }
831
+ #isa-bar .isa-label {
832
+ width: auto;
833
+ color: $text-muted;
834
+ padding: 0 1 0 0;
835
+ text-style: underline;
836
+ }
837
+ #isa-bar .preset-btn {
838
+ width: auto;
839
+ background: $surface;
840
+ color: $warning;
841
+ padding: 0 1;
842
+ margin: 0 0 0 1;
843
+ text-style: bold;
844
+ }
845
+ #results-list {
846
+ height: auto;
847
+ max-height: 20%;
848
+ border: solid $accent;
849
+ margin: 0 1;
850
+ overflow-y: scroll;
851
+ }
852
+ #detail-scroll {
853
+ height: 1fr;
854
+ margin: 0 1;
855
+ border: solid $primary;
856
+ }
857
+ #status-label {
858
+ dock: bottom;
859
+ height: 1;
860
+ margin: 0 1;
861
+ color: $text-muted;
862
+ }
863
+ """
864
+
865
+ BINDINGS = [
866
+ Binding("/", "focus_search", "Search", show=True),
867
+ Binding("ctrl+k", "focus_search", show=False),
868
+ Binding("?", "show_help", "Help", show=True, priority=True),
869
+ Binding("escape", "back", "Back", show=True),
870
+ Binding("f", "toggle_all", "Expand/Collapse All", show=True),
871
+ Binding("1-9", "pick(0)", "Pick result", show=True, key_display="1-9"),
872
+ Binding("c", "copy_detail", "Copy", show=True),
873
+ Binding("ctrl+t", "switch_tab", "Switch tab", show=True, priority=True, key_display="^t"),
874
+ Binding("ctrl+left", "ann_split(-0.05)", show=False, priority=True, key_display="^←"),
875
+ Binding("ctrl+right", "ann_split(0.05)", show=False, priority=True, key_display="^→"),
876
+ Binding("q", "quit", "Quit", show=True),
877
+ Binding("ctrl+d", "quit", "Quit", show=True, priority=True, key_display="^d"),
878
+ # Non-priority so TextArea / Input consume letters while focused —
879
+ # otherwise pasted asm containing "j"/"k" gets swallowed.
880
+ Binding("j", "list_cursor_down", show=False),
881
+ Binding("k", "list_cursor_up", show=False),
882
+ *[Binding(str(n), f"pick({n})", show=False) for n in range(1, 10)],
883
+ ]
884
+
885
+ def __init__(
886
+ self,
887
+ initial_query: str = "",
888
+ initial_preset: str | None = None,
889
+ *,
890
+ initial_view: str = "search",
891
+ initial_asm: str = "",
892
+ ) -> None:
893
+ super().__init__()
894
+ self._initial_query = initial_query
895
+ self._initial_preset = initial_preset
896
+ self._initial_view = initial_view if initial_view in ("search", "annotate") else "search"
897
+ self._initial_asm = initial_asm
898
+ self._annotate_debounce_timer = None # textual Timer, set in on_mount
899
+ self._annotate_last_text: str = ""
900
+ self._ann_split_ratio: float = 0.33 # fraction of width for input pane
901
+ self._current_results: list[SearchResult] = []
902
+ self._current_query: str = ""
903
+ self._has_more_results = False
904
+ self._all_expanded = False
905
+ self._conn: sqlite3.Connection | None = None
906
+ self._enabled_families: set[str] = set(DEFAULT_ENABLED_ISAS)
907
+ # Build initial sub-ISA set from defaults for enabled families
908
+ initial_subs: set[str] = set()
909
+ for fam in DEFAULT_ENABLED_ISAS:
910
+ initial_subs.update(DEFAULT_SUBS.get(fam, set()))
911
+ self._enabled_sub_isas: set[str] | None = initial_subs if initial_subs else None
912
+ self._current_detail: IntrinsicRecord | InstructionRecord | None = None
913
+ self._current_detail_token: int = 0
914
+ self._batch_toggle = False
915
+ # LRU caches keyed by (kind, key) so arrow-key navigation through
916
+ # results hits cache instead of re-loading + re-building.
917
+ self._detail_cache: dict[tuple[str, str], IntrinsicRecord | InstructionRecord] = {}
918
+ self._detail_cache_order: list[tuple[str, str]] = []
919
+ self._render_cache: dict[tuple[str, str], list[tuple]] = {}
920
+ self._render_cache_order: list[tuple[str, str]] = []
921
+ self._detail_cache_cap = 256
922
+ # Map family -> list of sub-ISA names present in the DB
923
+ self._family_subs: dict[str, list[str]] = {}
924
+ self._enabled_kinds: set[str] = {"intrinsic", "instruction"}
925
+ self._enabled_arm_arch: set[str] | None = None
926
+ self._ann_local_clipboard_valid = False
927
+ # Cached frozensets of filter state passed to _fts_search; cleared
928
+ # whenever a toggle changes. Avoids rebuilding sets per keystroke.
929
+ self._filter_fs_cache: tuple[frozenset[str], frozenset[str] | None] | None = None
930
+
931
+ def compose(self) -> ComposeResult:
932
+ yield Header(show_clock=False)
933
+ with Horizontal(id="tabs-bar"):
934
+ yield Static("Search", id="tab-search", classes="tab-btn")
935
+ yield Static("Annotate", id="tab-annotate", classes="tab-btn")
936
+ with ContentSwitcher(id="view-switcher", initial=f"view-{self._initial_view}"):
937
+ with Vertical(id="view-search"):
938
+ yield SearchInput(
939
+ placeholder="Search intrinsics and instructions...",
940
+ id="search-input",
941
+ value=self._initial_query,
942
+ )
943
+ with Horizontal(id="isa-bar"):
944
+ yield ToggleAllLabel("ISA:", classes="isa-label", id="isa-toggle-all")
945
+ for family in _ISA_FAMILIES:
946
+ yield IsaToggle(family, enabled=family in self._enabled_families)
947
+ yield PresetButton("Default", "default", classes="preset-btn")
948
+ yield PresetButton("Intel", "intel", classes="preset-btn")
949
+ yield PresetButton("Arm32", "arm32", classes="preset-btn")
950
+ yield PresetButton("Arm64", "arm64", classes="preset-btn")
951
+ yield PresetButton("RISC-V", "riscv", classes="preset-btn")
952
+ yield PresetButton("None", "none", classes="preset-btn")
953
+ yield PresetButton("All", "all", classes="preset-btn")
954
+ yield VerticalScroll(id="sub-isa-container")
955
+ with Horizontal(id="kind-bar"):
956
+ yield ToggleAllLabel("Kind:", classes="isa-label", id="kind-label")
957
+ yield KindToggle("intrinsic", "intrinsics", enabled="intrinsic" in self._enabled_kinds)
958
+ yield KindToggle("instruction", "asm", enabled="instruction" in self._enabled_kinds)
959
+ yield ListView(id="results-list")
960
+ yield VerticalScroll(id="detail-scroll")
961
+ with Vertical(id="view-annotate"):
962
+ with Horizontal(id="ann-toolbar"):
963
+ yield Label("ISA")
964
+ yield Select(
965
+ [("x86", "x86"), ("arm", "arm"), ("riscv", "riscv"), ("any", "")],
966
+ value="x86",
967
+ allow_blank=False,
968
+ id="ann-isa",
969
+ )
970
+ yield Label("Agg")
971
+ yield Select(
972
+ [("avg", "avg"), ("median", "median"), ("best", "best"), ("worst", "worst")],
973
+ value="avg",
974
+ allow_blank=False,
975
+ id="ann-agg",
976
+ )
977
+ yield Checkbox("perf", value=True, id="ann-perf")
978
+ yield Checkbox("docs", value=True, id="ann-docs")
979
+ yield Checkbox("modeled", value=True, id="ann-modeled")
980
+ yield Button("Annotate", id="ann-run", variant="primary")
981
+ yield Button("Clear", id="ann-clear")
982
+ with Horizontal(id="ann-panes"):
983
+ yield _AnnInput.code_editor(
984
+ self._initial_asm,
985
+ language=None,
986
+ soft_wrap=False,
987
+ id="ann-input",
988
+ )
989
+ yield _AnnSplitter(id="ann-splitter")
990
+ yield VerticalScroll(_AnnOutput("", id="ann-output"), id="ann-output-wrap")
991
+ yield Label("", id="status-label")
992
+ yield Label("", id="ann-status")
993
+ yield Footer()
994
+
995
+ def on_mount(self) -> None:
996
+ if not sqlite_schema_is_current():
997
+ self._needs_update = True
998
+ self.query_one("#status-label", Label).update(
999
+ f" Database not found or outdated. Press [bold]y[/] to update, [bold]n[/] to quit."
1000
+ )
1001
+ return
1002
+ self._needs_update = False
1003
+ self._conn = open_db()
1004
+ self._build_family_subs()
1005
+ if self._initial_preset:
1006
+ self._apply_initial_preset(self._initial_preset)
1007
+ # Defer until first layout pass so container width is known
1008
+ self.call_after_refresh(self._refresh_sub_isa_bar)
1009
+ # Apply initial tab + annotate seed before focusing anything.
1010
+ self._apply_ann_split(self._ann_split_ratio)
1011
+ self._set_tab(self._initial_view)
1012
+ if self._initial_view == "annotate":
1013
+ # _set_tab already focused ann-input and scheduled annotate.
1014
+ return
1015
+ if self._initial_query:
1016
+ self._run_initial_query()
1017
+ else:
1018
+ self.query_one("#search-input", Input).focus()
1019
+
1020
+ def on_resize(self) -> None:
1021
+ # Reflow sub-ISA chips only when width actually changes
1022
+ container = self.query_one("#sub-isa-container", VerticalScroll)
1023
+ new_w = container.size.width or 0
1024
+ if new_w and new_w != getattr(self, "_last_sub_width", 0):
1025
+ self._last_sub_width = new_w
1026
+ self.call_after_refresh(self._refresh_sub_isa_bar)
1027
+
1028
+ def _build_family_subs(self) -> None:
1029
+ """Build sub-ISA lists from ISA tokens present in the current DB."""
1030
+ assert self._conn is not None
1031
+ family_subs: dict[str, set[str]] = {}
1032
+ rows = self._conn.execute(
1033
+ """
1034
+ SELECT isa FROM instructions_data
1035
+ UNION ALL
1036
+ SELECT isa FROM intrinsics_data
1037
+ """
1038
+ ).fetchall()
1039
+ for row in rows:
1040
+ for raw_isa in str(row["isa"] or "").replace(",", " ").split():
1041
+ family = isa_family(raw_isa)
1042
+ sub_isa = isa_to_sub_isa(raw_isa)
1043
+ if family and sub_isa:
1044
+ family_subs.setdefault(family, set()).add(sub_isa)
1045
+ for fam, subs in family_subs.items():
1046
+ self._family_subs[fam] = sorted(subs, key=lambda sub: FAMILY_SUB_ORDER.get(fam, []).index(sub) if sub in FAMILY_SUB_ORDER.get(fam, []) else len(FAMILY_SUB_ORDER.get(fam, [])))
1047
+ self._enabled_sub_isas = _normalize_sub_isa_selection(self._enabled_families, self._enabled_sub_isas, self._family_subs)
1048
+
1049
+ def _refresh_sub_isa_bar(self) -> None:
1050
+ """Rebuild the sub-ISA rows — one or more rows per enabled family.
1051
+
1052
+ Fast path: when the set of (family, sub_isa) widgets hasn't
1053
+ changed, just update each SubIsaToggle's enabled state in place.
1054
+ Full remount is reserved for family-set or layout-width changes.
1055
+ """
1056
+ container = self.query_one("#sub-isa-container", VerticalScroll)
1057
+ current = list(container.query(SubIsaToggle))
1058
+ current_keys: set[tuple[str, str]] = set()
1059
+ for toggle in current:
1060
+ parent_row = toggle.parent
1061
+ # The family prefix label is the first child in each row.
1062
+ fam_label = parent_row.children[0] if parent_row is not None else None
1063
+ fam_text = getattr(fam_label, "renderable", None)
1064
+ fam = str(fam_text).split(":", 1)[0].strip() if fam_text else ""
1065
+ if fam:
1066
+ current_keys.add((fam, toggle.isa))
1067
+
1068
+ needed_keys: set[tuple[str, str]] = set()
1069
+ for fam in _ISA_FAMILIES:
1070
+ if fam not in self._enabled_families or fam not in self._family_subs:
1071
+ continue
1072
+ for sub in self._family_subs[fam]:
1073
+ needed_keys.add((fam, sub))
1074
+
1075
+ if current_keys == needed_keys and current:
1076
+ # Fast path — just flip enabled bits.
1077
+ for toggle in current:
1078
+ parent_row = toggle.parent
1079
+ fam_label = parent_row.children[0] if parent_row is not None else None
1080
+ fam_text = getattr(fam_label, "renderable", None)
1081
+ fam = str(fam_text).split(":", 1)[0].strip() if fam_text else ""
1082
+ if self._enabled_sub_isas is not None:
1083
+ enabled = toggle.isa in self._enabled_sub_isas
1084
+ else:
1085
+ defaults = DEFAULT_SUBS.get(fam, set())
1086
+ enabled = toggle.isa in defaults if defaults else True
1087
+ toggle.set_enabled(enabled)
1088
+ return
1089
+
1090
+ container.remove_children()
1091
+
1092
+ max_width = container.size.width or 120
1093
+ label_width = 10 # width of .sub-fam-label
1094
+
1095
+ for fam in _ISA_FAMILIES:
1096
+ if fam not in self._enabled_families or fam not in self._family_subs:
1097
+ continue
1098
+ subs = self._family_subs[fam]
1099
+ defaults = DEFAULT_SUBS.get(fam, set())
1100
+
1101
+ # Build toggles with their approximate widths
1102
+ toggles: list[SubIsaToggle] = []
1103
+ for sub in subs:
1104
+ if self._enabled_sub_isas is not None:
1105
+ enabled = sub in self._enabled_sub_isas
1106
+ else:
1107
+ enabled = sub in defaults if defaults else True
1108
+ toggles.append(SubIsaToggle(sub, enabled=enabled))
1109
+
1110
+ # Split into rows that fit within max_width
1111
+ row_children: list[ToggleAllLabel | SubIsaToggle] = [
1112
+ ToggleAllLabel(f"{fam}:", classes="sub-fam-label"),
1113
+ ]
1114
+ used = label_width
1115
+ for toggle in toggles:
1116
+ chip_w = len(toggle.isa) + 3 # padding + margin
1117
+ if used + chip_w > max_width and len(row_children) > 1:
1118
+ container.mount(Horizontal(*row_children, classes="sub-isa-row"))
1119
+ # Continuation row: indent with blank label
1120
+ row_children = [Static(" " * label_width, classes="sub-fam-label")]
1121
+ used = label_width
1122
+ row_children.append(toggle)
1123
+ used += chip_w
1124
+ if len(row_children) > 1:
1125
+ container.mount(Horizontal(*row_children, classes="sub-isa-row"))
1126
+
1127
+ def on_unmount(self) -> None:
1128
+ if self._conn is not None:
1129
+ self._conn.close()
1130
+ self._conn = None
1131
+
1132
+ @work
1133
+ async def _run_initial_query(self) -> None:
1134
+ """Populate results from initial query and open the first match."""
1135
+ self._do_search(self._initial_query)
1136
+ results_list = self.query_one("#results-list", ListView)
1137
+ if results_list.children:
1138
+ results_list.index = 0
1139
+ first = results_list.children[0]
1140
+ if isinstance(first, ResultItem):
1141
+ self._show_detail(first.result)
1142
+ self.query_one("#detail-scroll").focus()
1143
+
1144
+ @on(Input.Changed, "#search-input")
1145
+ def on_search_changed(self, event: Input.Changed) -> None:
1146
+ self._debounced_search(event.value.strip())
1147
+
1148
+ @on(Input.Submitted, "#search-input")
1149
+ def on_search_submitted(self, event: Input.Submitted) -> None:
1150
+ """Enter in search box: expand first result, collapse list, move focus."""
1151
+ if self._current_results:
1152
+ self._show_detail(self._current_results[0])
1153
+ results_list = self.query_one("#results-list", ListView)
1154
+ results_list.index = 0
1155
+ self.query_one("#detail-scroll").focus()
1156
+
1157
+ @on(_ToggleChip.Toggled)
1158
+ def on_toggle_changed(self, event: _ToggleChip.Toggled) -> None:
1159
+ """Re-run search when ISA or sub-ISA filter changes."""
1160
+ toggle = event.toggle
1161
+ if isinstance(toggle, IsaToggle):
1162
+ if toggle.enabled:
1163
+ self._enabled_families.add(toggle.family)
1164
+ # Add default subs for newly enabled family
1165
+ defaults = DEFAULT_SUBS.get(toggle.family, set())
1166
+ if defaults and self._enabled_sub_isas is not None:
1167
+ self._enabled_sub_isas.update(defaults)
1168
+ else:
1169
+ self._enabled_families.discard(toggle.family)
1170
+ # Remove subs belonging to disabled family
1171
+ if self._enabled_sub_isas is not None:
1172
+ fam_subs = set(self._family_subs.get(toggle.family, []))
1173
+ self._enabled_sub_isas -= fam_subs
1174
+ self._enabled_sub_isas = _normalize_sub_isa_selection(self._enabled_families, self._enabled_sub_isas, self._family_subs)
1175
+ self._refresh_sub_isa_bar()
1176
+ elif isinstance(toggle, SubIsaToggle):
1177
+ all_subs = list(self.query(SubIsaToggle))
1178
+ enabled = {t.isa for t in all_subs if t.enabled}
1179
+ all_shown = {t.isa for t in all_subs}
1180
+ self._enabled_sub_isas = enabled if enabled != all_shown else None
1181
+ self._enabled_sub_isas = _normalize_sub_isa_selection(self._enabled_families, self._enabled_sub_isas, self._family_subs)
1182
+ elif isinstance(toggle, KindToggle):
1183
+ if toggle.enabled:
1184
+ self._enabled_kinds.add(toggle.kind)
1185
+ else:
1186
+ self._enabled_kinds.discard(toggle.kind)
1187
+ query = self.query_one("#search-input", Input).value.strip()
1188
+ if query:
1189
+ self._do_search(query)
1190
+
1191
+ @on(ToggleAllLabel.Clicked)
1192
+ def on_toggle_all_clicked(self, event: ToggleAllLabel.Clicked) -> None:
1193
+ """Toggle all chips in the same bar/row."""
1194
+ label = event.label
1195
+ if label.id == "isa-toggle-all":
1196
+ toggles = list(self.query(IsaToggle))
1197
+ target = not any(t.enabled for t in toggles)
1198
+ for t in toggles:
1199
+ t.enabled = target
1200
+ t.remove_class("enabled" if not target else "disabled")
1201
+ t.add_class("enabled" if target else "disabled")
1202
+ self._enabled_families = {t.family for t in toggles if t.enabled}
1203
+ if target:
1204
+ # Add defaults for all families
1205
+ subs: set[str] = set()
1206
+ for fam in self._enabled_families:
1207
+ subs.update(DEFAULT_SUBS.get(fam, set()))
1208
+ self._enabled_sub_isas = subs if subs else None
1209
+ else:
1210
+ self._enabled_sub_isas = set()
1211
+ self._enabled_sub_isas = _normalize_sub_isa_selection(self._enabled_families, self._enabled_sub_isas, self._family_subs)
1212
+ self._refresh_sub_isa_bar()
1213
+ else:
1214
+ # Family-level sub toggle: find sibling SubIsaToggles in same row
1215
+ row = label.parent
1216
+ if row is None:
1217
+ return
1218
+ toggles = list(row.query(SubIsaToggle))
1219
+ if not toggles:
1220
+ return
1221
+ target = not any(t.enabled for t in toggles)
1222
+ for t in toggles:
1223
+ t.enabled = target
1224
+ t.remove_class("enabled" if not target else "disabled")
1225
+ t.add_class("enabled" if target else "disabled")
1226
+ # Rebuild enabled_sub_isas from all visible toggles
1227
+ all_subs = list(self.query(SubIsaToggle))
1228
+ enabled = {t.isa for t in all_subs if t.enabled}
1229
+ self._enabled_sub_isas = enabled if enabled else set()
1230
+ self._enabled_sub_isas = _normalize_sub_isa_selection(self._enabled_families, self._enabled_sub_isas, self._family_subs)
1231
+ query = self.query_one("#search-input", Input).value.strip()
1232
+ if query:
1233
+ self._do_search(query)
1234
+
1235
+ def _apply_initial_preset(self, name: str) -> None:
1236
+ """Apply a named preset before first render (used for --preset CLI flag)."""
1237
+ from simdref.filters import ARCH_PRESETS
1238
+
1239
+ preset = ARCH_PRESETS.get(name)
1240
+ if preset is None:
1241
+ return
1242
+ if name == "all":
1243
+ self._enabled_families = set(_ISA_FAMILIES)
1244
+ self._enabled_sub_isas = None
1245
+ else:
1246
+ self._enabled_families = {f for f in _ISA_FAMILIES if f in preset.families}
1247
+ preset_subs = set(preset.subs)
1248
+ subs: set[str] = set()
1249
+ for fam in self._enabled_families:
1250
+ available = set(self._family_subs.get(fam, []))
1251
+ subs.update(available & preset_subs)
1252
+ self._enabled_sub_isas = subs if subs else set()
1253
+ self._enabled_arm_arch = set(preset.arm_arch) if preset.arm_arch else None
1254
+ self._enabled_kinds = set(preset.kind)
1255
+ self._enabled_sub_isas = _normalize_sub_isa_selection(
1256
+ self._enabled_families, self._enabled_sub_isas, self._family_subs
1257
+ )
1258
+
1259
+ @on(PresetButton.Clicked)
1260
+ def on_preset_clicked(self, event: PresetButton.Clicked) -> None:
1261
+ """Apply ISA presets — families + subs + arm_arch + kind in one step."""
1262
+ from simdref.filters import ARCH_PRESETS
1263
+
1264
+ preset = ARCH_PRESETS.get(event.mode) or ARCH_PRESETS["default"]
1265
+ if event.mode == "all":
1266
+ self._enabled_families = set(_ISA_FAMILIES)
1267
+ self._enabled_sub_isas = None
1268
+ else:
1269
+ self._enabled_families = {f for f in _ISA_FAMILIES if f in preset.families}
1270
+ preset_subs = set(preset.subs)
1271
+ subs: set[str] = set()
1272
+ for fam in self._enabled_families:
1273
+ available = set(self._family_subs.get(fam, []))
1274
+ subs.update(available & preset_subs)
1275
+ self._enabled_sub_isas = subs if subs else set()
1276
+ self._enabled_arm_arch = set(preset.arm_arch) if preset.arm_arch else None
1277
+ self._enabled_kinds = set(preset.kind)
1278
+ self._enabled_sub_isas = _normalize_sub_isa_selection(self._enabled_families, self._enabled_sub_isas, self._family_subs)
1279
+ # Update ISA toggle visuals
1280
+ for t in self.query(IsaToggle):
1281
+ target = t.family in self._enabled_families
1282
+ t.enabled = target
1283
+ t.remove_class("enabled" if not target else "disabled")
1284
+ t.add_class("enabled" if target else "disabled")
1285
+ # Reflect kind toggle state.
1286
+ for t in self.query(KindToggle):
1287
+ target = t.kind in self._enabled_kinds
1288
+ t.enabled = target
1289
+ t.remove_class("enabled" if not target else "disabled")
1290
+ t.add_class("enabled" if target else "disabled")
1291
+ self._refresh_sub_isa_bar()
1292
+ query = self.query_one("#search-input", Input).value.strip()
1293
+ if query:
1294
+ self._do_search(query)
1295
+
1296
+ @work(exclusive=True)
1297
+ async def _debounced_search(self, query: str) -> None:
1298
+ """Debounce search: cancel previous, wait 50ms, then search.
1299
+
1300
+ Debounce is short because FTS5 returns in <5 ms for every measured
1301
+ query and @work(exclusive=True) cancels stale searches — we can
1302
+ afford to be eager without overwhelming the event loop.
1303
+ """
1304
+ import asyncio
1305
+ await asyncio.sleep(0.05)
1306
+ self._do_search(query)
1307
+
1308
+ def _move_result_focus(self, delta: int) -> None:
1309
+ """Move the highlighted result up or down and refresh detail."""
1310
+ if not self._current_results:
1311
+ return
1312
+ results_list = self.query_one("#results-list", ListView)
1313
+ current_index = results_list.index if results_list.index is not None else 0
1314
+ current_index = max(0, min(current_index, len(self._current_results) - 1))
1315
+ next_index = max(0, min(current_index + delta, len(self._current_results) - 1))
1316
+ self._maybe_load_more_results(next_index)
1317
+ results_list.index = next_index
1318
+ self._show_detail(self._current_results[next_index])
1319
+
1320
+ def _refine_search_from_key(self, event: events.Key) -> bool:
1321
+ """Route typing from results/detail back into the search input."""
1322
+ search_input = self.query_one("#search-input", Input)
1323
+ if event.key == "backspace":
1324
+ if not search_input.value:
1325
+ return False
1326
+ search_input.value = search_input.value[:-1]
1327
+ search_input.focus()
1328
+ search_input.cursor_position = len(search_input.value)
1329
+ self._debounced_search(search_input.value.strip())
1330
+ event.prevent_default()
1331
+ return True
1332
+ if not event.character or event.is_printable is False:
1333
+ return False
1334
+ if any(getattr(event, name, False) for name in ("ctrl", "alt", "meta")):
1335
+ return False
1336
+ # vim-style list navigation is handled by bindings; don't swallow as input.
1337
+ if event.key in {"j", "k"} and isinstance(self.focused, (ListView, VerticalScroll)):
1338
+ return False
1339
+ search_input.value += event.character
1340
+ search_input.focus()
1341
+ search_input.cursor_position = len(search_input.value)
1342
+ self._debounced_search(search_input.value.strip())
1343
+ event.prevent_default()
1344
+ return True
1345
+
1346
+ def _do_search(self, query: str) -> None:
1347
+ results_list = self.query_one("#results-list", ListView)
1348
+ results_list.clear()
1349
+ self._current_results = []
1350
+ self._current_query = query
1351
+ self._has_more_results = False
1352
+ if not query:
1353
+ self.query_one("#status-label", Label).update("")
1354
+ return
1355
+ assert self._conn is not None
1356
+ results = _fts_search(
1357
+ self._conn,
1358
+ query,
1359
+ self._enabled_families,
1360
+ self._enabled_sub_isas,
1361
+ offset=0,
1362
+ limit=_INITIAL_RESULT_BATCH,
1363
+ )
1364
+ if self._enabled_kinds and len(self._enabled_kinds) < 2:
1365
+ results = [r for r in results if r.kind in self._enabled_kinds]
1366
+ self._current_results = results
1367
+ self._has_more_results = len(results) == _INITIAL_RESULT_BATCH
1368
+ for i, result in enumerate(results, 1):
1369
+ results_list.append(ResultItem(result, i))
1370
+ suffix = " +" if self._has_more_results else ""
1371
+ self.query_one("#status-label", Label).update(f" {len(results)}{suffix} results for '{query}'")
1372
+ if results:
1373
+ results_list.index = 0
1374
+ self._show_detail(results[0])
1375
+ # Warm caches for the remaining top hits so arrow-key
1376
+ # navigation is instant. Exclusive group cancels stale
1377
+ # prefetches from the previous query.
1378
+ if len(results) > 1:
1379
+ self._prefetch_neighbours(list(results))
1380
+
1381
+ def _maybe_load_more_results(self, current_index: int) -> None:
1382
+ """Load another page when navigation reaches the end of the loaded list."""
1383
+ if not self._has_more_results or current_index < len(self._current_results) - _RESULT_PREFETCH_THRESHOLD:
1384
+ return
1385
+ assert self._conn is not None
1386
+ more = _fts_search(
1387
+ self._conn,
1388
+ self._current_query,
1389
+ self._enabled_families,
1390
+ self._enabled_sub_isas,
1391
+ offset=len(self._current_results),
1392
+ limit=_RESULT_BATCH_SIZE,
1393
+ )
1394
+ if self._enabled_kinds and len(self._enabled_kinds) < 2:
1395
+ more = [r for r in more if r.kind in self._enabled_kinds]
1396
+ if not more:
1397
+ self._has_more_results = False
1398
+ return
1399
+ self._current_results.extend(more)
1400
+ results_list = self.query_one("#results-list", ListView)
1401
+ start = len(self._current_results) - len(more) + 1
1402
+ for i, result in enumerate(more, start):
1403
+ results_list.append(ResultItem(result, i))
1404
+ self._has_more_results = len(more) == _RESULT_BATCH_SIZE
1405
+ suffix = " +" if self._has_more_results else ""
1406
+ self.query_one("#status-label", Label).update(
1407
+ f" {len(self._current_results)}{suffix} results for '{self._current_query}'"
1408
+ )
1409
+
1410
+ @on(ListView.Selected, "#results-list")
1411
+ def on_result_selected(self, event: ListView.Selected) -> None:
1412
+ item = event.item
1413
+ if not isinstance(item, ResultItem):
1414
+ return
1415
+ self._show_detail(item.result)
1416
+
1417
+ @on(ListView.Highlighted, "#results-list")
1418
+ def on_result_highlighted(self, event: ListView.Highlighted) -> None:
1419
+ item = event.item
1420
+ if not isinstance(item, ResultItem):
1421
+ return
1422
+ if item.result in self._current_results:
1423
+ self._maybe_load_more_results(self._current_results.index(item.result))
1424
+ # Only show detail if the item is in the current results list
1425
+ if item.result in self._current_results:
1426
+ self._show_detail(item.result)
1427
+
1428
+ def _get_detail_record(self, kind: str, key: str, conn: sqlite3.Connection | None = None):
1429
+ """Fetch an intrinsic/instruction record with an in-session LRU.
1430
+
1431
+ *conn* is optional so that workers running off the event loop can
1432
+ pass their own connection — sqlite3 Connection objects are not
1433
+ thread-safe and must not be shared across threads.
1434
+ """
1435
+ if conn is None:
1436
+ conn = self._conn
1437
+ assert conn is not None
1438
+ cache_key = (kind, key)
1439
+ cached = self._detail_cache.get(cache_key)
1440
+ if cached is not None:
1441
+ try:
1442
+ self._detail_cache_order.remove(cache_key)
1443
+ except ValueError:
1444
+ pass
1445
+ self._detail_cache_order.append(cache_key)
1446
+ return cached
1447
+ record = (
1448
+ load_intrinsic_from_db(conn, key)
1449
+ if kind == "intrinsic"
1450
+ else load_instruction_from_db(conn, key)
1451
+ )
1452
+ if record is not None:
1453
+ self._detail_cache[cache_key] = record
1454
+ self._detail_cache_order.append(cache_key)
1455
+ while len(self._detail_cache_order) > self._detail_cache_cap:
1456
+ evict = self._detail_cache_order.pop(0)
1457
+ self._detail_cache.pop(evict, None)
1458
+ return record
1459
+
1460
+ def _get_render_payload(
1461
+ self, kind: str, key: str, conn: sqlite3.Connection | None = None
1462
+ ) -> list[tuple] | None:
1463
+ """Pure-compute: return a list of mount-ready (type, args) sections.
1464
+
1465
+ Returned entries are one of:
1466
+ ("static", renderable)
1467
+ ("static_markup", text)
1468
+ ("collapsible", renderable, title, collapsed)
1469
+ Rich renderables (Panel/Table/Syntax) are immutable once built, so
1470
+ we can cache the list and rebuild the thin Static/Collapsible
1471
+ wrappers cheaply at mount time.
1472
+ """
1473
+ cache_key = (kind, key)
1474
+ cached = self._render_cache.get(cache_key)
1475
+ if cached is not None:
1476
+ try:
1477
+ self._render_cache_order.remove(cache_key)
1478
+ except ValueError:
1479
+ pass
1480
+ self._render_cache_order.append(cache_key)
1481
+ return cached
1482
+ record = self._get_detail_record(kind, key, conn=conn)
1483
+ if record is None:
1484
+ return None
1485
+ payload = (
1486
+ self._build_intrinsic_payload(record, conn=conn)
1487
+ if kind == "intrinsic"
1488
+ else self._build_instruction_payload(record)
1489
+ )
1490
+ self._render_cache[cache_key] = payload
1491
+ self._render_cache_order.append(cache_key)
1492
+ while len(self._render_cache_order) > self._detail_cache_cap:
1493
+ evict = self._render_cache_order.pop(0)
1494
+ self._render_cache.pop(evict, None)
1495
+ return payload
1496
+
1497
+ def _mount_payload(self, container, payload: list[tuple]) -> None:
1498
+ """Mount a precomputed payload list into *container*."""
1499
+ for section in payload:
1500
+ tag = section[0]
1501
+ if tag == "static":
1502
+ container.mount(Static(section[1]))
1503
+ elif tag == "static_markup":
1504
+ container.mount(Static(section[1], markup=True))
1505
+ elif tag == "collapsible":
1506
+ _, renderable, title, collapsed = section
1507
+ container.mount(
1508
+ Collapsible(Static(renderable), title=title, collapsed=collapsed)
1509
+ )
1510
+
1511
+ def _show_detail(self, result: SearchResult) -> None:
1512
+ """Render the detail pane for a search result.
1513
+
1514
+ Fast path: if the payload is already cached, mount synchronously
1515
+ so arrow-key navigation feels instant.
1516
+ Slow path: offload DB load + Rich renderable construction onto a
1517
+ thread worker and mount via ``call_from_thread`` when done. This
1518
+ keeps the event loop responsive during first-time renders of
1519
+ large records.
1520
+ """
1521
+ detail = self.query_one("#detail-scroll", VerticalScroll)
1522
+ detail.remove_children()
1523
+
1524
+ self._current_detail_token += 1
1525
+ token = self._current_detail_token
1526
+
1527
+ cache_key = (result.kind, result.key)
1528
+ cached_payload = self._render_cache.get(cache_key)
1529
+ cached_record = self._detail_cache.get(cache_key)
1530
+ if cached_payload is not None and cached_record is not None:
1531
+ try:
1532
+ self._render_cache_order.remove(cache_key)
1533
+ except ValueError:
1534
+ pass
1535
+ self._render_cache_order.append(cache_key)
1536
+ self._current_detail = cached_record
1537
+ self._mount_payload(detail, cached_payload)
1538
+ return
1539
+
1540
+ self._current_detail = None
1541
+ self._render_detail_worker(result, token)
1542
+
1543
+ @work(thread=True, exclusive=True, group="detail_render")
1544
+ def _render_detail_worker(self, result: SearchResult, token: int) -> None:
1545
+ """Build the detail payload off the event loop, then mount on UI.
1546
+
1547
+ Opens a dedicated sqlite connection — sqlite3 Connection objects
1548
+ are not thread-safe and sharing ``self._conn`` across threads
1549
+ raises ProgrammingError.
1550
+ """
1551
+ conn = open_db()
1552
+ try:
1553
+ payload = self._get_render_payload(result.kind, result.key, conn=conn)
1554
+ finally:
1555
+ conn.close()
1556
+ if payload is None:
1557
+ return
1558
+ record = self._detail_cache.get((result.kind, result.key))
1559
+
1560
+ def _mount() -> None:
1561
+ if token != self._current_detail_token:
1562
+ return
1563
+ detail = self.query_one("#detail-scroll", VerticalScroll)
1564
+ detail.remove_children()
1565
+ self._current_detail = record
1566
+ self._mount_payload(detail, payload)
1567
+
1568
+ self.call_from_thread(_mount)
1569
+
1570
+ @work(thread=True, exclusive=True, group="prefetch")
1571
+ def _prefetch_neighbours(self, results: list[SearchResult]) -> None:
1572
+ """Warm the detail + render caches for the top hits after a search.
1573
+
1574
+ Uses a dedicated sqlite connection (see ``_render_detail_worker``
1575
+ for the thread-safety note). Skips entries already cached.
1576
+ """
1577
+ conn = open_db()
1578
+ try:
1579
+ for result in results[:8]:
1580
+ if (result.kind, result.key) in self._render_cache:
1581
+ continue
1582
+ self._get_render_payload(result.kind, result.key, conn=conn)
1583
+ finally:
1584
+ conn.close()
1585
+
1586
+ # ------------------------------------------------------------------
1587
+ # Record lookups
1588
+ # ------------------------------------------------------------------
1589
+
1590
+ def _find_linked_instruction(
1591
+ self, intrinsic: IntrinsicRecord, conn: sqlite3.Connection | None = None
1592
+ ) -> InstructionRecord | None:
1593
+ if conn is None:
1594
+ conn = self._conn
1595
+ assert conn is not None
1596
+ linked = linked_instruction_records(None, intrinsic, conn=conn)
1597
+ # Prefer one with description data
1598
+ for item in linked:
1599
+ if item.description:
1600
+ return item
1601
+ return linked[0] if linked else None
1602
+
1603
+ # ------------------------------------------------------------------
1604
+ # Detail rendering
1605
+ # ------------------------------------------------------------------
1606
+
1607
+ # ------------------------------------------------------------------
1608
+ # Payload builders — pure CPU work, safe to run off the event loop
1609
+ # ------------------------------------------------------------------
1610
+
1611
+ def _build_intrinsic_payload(
1612
+ self, intrinsic: IntrinsicRecord, conn: sqlite3.Connection | None = None
1613
+ ) -> list[tuple]:
1614
+ payload: list[tuple] = []
1615
+ meta = Table(show_header=False, box=None)
1616
+ meta.add_row("signature", intrinsic.signature or "-")
1617
+ meta.add_row("header", intrinsic.header or "-")
1618
+ meta.add_row("isa", ", ".join(intrinsic.isa) or "-")
1619
+ category_display = intrinsic.category or "-"
1620
+ if intrinsic.subcategory:
1621
+ category_display = f"{intrinsic.subcategory} / {intrinsic.category}" if intrinsic.category else intrinsic.subcategory
1622
+ meta.add_row("category", category_display)
1623
+ if intrinsic.description:
1624
+ meta.add_row("description", intrinsic.description)
1625
+ if intrinsic.notes:
1626
+ meta.add_row("notes", "; ".join(intrinsic.notes))
1627
+ linked = self._find_linked_instruction(intrinsic, conn=conn)
1628
+ if linked:
1629
+ for key, value in instruction_metadata_rows(linked):
1630
+ if key == "summary":
1631
+ continue
1632
+ meta.add_row(key, value)
1633
+ payload.append(
1634
+ ("static", Panel(meta, title=f"intrinsic: {intrinsic.name}", border_style="cyan"))
1635
+ )
1636
+ if linked:
1637
+ self._append_operand_section(payload, linked)
1638
+ self._append_perf_sections(payload, linked)
1639
+ if intrinsic.doc_sections:
1640
+ self._append_description_sections(payload, intrinsic.doc_sections)
1641
+ if linked and linked.description:
1642
+ self._append_description_sections(payload, linked.description)
1643
+ return payload
1644
+
1645
+ def _build_instruction_payload(self, item: InstructionRecord) -> list[tuple]:
1646
+ payload: list[tuple] = []
1647
+ meta = Table(show_header=False, box=None)
1648
+ meta.add_row("mnemonic", item.mnemonic)
1649
+ meta.add_row("form", display_instruction_form(item.form))
1650
+ meta.add_row("isa", display_isa(item.isa))
1651
+ meta.add_row("summary", item.summary or "-")
1652
+ for key, value in instruction_metadata_rows(item):
1653
+ if key in {"isa"}:
1654
+ continue
1655
+ meta.add_row(key, value)
1656
+ payload.append(
1657
+ (
1658
+ "static",
1659
+ Panel(
1660
+ meta,
1661
+ title=f"instruction: {display_instruction_form(item.form) or item.mnemonic}",
1662
+ border_style="magenta",
1663
+ ),
1664
+ )
1665
+ )
1666
+ self._append_operand_section(payload, item)
1667
+ self._append_perf_sections(payload, item)
1668
+ if item.description:
1669
+ self._append_description_sections(payload, item.description)
1670
+ return payload
1671
+
1672
+ def _append_operand_section(self, payload: list[tuple], item: InstructionRecord) -> None:
1673
+ if not item.operand_details:
1674
+ return
1675
+ table = Table(header_style="bold blue")
1676
+ table.add_column("idx", width=4)
1677
+ table.add_column("rw", width=4)
1678
+ table.add_column("type", width=8)
1679
+ table.add_column("width", width=6)
1680
+ table.add_column("xtype", width=8)
1681
+ table.add_column("name", width=10)
1682
+ for operand in item.operand_details:
1683
+ rw = "".join(flag for flag in ("r", "w") if operand.get(flag) == "1")
1684
+ table.add_row(
1685
+ operand.get("idx", "-"),
1686
+ rw or "-",
1687
+ operand.get("type", "-"),
1688
+ operand.get("width", "-"),
1689
+ operand.get("xtype", "-"),
1690
+ operand.get("name", "-"),
1691
+ )
1692
+ payload.append(("static", Panel(table, title="operands", border_style="blue")))
1693
+
1694
+ def _append_perf_sections(self, payload: list[tuple], item: InstructionRecord) -> None:
1695
+ rows = measurement_rows(item)
1696
+ if not rows:
1697
+ lat, cpi = variant_perf_summary(item.arch_details)
1698
+ if lat != "-" or cpi != "-":
1699
+ payload.append(("static_markup", f" [green]latency:[/] {lat} [green]CPI:[/] {cpi}"))
1700
+ return
1701
+ label_map = {
1702
+ "uarch": "microarch",
1703
+ "latency": "LAT",
1704
+ "TP_loop": "CPI",
1705
+ "TP_unrolled": "CPI unroll",
1706
+ "TP_ports": "CPI ports",
1707
+ "TP": "CPI",
1708
+ "kind": "op",
1709
+ }
1710
+ for kind, group in split_perf_rows(rows):
1711
+ columns = [
1712
+ k for k in _MEASUREMENT_PREFERRED_ORDER
1713
+ if k not in _MEASUREMENT_EXCLUDE_KEYS
1714
+ and any(k in row for row in group)
1715
+ ]
1716
+ if not columns:
1717
+ continue
1718
+ border = perf_panel_border(kind)
1719
+ table = Table(header_style=f"bold {border}", expand=True)
1720
+ for col in columns:
1721
+ table.add_column(label_map.get(col, col), no_wrap=(col in ("uarch", "ports")))
1722
+ for row in sorted(group, key=lambda r: uarch_sort_key(r.get("uarch", ""))):
1723
+ cells = []
1724
+ for col in columns:
1725
+ val = row.get(col, "-")
1726
+ cells.append(display_uarch(str(val)) if col == "uarch" else str(val))
1727
+ table.add_row(*cells)
1728
+ panel = Panel(table, title=perf_panel_title(kind), border_style=border)
1729
+ payload.append(("collapsible", panel, perf_panel_title(kind), False))
1730
+
1731
+ def _append_description_sections(
1732
+ self, payload: list[tuple], description: dict[str, str]
1733
+ ) -> None:
1734
+ shown: set[str] = set()
1735
+ for key in _DESCRIPTION_ORDER:
1736
+ if key in description:
1737
+ self._append_one_section(payload, key, description[key])
1738
+ shown.add(key)
1739
+ for key, value in description.items():
1740
+ if key not in shown:
1741
+ self._append_one_section(payload, key, value)
1742
+
1743
+ def _append_one_section(self, payload: list[tuple], title: str, body: str) -> None:
1744
+ lang = _CODE_SECTION_LANG.get(title)
1745
+ renderable = Syntax(body, lang, theme="monokai", word_wrap=True) if lang else body
1746
+ expanded = title in _EXPANDED_SECTIONS or self._all_expanded
1747
+ payload.append(("collapsible", renderable, title, not expanded))
1748
+
1749
+ # ------------------------------------------------------------------
1750
+ # Actions
1751
+ # ------------------------------------------------------------------
1752
+
1753
+ def action_copy_detail(self) -> None:
1754
+ """Copy current detail as plain text to system clipboard."""
1755
+ record = self._current_detail
1756
+ if record is None:
1757
+ return
1758
+ lines: list[str] = []
1759
+ if isinstance(record, IntrinsicRecord):
1760
+ lines.append(record.name)
1761
+ lines.append(record.signature)
1762
+ if record.isa:
1763
+ lines.append(f"ISA: {', '.join(record.isa)}")
1764
+ if record.category:
1765
+ lines.append(f"Category: {record.category}")
1766
+ if record.description:
1767
+ lines.append("")
1768
+ lines.append(record.description)
1769
+ if record.instructions:
1770
+ lines.append("")
1771
+ lines.append(f"Instructions: {', '.join(record.instructions)}")
1772
+ elif isinstance(record, InstructionRecord):
1773
+ lines.append(record.key)
1774
+ if record.summary:
1775
+ lines.append(record.summary)
1776
+ if record.isa:
1777
+ lines.append(f"ISA: {', '.join(record.isa)}")
1778
+ if record.form:
1779
+ lines.append(f"Form: {record.form}")
1780
+ text = "\n".join(lines)
1781
+ self._copy_to_clipboard(text)
1782
+
1783
+ def _write_system_clipboard(self, text: str) -> bool:
1784
+ """Best-effort write to the OS clipboard using native tools."""
1785
+ prefer_wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
1786
+ commands: list[list[str]] = []
1787
+ if prefer_wayland:
1788
+ commands.append(["wl-copy"])
1789
+ else:
1790
+ commands.extend(
1791
+ [
1792
+ ["xclip", "-selection", "clipboard"],
1793
+ ["xsel", "--clipboard", "--input"],
1794
+ ]
1795
+ )
1796
+ commands.append(["pbcopy"])
1797
+ for cmd in commands:
1798
+ if shutil.which(cmd[0]):
1799
+ try:
1800
+ subprocess.run(cmd, input=text.encode(), check=True, timeout=5)
1801
+ return True
1802
+ except (subprocess.SubprocessError, OSError):
1803
+ continue
1804
+ if prefer_wayland and self._qt_write_system_clipboard(text):
1805
+ return True
1806
+ if not prefer_wayland:
1807
+ if self._qt_write_system_clipboard(text):
1808
+ return True
1809
+ return False
1810
+
1811
+ def copy_to_clipboard(self, text: str) -> None: # type: ignore[override]
1812
+ """Update Textual's local clipboard and mirror it to the OS clipboard."""
1813
+ super().copy_to_clipboard(text)
1814
+ self._ann_local_clipboard_valid = True
1815
+ self._write_system_clipboard(text)
1816
+
1817
+ def _copy_to_clipboard(self, text: str) -> None:
1818
+ """Copy text and show a concise status notification."""
1819
+ self.copy_to_clipboard(text)
1820
+ # OSC 52 fallback — the terminal emulator writes to the system
1821
+ # clipboard. Works in most modern terminals (gnome-terminal,
1822
+ # konsole, kitty, alacritty, iTerm2, Windows Terminal) without
1823
+ # any extra tooling.
1824
+ try:
1825
+ self.notify("Copied to clipboard", severity="information", timeout=2)
1826
+ return
1827
+ except Exception:
1828
+ pass
1829
+ self.notify(
1830
+ "Copied to local clipboard",
1831
+ severity="warning",
1832
+ timeout=3,
1833
+ )
1834
+
1835
+ def action_pick(self, index: int) -> None:
1836
+ """Select result by number and show its detail."""
1837
+ if index < 1 or index > len(self._current_results):
1838
+ return
1839
+ result = self._current_results[index - 1]
1840
+ results_list = self.query_one("#results-list", ListView)
1841
+ results_list.index = index - 1
1842
+ self._show_detail(result)
1843
+ self.query_one("#detail-scroll").focus()
1844
+
1845
+ # ── Tab switching ───────────────────────────────────────────────
1846
+ def _current_tab(self) -> str:
1847
+ try:
1848
+ switcher = self.query_one("#view-switcher", ContentSwitcher)
1849
+ current = getattr(switcher, "current", None) or ""
1850
+ except Exception:
1851
+ return self._initial_view
1852
+ return "annotate" if current.endswith("annotate") else "search"
1853
+
1854
+ def _set_tab(self, name: str) -> None:
1855
+ name = "annotate" if name == "annotate" else "search"
1856
+ try:
1857
+ switcher = self.query_one("#view-switcher", ContentSwitcher)
1858
+ switcher.current = f"view-{name}"
1859
+ except Exception:
1860
+ return
1861
+ for btn_id, active in (("tab-search", name == "search"), ("tab-annotate", name == "annotate")):
1862
+ try:
1863
+ btn = self.query_one(f"#{btn_id}", Static)
1864
+ except Exception:
1865
+ continue
1866
+ if active:
1867
+ btn.add_class("active")
1868
+ else:
1869
+ btn.remove_class("active")
1870
+ try:
1871
+ if name == "annotate":
1872
+ self.query_one("#ann-input", TextArea).focus()
1873
+ self._schedule_annotate()
1874
+ else:
1875
+ self.query_one("#search-input", Input).focus()
1876
+ except Exception:
1877
+ pass
1878
+
1879
+ def action_switch_tab(self) -> None:
1880
+ self._set_tab("search" if self._current_tab() == "annotate" else "annotate")
1881
+
1882
+ def on_click(self, event: events.Click) -> None: # pragma: no cover - UI wiring
1883
+ widget = getattr(event, "widget", None)
1884
+ tid = getattr(widget, "id", "") if widget is not None else ""
1885
+ if tid in ("tab-search", "tab-annotate"):
1886
+ self._set_tab("annotate" if tid == "tab-annotate" else "search")
1887
+ return
1888
+ if getattr(event, "button", 0) == 3 and tid in ("ann-input", "ann-output"):
1889
+ event.stop()
1890
+ self._open_ann_context_menu(tid)
1891
+
1892
+ def on_mouse_down(self, event: events.MouseDown) -> None: # pragma: no cover - UI wiring
1893
+ if getattr(event, "button", 0) != 3:
1894
+ return
1895
+ widget = getattr(event, "widget", None) or getattr(event, "control", None)
1896
+ tid = getattr(widget, "id", "") if widget is not None else ""
1897
+ if tid in ("ann-input", "ann-output"):
1898
+ event.stop()
1899
+ self._open_ann_context_menu(tid)
1900
+
1901
+ def _open_ann_context_menu(self, target_id: str) -> None:
1902
+ has_sel = False
1903
+ if target_id == "ann-input":
1904
+ try:
1905
+ ta = self.query_one("#ann-input", TextArea)
1906
+ has_sel = bool(getattr(ta, "selected_text", "") or "")
1907
+ except Exception:
1908
+ has_sel = False
1909
+ self.push_screen(AnnContextMenu(target_id=target_id, has_selection=has_sel))
1910
+
1911
+ def _ctx_action(self, bid: str, target_id: str) -> None:
1912
+ """Dispatcher for the right-click context menu."""
1913
+ try:
1914
+ ta = self.query_one("#ann-input", _AnnInput) if target_id == "ann-input" else None
1915
+ except Exception:
1916
+ ta = None
1917
+
1918
+ if bid == "ctx-paste":
1919
+ if ta is not None:
1920
+ ta.action_paste()
1921
+ ta.focus()
1922
+ return
1923
+ if bid == "ctx-clear":
1924
+ if ta is not None:
1925
+ ta.text = ""
1926
+ try:
1927
+ out = self.query_one("#ann-output", _AnnOutput)
1928
+ out.update("")
1929
+ except Exception:
1930
+ pass
1931
+ try:
1932
+ self.query_one("#ann-status", Label).update("")
1933
+ except Exception:
1934
+ pass
1935
+ return
1936
+ if bid == "ctx-select-all":
1937
+ if ta is not None:
1938
+ try:
1939
+ ta.action_select_all()
1940
+ except Exception:
1941
+ text = getattr(ta, "text", "") or ""
1942
+ if hasattr(ta, "selection"):
1943
+ try:
1944
+ ta.selection = ((0, 0), self._text_end(text))
1945
+ except Exception:
1946
+ pass
1947
+ else:
1948
+ # Output pane — "select all" means "copy all" in a TUI
1949
+ # (terminal mouse selection is limited to visible text).
1950
+ self._ann_copy_output()
1951
+ return
1952
+ if bid == "ctx-copy":
1953
+ if ta is not None:
1954
+ self._ann_copy_input(ta)
1955
+ else:
1956
+ self._ann_copy_output()
1957
+ return
1958
+ if bid == "ctx-cut":
1959
+ if ta is not None:
1960
+ self._ann_cut_input(ta)
1961
+ return
1962
+
1963
+ # ── Clipboard helpers for the annotate panes ────────────────────
1964
+ def _ann_copy_input(self, ta: "_AnnInput") -> None:
1965
+ text = getattr(ta, "selected_text", "") or ""
1966
+ if not text:
1967
+ self.notify("Nothing to copy", severity="information", timeout=2)
1968
+ return
1969
+ ta.action_copy()
1970
+ try:
1971
+ self.query_one("#ann-status", Label).update(f"Copied {len(text)} chars")
1972
+ except Exception:
1973
+ pass
1974
+
1975
+ def _ann_cut_input(self, ta: "_AnnInput") -> None:
1976
+ text = getattr(ta, "selected_text", "") or ""
1977
+ if not text:
1978
+ self.notify("Nothing to cut (no selection)", severity="information", timeout=2)
1979
+ return
1980
+ ta.action_cut()
1981
+ try:
1982
+ self.query_one("#ann-status", Label).update(f"Cut {len(text)} chars")
1983
+ except Exception:
1984
+ pass
1985
+
1986
+ def _prepare_annotate_clipboard_for_paste(self) -> str | None:
1987
+ text = self._read_system_clipboard()
1988
+ if text is not None:
1989
+ if not text:
1990
+ self.notify("Clipboard is empty", severity="information", timeout=2)
1991
+ return None
1992
+ self._clipboard = text
1993
+ self._ann_local_clipboard_valid = False
1994
+ return text
1995
+ local = self.clipboard
1996
+ if self._ann_local_clipboard_valid and local:
1997
+ return local
1998
+ self.notify("Paste failed: clipboard is unavailable", severity="warning", timeout=4)
1999
+ return None
2000
+
2001
+ def _ann_copy_output(self) -> None:
2002
+ try:
2003
+ out = self.query_one("#ann-output", _AnnOutput)
2004
+ except Exception:
2005
+ return
2006
+ text = str(getattr(out, "renderable", "") or "")
2007
+ if not text:
2008
+ self.notify("Nothing to copy", severity="information", timeout=2)
2009
+ return
2010
+ self._copy_to_clipboard(text)
2011
+ try:
2012
+ self.query_one("#ann-status", Label).update(f"Copied {len(text)} chars")
2013
+ except Exception:
2014
+ pass
2015
+
2016
+ @staticmethod
2017
+ def _text_end(text: str) -> tuple[int, int]:
2018
+ if not text:
2019
+ return (0, 0)
2020
+ lines = text.split("\n")
2021
+ return (len(lines) - 1, len(lines[-1]))
2022
+
2023
+ # ── Annotate pane ───────────────────────────────────────────────
2024
+ def _annotate_options(self):
2025
+ from simdref.annotate import AnnotateOptions
2026
+
2027
+ def _sel(widget_id: str, fallback: str) -> str:
2028
+ try:
2029
+ v = self.query_one(f"#{widget_id}", Select).value
2030
+ except Exception:
2031
+ return fallback
2032
+ return "" if v is None else str(v)
2033
+
2034
+ def _chk(widget_id: str, fallback: bool) -> bool:
2035
+ try:
2036
+ return bool(self.query_one(f"#{widget_id}", Checkbox).value)
2037
+ except Exception:
2038
+ return fallback
2039
+
2040
+ isa = _sel("ann-isa", "x86")
2041
+ return AnnotateOptions(
2042
+ performance=_chk("ann-perf", True),
2043
+ docs=_chk("ann-docs", True),
2044
+ arch=None,
2045
+ agg=_sel("ann-agg", "avg") or "avg",
2046
+ include_modeled=_chk("ann-modeled", True),
2047
+ block=False,
2048
+ unknown="mark",
2049
+ fmt="sa",
2050
+ ), isa
2051
+
2052
+ def _schedule_annotate(self, delay: float = 0.35) -> None:
2053
+ if self._annotate_debounce_timer is not None:
2054
+ try:
2055
+ self._annotate_debounce_timer.stop()
2056
+ except Exception:
2057
+ pass
2058
+ self._annotate_debounce_timer = None
2059
+ try:
2060
+ self._annotate_debounce_timer = self.set_timer(delay, self._run_annotate)
2061
+ except Exception:
2062
+ self._run_annotate()
2063
+
2064
+ def _run_annotate(self) -> None:
2065
+ from simdref.annotate import annotate_stream
2066
+
2067
+ try:
2068
+ ta = self.query_one("#ann-input", TextArea)
2069
+ out = self.query_one("#ann-output", Static)
2070
+ status = self.query_one("#ann-status", Label)
2071
+ except Exception:
2072
+ return
2073
+ text = getattr(ta, "text", "") or ""
2074
+ self._annotate_last_text = text
2075
+ if not text.strip():
2076
+ out.update("")
2077
+ status.update("")
2078
+ return
2079
+ opts, _isa = self._annotate_options()
2080
+ if self._conn is None:
2081
+ self._conn = open_db()
2082
+ try:
2083
+ lines = text.splitlines(keepends=True)
2084
+ rendered = "".join(annotate_stream(lines, opts=opts, conn=self._conn))
2085
+ except Exception as exc: # pragma: no cover - defensive
2086
+ status.update(f"error: {exc}")
2087
+ return
2088
+ out.update(rendered)
2089
+ # Count known/unknown from rendered output for a quick status line.
2090
+ known = sum(1 for ln in rendered.splitlines() if " # " in ln and "# ??" not in ln)
2091
+ unknown = rendered.count("# ??")
2092
+ status.update(f"annotated {known} / {known + unknown} ({unknown} unknown)")
2093
+
2094
+ @on(Button.Pressed, "#ann-run")
2095
+ def _on_ann_run(self, event) -> None:
2096
+ self._run_annotate()
2097
+
2098
+ @on(Button.Pressed, "#ann-clear")
2099
+ def _on_ann_clear(self, event) -> None:
2100
+ try:
2101
+ self.query_one("#ann-input", TextArea).text = ""
2102
+ self.query_one("#ann-output", Static).update("")
2103
+ self.query_one("#ann-status", Label).update("")
2104
+ except Exception:
2105
+ pass
2106
+
2107
+ @on(TextArea.Changed, "#ann-input")
2108
+ def _on_ann_input_changed(self, event) -> None:
2109
+ self._schedule_annotate()
2110
+
2111
+ def on_paste(self, event: events.Paste) -> None:
2112
+ focused = self.focused
2113
+ if getattr(focused, "id", None) != "ann-input":
2114
+ return
2115
+ try:
2116
+ self.query_one("#ann-status", Label).update(f"Pasted {len(event.text)} chars")
2117
+ except Exception:
2118
+ pass
2119
+ # Textual enables bracketed paste for supported terminals, so native
2120
+ # terminal paste arrives here as one logical payload. Back off the
2121
+ # debounce slightly for larger payloads to avoid re-render churn.
2122
+ self._schedule_annotate(delay=0.75 if len(event.text) > 5000 else 0.35)
2123
+
2124
+ @on(Checkbox.Changed, "#ann-perf, #ann-docs, #ann-modeled")
2125
+ def _on_ann_checkbox_changed(self, event) -> None:
2126
+ self._schedule_annotate(delay=0.05)
2127
+
2128
+ @on(Select.Changed, "#ann-isa, #ann-agg")
2129
+ def _on_ann_select_changed(self, event) -> None:
2130
+ self._schedule_annotate(delay=0.05)
2131
+
2132
+ # ── Annotate split-pane resize ──────────────────────────────────
2133
+ def _apply_ann_split(self, ratio: float) -> None:
2134
+ ratio = max(0.1, min(0.9, ratio))
2135
+ self._ann_split_ratio = ratio
2136
+ left = max(1, int(round(ratio * 100)))
2137
+ right = max(1, 100 - left)
2138
+ try:
2139
+ self.query_one("#ann-input").styles.width = f"{left}fr"
2140
+ self.query_one("#ann-output-wrap").styles.width = f"{right}fr"
2141
+ except Exception:
2142
+ pass
2143
+
2144
+ @on(_AnnSplitter.Moved)
2145
+ def _on_ann_split_moved(self, event: "_AnnSplitter.Moved") -> None:
2146
+ self._apply_ann_split(event.ratio)
2147
+
2148
+ def action_ann_split(self, delta: float) -> None:
2149
+ if self._current_tab() != "annotate":
2150
+ return
2151
+ self._apply_ann_split(self._ann_split_ratio + float(delta))
2152
+
2153
+ def action_ann_paste_clipboard(self) -> None:
2154
+ """Explicit paste action for callers that want to trigger paste."""
2155
+ if self._current_tab() != "annotate":
2156
+ self._set_tab("annotate")
2157
+ try:
2158
+ ta = self.query_one("#ann-input", _AnnInput)
2159
+ except Exception:
2160
+ return
2161
+ ta.action_paste()
2162
+
2163
+ def action_focus_search(self) -> None:
2164
+ if self._current_tab() != "search":
2165
+ self._set_tab("search")
2166
+ return
2167
+ self.query_one("#search-input", Input).focus()
2168
+
2169
+ def action_back(self) -> None:
2170
+ detail = self.query_one("#detail-scroll", VerticalScroll)
2171
+ if detail.children:
2172
+ detail.remove_children()
2173
+ self.query_one("#results-list", ListView).focus()
2174
+ else:
2175
+ self.query_one("#search-input", Input).focus()
2176
+
2177
+ def action_toggle_all(self) -> None:
2178
+ self._all_expanded = not self._all_expanded
2179
+ for section in self.query(Collapsible):
2180
+ section.collapsed = not self._all_expanded
2181
+
2182
+ def action_show_help(self) -> None:
2183
+ """Open a modal screen listing all active keybindings."""
2184
+ self.push_screen(HelpScreen(self.BINDINGS))
2185
+
2186
+ def action_list_cursor_down(self) -> None:
2187
+ """vim-style j: advance the results list when it (or the detail pane) has focus."""
2188
+ try:
2189
+ results = self.query_one("#results-list", ListView)
2190
+ except Exception:
2191
+ return
2192
+ focused = self.focused
2193
+ if not isinstance(focused, (ListView, VerticalScroll)):
2194
+ return
2195
+ results.action_cursor_down()
2196
+
2197
+ def action_list_cursor_up(self) -> None:
2198
+ """vim-style k: step the results list back."""
2199
+ try:
2200
+ results = self.query_one("#results-list", ListView)
2201
+ except Exception:
2202
+ return
2203
+ focused = self.focused
2204
+ if not isinstance(focused, (ListView, VerticalScroll)):
2205
+ return
2206
+ results.action_cursor_up()
2207
+
2208
+ def on_key(self, event: events.Key) -> None:
2209
+ if getattr(self, "_needs_update", False):
2210
+ if event.key == "y":
2211
+ event.prevent_default()
2212
+ self._run_update()
2213
+ elif event.key in ("n", "q"):
2214
+ event.prevent_default()
2215
+ self.exit(1)
2216
+ return
2217
+
2218
+ # '?' always opens the help modal, even when the search Input has
2219
+ # focus (Input's character-capture runs before App-level priority
2220
+ # bindings). BUT skip this when the annotate TextArea is focused —
2221
+ # pasted asm/text legitimately contains '?' and swallowing it would
2222
+ # break paste.
2223
+ if (
2224
+ event.character == "?"
2225
+ and not isinstance(self.screen, HelpScreen)
2226
+ and not isinstance(self.focused, TextArea)
2227
+ ):
2228
+ event.prevent_default()
2229
+ event.stop()
2230
+ self.action_show_help()
2231
+ return
2232
+
2233
+ focused = self.focused
2234
+ if isinstance(focused, (ListView, VerticalScroll)):
2235
+ if self._refine_search_from_key(event):
2236
+ return
2237
+
2238
+ def _qt_read_system_clipboard(self) -> str | None:
2239
+ script = (
2240
+ "import sys\n"
2241
+ "from PyQt6.QtGui import QGuiApplication\n"
2242
+ "app = QGuiApplication([])\n"
2243
+ "sys.stdout.write(app.clipboard().text())\n"
2244
+ )
2245
+ try:
2246
+ proc = subprocess.run(
2247
+ [sys.executable, "-c", script],
2248
+ capture_output=True,
2249
+ timeout=5,
2250
+ )
2251
+ except (subprocess.SubprocessError, OSError):
2252
+ return None
2253
+ if proc.returncode != 0:
2254
+ return None
2255
+ return proc.stdout.decode(errors="replace")
2256
+
2257
+ def _qt_write_system_clipboard(self, text: str) -> bool:
2258
+ script = (
2259
+ "import sys\n"
2260
+ "from PyQt6.QtCore import QTimer\n"
2261
+ "from PyQt6.QtGui import QGuiApplication\n"
2262
+ "app = QGuiApplication([])\n"
2263
+ "app.clipboard().setText(sys.stdin.read())\n"
2264
+ "QTimer.singleShot(50, app.quit)\n"
2265
+ "app.exec()\n"
2266
+ )
2267
+ try:
2268
+ proc = subprocess.run(
2269
+ [sys.executable, "-c", script],
2270
+ input=text.encode(),
2271
+ timeout=5,
2272
+ )
2273
+ except (subprocess.SubprocessError, OSError):
2274
+ return False
2275
+ return proc.returncode == 0
2276
+
2277
+ def _read_system_clipboard(self) -> str | None:
2278
+ """Read system clipboard via the first available paste tool.
2279
+
2280
+ On Wayland, avoid xclip/xsel as the primary source because they often
2281
+ see only the X11 clipboard. Prefer wl-clipboard or a Qt backend there.
2282
+ """
2283
+ prefer_wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
2284
+ commands: list[list[str]] = []
2285
+ if prefer_wayland:
2286
+ commands.append(["wl-paste"])
2287
+ else:
2288
+ commands.extend(
2289
+ [
2290
+ ["xclip", "-selection", "clipboard", "-o"],
2291
+ ["xsel", "--clipboard", "--output"],
2292
+ ]
2293
+ )
2294
+ commands.append(["pbpaste"])
2295
+ for cmd in commands:
2296
+ if not shutil.which(cmd[0]):
2297
+ continue
2298
+ try:
2299
+ proc = subprocess.run(
2300
+ cmd, capture_output=True, timeout=15
2301
+ )
2302
+ except (subprocess.SubprocessError, OSError):
2303
+ continue
2304
+ return proc.stdout.decode(errors="replace")
2305
+ if prefer_wayland:
2306
+ text = self._qt_read_system_clipboard()
2307
+ if text is not None:
2308
+ return text
2309
+ else:
2310
+ text = self._qt_read_system_clipboard()
2311
+ if text is not None:
2312
+ return text
2313
+ return None
2314
+
2315
+ @work(thread=True)
2316
+ def _run_update(self) -> None:
2317
+ from simdref.ingest import build_catalog
2318
+ from simdref.storage import build_sqlite, save_catalog
2319
+
2320
+ def _status(msg: str) -> None:
2321
+ self.call_from_thread(
2322
+ self.query_one("#status-label", Label).update,
2323
+ f" {msg}",
2324
+ )
2325
+
2326
+ _status("Fetching data and parsing...")
2327
+ catalog = build_catalog(include_sdm=False, status=_status)
2328
+ _status(f"Saving catalog ({len(catalog.intrinsics)} intrinsics)...")
2329
+ save_catalog(catalog)
2330
+ _status("Building search database...")
2331
+ build_sqlite(catalog)
2332
+ self._needs_update = False
2333
+ self._conn = open_db()
2334
+ # Purge stale caches — row contents may have shifted.
2335
+ self._detail_cache.clear()
2336
+ self._detail_cache_order.clear()
2337
+ self._render_cache.clear()
2338
+ self._render_cache_order.clear()
2339
+
2340
+ def _finish() -> None:
2341
+ self._build_family_subs()
2342
+ self.call_after_refresh(self._refresh_sub_isa_bar)
2343
+ self.query_one("#status-label", Label).update(
2344
+ f" Updated: {len(catalog.intrinsics)} intrinsics, {len(catalog.instructions)} instructions"
2345
+ )
2346
+ self.query_one("#search-input", Input).focus()
2347
+
2348
+ self.call_from_thread(_finish)
2349
+
2350
+
2351
+ def run_tui(
2352
+ initial_query: str = "",
2353
+ initial_preset: str | None = None,
2354
+ *,
2355
+ initial_view: str = "search",
2356
+ initial_asm: str = "",
2357
+ ) -> int:
2358
+ """Launch the interactive Textual TUI."""
2359
+ app = SimdrefApp(
2360
+ initial_query=initial_query,
2361
+ initial_preset=initial_preset,
2362
+ initial_view=initial_view,
2363
+ initial_asm=initial_asm,
2364
+ )
2365
+ app.run()
2366
+ return 0