aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 23:00:32 +0200
committerblasty <blasty@local>2026-07-09 23:00:32 +0200
commit7c58501232d493bc8f132d2d745f62a96ea27d82 (patch)
tree4d5ed2ac07cb813115e58380a936486f9ddb175f
parentxrefs: pre-select the site the dialog was invoked from (diff)
downloadida-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.md7
-rw-r--r--idatui/app.py159
-rw-r--r--tests/test_tui.py41
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