diff options
| author | blasty <blasty@local> | 2026-07-09 23:00:32 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 23:00:32 +0200 |
| commit | 7c58501232d493bc8f132d2d745f62a96ea27d82 (patch) | |
| tree | 4d5ed2ac07cb813115e58380a936486f9ddb175f | |
| parent | xrefs: pre-select the site the dialog was invoked from (diff) | |
| download | ida-tui-7c58501232d493bc8f132d2d745f62a96ea27d82.tar.gz ida-tui-7c58501232d493bc8f132d2d745f62a96ea27d82.tar.xz ida-tui-7c58501232d493bc8f132d2d745f62a96ea27d82.zip | |
names: command-palette fuzzy finder (Ctrl+N), overlay-first
Replace the docked names pane as the primary way to jump to symbols with a
command-palette overlay: Ctrl+N opens SymbolPalette, type to fuzzy-find (scored
subsequence match with matched-char highlighting), up/down to select, Enter to
open, Esc to close. Mouse-clickable too.
The docked FunctionsPanel now starts hidden and is still reachable/toggleable
with Ctrl+B (classic table view, sort, filter, goto all unchanged); a code view
takes focus on startup. Status hints 'Ctrl+N: find symbol'.
Pilot checks for open/fuzzy/subsequence/select/close. Tests reveal the docked
pane (Ctrl+B) for the existing table-driven checks. full suite 74/74.
| -rw-r--r-- | docs/TEXTUAL_NOTES.md | 7 | ||||
| -rw-r--r-- | idatui/app.py | 159 | ||||
| -rw-r--r-- | tests/test_tui.py | 41 |
3 files changed, 198 insertions, 9 deletions
diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md index eee40fc..6ce9037 100644 --- a/docs/TEXTUAL_NOTES.md +++ b/docs/TEXTUAL_NOTES.md @@ -45,6 +45,13 @@ Hard-won Textual behaviour and the patterns this app relies on. Pairs with the Footer (see `#search`/`#rename`/`#status`). - Textual ships **no C/C++ tree-sitter grammar** — `TextArea(language="cpp")` is a silent no-op. We highlight pseudocode with Pygments (`idatui/highlight.py`). +- **A modal's `Input` messages bubble to the App.** `SymbolPalette` (the Ctrl+N + fuzzy finder) has its own `Input`; its `Input.Changed`/`Input.Submitted` bubble + up to the app's handlers (which would run the `#search`/`#func-filter` logic and + even hide the palette's input). Call `event.stop()` in the modal's handlers. + A single-line `Input` doesn't bind `up`/`down`, so those bubble to the modal's + BINDINGS (used to move the result list while the input keeps focus); `enter` + is consumed by the Input → handle it via `on_input_submitted`. ## App patterns diff --git a/idatui/app.py b/idatui/app.py index 4d2b7a7..cd089f9 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -1049,6 +1049,121 @@ class XrefsScreen(ModalScreen): # --------------------------------------------------------------------------- # +# Symbol palette (fuzzy finder overlay) +# --------------------------------------------------------------------------- # +def _fuzzy(name: str, q: str): + """Fuzzy subsequence match of ``q`` in ``name`` (case-insensitive). Returns + ``(score, positions)`` or None. Higher score = better; positions are the + matched character indices (for highlighting).""" + if not q: + return (0.0, ()) + nl = name.lower() + pos: list[int] = [] + i = 0 + for ch in q: + j = nl.find(ch, i) + if j < 0: + return None + pos.append(j) + i = j + 1 + span = pos[-1] - pos[0] + score = -(span * 2.0) - pos[0] - len(name) * 0.01 + if q in nl: + score += 50.0 + if nl.startswith(q): + score += 100.0 + return (score, tuple(pos)) + + +class SymbolPalette(ModalScreen): + """A command-palette overlay: type to fuzzy-find a symbol, Enter opens it.""" + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("down,ctrl+n", "cursor_down", show=False), + Binding("up,ctrl+p", "cursor_up", show=False), + ] + LIMIT = 200 + + def __init__(self, funcs: list[Func]) -> None: + super().__init__() + self._funcs = funcs + self._results: list[Func] = [] + + def compose(self) -> ComposeResult: + with Vertical(id="pal-box"): + yield Static(" symbols", id="pal-title") + yield Input(placeholder="fuzzy find symbol… ↑↓ select · Enter open · Esc close", + id="pal-input") + yield OptionList(id="pal-list") + + def on_mount(self) -> None: + self._apply("") + self.query_one("#pal-input", Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + event.stop() # don't leak to the app's #search/#filter handlers + self._apply(event.value.strip()) + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self.action_choose() + + def _apply(self, query: str) -> None: + if query: + scored = [] + for f in self._funcs: + m = _fuzzy(f.name, query) + if m is not None: + scored.append((m[0], m[1], f)) + scored.sort(key=lambda t: (-t[0], t[2].name)) + rows = [(f, pos) for _, pos, f in scored[:self.LIMIT]] + else: + rows = [(f, ()) for f in self._funcs[:self.LIMIT]] + self._results = [f for f, _ in rows] + ol = self.query_one(OptionList) + ol.clear_options() + opts = [] + for f, pos in rows: + label = Text() + label.append(f"{f.addr:08X} ", _S_ADDR) + nm = Text(f.name) + for p in pos: + if p < len(f.name): + nm.stylize(_S_NAME_MATCH, p, p + 1) + label.append_text(nm) + opts.append(Option(label)) + ol.add_options(opts) + if self._results: + ol.highlighted = 0 + self.query_one("#pal-title", Static).update( + f" symbols: {len(self._results)}" + ("+" if len(self._results) == self.LIMIT else "")) + + def action_cursor_down(self) -> None: + ol = self.query_one(OptionList) + if ol.option_count: + ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1) + + def action_cursor_up(self) -> None: + ol = self.query_one(OptionList) + if ol.option_count: + ol.highlighted = max((ol.highlighted or 0) - 1, 0) + + def action_choose(self) -> None: + ol = self.query_one(OptionList) + i = ol.highlighted + if i is not None and 0 <= i < len(self._results): + self.dismiss(self._results[i].addr) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if 0 <= event.option_index < len(self._results): + self.dismiss(self._results[event.option_index].addr) + + def action_close(self) -> None: + self.dismiss(None) + + +# --------------------------------------------------------------------------- # # The app # --------------------------------------------------------------------------- # class IdaTui(App): @@ -1081,13 +1196,20 @@ class IdaTui(App): #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%; } + SymbolPalette { align: center middle; } + #pal-box { width: 96; max-width: 92%; height: auto; max-height: 80%; + border: thick $accent; background: $panel; } + #pal-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; } + #pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; } + #pal-list { height: auto; max-height: 24; } """ BINDINGS = [ Binding("q", "quit", "Quit"), - Binding("slash", "filter", "Filter"), + Binding("ctrl+n", "symbols", "Symbols"), Binding("g", "goto", "Goto"), - Binding("ctrl+b", "toggle_functions", "Names"), + Binding("slash", "filter", "Filter", show=False), + Binding("ctrl+b", "toggle_functions", "Names", show=False), Binding("tab,shift+tab", "toggle_view", "Disasm/Pseudocode", priority=True), Binding("ctrl+s", "save", "Save"), Binding("escape", "back", "Back"), @@ -1122,8 +1244,9 @@ class IdaTui(App): def compose(self) -> ComposeResult: yield Header(show_clock=True) with Horizontal(id="panes"): - with FunctionsPanel(id="left"): - pass + fp = FunctionsPanel(id="left") + fp.display = False # overlay-first: reveal the docked pane with Ctrl+B + yield fp yield DisasmView() dv = DecompView() dv.display = False @@ -1147,7 +1270,9 @@ class IdaTui(App): # Keep the hidden command input out of the focus chain until summoned. inp = self.query_one("#func-filter", Input) inp.can_focus = False - self.query_one("#func-table", DataTable).focus() + # The names pane is an overlay now (Ctrl+N); focus a code view so app + # bindings work before anything is opened. + self.query_one(DisasmView).focus() self._connect() # -- status helper ----------------------------------------------------- # @@ -1212,7 +1337,8 @@ class IdaTui(App): 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") + self.app.call_from_thread( + self._status, f"{module} — {len(idx)} functions (Ctrl+N: find symbol)") def _module(self) -> str: try: @@ -1308,7 +1434,7 @@ class IdaTui(App): # -- actions ----------------------------------------------------------- # def action_toggle_functions(self) -> None: - """Show/hide the functions pane; give the disasm view all the width.""" + """Show/hide the classic docked functions pane (the overlay is Ctrl+N).""" left = self.query_one("#left", FunctionsPanel) left.display = not left.display if not left.display: @@ -1316,6 +1442,21 @@ class IdaTui(App): else: self.query_one("#func-table", DataTable).focus() + def action_symbols(self) -> None: + """Ctrl+N: fuzzy-find a symbol in a command-palette overlay.""" + idx = self._func_index + funcs = idx.all_loaded() if idx is not None else [] + if not funcs: + self._status("functions still loading…") + return + self.push_screen(SymbolPalette(funcs), self._on_symbol_chosen) + + def _on_symbol_chosen(self, addr: int | None) -> None: + if addr is None: + return + f = self._func_index.by_addr(addr) if self._func_index else None + self._open_function(addr, f.name if f else hex(addr)) + def action_toggle_view(self) -> None: """Tab: switch the code pane between disassembly and pseudocode.""" if self._cur is None: @@ -1348,8 +1489,10 @@ class IdaTui(App): if len(self._nav) > 1: self._nav.pop() self._open_entry(self._nav[-1], push=False) - else: + elif self.query_one("#left", FunctionsPanel).display: table.focus() + else: + self.query_one(DisasmView).focus() # -- input submit (filter / goto) ------------------------------------- # def on_search_requested(self, msg: SearchRequested) -> None: diff --git a/tests/test_tui.py b/tests/test_tui.py index 8874c54..afa995e 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -19,7 +19,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.app import ( # noqa: E402 - DecompView, DisasmView, FunctionsPanel, IdaTui, XrefsScreen, + DecompView, DisasmView, FunctionsPanel, IdaTui, SymbolPalette, XrefsScreen, ) from textual.widgets import DataTable, Input, OptionList, Static # noqa: E402 from rich.text import Text # noqa: E402 @@ -66,7 +66,13 @@ async def run(db): loaded = await wait_until(pilot, lambda: table.row_count > 0) check("function list populated", loaded, f"rows={table.row_count}") + # The names pane is an overlay now (Ctrl+N) and starts hidden; reveal the + # classic docked pane (Ctrl+B) for the table-driven checks below. left = app.query_one("#left") + check("names pane starts hidden (overlay-first)", not left.display, + f"display={left.display}") + await pilot.press("ctrl+b") + await wait_until(pilot, lambda: left.display, timeout=5) check("function pane width is capped (doesn't eat the screen)", left.size.width <= 44, f"width={left.size.width}") @@ -78,6 +84,39 @@ async def run(db): nfuncs = table.row_count print(f" loaded {nfuncs} functions") + # Symbol palette (Ctrl+N): fuzzy find + open, overlay-style. + await pilot.press("ctrl+n") + pal_open = await wait_until(pilot, lambda: isinstance(app.screen, SymbolPalette), 10) + check("Ctrl+N opens the symbol palette", pal_open, + f"screen={type(app.screen).__name__}") + if pal_open: + pal = app.screen + pinp = pal.query_one(Input) + pinp.value = "main" + await wait_until(pilot, lambda: pal._results and pal._results[0].name == "main", 10) + check("palette fuzzy-finds (top result matches the query)", + bool(pal._results) and pal._results[0].name == "main", + f"top={pal._results[0].name if pal._results else None}") + pinp.value = "eror" # scattered subsequence of 'error' + await wait_until(pilot, lambda: any(f.name == "error" for f in pal._results), 10) + check("palette matches a fuzzy subsequence", + any(f.name == "error" for f in pal._results), + f"results={[f.name for f in pal._results[:4]]}") + pinp.value = "main" + await wait_until(pilot, lambda: pal._results and pal._results[0].name == "main", 10) + want = pal._results[0].addr + await pilot.press("enter") + await wait_until(pilot, lambda: not isinstance(app.screen, SymbolPalette), 10) + await wait_until(pilot, lambda: app._cur and app._cur.ea == want, 20) + check("selecting a palette entry opens that function", + bool(app._cur) and app._cur.ea == want, + f"cur={app._cur.ea if app._cur else None}") + await pilot.press("ctrl+n") + await wait_until(pilot, lambda: isinstance(app.screen, SymbolPalette), 10) + await pilot.press("escape") + await wait_until(pilot, lambda: not isinstance(app.screen, SymbolPalette), 10) + check("Esc closes the palette", not isinstance(app.screen, SymbolPalette)) + # Open the biggest function we can find (scan a sample of rows for size). # Simpler: select the row whose Size column is largest among first N. biggest_i, biggest_sz = 0, -1 |
