aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 12:58:10 +0200
committerblasty <blasty@local>2026-07-09 12:58:10 +0200
commit01efa57c2a9547ae37add7ebb11cafd8de50ddbf (patch)
treeb37fb90b38451ec154bd4d174f6a78434bf33ad1
parentvim-style search in disasm + pseudocode views (diff)
downloadida-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.py103
-rw-r--r--tests/test_tui.py20
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