diff options
| author | blasty <blasty@local> | 2026-07-09 13:34:43 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 13:34:43 +0200 |
| commit | 55b726d780404d77fe781b6cc08a4b3f4a039b0c (patch) | |
| tree | b962f7ff33468373e4f0ada68963564d08cfb33b | |
| parent | search UX: visible prompt + incremental (as-you-type) highlighting (diff) | |
| download | ida-tui-55b726d780404d77fe781b6cc08a4b3f4a039b0c.tar.gz ida-tui-55b726d780404d77fe781b6cc08a4b3f4a039b0c.tar.xz ida-tui-55b726d780404d77fe781b6cc08a4b3f4a039b0c.zip | |
function-name filter: incremental + highlight + clear
- Incremental client-side filter over the cached function list (no per-keystroke
server round-trips): narrows + highlights the matched substring in each name
as you type, debounced ~80ms. Substring or glob (*/?), smartcase.
- Clear the filter: Esc in the filter box, OR Esc on the focused list, OR empty
Enter. Status shows 'filter <q>: N/total'.
- streaming pauses while a filter is active; re-applied over the full set once
load completes. Row selection resolves the name from the index (cells can now
be styled Text).
- FunctionIndex.all_loaded(); pilot suite 27/27.
| -rw-r--r-- | idatui/app.py | 133 | ||||
| -rw-r--r-- | idatui/domain.py | 4 | ||||
| -rw-r--r-- | tests/test_tui.py | 28 |
3 files changed, 137 insertions, 28 deletions
diff --git a/idatui/app.py b/idatui/app.py index ac95657..3ba5887 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -20,6 +20,7 @@ from dataclasses import dataclass from rich.segment import Segment from rich.style import Style +from rich.text import Text from textual import work from textual.app import App, ComposeResult from textual.binding import Binding @@ -45,6 +46,7 @@ _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 +_S_NAME_MATCH = Style(bgcolor="#b58900", color="black") # filter match in a name @dataclass @@ -641,7 +643,10 @@ class IdaTui(App): self.program: Program | None = None self._ka = None self._nav: list[NavEntry] = [] - self._cur_filter: str | None = None + self._func_index = None # the unfiltered FunctionIndex (source of truth) + self._filter_term = "" + self._pending_filter = "" + self._filter_timer = None self._active = "disasm" # or "decomp" self._cur: NavEntry | None = None self._search_ctx: tuple[object | None, int] = (None, 1) @@ -711,18 +716,11 @@ class IdaTui(App): self._load_functions() @work(thread=True, exclusive=True, group="load-funcs") - def _load_functions(self, filter: str | None = None) -> None: + def _load_functions(self) -> None: assert self.program is not None - idx = self.program.functions(filter=filter) - # Stream rows in as pages arrive so the UI fills progressively. - table_reset = {"done": False} - - def reset_table(): - t = self.query_one("#func-table", DataTable) - t.clear() - table_reset["done"] = True - - self.app.call_from_thread(reset_table) + idx = self.program.functions() + self._func_index = idx + self.app.call_from_thread(lambda: self.query_one("#func-table", DataTable).clear()) last = 0 module = self._module() while not idx.complete: @@ -734,7 +732,11 @@ class IdaTui(App): self.app.call_from_thread( self._status, f"{module} — {last} functions…" ) - self.app.call_from_thread(self._status, f"{module} — {len(idx)} functions") + # If a filter is active (typed during load), re-apply it over the full set. + if self._filter_term: + self.app.call_from_thread(self._apply_filter, self._filter_term) + else: + self.app.call_from_thread(self._status, f"{module} — {len(idx)} functions") def _module(self) -> str: try: @@ -743,10 +745,65 @@ class IdaTui(App): return "?" def _append_rows(self, rows: list[Func]) -> None: + if self._filter_term: # streaming paused while a filter is active + return table = self.query_one("#func-table", DataTable) for f in rows: table.add_row(f"{f.addr:08X}", f.name, f"{f.size:#x}", key=str(f.addr)) + # -- incremental function-name filter --------------------------------- # + def _apply_filter(self, term: str) -> None: + """Client-side incremental filter over the cached function list, with the + matched substring highlighted in each name. Glob (``*``/``?``) or plain + substring; smartcase (case-insensitive unless the query has uppercase). + """ + import fnmatch + + term = term.strip() + self._filter_term = term + idx = self._func_index + if idx is None: + return + funcs = idx.all_loaded() + total = len(funcs) + ci = term.islower() + is_glob = ("*" in term) or ("?" in term) + needle = term.lower() if ci else term + pat = needle if (is_glob and ("*" in needle or "?" in needle)) else f"*{needle}*" + matched: list[tuple[Func, tuple[int, int] | None]] = [] + for f in funcs: + if not term: + matched.append((f, None)) + continue + hay = f.name.lower() if ci else f.name + if is_glob: + if fnmatch.fnmatch(hay, pat): + matched.append((f, None)) + else: + i = hay.find(needle) + if i >= 0: + matched.append((f, (i, i + len(term)))) + table = self.query_one("#func-table", DataTable) + table.clear() + for f, rng in matched: + if rng: + name: object = Text(f.name) + name.stylize(_S_NAME_MATCH, rng[0], rng[1]) + else: + name = f.name + table.add_row(f"{f.addr:08X}", name, f"{f.size:#x}", key=str(f.addr)) + if term: + self._status(f"filter '{term}': {len(matched)}/{total}") + else: + self._status(f"{self._module()} — {total} functions") + + def _apply_pending_filter(self) -> None: + self._filter_timer = None + self._apply_filter(self._pending_filter) + + def clear_filter(self) -> None: + self._apply_filter("") + # -- actions ----------------------------------------------------------- # def action_toggle_functions(self) -> None: """Show/hide the functions pane; give the disasm view all the width.""" @@ -766,10 +823,10 @@ class IdaTui(App): def action_filter(self) -> None: inp = self.query_one("#func-filter", Input) - inp.placeholder = "filter (glob, e.g. sub_*) — Enter to apply" + inp.placeholder = "filter names… Enter=keep Esc=clear" inp.can_focus = True inp.display = True - inp.value = self._cur_filter or "" + inp.value = self._filter_term inp.focus() def action_goto(self) -> None: @@ -782,11 +839,15 @@ class IdaTui(App): self._goto_mode = True def action_back(self) -> None: + table = self.query_one("#func-table", DataTable) + if self.focused is table and self._filter_term: + self.clear_filter() # Esc on the list clears an active filter + return if len(self._nav) > 1: self._nav.pop() self._open_entry(self._nav[-1], push=False) else: - self.query_one("#func-table", DataTable).focus() + table.focus() # -- input submit (filter / goto) ------------------------------------- # def on_search_requested(self, msg: SearchRequested) -> None: @@ -801,11 +862,16 @@ class IdaTui(App): inp.focus() def on_input_changed(self, event: Input.Changed) -> None: - if event.input.id != "search": + if event.input.id == "search": + view, _ = self._search_ctx + if view is not None: + view.search_update(event.value) return - view, _ = self._search_ctx - if view is not None: - view.search_update(event.value) + if event.input.id == "func-filter" and not getattr(self, "_goto_mode", False): + self._pending_filter = event.value + if self._filter_timer is not None: + self._filter_timer.stop() + self._filter_timer = self.set_timer(0.08, self._apply_pending_filter) def _end_search(self, cancel: bool = False) -> None: inp = self.query_one("#search", Input) @@ -819,10 +885,24 @@ class IdaTui(App): view.focus() def on_key(self, event) -> None: # type: ignore[no-untyped-def] - if event.key == "escape" and self.query_one("#search", Input).display: + if event.key != "escape": + return + if self.query_one("#search", Input).display: event.stop() event.prevent_default() self._end_search(cancel=True) + return + fi = self.query_one("#func-filter", Input) + if fi.display: + event.stop() + event.prevent_default() + fi.display = False + fi.can_focus = False + if getattr(self, "_goto_mode", False): + self._goto_mode = False + else: + self.clear_filter() # Esc in the filter box clears it + self.query_one("#func-table", DataTable).focus() def on_input_submitted(self, event: Input.Submitted) -> None: value = event.value.strip() @@ -840,15 +920,13 @@ class IdaTui(App): return if getattr(self, "_goto_mode", False): self._goto_mode = False - inp.placeholder = "filter (glob, e.g. sub_*) — Enter to apply" self.query_one("#func-table", DataTable).focus() if value: self._goto(value) return - # filter mode - self._cur_filter = value or None + # filter mode: already applied incrementally; Enter just confirms + closes. + self._apply_filter(value) self.query_one("#func-table", DataTable).focus() - self._load_functions(filter=self._cur_filter) @work(thread=True, exclusive=True, group="goto") def _goto(self, target: str) -> None: @@ -865,9 +943,8 @@ class IdaTui(App): # -- opening functions ------------------------------------------------- # def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: ea = int(event.row_key.value) - table = self.query_one("#func-table", DataTable) - row = table.get_row(event.row_key) - name = row[1] if row else hex(ea) + f = self._func_index.by_addr(ea) if self._func_index else None + name = f.name if f else hex(ea) self._open_function(ea, name) def _open_function(self, ea: int, name: str, push: bool = True) -> None: diff --git a/idatui/domain.py b/idatui/domain.py index 9417150..9ed2432 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -185,6 +185,10 @@ class FunctionIndex: with self._lock: return self._by_addr.get(ea) + def all_loaded(self) -> list[Func]: + with self._lock: + return list(self._funcs) + # --------------------------------------------------------------------------- # # Disassembly model: block-cached windowed listing for ONE function diff --git a/tests/test_tui.py b/tests/test_tui.py index 2156fad..2aaf8ff 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -14,6 +14,7 @@ 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 rich.text import Text # noqa: E402 PASS = FAIL = 0 @@ -199,6 +200,33 @@ async def run(db): status.display and not si.display and not dis._matches, f"status={status.display} si={si.display} m={len(dis._matches)}") + # Incremental function-name filter + highlight + Esc-clear. + table.focus() + await pilot.pause(0.1) + if app._filter_term: # clear any leftover filter from earlier + await pilot.press("escape") + await pilot.pause(0.2) + full = table.row_count + await pilot.press("slash") + await pilot.pause(0.2) + for ch in "sub_": + await pilot.press(ch) + await pilot.pause(0.1) + await pilot.pause(0.2) + check("filter narrows incrementally as you type", + 0 < table.row_count < full, f"{table.row_count}/{full}") + cell = table.get_row_at(0)[1] + check("filter highlights matched substring in name", + isinstance(cell, Text) and any(s.style for s in cell.spans), repr(str(cell))) + await pilot.press("enter") + await pilot.pause(0.2) + check("Enter keeps filter + focuses table", + isinstance(app.focused, DataTable) and table.row_count < full) + await pilot.press("escape") + await pilot.pause(0.3) + check("Esc on the list clears the filter", table.row_count == full, + f"{table.row_count}/{full}") + def main(argv): db = None |
