aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 13:54:40 +0200
committerblasty <blasty@local>2026-07-09 13:54:40 +0200
commit8d9644e8c6158af45ce95467c4a0611e8e10fa22 (patch)
treec22c94a1f130308fcc2514a83b01c6f93f6e8f73
parentfunction-name filter: incremental + highlight + clear (diff)
downloadida-tui-8d9644e8c6158af45ce95467c4a0611e8e10fa22.tar.gz
ida-tui-8d9644e8c6158af45ce95467c4a0611e8e10fa22.tar.xz
ida-tui-8d9644e8c6158af45ce95467c4a0611e8e10fa22.zip
xrefs + follow-under-cursor (disasm & pseudocode)
- Enter follows the reference under the cursor: disasm uses xref_query(from) to find the code target; pseudocode matches a decompiler ref name on the line. Navigates to the containing function at the exact line (address-history push). - x opens an xrefs popup (XrefsScreen modal): xrefs_to the subject under cursor (call target if any, else the current item); Enter jumps to a referencing site, Esc closes. - domain: Program.function_of (mid-addr -> containing func via lookup_funcs), xrefs_from/xrefs_to (xref_query), Xref model, DisasmModel ea->index map for landing on an exact address. - fix: goto to a mid-function address now lands on the right line (was opening it as a bogus function start). - pilot suite 32/32; domain 17/17.
-rw-r--r--idatui/app.py190
-rw-r--r--idatui/domain.py81
-rw-r--r--tests/test_tui.py42
3 files changed, 305 insertions, 8 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 3ba5887..d405f38 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -16,6 +16,7 @@ Design notes:
from __future__ import annotations
+import re
from dataclasses import dataclass
from rich.segment import Segment
@@ -28,9 +29,11 @@ from textual.containers import Horizontal, Vertical
from textual.geometry import Region, Size
from textual.message import Message
from textual.reactive import reactive
+from textual.screen import ModalScreen
from textual.scroll_view import ScrollView
from textual.strip import Strip
-from textual.widgets import DataTable, Footer, Header, Input, Static
+from textual.widgets import DataTable, Footer, Header, Input, OptionList, Static
+from textual.widgets.option_list import Option
from .highlight import highlight_c
@@ -65,6 +68,38 @@ class SearchRequested(Message):
self.direction = direction
+class FollowRequested(Message):
+ """A code view asks to follow the reference under the cursor."""
+
+ def __init__(self, view) -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+
+
+class XrefsRequested(Message):
+ """A code view asks for cross-references to the item under the cursor."""
+
+ def __init__(self, view) -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+
+
+class NavMixin:
+ """Follow / xrefs actions shared by the code views (bindings live on each
+ view since Textual only merges BINDINGS from DOMNode subclasses)."""
+
+ NAV_BINDINGS = [
+ Binding("enter", "follow", "Follow"),
+ Binding("x", "xrefs", "Xrefs"),
+ ]
+
+ def action_follow(self) -> None:
+ self.post_message(FollowRequested(self))
+
+ def action_xrefs(self) -> None:
+ self.post_message(XrefsRequested(self))
+
+
def _overlay_ranges(strip: Strip, ranges: list[tuple[int, int]], style: Style) -> Strip:
"""Return a copy of ``strip`` with ``style`` merged over the given cell
ranges (pseudocode/disasm are ASCII, so char offset == cell offset)."""
@@ -252,7 +287,7 @@ class SearchMixin:
# --------------------------------------------------------------------------- #
# Virtualized disassembly view
# --------------------------------------------------------------------------- #
-class DisasmView(SearchMixin, ScrollView, can_focus=True):
+class DisasmView(SearchMixin, NavMixin, ScrollView, can_focus=True):
"""A line-virtualized disassembly listing for a single function."""
BINDINGS = [
@@ -266,6 +301,7 @@ class DisasmView(SearchMixin, ScrollView, can_focus=True):
Binding("G,end", "goto_bottom", "Bottom", show=False),
Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True),
*SearchMixin.SEARCH_BINDINGS,
+ *NavMixin.NAV_BINDINGS,
]
cursor = reactive(0, repaint=False)
@@ -477,7 +513,7 @@ def _refresh_lines(view, *indices: int) -> None:
# --------------------------------------------------------------------------- #
# Decompiler (pseudocode) view
# --------------------------------------------------------------------------- #
-class DecompView(SearchMixin, ScrollView, can_focus=True):
+class DecompView(SearchMixin, NavMixin, 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).
@@ -494,6 +530,7 @@ class DecompView(SearchMixin, ScrollView, can_focus=True):
Binding("home", "goto_top", "Top", show=False),
Binding("G,end", "goto_bottom", "Bottom", show=False),
*SearchMixin.SEARCH_BINDINGS,
+ *NavMixin.NAV_BINDINGS,
]
cursor = reactive(0, repaint=False)
@@ -605,6 +642,34 @@ class FunctionsPanel(Vertical):
# --------------------------------------------------------------------------- #
+# Xrefs popup
+# --------------------------------------------------------------------------- #
+class XrefsScreen(ModalScreen):
+ """A modal list of cross-references; Enter jumps, Esc closes."""
+
+ BINDINGS = [Binding("escape", "close", "Close")]
+
+ def __init__(self, label: str, items: list[tuple[int, str]]) -> None:
+ super().__init__()
+ self._label = label
+ self._items = items
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="xref-box"):
+ yield Static(self._label, id="xref-title")
+ yield OptionList(*[Option(text) for _, text in self._items], id="xref-list")
+
+ def on_mount(self) -> None:
+ self.query_one(OptionList).focus()
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ self.dismiss(self._items[event.option_index][0])
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+# --------------------------------------------------------------------------- #
# The app
# --------------------------------------------------------------------------- #
class IdaTui(App):
@@ -621,6 +686,10 @@ class IdaTui(App):
background: $primary-darken-2; color: $text;
}
#status { dock: bottom; height: 1; background: $panel; color: $text; padding: 0 1; }
+ XrefsScreen { align: center middle; }
+ #xref-box { width: 84; max-height: 70%; height: auto; border: thick $accent; background: $panel; }
+ #xref-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; }
+ #xref-list { height: auto; max-height: 100%; }
"""
BINDINGS = [
@@ -861,6 +930,115 @@ class IdaTui(App):
inp.value = ""
inp.focus()
+ # -- follow / xrefs ---------------------------------------------------- #
+ def on_follow_requested(self, msg: FollowRequested) -> None:
+ view = msg.view
+ if isinstance(view, DisasmView):
+ ea = view._cursor_ea()
+ if ea is not None:
+ self._follow_disasm(ea)
+ elif isinstance(view, DecompView) and view._texts:
+ self._follow_decomp(view._texts[view.cursor])
+
+ def on_xrefs_requested(self, msg: XrefsRequested) -> None:
+ view = msg.view
+ if isinstance(view, DisasmView):
+ ea = view._cursor_ea()
+ if ea is not None:
+ self._xrefs_disasm(ea)
+ elif isinstance(view, DecompView) and view._texts:
+ self._xrefs_decomp(view._texts[view.cursor])
+
+ @work(thread=True, group="nav")
+ def _follow_disasm(self, ea: int) -> None:
+ assert self.program is not None
+ 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)
+ if tgt is None or tgt.to is None:
+ self.app.call_from_thread(self._status, "nothing to follow here")
+ return
+ 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:
+ self.app.call_from_thread(self._status, "nothing to follow on this line")
+ return
+ self._do_navigate(target, push=True)
+
+ def _ref_on_line(self, line: str) -> int | None:
+ """Address of the first decompiler ref whose name appears on ``line``."""
+ if self._cur is None or self.program is None:
+ return None
+ dec = self.program.decompile(self._cur.ea)
+ toks = set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", line))
+ for r in dec.refs:
+ if r.name in toks:
+ return r.addr
+ return None
+
+ @work(thread=True, group="xrefs")
+ def _xrefs_disasm(self, ea: int) -> 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)
+ 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
+ if subj is not None:
+ self._xrefs_present(subj)
+
+ def _xrefs_present(self, subj: int) -> None: # worker context
+ assert self.program is not None
+ xr = self.program.xrefs_to(subj)
+ fn = self.program.function_of(subj)
+ if fn is not None:
+ label = f"xrefs to {fn.name}"
+ if subj != fn.addr:
+ label += f"+{subj - fn.addr:#x}"
+ else:
+ label = f"xrefs to {subj:#x}"
+ items = [(x.frm, f"{x.frm:08X} {x.fn_name or '?':<24} [{x.type}]") for x in xr]
+ self.app.call_from_thread(self._present_xrefs, label, items)
+
+ def _present_xrefs(self, label: str, items: list[tuple[int, str]]) -> None:
+ if not items:
+ self._status(f"{label}: none")
+ return
+ self._status(f"{label}: {len(items)}")
+ self.push_screen(XrefsScreen(label, items), self._on_xref_chosen)
+
+ def _on_xref_chosen(self, addr: int | None) -> None:
+ if addr is not None:
+ self._goto_ea(addr, push=True)
+
+ # -- navigation to an arbitrary address ------------------------------- #
+ @work(thread=True, group="nav")
+ def _goto_ea(self, ea: int, push: bool = True) -> None:
+ self._do_navigate(ea, push)
+
+ def _do_navigate(self, ea: int, push: bool) -> None: # worker context
+ assert self.program is not None
+ fn = self.program.function_of(ea)
+ if fn is None:
+ self.app.call_from_thread(self._status, f"no function contains {ea:#x}")
+ return
+ model = self.program.disasm(fn.addr, fn.name)
+ idx = 0 if ea == fn.addr else model.index_of_ea(ea)
+ self.app.call_from_thread(self._open_at, fn.addr, fn.name, idx, push)
+
+ def _open_at(self, ea: int, name: str, cursor: int, push: bool) -> None:
+ entry = NavEntry(ea=ea, name=name, cursor=cursor)
+ if push:
+ self._nav.append(entry)
+ self._open_entry(entry, push=False)
+
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "search":
view, _ = self._search_ctx
@@ -936,9 +1114,9 @@ class IdaTui(App):
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._status, f"goto: {e}")
return
- f = self.program.functions().by_addr(ea)
- name = f.name if f else target
- self.app.call_from_thread(self._open_function, ea, name)
+ # Navigate to the containing function at the right line (handles both a
+ # function name and a mid-function address).
+ self._do_navigate(ea, push=True)
# -- opening functions ------------------------------------------------- #
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
diff --git a/idatui/domain.py b/idatui/domain.py
index 9ed2432..6578932 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -21,6 +21,7 @@ Textual worker threads; the internal prefetch pool is separate and small.
from __future__ import annotations
+import bisect
import json
import re
import threading
@@ -83,6 +84,15 @@ class Ref:
@dataclass
+class Xref:
+ frm: int # the referencing address
+ to: int | None # the referenced address
+ type: str # "code" | "data" | ...
+ fn_name: str | None # function containing `frm`
+ fn_addr: int | None
+
+
+@dataclass
class Decompilation:
ea: int
code: str | None
@@ -210,6 +220,7 @@ class DisasmModel:
self.name = name
self._blocks: dict[int, list[Line]] = {}
self._total: int | None = None
+ self._ea_list: list[int] | None = None
self._lock = threading.Lock()
self._inflight: set[int] = set()
@@ -306,10 +317,35 @@ class DisasmModel:
with self._lock:
return len(self._blocks)
+ def ensure_ea_index(self) -> list[int]:
+ """Build (once) a sorted list of every line's ea, for ea->index lookup.
+ Fetches the whole function; cached. Only needed for mid-function jumps."""
+ if self._ea_list is not None:
+ return self._ea_list
+ total = self.total()
+ eas: list[int] = []
+ off = 0
+ while off < total:
+ lines = self.lines(off, self.BLOCK, prefetch=False)
+ if not lines:
+ break
+ eas.extend(ln.ea for ln in lines)
+ off += len(lines)
+ with self._lock:
+ self._ea_list = eas
+ return eas
+
+ def index_of_ea(self, ea: int) -> int:
+ """Instruction index of the line at/containing ``ea`` (0 if before start)."""
+ eas = self.ensure_ea_index()
+ i = bisect.bisect_right(eas, ea) - 1
+ return i if 0 <= i < len(eas) else 0
+
def invalidate(self) -> None:
with self._lock:
self._blocks.clear()
self._total = None
+ self._ea_list = None
# --------------------------------------------------------------------------- #
@@ -394,6 +430,29 @@ class Program:
except Exception: # noqa: BLE001 -- fall back to the truncated preview
return None
+ # -- cross-references & containing function --------------------------- #
+ def function_of(self, ea: int) -> Func | None:
+ """Return the function containing ``ea`` (resolves mid-function addrs)."""
+ payload = self.client.call("lookup_funcs", queries=[hex(ea)])
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ fn = res[0].get("fn") if res and isinstance(res[0], dict) else None
+ return Func.from_raw(fn) if fn else None
+
+ def xrefs_from(self, ea: int) -> list[Xref]:
+ payload = self.client.call(
+ "xref_query",
+ queries=[{"addr": hex(ea), "direction": "from", "include_fn": True}],
+ )
+ return _parse_xrefs(payload)
+
+ def xrefs_to(self, ea: int, limit: int = 2000) -> list[Xref]:
+ payload = self.client.call(
+ "xref_query",
+ queries=[{"addr": hex(ea), "direction": "to", "include_fn": True,
+ "dedup": True, "count": limit}],
+ )
+ return _parse_xrefs(payload)
+
# -- address resolution ------------------------------------------------ #
def resolve(self, target: int | str) -> int:
"""Resolve an int/hex-string/symbol name to an address (ea)."""
@@ -431,6 +490,28 @@ class Program:
self._indices.clear()
+def _parse_xrefs(payload) -> list[Xref]:
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ if not res:
+ return []
+ data = res[0].get("data", []) or []
+ out: list[Xref] = []
+ for d in data:
+ if not isinstance(d, dict):
+ continue
+ fn = d.get("fn") or {}
+ frm = d.get("from", d.get("addr"))
+ to = d.get("to")
+ out.append(Xref(
+ frm=_as_int(frm) if frm is not None else 0,
+ to=_as_int(to) if to is not None else None,
+ type=d.get("type", "?"),
+ fn_name=fn.get("name"),
+ fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
+ ))
+ return out
+
+
def _parse_decompilation(ea: int, payload) -> Decompilation:
if not isinstance(payload, dict):
return Decompilation(ea, None, True, "unexpected payload", False, None)
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 2aaf8ff..999d341 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -12,8 +12,10 @@ import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.app import DecompView, DisasmView, FunctionsPanel, IdaTui # noqa: E402
-from textual.widgets import DataTable, Input, Static # noqa: E402
+from idatui.app import ( # noqa: E402
+ DecompView, DisasmView, FunctionsPanel, IdaTui, XrefsScreen,
+)
+from textual.widgets import DataTable, Input, OptionList, Static # noqa: E402
from rich.text import Text # noqa: E402
PASS = FAIL = 0
@@ -227,6 +229,42 @@ async def run(db):
check("Esc on the list clears the filter", table.row_count == full,
f"{table.row_count}/{full}")
+ # Follow (Enter) + back (Esc) + xrefs (x), using a real call site.
+ dis = app.query_one(DisasmView)
+ dis.focus()
+ await pilot.pause(0.1)
+ lines = dis.model.lines(0, 400, prefetch=False)
+ call_idx = next((i for i, ln in enumerate(lines)
+ if ln.text.startswith("call ")), None)
+ if call_idx is None:
+ check("found a call line to exercise follow/xrefs", False, "no call in first 400")
+ else:
+ dis.cursor = call_idx
+ dis.refresh()
+ orig = app._cur.ea
+ depth = len(app._nav)
+ await pilot.press("enter")
+ await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25)
+ check("Enter follows the call into another function",
+ app._cur.ea != orig and len(app._nav) > depth, f"cur={app._cur.ea:#x}")
+ await pilot.press("escape")
+ await pilot.pause(0.2)
+ check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}")
+ dis.cursor = call_idx
+ dis.refresh()
+ await pilot.press("x")
+ opened = await wait_until(
+ pilot, lambda: isinstance(app.screen, XrefsScreen), timeout=25)
+ check("'x' opens the xrefs popup", opened,
+ f"screen={type(app.screen).__name__}")
+ if opened:
+ check("xrefs popup has entries",
+ app.screen.query_one(OptionList).option_count >= 1)
+ await pilot.press("escape")
+ await pilot.pause(0.2)
+ check("Esc closes the xrefs popup",
+ not isinstance(app.screen, XrefsScreen))
+
def main(argv):
db = None