diff options
| author | blasty <blasty@local> | 2026-07-09 12:58:10 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 12:58:10 +0200 |
| commit | 01efa57c2a9547ae37add7ebb11cafd8de50ddbf (patch) | |
| tree | b37fb90b38451ec154bd4d174f6a78434bf33ad1 | |
| parent | vim-style search in disasm + pseudocode views (diff) | |
| download | ida-tui-01efa57c2a9547ae37add7ebb11cafd8de50ddbf.tar.gz ida-tui-01efa57c2a9547ae37add7ebb11cafd8de50ddbf.tar.xz ida-tui-01efa57c2a9547ae37add7ebb11cafd8de50ddbf.zip | |
search UX: visible prompt + incremental (as-you-type) highlighting
- Fix overlap: #search, #status and Footer all docked bottom, and Input defaults
to a 3-row bordered widget squeezed into 1 row. Now the search bar is a clean
1-row (border:none) that REPLACES the status line while active (status hidden),
so the query is always visible.
- Incremental search: highlight all matches + preview-jump from the origin on
every keystroke (Input.Changed), not just on submit. Disasm indexes once then
each keystroke is instant. Enter keeps; empty Enter repeats last; Esc cancels
and restores the origin cursor + status line.
- pilot suite 23/23 (adds: bar-visible/no-overlap, incremental-before-Enter,
Esc-cancel).
| -rw-r--r-- | idatui/app.py | 103 | ||||
| -rw-r--r-- | tests/test_tui.py | 20 |
2 files changed, 109 insertions, 14 deletions
diff --git a/idatui/app.py b/idatui/app.py index 1c7d032..ac95657 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -125,19 +125,67 @@ class SearchMixin: 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: + # --- driven by the app's prompt (incremental / as-you-type) --- + def search_begin(self, direction: int) -> None: + self._search_origin = self.cursor + self._search_dir = direction + + def search_update(self, term: str) -> None: + """Live preview: highlight all matches and jump from the origin.""" + if not term: + self._term = "" + self._matches = [] + self._ranges = {} + self.cursor = getattr(self, "_search_origin", self.cursor) + self.refresh() + self._app_status("/") + return self._term = term self._ci = term.islower() # smartcase - self._pending_dir = direction - self._search_ensure(self._after_search_ready) + self._search_ensure(self._after_incremental) - def _after_search_ready(self) -> None: + def _after_incremental(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) + self._jump_from(getattr(self, "_search_origin", 0), + getattr(self, "_search_dir", 1), include_current=True) + n = len(self._matches) + self._app_status(f"/{self._term} {n} match{'' if n == 1 else 'es'}") + + def _jump_from(self, origin: int, direction: int, include_current: bool) -> None: + if not self._matches: + return + if direction >= 0: + nxt = next((m for m in self._matches + if (m >= origin if include_current else m > origin)), + self._matches[0]) + else: + nxt = next((m for m in reversed(self._matches) + if (m <= origin if include_current else m < origin)), + self._matches[-1]) + self._goto_line(nxt) + + def search_commit(self) -> None: + if self._term: + self._last_term = self._term + + def search_cancel(self) -> None: + self._term = "" + self._matches = [] + self._ranges = {} + self.cursor = getattr(self, "_search_origin", self.cursor) + self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False) + self.refresh() + + def repeat_last(self, direction: int) -> None: + term = getattr(self, "_last_term", "") + if not term: + self._app_status("no previous search") + return + self._term = term + self._ci = term.islower() + self._search_ensure(lambda: (self._compute_matches(), self.refresh(), + self.search_repeat(direction))) def _compute_matches(self) -> None: term = self._term @@ -566,7 +614,10 @@ class IdaTui(App): #func-filter { dock: top; } DisasmView { width: 1fr; padding: 0 1; } DecompView { width: 1fr; } - #search { dock: bottom; height: 1; } + #search { + dock: bottom; height: 1; border: none; padding: 0 1; + background: $primary-darken-2; color: $text; + } #status { dock: bottom; height: 1; background: $panel; color: $text; padding: 0 1; } """ @@ -740,13 +791,39 @@ class IdaTui(App): # -- input submit (filter / goto) ------------------------------------- # def on_search_requested(self, msg: SearchRequested) -> None: self._search_ctx = (msg.view, msg.direction) + msg.view.search_begin(msg.direction) + self.query_one("#status", Static).display = False # give the row to search inp = self.query_one("#search", Input) - inp.placeholder = "search /" if msg.direction >= 0 else "search backward ?" + inp.placeholder = "type to search… Enter=keep Esc=cancel" inp.can_focus = True inp.display = True inp.value = "" inp.focus() + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id != "search": + return + view, _ = self._search_ctx + if view is not None: + view.search_update(event.value) + + def _end_search(self, cancel: bool = False) -> None: + inp = self.query_one("#search", Input) + inp.display = False + inp.can_focus = False + self.query_one("#status", Static).display = True + view, _ = self._search_ctx + if view is not None: + if cancel: + view.search_cancel() + view.focus() + + def on_key(self, event) -> None: # type: ignore[no-untyped-def] + if event.key == "escape" and self.query_one("#search", Input).display: + event.stop() + event.prevent_default() + self._end_search(cancel=True) + def on_input_submitted(self, event: Input.Submitted) -> None: value = event.value.strip() inp = event.input @@ -756,10 +833,10 @@ class IdaTui(App): view, direction = self._search_ctx if view is not None: if value: - view.set_search(value, direction) + view.search_commit() else: - view.search_repeat(direction) - view.focus() + view.repeat_last(direction) + self._end_search() return if getattr(self, "_goto_mode", False): self._goto_mode = False diff --git a/tests/test_tui.py b/tests/test_tui.py index 3b97739..2156fad 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -13,7 +13,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, Static # noqa: E402 +from textual.widgets import DataTable, Input, Static # noqa: E402 PASS = FAIL = 0 @@ -181,6 +181,24 @@ async def run(db): check("'?' repeats to previous match", dis.cursor in dis._matches, f"cursor={dis.cursor}") + # Incremental search + visible bar + Esc cancel. + si = app.query_one("#search", Input) + status = app.query_one("#status", Static) + await pilot.press("slash") + await pilot.pause(0.2) + check("search bar visible, status hidden (no overlap)", + si.display and not status.display, f"si={si.display} status={status.display}") + for ch in term: + await pilot.press(ch) + await pilot.pause(0.1) + check("matches highlight incrementally (before Enter)", + len(dis._matches) > 0 and si.value == term, f"val={si.value!r}") + await pilot.press("escape") + await pilot.pause(0.2) + check("Esc cancels: status restored, matches cleared", + status.display and not si.display and not dis._matches, + f"status={status.display} si={si.display} m={len(dis._matches)}") + def main(argv): db = None |
