aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--idatui/app.py148
-rw-r--r--tests/test_scenarios.py78
3 files changed, 221 insertions, 10 deletions
diff --git a/README.md b/README.md
index 86883d4..c52019b 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,11 @@ a 400 MB binary scrolls like a text file.
**Decompiler** — Hex-Rays pseudocode with syntax highlighting, per-line address
anchors, and rename/retype/comment that write back.
+**Structs / types** (`ctrl+t`) — local types as plain C: the list on the left,
+an editable, syntax-highlighted definition on the right. `Ctrl+S` declares it
+back into the database and reformats to IDA's own layout, `Ctrl+N` starts a new
+one, `d` deletes, and `/` fuzzy-filters the list.
+
**Graph** (`space`) — the current function's basic blocks, laid out with a real
layered (Sugiyama) algorithm and routed edges: green taken, red fall-through,
blue unconditional, purple loop. The boxes hold the *same rows* as the listing,
diff --git a/idatui/app.py b/idatui/app.py
index f8305be..608cc4d 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -4267,14 +4267,24 @@ class StructEditor(ModalScreen):
selected struct; Ctrl+S declares (creates or updates) it; Ctrl+N starts a
new one; Delete removes the highlighted struct; Esc returns to the list then
closes.
+
+ ``/`` from the list opens a fuzzy filter over the struct names -- the same
+ key and the same matcher as the code views' search and the symbol palette,
+ rather than a third way to find something by typing.
"""
BINDINGS = [
Binding("ctrl+s", "save", "Save", priority=True),
Binding("ctrl+n", "new", "New", priority=True),
Binding("ctrl+y", "copy", "Copy", priority=True),
+ Binding("slash", "filter", "Filter", show=False),
Binding("delete,d", "delete", "Delete", show=False),
Binding("escape", "close", "Close"),
+ # Only ever reached while the FILTER has focus: a focused OptionList
+ # consumes up/down itself, so these move its highlight from the prompt
+ # (type to narrow, arrow to pick, exactly like the symbol palette).
+ Binding("up", "cursor_up", "Up", show=False),
+ Binding("down", "cursor_down", "Down", show=False),
]
NEW_TEMPLATE = "struct NewStruct\n{\n int field;\n};\n"
@@ -4284,7 +4294,9 @@ class StructEditor(ModalScreen):
def __init__(self, program: Program) -> None:
super().__init__()
self._program = program
- self._structs: list[Struct] = []
+ self._all: list[Struct] = [] # every struct the database has
+ self._structs: list[Struct] = [] # the VISIBLE rows (== _all when unfiltered)
+ self._filter = ""
self._loaded: str | None = None # name currently in the editor
self._loaded_src: str | None = None # its source, to detect unsaved edits
@@ -4313,12 +4325,17 @@ class StructEditor(ModalScreen):
with Horizontal(id="se-panes"):
with Vertical(id="se-left"):
yield Static("structs", id="se-title")
+ yield Input(placeholder="fuzzy filter\u2026 \u2191\u2193 pick \u00b7 "
+ "Enter edit \u00b7 Esc clear",
+ id="se-filter")
yield OptionList(id="se-list")
with Vertical(id="se-right"):
yield Static("C definition", id="se-hint")
yield CTextArea("", id="se-edit")
+ # Esc is on the border subtitle; repeating it here cost the row.
yield Static(
- "Enter edit · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · d/Del delete · Esc close",
+ "Enter edit · / filter · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · "
+ "d/Del delete",
id="se-status")
def on_mount(self) -> None:
@@ -4336,31 +4353,134 @@ class StructEditor(ModalScreen):
self.app.call_from_thread(self._populate, structs, select)
def _populate(self, structs: list[Struct], select: str | None) -> None:
- self._structs = structs
+ self._all = structs
+ self._show(select)
+
+ def _show(self, select: str | None = None) -> None:
+ """Rebuild the list from ``_all`` through the current filter.
+
+ ``_structs`` stays the VISIBLE rows, because every action here indexes
+ it by the list's highlighted row -- load, delete and the confirm dialogs
+ would all address the wrong struct if the two ever drifted apart.
+ """
+ q = self._filter.strip()
+ # A struct we were told to select but the filter would hide (a rename, or
+ # one just created under a stale filter) beats the filter -- otherwise a
+ # successful save looks like the struct vanished.
+ if q and select is not None and _fuzzy(select, q) is None:
+ inp = self.query_one("#se-filter", Input)
+ inp.value = ""
+ inp.display = False
+ self._filter = q = ""
+ rows: list[tuple[Struct, tuple[int, ...]]] = []
+ if q:
+ scored = []
+ for s in self._all:
+ m = _fuzzy(s.name, q)
+ if m is not None:
+ scored.append((m[0], m[1], s))
+ scored.sort(key=lambda t: (-t[0], t[2].name))
+ rows = [(s, pos) for _, pos, s in scored]
+ else:
+ rows = [(s, ()) for s in self._all]
+ self._structs = [s for s, _ in rows]
+
ol = self.query_one("#se-list", OptionList)
ol.clear_options()
# Ragged rows read as noise, so the name column is padded to the widest
# name actually present (capped, so one monstrous C++ mangling can't
# push the size/count columns off the pane).
- width = min(max((len(s.name) for s in structs), default=0), 22)
- for s in structs:
+ width = min(max((len(s.name) for s, _ in rows), default=0), 22)
+ opts = []
+ for s, pos in rows:
kw = "union" if s.is_union else "struct"
name = s.name if len(s.name) <= width else s.name[:width - 1] + "\u2026"
label = Text()
- label.append(f"{name:<{width}}", _S_LABEL)
+ nm = Text(f"{name:<{width}}", style=_S_LABEL)
+ for p in pos:
+ if p < len(name):
+ nm.stylize(_S_NAME_MATCH, p, p + 1)
+ label.append_text(nm)
label.append(f"{s.size:>#7x}{s.members:>4}f {kw}", _S_DIM)
- ol.add_option(Option(label))
- if structs:
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if rows:
idx = 0
if select is not None:
- idx = next((i for i, s in enumerate(structs) if s.name == select), 0)
+ idx = next((i for i, s in enumerate(self._structs)
+ if s.name == select), 0)
ol.highlighted = idx
+ cap = "structs"
+ if q:
+ cap = f"structs {len(rows)}/{len(self._all)}"
+ self.query_one("#se-title", Static).update(cap)
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
i = event.option_index
if 0 <= i < len(self._structs):
self._confirm_discard(lambda n=self._structs[i].name: self._load(n))
+ # -- filter ("/" from the list) ----------------------------------------- #
+ def action_filter(self) -> None:
+ """'/': fuzzy-filter the struct list, as you type."""
+ # A '/' typed into the definition is a division operator, and one typed
+ # into the filter is the Input's business; only the list opens this.
+ if self.focused is not self.query_one("#se-list", OptionList):
+ return
+ inp = self.query_one("#se-filter", Input)
+ inp.display = True
+ inp.value = self._filter
+ inp.focus()
+
+ def _clear_filter(self, focus_list: bool = True) -> None:
+ inp = self.query_one("#se-filter", Input)
+ inp.value = ""
+ inp.display = False
+ self._filter = ""
+ self._show(select=self._loaded)
+ if focus_list:
+ self.query_one("#se-list", OptionList).focus()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ # Modal Input messages bubble to the App (which owns #func-filter and
+ # the search prompt) -- stop them here or they drive the main screen.
+ if event.input.id != "se-filter":
+ return
+ event.stop()
+ self._filter = event.value
+ self._show()
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ if event.input.id != "se-filter":
+ return
+ event.stop()
+ # Enter on a filtered list loads the highlighted struct, like Enter on
+ # the list itself -- typing a name and pressing Enter should open it.
+ ol = self.query_one("#se-list", OptionList)
+ i = ol.highlighted
+ if i is not None and 0 <= i < len(self._structs):
+ self._confirm_discard(lambda n=self._structs[i].name: self._load(n))
+ else:
+ ol.focus()
+
+ def _filter_focused(self) -> bool:
+ return self.focused is self.query_one("#se-filter", Input)
+
+ def action_cursor_up(self) -> None:
+ self._move_highlight(-1)
+
+ def action_cursor_down(self) -> None:
+ self._move_highlight(1)
+
+ def _move_highlight(self, delta: int) -> None:
+ if not self._filter_focused():
+ return # the list has focus and moves itself
+ ol = self.query_one("#se-list", OptionList)
+ if not ol.option_count:
+ return
+ cur = ol.highlighted or 0
+ ol.highlighted = max(0, min(cur + delta, ol.option_count - 1))
+
@work(thread=True, exclusive=True, group="se-load")
def _load(self, name: str) -> None:
try:
@@ -4485,10 +4605,15 @@ class StructEditor(ModalScreen):
self._set_status(f"copied {what} ({n} chars) to clipboard")
def action_close(self) -> None:
- # A stray Esc while editing returns to the list instead of discarding.
+ # Esc backs out one level at a time: out of the definition, then out of
+ # the filter, and only then out of the dialog. Closing the editor from
+ # under a half-typed filter is the kind of thing you only do once.
if self.focused is self.query_one("#se-edit", TextArea):
self.query_one("#se-list", OptionList).focus()
return
+ if self._filter_focused() or self._filter:
+ self._clear_filter()
+ return
self._confirm_discard(lambda: self.dismiss(None),
"Discard unsaved changes and close?")
@@ -4727,6 +4852,9 @@ class IdaTui(App):
/* Column captions, not title bars: the dialog already has one title. */
#se-title, #se-hint { height: 1; color: $text-muted; text-style: bold;
padding: 0 1; }
+ /* Hidden until '/'; same one-row prompt shape as the app's own prompts. */
+ #se-filter { display: none; height: 1; border: none; padding: 0 1;
+ background: $primary-darken-2; color: $text; }
#se-list { height: 1fr; }
#se-edit { height: 1fr; border: none; padding: 0 1; }
/* A footer, but a quiet one: a shade of the dialog's own background
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index ff45281..d7c1f14 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -998,6 +998,84 @@ async def s_structs(c: Ctx):
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("struct_filter")
+async def s_struct_filter(c: Ctx):
+ app = c.app
+ await c.press("ctrl+t")
+ if not await c.wait(lambda: isinstance(app.screen, StructEditor), 10):
+ c.check("Ctrl+T opens the struct editor", False,
+ f"screen={type(app.screen).__name__}")
+ return
+ se = app.screen
+ await c.wait(lambda: bool(se._structs), 15)
+ total = len(se._structs)
+ ol = se.query_one("#se-list", OptionList)
+ inp = se.query_one("#se-filter", Input)
+ # Something past the first row, so a filter that "works" by doing nothing
+ # can't pass: its own name must survive and the others must not.
+ target = se._structs[min(2, total - 1)].name
+ q = "".join(ch for ch in target.lower() if ch.isalnum())[:3]
+
+ ol.focus()
+ await c.press("slash")
+ opened = await c.wait(lambda: inp.display and app.focused is inp, 5)
+ c.check("'/' from the list opens the struct filter", opened,
+ f"display={inp.display} focus={getattr(app.focused, 'id', None)}")
+ for ch in q:
+ await c.press(ch)
+ await c.wait(lambda: len(se._structs) < total, 5)
+ c.check("typing fuzzy-filters the struct list",
+ 0 < len(se._structs) < total and
+ any(s.name == target for s in se._structs),
+ f"q={q!r} {len(se._structs)}/{total}")
+ cap = str(se.query_one("#se-title", Static).render())
+ c.check("the caption counts what the filter kept",
+ f"{len(se._structs)}/{total}" in cap, f"caption={cap!r}")
+
+ # 'd' is the delete binding on this screen: in the prompt it must be a
+ # character, not a destructive verb aimed at the highlighted struct.
+ await c.press("d")
+ await c.pause(0.05)
+ c.check("'d' in the filter types instead of deleting",
+ isinstance(app.screen, StructEditor) and inp.value == q + "d",
+ f"screen={type(app.screen).__name__} value={inp.value!r}")
+ await c.press("backspace")
+ await c.wait(lambda: inp.value == q, 5)
+
+ # Arrows drive the list while the prompt keeps focus (symbol-palette feel).
+ before = ol.highlighted
+ await c.press("down")
+ await c.pause(0.05)
+ c.check("arrows move the list while the filter has focus",
+ app.focused is inp and (ol.highlighted != before
+ or ol.option_count == 1),
+ f"{before} -> {ol.highlighted} of {ol.option_count}")
+
+ sel = se._structs[ol.highlighted or 0].name
+ ta = se.query_one(TextArea)
+ ta.text = ""
+ await c.press("enter")
+ loaded = await c.wait(lambda: sel in ta.text, 15)
+ c.check("Enter in the filter loads the highlighted struct", loaded,
+ f"want {sel!r} in {ta.text[:40]!r}")
+
+ # Esc backs out one level at a time: definition -> filter -> dialog.
+ await c.press("escape")
+ await c.wait(lambda: app.focused is ol, 5)
+ c.check("Esc leaves the definition for the list", app.focused is ol,
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("escape")
+ cleared = await c.wait(lambda: len(se._structs) == total, 5)
+ c.check("Esc clears the filter instead of closing",
+ cleared and not inp.display and isinstance(app.screen, StructEditor),
+ f"n={len(se._structs)}/{total} display={inp.display}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, StructEditor), 10)
+ c.check("a third Esc closes the editor",
+ not isinstance(app.screen, StructEditor),
+ f"screen={type(app.screen).__name__}")
+
+
@scenario("open_default_view")
async def s_open(c: Ctx):
app = c.app