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/__init__.py +6 -0
- simdref/__main__.py +6 -0
- simdref/annotate.py +448 -0
- simdref/arm_instructions.py +417 -0
- simdref/cli.py +1598 -0
- simdref/display.py +963 -0
- simdref/filters.py +318 -0
- simdref/ingest.py +113 -0
- simdref/ingest_catalog.py +1172 -0
- simdref/ingest_pdf.py +188 -0
- simdref/ingest_sources.py +580 -0
- simdref/lsp.py +208 -0
- simdref/manpages.py +139 -0
- simdref/models.py +225 -0
- simdref/pdfparse/__init__.py +13 -0
- simdref/pdfparse/base.py +116 -0
- simdref/pdfparse/intel.py +614 -0
- simdref/pdfparse/registry.py +19 -0
- simdref/pdfparse/types.py +77 -0
- simdref/pdfrefs.py +95 -0
- simdref/perf.py +220 -0
- simdref/perf_sources/__init__.py +51 -0
- simdref/perf_sources/cores.py +101 -0
- simdref/perf_sources/llvm_mca.py +176 -0
- simdref/perf_sources/llvm_scheduling.py +625 -0
- simdref/perf_sources/merge.py +121 -0
- simdref/queries.py +207 -0
- simdref/riscv.py +446 -0
- simdref/search.py +288 -0
- simdref/storage.py +504 -0
- simdref/templates/__init__.py +0 -0
- simdref/templates/app.js +1590 -0
- simdref/templates/favicon.svg +5 -0
- simdref/templates/index.html +112 -0
- simdref/templates/logo.svg +12 -0
- simdref/templates/style.css +680 -0
- simdref/tui.py +2366 -0
- simdref/web.py +403 -0
- simdref-0.0.0.dist-info/METADATA +240 -0
- simdref-0.0.0.dist-info/RECORD +44 -0
- simdref-0.0.0.dist-info/WHEEL +5 -0
- simdref-0.0.0.dist-info/entry_points.txt +4 -0
- simdref-0.0.0.dist-info/licenses/LICENSE +674 -0
- simdref-0.0.0.dist-info/top_level.txt +1 -0
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
|