aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/highlight.py
diff options
context:
space:
mode:
Diffstat (limited to 'idatui/highlight.py')
-rw-r--r--idatui/highlight.py132
1 files changed, 120 insertions, 12 deletions
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)