diff options
| author | blasty <blasty@local> | 2026-08-09 23:16:54 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-09 23:16:54 +0200 |
| commit | 0717de70acb7ff9d2b57044853b68a921a73b2a6 (patch) | |
| tree | da498d8a594c932611f32c4c24c77d6a79e43e05 /tests | |
| parent | README: drop the Docs section (diff) | |
| download | ida-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.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_scenarios.py | 107 |
1 files changed, 107 insertions, 0 deletions
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) |
