diff options
| author | blasty <blasty@local> | 2026-07-09 14:07:22 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 14:07:22 +0200 |
| commit | b441f2d89bd9273dad9253914a09765e52679666 (patch) | |
| tree | fe756d0c415b4c513916e65d758957e9546dd4b2 | |
| parent | xrefs + follow-under-cursor (disasm & pseudocode) (diff) | |
| download | ida-tui-b441f2d89bd9273dad9253914a09765e52679666.tar.gz ida-tui-b441f2d89bd9273dad9253914a09765e52679666.tar.xz ida-tui-b441f2d89bd9273dad9253914a09765e52679666.zip | |
column cursor: real left/right movement + follow the ref UNDER the cursor
- ColumnCursor mixin for both views: h/l/left/right, w/b word-hops, 0/$ line
ends; block cursor cell + word-under-cursor highlight rendered on the cursor
line (region-refresh so it stays cheap). vertical moves clamp the column.
- follow/xrefs now use the token under the cursor, so a line with multiple refs
follows the one you're pointing at (proven: 'return sub_A(sub_B,..)' -> cursor
on sub_B follows sub_B). decomp matches a decompiler ref by exact name; disasm
resolves the symbol, falling back to the line's from-xref (address column /
hex operands are excluded so the default position still follows the target).
- horizontal scroll follows the cursor in the pseudocode view.
- dropped the f/b paging aliases (b is now word-back).
- pilot suite 34/34.
| -rw-r--r-- | idatui/app.py | 277 | ||||
| -rw-r--r-- | tests/test_tui.py | 28 |
2 files changed, 264 insertions, 41 deletions
diff --git a/idatui/app.py b/idatui/app.py index d405f38..d8900c0 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -50,6 +50,8 @@ _S_DIM = Style(color="grey42", italic=True) _S_MATCH = Style(bgcolor="#7a5c00") # all search matches _S_MATCH_CUR = Style(bgcolor="#b58900", color="black") # the current match _S_NAME_MATCH = Style(bgcolor="#b58900", color="black") # filter match in a name +_S_WORD = Style(bgcolor="#264f78") # identifier under the cursor +_S_CELL = Style(reverse=True) # the block cursor cell @dataclass @@ -120,6 +122,138 @@ def _overlay_ranges(strip: Strip, ranges: list[tuple[int, int]], style: Style) - return Strip.join(parts) if parts else strip +def _word_bounds(text: str, x: int) -> tuple[int, int]: + """[start, end) of the identifier at column ``x`` (empty span if none).""" + n = len(text) + if n == 0: + return (0, 0) + x = max(0, min(x, n - 1)) + isw = lambda c: c.isalnum() or c == "_" # noqa: E731 + if not isw(text[x]): + return (x, x) + s = x + while s > 0 and isw(text[s - 1]): + s -= 1 + e = x + while e < n and isw(text[e]): + e += 1 + return (s, e) + + +def _overlay_over(strip: Strip, ranges: list[tuple[int, int]], style: Style) -> Strip: + """Like _overlay_ranges but ``style`` OVERRIDES the existing cell styles + (used for the cursor cell / word, which must win over the line background).""" + total = strip.cell_length + parts: list[Strip] = [] + pos = 0 + for a, b in ranges: + a, b = max(a, 0), min(b, total) + if a >= b: + continue + if a > pos: + parts.append(strip.crop(pos, a)) + mid = strip.crop(a, b) + parts.append(Strip([Segment(s.text, (s.style or Style()) + style) for s in mid])) + pos = b + if pos < total: + parts.append(strip.crop(pos, total)) + return Strip.join(parts) if parts else strip + + +def _cursor_decorate(strip: Strip, plain: str, x: int) -> Strip: + """Add the cursor-line background, the word-under-cursor highlight, and the + block cursor cell at column ``x``.""" + total = strip.cell_length + strip = strip.apply_style(_S_CURSOR) # line background (fills empty bg) + if total > 0: + xx = max(0, min(x, total - 1)) + ws, we = _word_bounds(plain, xx) + if we > ws: + strip = _overlay_over(strip, [(ws, we)], _S_WORD) + strip = _overlay_over(strip, [(xx, xx + 1)], _S_CELL) + return strip + + +class ColumnCursor: + """Horizontal (column) cursor for the code views: h/l/←/→, w/b word hops, + 0/$ line ends. Requires the view to define the ``cursor_x`` reactive and the + ``_line_plain(idx)`` / ``_hscroll()`` hooks. + """ + + COL_BINDINGS = [ + Binding("l,right", "col_right", "→", show=False), + Binding("h,left", "col_left", "←", show=False), + Binding("w", "col_word(1)", "word→", show=False), + Binding("b", "col_word(-1)", "word←", show=False), + Binding("0", "col_home", "bol", show=False), + Binding("dollar_sign", "col_end", "eol", show=False), + ] + + def _line_plain(self, idx: int) -> str | None: + raise NotImplementedError + + def _hscroll(self) -> None: + pass + + def _clamp_x(self) -> None: + plain = self._line_plain(self.cursor) + if plain is not None: + self.cursor_x = min(self.cursor_x, max(len(plain) - 1, 0)) + + def _move_x(self, dx: int) -> None: + plain = self._line_plain(self.cursor) or "" + self.cursor_x = max(0, min(max(len(plain) - 1, 0), self.cursor_x + dx)) + self._hscroll() + _refresh_lines(self, self.cursor) + + def action_col_left(self) -> None: + self._move_x(-1) + + def action_col_right(self) -> None: + self._move_x(1) + + def action_col_home(self) -> None: + self.cursor_x = 0 + self._hscroll() + _refresh_lines(self, self.cursor) + + def action_col_end(self) -> None: + plain = self._line_plain(self.cursor) or "" + self.cursor_x = max(len(plain) - 1, 0) + self._hscroll() + _refresh_lines(self, self.cursor) + + def action_col_word(self, direction: int) -> None: + plain = self._line_plain(self.cursor) or "" + n = len(plain) + if n == 0: + return + isw = lambda c: c.isalnum() or c == "_" # noqa: E731 + i = max(0, min(self.cursor_x, n - 1)) + if direction > 0: + while i < n and isw(plain[i]): + i += 1 + while i < n and not isw(plain[i]): + i += 1 + self.cursor_x = min(i, n - 1) + else: + i -= 1 + while i > 0 and not isw(plain[i]): + i -= 1 + while i > 0 and isw(plain[i - 1]): + i -= 1 + self.cursor_x = max(i, 0) + self._hscroll() + _refresh_lines(self, self.cursor) + + def word_under_cursor(self) -> str | None: + plain = self._line_plain(self.cursor) + if not plain: + return None + s, e = _word_bounds(plain, min(self.cursor_x, len(plain) - 1)) + return plain[s:e] or None + + class SearchMixin: """Vim-style in-view search shared by the disasm and pseudocode views. @@ -287,7 +421,7 @@ class SearchMixin: # --------------------------------------------------------------------------- # # Virtualized disassembly view # --------------------------------------------------------------------------- # -class DisasmView(SearchMixin, NavMixin, ScrollView, can_focus=True): +class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True): """A line-virtualized disassembly listing for a single function.""" BINDINGS = [ @@ -295,16 +429,18 @@ class DisasmView(SearchMixin, NavMixin, ScrollView, can_focus=True): 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("pagedown", "page(1)", "PgDn", show=False), + Binding("pageup", "page(-1)", "PgUp", show=False), Binding("home", "goto_top", "Top", show=False), Binding("G,end", "goto_bottom", "Bottom", show=False), Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True), *SearchMixin.SEARCH_BINDINGS, *NavMixin.NAV_BINDINGS, + *ColumnCursor.COL_BINDINGS, ] cursor = reactive(0, repaint=False) + cursor_x = reactive(0, repaint=False) class CursorMoved(Message): """Posted when the disasm cursor moves; carries the instruction ea.""" @@ -324,12 +460,24 @@ class DisasmView(SearchMixin, NavMixin, ScrollView, can_focus=True): self._ranges: dict[int, list[tuple[int, int]]] = {} self._search_texts: list[str] | None = None + def _line_plain(self, idx: int) -> str | None: + if self.model is None: + return None + line = self.model.cached_line(idx) + if line is None: + return None + s = f"{line.ea:08X} " + if line.label: + s += f"{line.label}: " + return s + line.text + # -- public API -------------------------------------------------------- # def load(self, model: DisasmModel, name: str, cursor: int = 0) -> None: self.model = model self._name = name self.total = 0 self.cursor = cursor + self.cursor_x = 0 self.virtual_size = Size(0, 0) self._matches = [] self._ranges = {} @@ -406,25 +554,23 @@ class DisasmView(SearchMixin, NavMixin, ScrollView, can_focus=True): if idx >= self.total: return Strip([Segment("".ljust(width), _S_INSN)]) line = model.cached_line(idx) - cursor = idx == self.cursor - base = _S_CURSOR if cursor else None + is_cursor = idx == self.cursor if line is None: - seg = Segment(f" {idx:>8} …".ljust(width), (base or Style()) + _S_DIM) - return Strip([seg]) - segs: list[Segment] = [] - addr = f"{line.ea:08X}" - segs.append(Segment(f"{addr} ", _join(base, _S_ADDR))) - if line.label: - segs.append(Segment(f"{line.label}: ", _join(base, _S_LABEL))) - text = line.text - mnem, _, rest = text.partition(" ") - segs.append(Segment(mnem, _join(base, _S_MNEM))) - if rest: - segs.append(Segment(" " + rest, _join(base, _S_INSN))) - strip = Strip(segs) + strip = Strip([Segment(f" {idx:>8} …", _S_DIM)]) + else: + segs: list[Segment] = [Segment(f"{line.ea:08X} ", _S_ADDR)] + if line.label: + segs.append(Segment(f"{line.label}: ", _S_LABEL)) + mnem, _, rest = line.text.partition(" ") + segs.append(Segment(mnem, _S_MNEM)) + if rest: + segs.append(Segment(" " + rest, _S_INSN)) + strip = Strip(segs) if idx in self._ranges: strip = _overlay_ranges(strip, self._ranges[idx], self._match_style(idx)) - return strip.adjust_cell_length(width, _join(base, _S_INSN) or _S_INSN) + if is_cursor: + strip = _cursor_decorate(strip, self._line_plain(idx) or "", self.cursor_x) + return strip.adjust_cell_length(width, _S_INSN) def _ensure_window(self, top: int) -> None: if self.model is None: @@ -463,6 +609,7 @@ class DisasmView(SearchMixin, NavMixin, ScrollView, can_focus=True): old = self.cursor before = round(self.scroll_offset.y) self.cursor = max(0, min(self.total - 1, self.cursor + delta)) + self._clamp_x() self._scroll_cursor_into_view() if round(self.scroll_offset.y) != before: self.refresh() # scrolled: the whole viewport shifted @@ -513,7 +660,7 @@ def _refresh_lines(view, *indices: int) -> None: # --------------------------------------------------------------------------- # # Decompiler (pseudocode) view # --------------------------------------------------------------------------- # -class DecompView(SearchMixin, NavMixin, ScrollView, can_focus=True): +class DecompView(SearchMixin, NavMixin, ColumnCursor, 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). @@ -525,15 +672,17 @@ class DecompView(SearchMixin, NavMixin, ScrollView, can_focus=True): 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("pagedown", "page(1)", "PgDn", show=False), + Binding("pageup", "page(-1)", "PgUp", show=False), Binding("home", "goto_top", "Top", show=False), Binding("G,end", "goto_bottom", "Bottom", show=False), *SearchMixin.SEARCH_BINDINGS, *NavMixin.NAV_BINDINGS, + *ColumnCursor.COL_BINDINGS, ] cursor = reactive(0, repaint=False) + cursor_x = reactive(0, repaint=False) def __init__(self) -> None: super().__init__(id="decomp") @@ -544,12 +693,24 @@ class DecompView(SearchMixin, NavMixin, ScrollView, can_focus=True): self._matches: list[int] = [] self._ranges: dict[int, list[tuple[int, int]]] = {} + def _line_plain(self, idx: int) -> str | None: + return self._texts[idx] if 0 <= idx < len(self._texts) else None + + def _hscroll(self) -> None: + width = self.size.width + sx = round(self.scroll_offset.x) + if self.cursor_x < sx: + self.scroll_to(x=self.cursor_x, animate=False) + elif self.cursor_x >= sx + width: + self.scroll_to(x=self.cursor_x - width + 1, animate=False) + def show(self, ea: int, text: str) -> None: seglists = highlight_c(text) self._strips = [Strip(segs) for segs in seglists] self._texts = ["".join(seg.text for seg in segs) for segs in seglists] self.loaded_ea = ea self.cursor = 0 + self.cursor_x = 0 self._matches = [] self._ranges = {} maxw = max((s.cell_length for s in self._strips), default=0) @@ -578,10 +739,9 @@ class DecompView(SearchMixin, NavMixin, ScrollView, can_focus=True): base = self._strips[idx] if idx in self._ranges: base = _overlay_ranges(base, self._ranges[idx], self._match_style(idx)) - strip = base.crop(x, x + width).adjust_cell_length(width) if idx == self.cursor: - strip = strip.apply_style(_S_CURSOR) - return strip + base = _cursor_decorate(base, self._texts[idx], self.cursor_x) + return base.crop(x, x + width).adjust_cell_length(width) # -- navigation (mirrors DisasmView) ---------------------------------- # def _visible_height(self) -> int: @@ -601,6 +761,7 @@ class DecompView(SearchMixin, NavMixin, ScrollView, can_focus=True): old = self.cursor before = round(self.scroll_offset.y) self.cursor = max(0, min(len(self._strips) - 1, self.cursor + delta)) + self._clamp_x() self._scroll_cursor_into_view() if round(self.scroll_offset.y) != before: self.refresh() @@ -933,25 +1094,42 @@ class IdaTui(App): # -- follow / xrefs ---------------------------------------------------- # def on_follow_requested(self, msg: FollowRequested) -> None: view = msg.view + word = view.word_under_cursor() if isinstance(view, DisasmView): ea = view._cursor_ea() if ea is not None: - self._follow_disasm(ea) + self._follow_disasm(ea, word) elif isinstance(view, DecompView) and view._texts: - self._follow_decomp(view._texts[view.cursor]) + self._follow_decomp(view._texts[view.cursor], word) def on_xrefs_requested(self, msg: XrefsRequested) -> None: view = msg.view + word = view.word_under_cursor() if isinstance(view, DisasmView): ea = view._cursor_ea() if ea is not None: - self._xrefs_disasm(ea) + self._xrefs_disasm(ea, word) elif isinstance(view, DecompView) and view._texts: - self._xrefs_decomp(view._texts[view.cursor]) + self._xrefs_decomp(view._texts[view.cursor], word) + + @staticmethod + def _looks_like_symbol(word: str | None) -> bool: + # A symbol has a letter but is not a bare hex number (the address column + # and hex operands are hex-only, e.g. "0026D4C0" -> follow via xref). + if not word or not any(c.isalpha() for c in word): + return False + return not all(c in "0123456789abcdefABCDEF" for c in word) @work(thread=True, group="nav") - def _follow_disasm(self, ea: int) -> None: + def _follow_disasm(self, ea: int, word: str | None) -> None: assert self.program is not None + # Prefer the symbol under the cursor (handles multiple refs on a line). + if self._looks_like_symbol(word): + try: + self._do_navigate(self.program.resolve(word), push=True) + return + except Exception: # noqa: BLE001 -- not a resolvable name; fall back + pass xr = self.program.xrefs_from(ea) tgt = next((x for x in xr if x.type == "code" and x.to), None) tgt = tgt or next((x for x in xr if x.to), None) @@ -961,12 +1139,19 @@ class IdaTui(App): self._do_navigate(tgt.to, push=True) @work(thread=True, group="nav") - def _follow_decomp(self, line: str) -> None: - target = self._ref_on_line(line) - if target is None: + def _follow_decomp(self, line: str, word: str | None) -> None: + if self._cur is None: + return + dec = self.program.decompile(self._cur.ea) + addr = None + if word: # the ref whose name is exactly the token under the cursor + addr = next((r.addr for r in dec.refs if r.name == word), None) + if addr is None: + addr = self._ref_on_line(line) + if addr is None: self.app.call_from_thread(self._status, "nothing to follow on this line") return - self._do_navigate(target, push=True) + self._do_navigate(addr, push=True) def _ref_on_line(self, line: str) -> int | None: """Address of the first decompiler ref whose name appears on ``line``.""" @@ -980,17 +1165,27 @@ class IdaTui(App): return None @work(thread=True, group="xrefs") - def _xrefs_disasm(self, ea: int) -> None: + def _xrefs_disasm(self, ea: int, word: str | None) -> None: assert self.program is not None - frm = self.program.xrefs_from(ea) - subj = next((x.to for x in frm if x.type == "code" and x.to), ea) + subj: int | None = None + if self._looks_like_symbol(word): + try: + subj = self.program.resolve(word) + except Exception: # noqa: BLE001 + subj = None + if subj is None: + frm = self.program.xrefs_from(ea) + subj = next((x.to for x in frm if x.type == "code" and x.to), ea) self._xrefs_present(subj) @work(thread=True, group="xrefs") - def _xrefs_decomp(self, line: str) -> None: - subj = self._ref_on_line(line) - if subj is None and self._cur is not None: - subj = self._cur.ea + def _xrefs_decomp(self, line: str, word: str | None) -> None: + subj: int | None = None + if self._cur is not None and word: + dec = self.program.decompile(self._cur.ea) + subj = next((r.addr for r in dec.refs if r.name == word), None) + if subj is None: + subj = self._ref_on_line(line) or (self._cur.ea if self._cur else None) if subj is not None: self._xrefs_present(subj) diff --git a/tests/test_tui.py b/tests/test_tui.py index 999d341..22d50b1 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -9,6 +9,7 @@ status update. Uses ~/ida-venv python (has textual). """ import asyncio import os +import re import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -265,6 +266,33 @@ async def run(db): check("Esc closes the xrefs popup", not isinstance(app.screen, XrefsScreen)) + # Column cursor: h/l move it; following the symbol under the cursor. + dis.focus() + dis.cursor = call_idx + dis.cursor_x = 5 + dis.refresh() + await pilot.press("h") + check("h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}") + await pilot.press("l") + await pilot.press("l") + check("l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}") + plain = dis._line_plain(call_idx) or "" + m = re.search(r"\b(sub_[0-9A-Fa-f]+)", plain) + if m: + dis.cursor = call_idx + dis.cursor_x = m.start(1) + 1 + dis.refresh() + await pilot.pause(0.1) + check("word-under-cursor is the operand symbol", + dis.word_under_cursor() == m.group(1), + f"{dis.word_under_cursor()!r} vs {m.group(1)!r}") + depth = len(app._nav) + want = app.program.resolve(m.group(1)) + await pilot.press("enter") + await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) + check("follows the symbol under the cursor", + app._cur.ea == want, f"cur={app._cur.ea:#x} want={want:#x}") + def main(argv): db = None |
