From 8012e9a8bc2426f0bee99e703364d56707ded8bc Mon Sep 17 00:00:00 2001 From: blasty Date: Fri, 7 Aug 2026 20:04:12 +0200 Subject: Syntax-highlight the struct editor's C definitions --- docs/TEXTUAL_NOTES.md | 8 +++ idatui/app.py | 4 +- idatui/highlight.py | 132 +++++++++++++++++++++++++++++++++++++++++++----- tests/test_scenarios.py | 13 +++++ 4 files changed, 143 insertions(+), 14 deletions(-) diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md index c8b265c..9970416 100644 --- a/docs/TEXTUAL_NOTES.md +++ b/docs/TEXTUAL_NOTES.md @@ -45,6 +45,14 @@ Hard-won Textual behaviour and the patterns this app relies on. Pairs with the Footer (see `#search`/`#rename`/`#status`). - Textual ships **no C/C++ tree-sitter grammar** — `TextArea(language="cpp")` is a silent no-op. We highlight pseudocode with Pygments (`idatui/highlight.py`). + For the **editable** C in the struct editor, `highlight.CTextArea` overrides + `TextArea._build_highlight_map()` and fills the widget's own `_highlights` + map from the same lexer — the hook the tree-sitter path would fill, so the + line cache, selection and cursor stay stock. Two traps: those spans are + **byte** offsets into the line (the renderer decodes them with + `build_byte_to_codepoint_dict`, so character offsets smear on non-ASCII), and + a `TextAreaTheme` that sets `base_style` overrides the widget's CSS colours — + ours sets only `syntax_styles` so the editor keeps the app's background. - **A modal's `Input` messages bubble to the App.** `SymbolPalette` (the Ctrl+N fuzzy finder) has its own `Input`; its `Input.Changed`/`Input.Submitted` bubble up to the app's handlers (which would run the `#search`/`#func-filter` logic and diff --git a/idatui/app.py b/idatui/app.py index a4b03a1..c331904 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -50,7 +50,7 @@ from . import kittygfx from .edit_ctl import EditController from .prompt import PromptBar from .trace_ctl import TraceController -from .highlight import highlight_c +from .highlight import CTextArea, highlight_c from .errors import IDAToolError, IDAConnectionError from .codemode_client import CodeModeClient, registered_database @@ -4314,7 +4314,7 @@ class StructEditor(ModalScreen): yield OptionList(id="se-list") with Vertical(id="se-right"): yield Static(" C definition", id="se-hint") - yield TextArea("", id="se-edit") + yield CTextArea("", id="se-edit") yield Static( "Enter edit · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · d/Del delete · Esc close", id="se-status") diff --git a/idatui/highlight.py b/idatui/highlight.py index a19f86a..0bd76af 100644 --- a/idatui/highlight.py +++ b/idatui/highlight.py @@ -1,10 +1,16 @@ -"""Pygments-based C highlighting for Hex-Rays pseudocode. +"""Pygments-based C highlighting for Hex-Rays pseudocode and the struct editor. Textual's TextArea has no C/C++ tree-sitter grammar (its bundled languages are python/rust/go/... only), so ``language="cpp"`` silently does nothing. Pygments (already a Rich/Textual dependency) has a solid C lexer, so we tokenize once and map tokens to Rich styles, producing per-line Segment lists the virtualized view can cache and paint instantly. + +The same tokenizer feeds two consumers, so one palette covers both: + +* ``highlight_c`` -> Segment lists for the read-only pseudocode view. +* ``CTextArea`` -> an *editable* TextArea (the struct editor) highlighted by + filling TextArea's own ``_highlights`` map, the hook tree-sitter would use. """ from __future__ import annotations @@ -13,8 +19,12 @@ from rich.segment import Segment from rich.style import Style from pygments.lexers import CLexer from pygments.token import Token +from textual.widgets import TextArea +from textual.widgets.text_area import TextAreaTheme -# Token -> style, checked in priority order (first hierarchical match wins). +# Token -> (highlight name, style), checked in priority order (first +# hierarchical match wins). The names are what TextArea's theme maps to styles; +# the styles are what the pseudocode view paints directly. # # Same measured palette as the listing (see the theme notes): one hue = one # meaning across BOTH panes, so a string is the same green and a symbol the same @@ -25,18 +35,24 @@ from pygments.token import Token # Control keywords take the brightest NEUTRAL rather than a hue, mirroring the # mnemonic column: they're the skeleton you scan for, and a hue there would # claim a meaning the rest of the palette already assigns. -_STYLES: list[tuple[object, Style]] = [ - (Token.Comment, Style(color="#7c8b9e", italic=True)), # 5.2:1 commentary - (Token.Keyword.Type, Style(color="#93aee0")), # 8.1:1 type info - (Token.Keyword, Style(color="#e8ecf2", bold=True)), # 15.3:1 control flow - (Token.Name.Builtin, Style(color="#93aee0")), # 8.1:1 type info - (Token.Literal.String, Style(color="#9ece6a")), # 9.9:1 strings - (Token.Literal.Number, Style(color="#d8a657")), # 8.2:1 data/number - (Token.Operator, Style(color="#c3cad3")), # 11.0:1 body - (Token.Punctuation, Style(color="#626c7a")), # 3.4:1 structure - (Token.Name, Style(color="#7aa2f7")), # 7.2:1 symbol names +_PALETTE: list[tuple[str, object, Style]] = [ + ("comment", Token.Comment, Style(color="#7c8b9e", italic=True)), # 5.2:1 commentary + ("type", Token.Keyword.Type, Style(color="#93aee0")), # 8.1:1 type info + ("keyword", Token.Keyword, Style(color="#e8ecf2", bold=True)), # 15.3:1 control flow + ("builtin", Token.Name.Builtin, Style(color="#93aee0")), # 8.1:1 type info + ("string", Token.Literal.String, Style(color="#9ece6a")), # 9.9:1 strings + ("number", Token.Literal.Number, Style(color="#d8a657")), # 8.2:1 data/number + ("operator", Token.Operator, Style(color="#c3cad3")), # 11.0:1 body + ("punctuation", Token.Punctuation, Style(color="#626c7a")), # 3.4:1 structure + ("name", Token.Name, Style(color="#7aa2f7")), # 7.2:1 symbol names ] +_STYLES: list[tuple[object, Style]] = [(t, s) for _, t, s in _PALETTE] _DEFAULT = Style(color="#c3cad3") # 11.0:1 body +_DEFAULT_NAME = "text" + +#: highlight name -> style, for TextArea themes (see ``CTextArea``). +SYNTAX_STYLES: dict[str, Style] = {name: style for name, _, style in _PALETTE} +SYNTAX_STYLES[_DEFAULT_NAME] = _DEFAULT _lexer = CLexer(stripnl=False, ensurenl=False) @@ -47,6 +63,7 @@ _lexer = CLexer(stripnl=False, ensurenl=False) #: without this every token in the body pays up to nine of those walks. It was a #: quarter of the time spent highlighting a function. _STYLE_CACHE: dict[object, Style] = {} +_NAME_CACHE: dict[object, str] = {} def _style_for(token) -> Style: @@ -61,6 +78,18 @@ def _style_for(token) -> Style: return style +def _name_for(token) -> str: + name = _NAME_CACHE.get(token) + if name is None: + name = _DEFAULT_NAME + for candidate, ttype, _ in _PALETTE: + if token in ttype: + name = candidate + break + _NAME_CACHE[token] = name + return name + + def highlight_c(code: str) -> list[list[Segment]]: """Return one list of Segments per source line (no trailing newline segs).""" lines: list[list[Segment]] = [[]] @@ -81,3 +110,82 @@ def highlight_c(code: str) -> list[list[Segment]]: if len(lines) > 1 and not lines[-1]: lines.pop() return lines + + +def highlight_c_spans(code: str) -> dict[int, list[tuple[int, int, str]]]: + """Return ``{row: [(start_byte, end_byte, highlight_name), ...]}`` for ``code``. + + The shape TextArea's ``_highlights`` map wants. Columns are **byte** offsets + into the line, not character offsets -- that's the tree-sitter convention + TextArea's renderer decodes with ``build_byte_to_codepoint_dict``, so a + non-ASCII identifier or string would smear its styling one cell per extra + byte if we handed it character offsets. + """ + spans: dict[int, list[tuple[int, int, str]]] = {} + row = 0 + col = 0 + for token, value in _lexer.get_tokens(code): + if not value: + continue + name = _name_for(token) + parts = value.split("\n") + for i, part in enumerate(parts): + if i: + row += 1 + col = 0 + if not part: + continue + width = len(part) if part.isascii() else len(part.encode("utf-8")) + if part.strip(): # whitespace carries no visible style + spans.setdefault(row, []).append((col, col + width, name)) + col += width + return spans + + +#: TextArea theme carrying our palette. Everything else (background, cursor, +#: selection) is deliberately left unset so it keeps falling back to the app's +#: CSS -- this theme only says how C tokens are coloured. +C_TEXTAREA_THEME = TextAreaTheme(name="idatui-c", syntax_styles=SYNTAX_STYLES) + + +class CTextArea(TextArea): + """An editable TextArea that syntax-highlights C. + + ``language="cpp"`` is not available (no bundled grammar), so instead of a + tree-sitter query we fill the very same ``_highlights`` map the tree-sitter + path fills, from the Pygments lexer above. Everything downstream -- + per-line style application, the line cache, selection, the cursor -- is + stock TextArea, and the colours match the pseudocode pane token for token. + """ + + #: Above this, re-lexing on every keystroke would cost more than the colour + #: is worth. Struct definitions are a few hundred bytes; this is a guard, + #: not a limit anyone should hit. + MAX_HIGHLIGHT_CHARS = 200_000 + + def __init__(self, text: str = "", **kwargs) -> None: + super().__init__(text, **kwargs) + self.register_theme(C_TEXTAREA_THEME) + self.theme = C_TEXTAREA_THEME.name + # __init__ built the document (and so the highlight map) before the + # theme existed; redo it now that tokens can resolve to styles. + self._build_highlight_map() + + def _build_highlight_map(self) -> None: + """Lex the buffer and publish per-line highlight spans. + + Called by TextArea on every document change, so it must be cheap and it + must never raise: a lexer hiccup should cost colour, not the editor. + """ + self._line_cache.clear() + highlights = self._highlights + highlights.clear() + text = self.document.text + if not text or len(text) > self.MAX_HIGHLIGHT_CHARS: + return + try: + spans = highlight_c_spans(text) + except Exception: # noqa: BLE001 - highlighting is never load-bearing + return + for row, row_spans in spans.items(): + highlights[row].extend(row_spans) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index e19ea11..ff45281 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -920,6 +920,15 @@ async def s_structs(c: Ctx): await c.wait(lambda: tname in ta.text and "{" in ta.text, 15) c.check("selecting a struct shows its C definition", tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}") + # The definition is C, so it must be coloured as C (no tree-sitter grammar + # for it: idatui.highlight fills TextArea's highlight map from Pygments). + names = {n for spans in ta._highlights.values() for _, _, n in spans} + c.check("the C definition is syntax-highlighted", + {"keyword", "name"} <= names, f"names={sorted(names)}") + styled = {s.style.color.name for s in ta.render_line(0) + if s.style and s.style.color} + c.check("highlight styles reach the rendered line", len(styled) > 1, + f"colors={sorted(styled)}") app._clipboard = "" se.query_one(TextArea).focus() await c.press("ctrl+y") @@ -936,6 +945,10 @@ async def s_structs(c: Ctx): c.check("Ctrl+S declares a new struct", any(s.name == sname for s in se._structs), "not created") await c.wait(lambda: "\n" in ta.text, 10) + c.check("editing re-highlights the definition", + any(n == "keyword" for spans in ta._highlights.values() + for _, _, n in spans), + f"rows={len(ta._highlights)}") c.check("save auto-formats the definition in the editor", ta.text.count("\n") >= 3 and f"struct {sname}" in ta.text and not se._is_dirty(), f"text={ta.text[:50]!r}") -- cgit v1.3.1-sl0p