aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-09 23:16:54 +0200
committerblasty <blasty@local>2026-08-09 23:16:54 +0200
commit0717de70acb7ff9d2b57044853b68a921a73b2a6 (patch)
treeda498d8a594c932611f32c4c24c77d6a79e43e05
parentREADME: drop the Docs section (diff)
downloadida-tui-0717de70acb7ff9d2b57044853b68a921a73b2a6.tar.gz
ida-tui-0717de70acb7ff9d2b57044853b68a921a73b2a6.tar.xz
ida-tui-0717de70acb7ff9d2b57044853b68a921a73b2a6.zip
PgUp/PgDn page the list overlays, not just the code views
The help screen has always advertised 'PgDn / PgUp - page down / up', but only the four code views implemented it. In the palettes the keys did nothing at all: those screens focus a filter Input, so the OptionList's own pageup/pagedown bindings never fire -- every key goes to the Input, and an unhandled one is silently dropped. Adds OptionListNav, a mixin carrying the forwarding actions, and puts the six Input+OptionList overlays on it: symbols, search, strings, registers, load options, projects. They already held six BYTE-IDENTICAL copies of action_cursor_down/up, so this removes more than it adds. Paging delegates to the widget's own action_page_up/down instead of moving by a guessed N: those know the live viewport height, skip disabled options and clamp at both ends -- and it keeps the forwarded panes behaving exactly like the ones that page natively. Two panes deliberately stay off the mixin: * XrefsScreen focuses its list, so Textual already pages it. Now covered by a test so nobody 'fixes' it into double-stepping. * StructEditor binds ctrl+n to 'new type', so it cannot take NAV_BINDINGS; it gets page actions through its existing filter-focused guard instead. BINDINGS do not merge from a plain mixin (Textual only merges them from DOMNode subclasses), so every screen splats *NAV_BINDINGS explicitly -- the same trap SearchMixin documents. Tests gate on scrollable_content_region.height >= 1 first: paging is geometry, and before layout the page size is 0, so every check would pass against a no-op. Verified by removing the bindings again -- 3 checks fail with highlighted=0, which is the exact silent failure being fixed. Full gate: 1040 passed, 0 failed.
-rw-r--r--idatui/app.py178
-rw-r--r--tests/test_scenarios.py107
2 files changed, 200 insertions, 85 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 4b3d5fb..e193c65 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -3110,6 +3110,58 @@ class FunctionsPanel(Vertical):
# --------------------------------------------------------------------------- #
+# Shared list navigation for the overlays
+# --------------------------------------------------------------------------- #
+class OptionListNav:
+ """Cursor + paging for an overlay whose filter ``Input`` keeps the focus.
+
+ These screens focus a filter box, not the list, so the ``OptionList``'s own
+ bindings never fire -- the Input sees every key first. These actions forward
+ to the list on its behalf.
+
+ Paging delegates to the widget's OWN ``action_page_up``/``action_page_down``
+ rather than reimplementing it: those know the live viewport height, skip
+ disabled options and clamp at both ends. A hand-rolled "move by N" here
+ would have to guess the height and would drift from the list that DOES have
+ focus (``XrefsScreen``, or the struct list), which pages natively.
+
+ Textual only merges ``BINDINGS`` from ``DOMNode`` subclasses, so a plain
+ mixin's are silently dropped -- every screen must splat ``*NAV_BINDINGS``
+ (or list its own keys) explicitly. Same trap as ``SearchMixin``.
+ """
+
+ #: The common key set. ``StructEditor`` deliberately does NOT use this: it
+ #: binds ctrl+n to "new type", so it lists bare up/down itself.
+ NAV_BINDINGS = [
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("pagedown", "page_down", show=False),
+ Binding("pageup", "page_up", show=False),
+ ]
+
+ def _nav_list(self):
+ """The list to drive, or None when there is nothing to move through."""
+ ol = self.query_one(OptionList)
+ return ol if ol.option_count else None
+
+ def action_cursor_down(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_page_down(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.action_page_down()
+
+ def action_page_up(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.action_page_up()
+
+
+# --------------------------------------------------------------------------- #
# Xrefs popup
# --------------------------------------------------------------------------- #
class XrefsScreen(ModalScreen):
@@ -3181,13 +3233,12 @@ def _fuzzy(name: str, q: str):
return (score, tuple(pos))
-class SymbolPalette(ModalScreen):
+class SymbolPalette(OptionListNav, 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),
+ *OptionListNav.NAV_BINDINGS,
# F2, not ctrl+a: the focused Input binds "home,ctrl+a" so it would never
# reach us. Function keys are untouched by Input.
Binding("f2", "scope", "This binary / whole project", show=False),
@@ -3291,16 +3342,6 @@ class SymbolPalette(ModalScreen):
self.query_one("#pal-box").border_title = Text(
f"symbols [{scope}]: {len(self._results)}{more}{hint}")
- 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
@@ -3326,7 +3367,7 @@ def _str_display(text: str, limit: int = 200) -> str:
return out[:limit] + ("\u2026" if len(out) > limit else "")
-class SearchPalette(ModalScreen):
+class SearchPalette(OptionListNav, ModalScreen):
"""Ctrl+F: search the whole database, by text or by bytes.
Unlike every other palette here this does NOT filter as you type: each
@@ -3343,8 +3384,7 @@ class SearchPalette(ModalScreen):
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("f2", "mode", "Text / bytes", show=False),
]
LIMIT = 500
@@ -3478,16 +3518,6 @@ class SearchPalette(ModalScreen):
f"hit{'' if n == 1 else 's'} \u2014 Enter opens")
# -- moving / choosing --------------------------------------------------- #
- 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
@@ -3502,14 +3532,13 @@ class SearchPalette(ModalScreen):
self.dismiss(None)
-class StringsPalette(ModalScreen):
+class StringsPalette(OptionListNav, ModalScreen):
"""Every string in the binary (IDA's Shift+F12), filterable; Enter jumps to
it in the unified listing."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("f2", "scope", "This binary / whole project", show=False),
]
LIMIT = 500
@@ -3607,16 +3636,6 @@ class StringsPalette(ModalScreen):
self.query_one("#pal-box").border_title = Text(
f"strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}")
- 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
@@ -3824,7 +3843,7 @@ class HelpScreen(ModalScreen):
self.dismiss(None)
-class RegWriteScreen(ModalScreen):
+class RegWriteScreen(OptionListNav, ModalScreen):
"""Registers, and the instruction that set each one.
"Which instruction set this register to its current value?" is the question
@@ -3835,8 +3854,7 @@ class RegWriteScreen(ModalScreen):
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("enter", "choose", show=False, priority=True),
Binding("f", "choose_forward", show=False),
]
@@ -3874,16 +3892,6 @@ class RegWriteScreen(ModalScreen):
ol.highlighted = 0
ol.focus()
- 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 _pick(self, forward: bool) -> None:
i = self.query_one(OptionList).highlighted
if i is None or not (0 <= i < len(self._rows)):
@@ -4039,7 +4047,7 @@ class TraceTimeline(Static):
return out
-class LoadOptionsScreen(ModalScreen):
+class LoadOptionsScreen(OptionListNav, ModalScreen):
"""Ask how to load a file no loader recognised.
IDA's own answer to an unidentified file is a dialog; ours is this. Without
@@ -4054,8 +4062,7 @@ class LoadOptionsScreen(ModalScreen):
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("enter", "choose", show=False, priority=True),
]
@@ -4138,16 +4145,6 @@ class LoadOptionsScreen(ModalScreen):
self.query_one("#pal-box").border_title = Text(
f"unrecognised file \u2014 processor? ({len(rows)})")
- 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
@@ -4177,14 +4174,13 @@ class LoadOptionsScreen(ModalScreen):
self.dismiss({})
-class ProjectPalette(ModalScreen):
+class ProjectPalette(OptionListNav, ModalScreen):
"""The project's binaries; Enter switches to one. Shows which are resident
(a live worker, so switching is instant) vs cold (needs an open)."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
]
def __init__(self, entries: list[dict]) -> None:
@@ -4244,16 +4240,6 @@ class ProjectPalette(ModalScreen):
self.query_one("#pal-box").border_title = Text(
f"binaries: {len(rows)} of {len(self._entries)}")
- 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:
i = self.query_one(OptionList).highlighted
if i is not None and 0 <= i < len(self._results):
@@ -4522,10 +4508,13 @@ class StructEditor(ModalScreen):
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
+ # consumes these itself, so they move its highlight from the prompt
# (type to narrow, arrow to pick, exactly like the symbol palette).
+ # NOT OptionListNav.NAV_BINDINGS: ctrl+n is "new type" here.
Binding("up", "cursor_up", "Up", show=False),
Binding("down", "cursor_down", "Down", show=False),
+ Binding("pageup", "page_up", show=False),
+ Binding("pagedown", "page_down", show=False),
]
NEW_TEMPLATE = "struct NewStruct\n{\n int field;\n};\n"
@@ -4713,14 +4702,33 @@ class StructEditor(ModalScreen):
def action_cursor_down(self) -> None:
self._move_highlight(1)
- def _move_highlight(self, delta: int) -> None:
+ def action_page_up(self) -> None:
+ self._page(-1)
+
+ def action_page_down(self) -> None:
+ self._page(1)
+
+ def _list_from_filter(self):
+ """The type list, but only while the FILTER holds focus.
+
+ When the list itself has focus it handles these keys natively, and
+ forwarding as well would move the highlight twice.
+ """
if not self._filter_focused():
- return # the list has focus and moves itself
+ return None
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))
+ return ol if ol.option_count else None
+
+ def _move_highlight(self, delta: int) -> None:
+ ol = self._list_from_filter()
+ if ol is not None:
+ ol.highlighted = max(0, min((ol.highlighted or 0) + delta,
+ ol.option_count - 1))
+
+ def _page(self, direction: int) -> None:
+ ol = self._list_from_filter()
+ if ol is not None:
+ (ol.action_page_down if direction > 0 else ol.action_page_up)()
@work(thread=True, exclusive=True, group="se-load")
def _load(self, name: str) -> None:
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index d5090e0..038cac5 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -412,6 +412,98 @@ async def s_auto_land(c: Ctx):
c.check("auto-land is idempotent (guarded)", app._cur is prev)
+@scenario("palette_paging")
+async def s_palette_paging(c: Ctx):
+ """PgUp/PgDn move the palette list by a viewport, with the Input focused.
+
+ The palettes focus their filter box, not the list, so the OptionList's own
+ pageup/pagedown bindings never fire -- OptionListNav forwards them. That
+ forwarding is the thing under test; if it regresses, these keys silently do
+ nothing (the failure mode is a no-op, not an error).
+ """
+ app = c.app
+ await c.press("ctrl+n")
+ if not await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10):
+ c.check("palette opens for the paging test", False)
+ return
+ pal = app.screen
+ ol = pal.query_one(OptionList)
+ inp = pal.query_one(Input)
+ await c.wait(lambda: ol.option_count > 5, 10)
+
+ # Paging is GEOMETRY: the widget moves by scrollable_content_region.height,
+ # which is 0 until a frame has been laid out. Without this wait every check
+ # below would pass vacuously against a zero-height page.
+ await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10)
+ page = ol.scrollable_content_region.height
+ c.check("the palette list has a real viewport to page by", page >= 1,
+ f"height={page}")
+ if ol.option_count <= 2:
+ c.check("enough symbols to page through", False, f"n={ol.option_count}")
+ return
+
+ c.check("the filter Input holds focus (so the list never sees the key)",
+ pal.focused is inp, f"focused={type(pal.focused).__name__}")
+
+ ol.highlighted = 0
+ await c.press("pagedown")
+ down = ol.highlighted or 0
+ # A page, not a line: the bug this guards against is PgDn falling through to
+ # the Input and moving nothing, or degrading to a single-step cursor move.
+ c.check("PgDn moves the symbol list by more than one row", down > 1,
+ f"highlighted={down} page={page} n={ol.option_count}")
+ c.check("PgDn moves by about a viewport (or lands on the last row)",
+ down >= min(page, ol.option_count - 1) - 1,
+ f"highlighted={down} page={page} n={ol.option_count}")
+
+ await c.press("pageup")
+ c.check("PgUp comes back to the top", (ol.highlighted or 0) == 0,
+ f"highlighted={ol.highlighted}")
+
+ # Clamping: hammering past the end must settle on the last row, not wrap or
+ # raise. 12 pages clears any list this palette will show.
+ for _ in range(12):
+ await c.press("pagedown")
+ c.check("PgDn clamps at the last row",
+ ol.highlighted == ol.option_count - 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}")
+ for _ in range(12):
+ await c.press("pageup")
+ c.check("PgUp clamps at the first row", ol.highlighted == 0,
+ f"highlighted={ol.highlighted}")
+
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
+
+
+@scenario("xrefs_paging")
+async def s_xrefs_paging(c: Ctx):
+ """The xrefs popup focuses its list, so paging is Textual's own.
+
+ A regression guard for the other half of the split: OptionListNav must not
+ be needed here, and must not double-move if someone adds it later.
+ """
+ app = c.app
+ await c.open_biggest("listing")
+ await c.press("x")
+ if not await c.wait(lambda: isinstance(app.screen, XrefsScreen), 10):
+ c.check("xrefs popup opens for the paging test", True,
+ "skipped: no xrefs at this cursor")
+ return
+ scr = app.screen
+ ol = scr.query_one(OptionList)
+ await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10)
+ c.check("the xrefs list itself has focus", scr.focused is ol,
+ f"focused={type(scr.focused).__name__}")
+ if ol.option_count > 2:
+ ol.highlighted = 0
+ await c.press("pagedown")
+ c.check("PgDn pages the xrefs list natively", (ol.highlighted or 0) > 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 10)
+
+
@scenario("palette")
async def s_palette(c: Ctx):
app, pilot = c.app, c.pilot
@@ -1386,6 +1478,21 @@ async def s_struct_filter(c: Ctx):
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)}")
+
+ # PgDn from the FILTER: this screen can't use OptionListNav (ctrl+n is "new
+ # type" here), so it forwards through its own guarded _page(). Checked while
+ # the list is still unfiltered, so there is something to page through.
+ await c.wait(lambda: ol.scrollable_content_region.height >= 1, 5)
+ if ol.option_count > 2:
+ ol.highlighted = 0
+ await c.press("pagedown")
+ c.check("PgDn pages the struct list from the filter prompt",
+ (ol.highlighted or 0) > 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}")
+ await c.press("pageup")
+ c.check("PgUp returns to the first struct", (ol.highlighted or 0) == 0,
+ f"highlighted={ol.highlighted}")
+
for ch in q:
await c.press(ch)
await c.wait(lambda: len(se._structs) < total, 5)