diff options
| author | blasty <blasty@local> | 2026-07-09 12:34:32 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 12:34:32 +0200 |
| commit | a6a1aae9cbd92be269840da9ca62b71d6af827b0 (patch) | |
| tree | c2b36a2d16623aeab23be52bbbdf25110b53ecbe | |
| parent | decompiler view: Tab/Shift+Tab toggle disasm<->pseudocode, full bodies (diff) | |
| download | ida-tui-a6a1aae9cbd92be269840da9ca62b71d6af827b0.tar.gz ida-tui-a6a1aae9cbd92be269840da9ca62b71d6af827b0.tar.xz ida-tui-a6a1aae9cbd92be269840da9ca62b71d6af827b0.zip | |
fix decompiler highlighting + cursor jank
Highlighting: Textual has NO C/C++ tree-sitter grammar (and the syntax extra
wasn't installed), so language='cpp' was a silent no-op. Replace TextArea with a
virtualized, Pygments-highlighted ScrollView:
- idatui/highlight.py: CLexer -> Rich styles, per-line Segment lists
- DecompView: lines highlighted ONCE at load, cached as Strips; O(1) scroll
Jank: profiling showed our code is ~3.6ms/move (render_line 0.018ms) -- the cost
was terminal repaint volume, since every cursor move refreshed the whole ~43-line
viewport. Now:
- cursor reactive repaint=False (no implicit full refresh)
- region-limited refresh: in-place moves repaint only the 2 changed rows
(measured 43 -> 2 render_line calls/move), full refresh only on scroll
Applied to both DisasmView and DecompView.
pilot suite 15/15 (adds highlighting assertion).
| -rw-r--r-- | idatui/app.py | 126 | ||||
| -rw-r--r-- | idatui/highlight.py | 58 | ||||
| -rw-r--r-- | pyproject.toml | 2 | ||||
| -rw-r--r-- | tests/test_tui.py | 10 |
4 files changed, 175 insertions, 21 deletions
diff --git a/idatui/app.py b/idatui/app.py index 22b5879..8d06891 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -24,12 +24,14 @@ from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical -from textual.geometry import Size +from textual.geometry import Region, Size from textual.message import Message from textual.reactive import reactive from textual.scroll_view import ScrollView from textual.strip import Strip -from textual.widgets import DataTable, Footer, Header, Input, Static, TextArea +from textual.widgets import DataTable, Footer, Header, Input, Static + +from .highlight import highlight_c from .client import IDAClient from .domain import DisasmModel, Func, Program @@ -68,7 +70,7 @@ class DisasmView(ScrollView, can_focus=True): Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True), ] - cursor = reactive(0) + cursor = reactive(0, repaint=False) class CursorMoved(Message): """Posted when the disasm cursor moves; carries the instruction ea.""" @@ -176,9 +178,14 @@ class DisasmView(ScrollView, can_focus=True): def _move(self, delta: int) -> None: if self.total == 0: return + old = self.cursor + before = round(self.scroll_offset.y) self.cursor = max(0, min(self.total - 1, self.cursor + delta)) self._scroll_cursor_into_view() - self.refresh() + if round(self.scroll_offset.y) != before: + self.refresh() # scrolled: the whole viewport shifted + else: + _refresh_lines(self, old, self.cursor) # only the two changed rows self.post_message(DisasmView.CursorMoved(self.cursor, self._cursor_ea())) def _cursor_ea(self) -> int | None: @@ -210,30 +217,113 @@ def _join(base: Style | None, style: Style) -> Style: return (base + style) if base is not None else style +def _refresh_lines(view, *indices: int) -> None: + """Repaint only the given virtual line indices (cheap in-place cursor move).""" + top = round(view.scroll_offset.y) + height = view.size.height + width = view.size.width + for idx in indices: + row = idx - top + if 0 <= row < height: + view.refresh(Region(0, row, width, 1)) + + # --------------------------------------------------------------------------- # # Decompiler (pseudocode) view # --------------------------------------------------------------------------- # -class DecompView(TextArea): - """Read-only Hex-Rays pseudocode for one function. Bounded text (the domain - fetches the full body even when the server truncates), so a plain scrollable - TextArea is the right tool.""" +class DecompView(ScrollView, can_focus=True): + """Read-only, line-virtualized Hex-Rays pseudocode with Pygments C + highlighting. Lines are highlighted once at load and cached as Strips, so + cursor movement and scrolling are O(1) (no TextArea/tree-sitter overhead). + """ + + BINDINGS = [ + Binding("tab,shift+tab", "app.toggle_view", "Disasm", priority=True), + Binding("j,down", "cursor_down", "Down", show=False), + Binding("k,up", "cursor_up", "Up", show=False), + Binding("ctrl+d", "half_page(1)", "½↓", show=False), + Binding("ctrl+u", "half_page(-1)", "½↑", show=False), + Binding("pagedown,f", "page(1)", "PgDn", show=False), + Binding("pageup,b", "page(-1)", "PgUp", show=False), + Binding("home", "goto_top", "Top", show=False), + Binding("G,end", "goto_bottom", "Bottom", show=False), + ] - BINDINGS = [Binding("tab,shift+tab", "app.toggle_view", "Disasm", priority=True)] + cursor = reactive(0, repaint=False) def __init__(self) -> None: - super().__init__("", read_only=True, show_line_numbers=True, id="decomp") + super().__init__(id="decomp") self.loaded_ea: int | None = None - - def on_mount(self) -> None: - try: - self.language = "cpp" # best-effort syntax highlighting - except Exception: # noqa: BLE001 -- no tree-sitter grammar available - pass + self._strips: list[Strip] = [] + self._widths: list[int] = [] def show(self, ea: int, text: str) -> None: - self.load_text(text) + self._strips = [Strip(segs) for segs in highlight_c(text)] + self._widths = [s.cell_length for s in self._strips] self.loaded_ea = ea - self.move_cursor((0, 0)) + self.cursor = 0 + maxw = max(self._widths, default=0) + self.virtual_size = Size(maxw, len(self._strips)) + self.scroll_to(0, 0, animate=False) + self.refresh() + + @property + def total(self) -> int: + return len(self._strips) + + def render_line(self, y: int) -> Strip: + width = self.size.width + top = round(self.scroll_offset.y) + idx = top + y + if idx >= len(self._strips): + return Strip.blank(width) + x = round(self.scroll_offset.x) + strip = self._strips[idx].crop(x, x + width).adjust_cell_length(width) + if idx == self.cursor: + strip = strip.apply_style(_S_CURSOR) + return strip + + # -- navigation (mirrors DisasmView) ---------------------------------- # + def _visible_height(self) -> int: + return max(self.size.height, 1) + + def _scroll_cursor_into_view(self) -> None: + height = self._visible_height() + top = round(self.scroll_offset.y) + if self.cursor < top: + self.scroll_to(y=self.cursor, animate=False) + elif self.cursor >= top + height: + self.scroll_to(y=max(self.cursor - height + 1, 0), animate=False) + + def _move(self, delta: int) -> None: + if not self._strips: + return + old = self.cursor + before = round(self.scroll_offset.y) + self.cursor = max(0, min(len(self._strips) - 1, self.cursor + delta)) + self._scroll_cursor_into_view() + if round(self.scroll_offset.y) != before: + self.refresh() + else: + _refresh_lines(self, old, self.cursor) + + def action_cursor_down(self) -> None: + self._move(1) + + def action_cursor_up(self) -> None: + self._move(-1) + + def action_half_page(self, direction: int) -> None: + self._move(direction * (self._visible_height() // 2)) + + def action_page(self, direction: int) -> None: + self._move(direction * self._visible_height()) + + def action_goto_top(self) -> None: + self._move(-len(self._strips)) + + def action_goto_bottom(self) -> None: + self._move(len(self._strips)) # --------------------------------------------------------------------------- # diff --git a/idatui/highlight.py b/idatui/highlight.py new file mode 100644 index 0000000..9fc692f --- /dev/null +++ b/idatui/highlight.py @@ -0,0 +1,58 @@ +"""Pygments-based C highlighting for Hex-Rays pseudocode. + +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. +""" + +from __future__ import annotations + +from rich.segment import Segment +from rich.style import Style +from pygments.lexers import CLexer +from pygments.token import Token + +# Token -> style, checked in priority order (first hierarchical match wins). +# Palette roughly follows a dark IDE theme. +_STYLES: list[tuple[object, Style]] = [ + (Token.Comment, Style(color="grey54", italic=True)), + (Token.Keyword.Type, Style(color="#4ec9b0")), # __int64, char, ... + (Token.Keyword, Style(color="#c586c0", bold=True)), # if/else/return/goto + (Token.Name.Builtin, Style(color="#4ec9b0")), + (Token.Literal.String, Style(color="#ce9178")), # "..." + (Token.Literal.Number, Style(color="#b5cea8")), # 0x10, 42 + (Token.Operator, Style(color="#d4d4d4")), + (Token.Punctuation, Style(color="grey70")), + (Token.Name, Style(color="#dcdcaa")), # identifiers / calls +] +_DEFAULT = Style(color="#d4d4d4") + +_lexer = CLexer(stripnl=False, ensurenl=False) + + +def _style_for(token) -> Style: + for ttype, style in _STYLES: + if token in ttype: + return style + return _DEFAULT + + +def highlight_c(code: str) -> list[list[Segment]]: + """Return one list of Segments per source line (no trailing newline segs).""" + lines: list[list[Segment]] = [[]] + for token, value in _lexer.get_tokens(code): + if not value: + continue + style = _style_for(token) + parts = value.split("\n") + for i, part in enumerate(parts): + if i > 0: + lines.append([]) + if part: + lines[-1].append(Segment(part, style)) + # get_tokens tends to append a final empty line; drop a lone trailing blank. + if len(lines) > 1 and not lines[-1]: + lines.pop() + return lines diff --git a/pyproject.toml b/pyproject.toml index cb23cfa..9802a8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [] [project.optional-dependencies] # The TUI layer pulls in Textual; the client/domain layers are stdlib-only. -tui = ["textual>=8"] +tui = ["textual>=8", "pygments>=2"] # pygments ships with rich; explicit for the C lexer dev = ["pytest>=8"] [project.scripts] diff --git a/tests/test_tui.py b/tests/test_tui.py index 683f896..0054765 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -135,8 +135,14 @@ async def run(db): pc = await wait_until(pilot, lambda: dec.display and dec.loaded_ea is not None, 25) check("tab shows pseudocode", pc and app._active == "decomp", f"active={app._active} disp={dec.display}") - check("pseudocode has real body (not 1KB stub)", len(dec.text) > 1200, - f"len={len(dec.text)}") + check("pseudocode has many lines", dec.total > 20, f"lines={dec.total}") + # Highlighting: at least one styled (colored) segment across the body. + styled = any( + seg.style is not None and seg.style.color is not None + for strip in dec._strips[: min(dec.total, 200)] + for seg in strip + ) + check("pseudocode is syntax-highlighted", styled) await pilot.press("shift+tab") await pilot.pause(0.2) check("shift+tab returns to disassembly", |
