aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-07 23:24:12 +0200
committerblasty <blasty@local>2026-08-07 23:24:12 +0200
commit5ccbc09ce7d43d226683589e8141f9e216f2f176 (patch)
tree86e83b19c322b1effd4fce42ade3c9f3fbb9cb1e
parentCtrl+F: search the whole database, by text or by bytes (diff)
downloadida-tui-5ccbc09ce7d43d226683589e8141f9e216f2f176.tar.gz
ida-tui-5ccbc09ce7d43d226683589e8141f9e216f2f176.tar.xz
ida-tui-5ccbc09ce7d43d226683589e8141f9e216f2f176.zip
Centre modals with a rule about modals, not a list of them
SearchPalette opened pinned to the top of the screen: the CSS named the screens that centre (`SymbolPalette, StringsPalette, ProjectPalette, …`) and a new dialog is not on a list nobody remembers to edit. The comment sitting above that rule — "every #pal-box palette centres, not just the symbol one" — was the *first* time this happened. `ModalScreen { align: center middle; }` matches subclasses, so every dialog inherits it and the next one is centred for free; the eight per-screen rules that only repeated it are gone. Textual's own Ctrl+P CommandPalette is a ModalScreen too and wants its stock top alignment, so it opts out in one visible line rather than by omission. The `modal_centering` scenario checks both halves: that centring is expressed as a rule, and that it actually reaches a dialog's laid-out region (above/below and left/right within a cell). 894 passed, 0 failed.
-rw-r--r--docs/TEXTUAL_NOTES.md7
-rw-r--r--idatui/app.py21
-rw-r--r--tests/test_scenarios.py45
3 files changed, 63 insertions, 10 deletions
diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md
index 83d385c..10df669 100644
--- a/docs/TEXTUAL_NOTES.md
+++ b/docs/TEXTUAL_NOTES.md
@@ -53,6 +53,13 @@ Hard-won Textual behaviour and the patterns this app relies on. Pairs with
`build_byte_to_codepoint_dict`, so character offsets smear on non-ASCII), and
a `TextAreaTheme` that sets `base_style` overrides the widget's CSS colours —
ours sets only `syntax_styles` so the editor keeps the app's background.
+- **Centre modals with a rule, not a list.** `ModalScreen { align: center middle; }`
+ matches subclasses, so every dialog inherits it and the next one is centred
+ for free. Naming the screens instead (`SymbolPalette, StringsPalette, …`) is
+ how two palettes shipped pinned to the top of the screen. Textual's own
+ `CommandPalette` is a ModalScreen too and wants its stock top alignment, so it
+ opts out explicitly. The `modal_centering` scenario asserts both the rule and
+ that it reaches a real dialog's `region`.
- **Modal chrome lives in ONE grouped CSS rule** (`#quit-box, #help-box, …` in
`IdaTui.CSS`): `border: round`, title colour, subtitle colour. Per-box rules
carry only size. Dialog titles are `border_title`/`border_subtitle` on the box,
diff --git a/idatui/app.py b/idatui/app.py
index 9082d99..bbf8e4e 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -4997,11 +4997,9 @@ class IdaTui(App):
#pal-box OptionList, #se-box OptionList {
border: none; background: transparent; padding: 0 1;
}
- QuitScreen { align: center middle; }
#quit-box { width: 64; height: auto; padding: 1 1 0 1; }
#quit-list { height: auto; padding: 0 1; }
#quit-help { height: 1; color: $text-muted; padding: 0 1; margin-top: 1; }
- HelpScreen { align: center middle; }
#help-box { width: auto; max-width: 98%; height: auto; max-height: 90%; }
#help-body { height: auto; max-height: 100%; width: auto; padding: 1 1; }
#help-cols { height: auto; width: auto; }
@@ -5009,12 +5007,19 @@ class IdaTui(App):
.help-card { height: auto; width: auto; padding: 0 1;
border: round $panel-lighten-2; }
#help-foot { dock: bottom; height: 1; color: $text-muted; padding: 0 2; }
- XrefsScreen { align: center middle; }
#xref-box { width: 84; max-height: 70%; height: auto; padding: 0 0; }
#xref-list { height: auto; max-height: 100%; }
- /* every #pal-box palette centres, not just the symbol one */
- SymbolPalette, StringsPalette, ProjectPalette,
- LoadOptionsScreen, RegWriteScreen { align: center middle; }
+ /* A dialog centres because it is a dialog — not because someone remembered
+ to add it to a list. That list is how SearchPalette shipped pinned to the
+ top of the screen, and the comment that used to sit here ("every #pal-box
+ palette centres, not just the symbol one") was itself the second time.
+ ModalScreen matches subclasses, so every modal below inherits this and
+ the next one gets it for free; `modal_centering` in the pilot suite
+ fails if one ever opts out by accident. Textual's own Ctrl+P palette is a
+ ModalScreen too and wants its stock top alignment, so it opts out here,
+ deliberately and visibly. */
+ ModalScreen { align: center middle; }
+ CommandPalette { align: center top; }
/* Give the stock Ctrl+P command palette side padding instead of full width;
the input + results inherit this width (results is an overlay, so pin it). */
CommandPalette > Vertical { width: 80%; max-width: 120; }
@@ -5035,7 +5040,6 @@ class IdaTui(App):
#load-base { border: none; height: 1; margin: 1 1 0 1; background: $panel; color: $text; }
#load-help { height: 1; padding: 0 1; color: $text-muted; }
#confirm-note { height: auto; color: $text-muted; }
- StructEditor { align: center middle; }
#se-box { width: 90%; height: 84%; }
#se-panes { height: 1fr; }
#se-left { width: 38; border-right: solid $panel-lighten-3; }
@@ -5052,18 +5056,15 @@ class IdaTui(App):
instead of the old near-black bar. */
#se-status { height: 1; background: $panel-darken-1; color: $text-muted;
padding: 0 1; }
- ConfirmScreen { align: center middle; }
#confirm-box { width: 60; height: auto; padding: 1 2; }
#confirm-msg { height: auto; }
#confirm-help { height: 1; color: $text-muted; margin-top: 1; }
- LoadingScreen { align: center middle; }
#loading-box { width: 72; height: auto; padding: 1 2; }
#loading-logo { width: 100%; height: auto; margin-bottom: 1; }
#loading-image { width: 100%; margin-bottom: 1; }
#loading-title { width: 1fr; height: 1; text-style: bold; }
#loading-note { height: auto; color: $text-muted; margin-top: 1; }
#loading-help { height: auto; color: $text-muted; margin-top: 1; }
- BusyScreen { align: center middle; }
#busy-box { width: auto; min-width: 26; height: auto; padding: 1 2; }
#busy-msg { height: 1; text-style: bold; }
#busy-help { height: 1; color: $text-muted; margin-top: 1; }
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 6d95564..37ec017 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -1094,6 +1094,51 @@ async def s_structs(c: Ctx):
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("modal_centering")
+async def s_modal_centering(c: Ctx):
+ """Every dialog we define is centred, without anyone maintaining a list.
+
+ The CSS used to name the screens that centre, so a new palette shipped
+ pinned to the top of the screen (twice). The rule is on ModalScreen now;
+ this fails if a modal ever opts out by accident, which the naked eye only
+ catches when the dialog is already in front of a user.
+ """
+ from textual.screen import ModalScreen
+
+ import idatui.app as A
+
+ ours = sorted(
+ (n for n, v in vars(A).items()
+ if isinstance(v, type) and issubclass(v, ModalScreen)
+ and v is not ModalScreen and v.__module__ == A.__name__),
+ key=str)
+ c.check("found the app's modal screens", len(ours) >= 8, f"{ours}")
+ styles = A.IdaTui.CSS
+ c.check("centring is a rule about modals, not a list of them",
+ "ModalScreen { align: center middle; }" in styles,
+ "the ModalScreen rule is gone")
+ # And prove it REACHES a dialog, rather than just being present in the text.
+ await c.press("ctrl+f")
+ opened = await c.wait(lambda: isinstance(c.app.screen, A.SearchPalette), 10)
+ if not opened:
+ c.check("the search palette opened", False,
+ f"screen={type(c.app.screen).__name__}")
+ return
+ scr = c.app.screen
+ await c.wait(lambda: scr.query_one("#pal-box").region.height > 0, 5)
+ box = scr.query_one("#pal-box").region
+ above, below = box.y, c.app.size.height - (box.y + box.height)
+ c.check("the search palette is vertically centred",
+ box.height > 0 and abs(above - below) <= 1,
+ f"box={box} screen={c.app.size} above={above} below={below}")
+ left = box.x
+ right = c.app.size.width - (box.x + box.width)
+ c.check("and horizontally centred", abs(left - right) <= 1,
+ f"left={left} right={right}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(c.app.screen, A.SearchPalette), 5)
+
+
@scenario("db_search")
async def s_db_search(c: Ctx):
"""Ctrl+F: search the whole database, by disassembly text or by bytes."""