diff options
| author | blasty <blasty@local> | 2026-07-09 12:52:02 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 12:52:02 +0200 |
| commit | 4f18db24e61bd0b85376020c6c749d86ec58a749 (patch) | |
| tree | 924c616fca1c16ac7da875ace51cba5c45aade86 | |
| parent | fix decompiler highlighting + cursor jank (diff) | |
| download | ida-tui-4f18db24e61bd0b85376020c6c749d86ec58a749.tar.gz ida-tui-4f18db24e61bd0b85376020c6c749d86ec58a749.tar.xz ida-tui-4f18db24e61bd0b85376020c6c749d86ec58a749.zip | |
vim-style search in disasm + pseudocode views
- SearchMixin shared by both views: '/' search forward, '?' backward, empty query
repeats last; n/N next/prev. Smartcase. All matches highlighted (substring
overlay), cursor lands on current match; status shows k/N.
- disasm indexes line text lazily in a worker (handles huge funcs); pseudocode
has text in hand. Substring highlight via crop/join Strip overlay.
- app: bottom search prompt, SearchRequested routing; '/' is context-sensitive
(filters when the function table is focused, searches in code views).
- gotcha fixed: Textual only merges BINDINGS from DOMNode subclasses, so mixin
bindings are ignored -> each view lists SEARCH_BINDINGS explicitly.
- pilot suite 20/20 (adds search finds/hl/repeat-fwd/repeat-back).
| -rw-r--r-- | idatui/app.py | 251 | ||||
| -rw-r--r-- | tests/test_tui.py | 34 |
2 files changed, 277 insertions, 8 deletions
diff --git a/idatui/app.py b/idatui/app.py index 8d06891..1c7d032 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -43,6 +43,8 @@ _S_INSN = Style(color="white") _S_MNEM = Style(color="cyan") _S_CURSOR = Style(bgcolor="grey30") _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 @dataclass @@ -52,10 +54,155 @@ class NavEntry: cursor: int = 0 +class SearchRequested(Message): + """A code view asks the app to open the search prompt.""" + + def __init__(self, view: "SearchMixin", direction: int) -> None: + super().__init__() + self.view = view + self.direction = direction + + +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).""" + total = strip.cell_length + parts: list[Strip] = [] + pos = 0 + for a, b in ranges: + a = max(a, 0) + b = min(b, total) + if a >= b: + continue + if a > pos: + parts.append(strip.crop(pos, a)) + parts.append(strip.crop(a, b).apply_style(style)) + pos = b + if pos < total: + parts.append(strip.crop(pos, total)) + return Strip.join(parts) if parts else strip + + +class SearchMixin: + """Vim-style in-view search shared by the disasm and pseudocode views. + + ``/`` searches forward, ``?`` backward; an empty query repeats the last one. + ``n``/``N`` jump next/prev. All matches are highlighted; the cursor lands on + the current match. Subclasses provide the line-text source via the three + ``_search_*`` hooks (disasm indexes lazily; pseudocode has text in hand). + """ + + # NOTE: Textual only merges BINDINGS from DOMNode subclasses, so a plain + # mixin's BINDINGS are ignored. Each view lists SEARCH_BINDINGS explicitly. + SEARCH_BINDINGS = [ + Binding("slash", "search(1)", "Search"), + Binding("question_mark", "search(-1)", "Search ↑", show=False), + Binding("n", "search_repeat(1)", "Next", show=False), + Binding("N", "search_repeat(-1)", "Prev", show=False), + ] + + # --- state (subclasses must init these in __init__) --- + _term: str + _matches: list[int] + _ranges: dict[int, list[tuple[int, int]]] + + # --- hooks a subclass implements --- + def _search_line_count(self) -> int: + raise NotImplementedError + + def _search_line_text(self, i: int) -> str | None: + raise NotImplementedError + + def _search_ensure(self, done) -> None: + """Ensure all line texts are available, then call ``done()`` on the UI + thread. Default: assume ready.""" + done() + + # --- actions --- + def action_search(self, direction: int) -> None: + self.post_message(SearchRequested(self, direction)) + + def action_search_repeat(self, direction: int) -> None: + self.search_repeat(direction) + + # --- driven by the app's prompt --- + def set_search(self, term: str, direction: int) -> None: + self._term = term + self._ci = term.islower() # smartcase + self._pending_dir = direction + self._search_ensure(self._after_search_ready) + + def _after_search_ready(self) -> None: + self._compute_matches() + self._app_status(f"/{self._term}/ {len(self._matches)} matches") + self.refresh() + if self._matches: + self.search_repeat(self._pending_dir, include_current=True) + + def _compute_matches(self) -> None: + term = self._term + needle = term.lower() if getattr(self, "_ci", True) else term + matches: list[int] = [] + ranges: dict[int, list[tuple[int, int]]] = {} + for i in range(self._search_line_count()): + s = self._search_line_text(i) + if not s: + continue + hay = s.lower() if getattr(self, "_ci", True) else s + pos, rs = 0, [] + while True: + j = hay.find(needle, pos) + if j < 0: + break + rs.append((j, j + len(term))) + pos = j + len(term) + if rs: + matches.append(i) + ranges[i] = rs + self._matches = matches + self._ranges = ranges + + def search_repeat(self, direction: int, include_current: bool = False) -> None: + if not getattr(self, "_term", ""): + return + if not self._matches: + self._app_status(f"no matches for /{self._term}/") + return + cur = self.cursor + if direction >= 0: + nxt = next((m for m in self._matches + if (m >= cur if include_current else m > cur)), self._matches[0]) + else: + nxt = next((m for m in reversed(self._matches) if m < cur), self._matches[-1]) + self._goto_line(nxt) + k = self._matches.index(nxt) + 1 + self._app_status(f"/{self._term}/ {k}/{len(self._matches)} line {nxt}") + + def _goto_line(self, idx: int) -> None: + total = self._search_line_count() + self.cursor = max(0, min(total - 1, idx)) + self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False) + self.refresh() + + def clear_search(self) -> None: + self._term = "" + self._matches = [] + self._ranges = {} + self.refresh() + + def _match_style(self, idx: int) -> Style: + return _S_MATCH_CUR if idx == self.cursor else _S_MATCH + + def _app_status(self, text: str) -> None: + app = self.app + if hasattr(app, "_status"): + app._status(text) + + # --------------------------------------------------------------------------- # # Virtualized disassembly view # --------------------------------------------------------------------------- # -class DisasmView(ScrollView, can_focus=True): +class DisasmView(SearchMixin, ScrollView, can_focus=True): """A line-virtualized disassembly listing for a single function.""" BINDINGS = [ @@ -68,6 +215,7 @@ class DisasmView(ScrollView, can_focus=True): 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, ] cursor = reactive(0, repaint=False) @@ -85,6 +233,10 @@ class DisasmView(ScrollView, can_focus=True): self.model: DisasmModel | None = None self.total = 0 self._name = "" + self._term = "" + self._matches: list[int] = [] + self._ranges: dict[int, list[tuple[int, int]]] = {} + self._search_texts: list[str] | None = None # -- public API -------------------------------------------------------- # def load(self, model: DisasmModel, name: str, cursor: int = 0) -> None: @@ -93,8 +245,50 @@ class DisasmView(ScrollView, can_focus=True): self.total = 0 self.cursor = cursor self.virtual_size = Size(0, 0) + self._matches = [] + self._ranges = {} + self._search_texts = None self._prime() + # -- search hooks ------------------------------------------------------ # + @staticmethod + def _fmt(line) -> str: # type: ignore[no-untyped-def] + s = f"{line.ea:08X} " + if line.label: + s += f"{line.label}: " + return s + line.text + + def _search_line_count(self) -> int: + return self.total + + def _search_line_text(self, i: int) -> str | None: + t = self._search_texts + return t[i] if t is not None and 0 <= i < len(t) else None + + def _search_ensure(self, done) -> None: + if self._search_texts is not None: + done() + return + self._app_status(f"/{self._term}/ indexing {self.total} lines…") + self._index_for_search(done) + + @work(thread=True, exclusive=True, group="search-index") + def _index_for_search(self, done) -> None: + model = self.model + if model is None: + self.app.call_from_thread(done) + return + texts: list[str] = [] + off, total = 0, self.total + while off < total: + lines = model.lines(off, DisasmModel.BLOCK, prefetch=False) + if not lines: + break + texts.extend(self._fmt(ln) for ln in lines) + off += len(lines) + self._search_texts = texts + self.app.call_from_thread(done) + @work(thread=True, exclusive=True, group="disasm-prime") def _prime(self) -> None: model = self.model @@ -142,6 +336,8 @@ class DisasmView(ScrollView, can_focus=True): if rest: segs.append(Segment(" " + rest, _join(base, _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) def _ensure_window(self, top: int) -> None: @@ -231,7 +427,7 @@ def _refresh_lines(view, *indices: int) -> None: # --------------------------------------------------------------------------- # # Decompiler (pseudocode) view # --------------------------------------------------------------------------- # -class DecompView(ScrollView, can_focus=True): +class DecompView(SearchMixin, 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). @@ -247,6 +443,7 @@ class DecompView(ScrollView, can_focus=True): Binding("pageup,b", "page(-1)", "PgUp", show=False), Binding("home", "goto_top", "Top", show=False), Binding("G,end", "goto_bottom", "Bottom", show=False), + *SearchMixin.SEARCH_BINDINGS, ] cursor = reactive(0, repaint=False) @@ -255,14 +452,20 @@ class DecompView(ScrollView, can_focus=True): super().__init__(id="decomp") self.loaded_ea: int | None = None self._strips: list[Strip] = [] - self._widths: list[int] = [] + self._texts: list[str] = [] + self._term = "" + self._matches: list[int] = [] + self._ranges: dict[int, list[tuple[int, int]]] = {} def show(self, ea: int, text: str) -> None: - self._strips = [Strip(segs) for segs in highlight_c(text)] - self._widths = [s.cell_length for s in self._strips] + 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 - maxw = max(self._widths, default=0) + self._matches = [] + self._ranges = {} + maxw = max((s.cell_length for s in self._strips), default=0) self.virtual_size = Size(maxw, len(self._strips)) self.scroll_to(0, 0, animate=False) self.refresh() @@ -271,6 +474,13 @@ class DecompView(ScrollView, can_focus=True): def total(self) -> int: return len(self._strips) + # -- search hooks ------------------------------------------------------ # + def _search_line_count(self) -> int: + return len(self._strips) + + def _search_line_text(self, i: int) -> str | None: + return self._texts[i] if 0 <= i < len(self._texts) else None + def render_line(self, y: int) -> Strip: width = self.size.width top = round(self.scroll_offset.y) @@ -278,7 +488,10 @@ class DecompView(ScrollView, can_focus=True): 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) + 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 @@ -353,6 +566,7 @@ class IdaTui(App): #func-filter { dock: top; } DisasmView { width: 1fr; padding: 0 1; } DecompView { width: 1fr; } + #search { dock: bottom; height: 1; } #status { dock: bottom; height: 1; background: $panel; color: $text; padding: 0 1; } """ @@ -379,6 +593,7 @@ class IdaTui(App): self._cur_filter: str | None = None self._active = "disasm" # or "decomp" self._cur: NavEntry | None = None + self._search_ctx: tuple[object | None, int] = (None, 1) # -- layout ------------------------------------------------------------ # def compose(self) -> ComposeResult: @@ -390,6 +605,10 @@ class IdaTui(App): dv = DecompView() dv.display = False yield dv + si = Input(id="search") + si.display = False + si.can_focus = False + yield si yield Static("connecting…", id="status") yield Footer() @@ -519,11 +738,29 @@ class IdaTui(App): self.query_one("#func-table", DataTable).focus() # -- input submit (filter / goto) ------------------------------------- # + def on_search_requested(self, msg: SearchRequested) -> None: + self._search_ctx = (msg.view, msg.direction) + inp = self.query_one("#search", Input) + inp.placeholder = "search /" if msg.direction >= 0 else "search backward ?" + inp.can_focus = True + inp.display = True + inp.value = "" + inp.focus() + def on_input_submitted(self, event: Input.Submitted) -> None: value = event.value.strip() inp = event.input inp.display = False inp.can_focus = False + if inp.id == "search": + view, direction = self._search_ctx + if view is not None: + if value: + view.set_search(value, direction) + else: + view.search_repeat(direction) + view.focus() + return if getattr(self, "_goto_mode", False): self._goto_mode = False inp.placeholder = "filter (glob, e.g. sub_*) — Enter to apply" diff --git a/tests/test_tui.py b/tests/test_tui.py index 0054765..3b97739 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -100,7 +100,11 @@ async def run(db): check("goto-bottom lands near end", view.cursor >= view.total - 1, f"cursor={view.cursor}/{view.total}") - # Filter round-trip. + # Filter round-trip. '/' is context-sensitive: it filters when the + # function table is focused (and searches when a code view is focused), + # so focus the table first. + table.focus() + await pilot.pause(0.1) await pilot.press("slash") await pilot.pause(0.1) for ch in "sub_1*": @@ -149,6 +153,34 @@ async def run(db): app._active == "disasm" and dis.display and not dec.display, f"active={app._active}") + # Vim-style search in the disassembly view. + line0 = dis.model.cached_line(0) + raw = (line0.text.split() or ["push"])[0] if line0 else "push" + term = "".join(c for c in raw if c.isalnum())[:4] or "push" + await pilot.press("slash") + await pilot.pause(0.2) + for ch in term: + await pilot.press(ch) + await pilot.press("enter") + await wait_until(pilot, lambda: bool(dis._matches), timeout=25) + check("search finds matches", len(dis._matches) > 0, f"term={term!r}") + check("cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}") + check("match substring highlighted", + bool(dis._ranges.get(dis.cursor)), str(dis._ranges.get(dis.cursor))) + prev = dis.cursor + await pilot.press("slash") + await pilot.pause(0.1) + await pilot.press("enter") + await pilot.pause(0.1) + check("'/' repeats to next match", + dis.cursor != prev and dis.cursor in dis._matches, f"cursor={dis.cursor}") + await pilot.press("question_mark") + await pilot.pause(0.1) + await pilot.press("enter") + await pilot.pause(0.1) + check("'?' repeats to previous match", dis.cursor in dis._matches, + f"cursor={dis.cursor}") + def main(argv): db = None |
