aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 12:34:32 +0200
committerblasty <blasty@local>2026-07-09 12:34:32 +0200
commita6a1aae9cbd92be269840da9ca62b71d6af827b0 (patch)
treec2b36a2d16623aeab23be52bbbdf25110b53ecbe
parentdecompiler view: Tab/Shift+Tab toggle disasm<->pseudocode, full bodies (diff)
downloadida-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.py126
-rw-r--r--idatui/highlight.py58
-rw-r--r--pyproject.toml2
-rw-r--r--tests/test_tui.py10
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",