diff options
| -rw-r--r-- | docs/TEXTUAL_NOTES.md | 14 | ||||
| -rw-r--r-- | experiments/modal_shot.py | 123 | ||||
| -rw-r--r-- | idatui/app.py | 142 |
3 files changed, 223 insertions, 56 deletions
diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md index 9970416..83d385c 100644 --- a/docs/TEXTUAL_NOTES.md +++ b/docs/TEXTUAL_NOTES.md @@ -53,6 +53,20 @@ 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. +- **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, + not a docked `Static` — but **`border_title` parses Textual markup** when you + give it a `str` (`render_str`), so a title like `symbols [this binary]: 128` + loses or breaks on the `[…]`. Pass a `rich.text.Text` and it's taken literally. + Also note it keeps only the FIRST line of a multi-line title. +- **Widgets inside a dialog bring their own frames.** `OptionList` defaults to + `border: tall $border-blurred` + `background: $surface`, which inside a bordered + dialog reads as a second panel; the modal rule clears both so the dialog has one + edge. Focus still reads from the highlight bar (bright focused / dim blurred). +- **Look at chrome with `experiments/modal_shot.py`** (no IDA, no worker, any + python3 with textual): it renders each dialog over a fake background at a size + you choose. A tiled pane is the worst possible place to judge a dialog. - **A modal's `Input` messages bubble to the App.** `SymbolPalette` (the Ctrl+N fuzzy finder) has its own `Input`; its `Input.Changed`/`Input.Submitted` bubble up to the app's handlers (which would run the `#search`/`#func-filter` logic and diff --git a/experiments/modal_shot.py b/experiments/modal_shot.py new file mode 100644 index 0000000..2e6cd7a --- /dev/null +++ b/experiments/modal_shot.py @@ -0,0 +1,123 @@ +"""Render a modal's chrome headless, with fake data, and print it. + + python3 experiments/modal_shot.py [name] [cols] [rows] + python3 experiments/modal_shot.py --list + +Chrome (borders, titles, spacing) is the one thing you cannot judge from the +pane you are working in, and opening a binary just to look at a dialog costs a +full auto-analysis. Every modal here is fed a stub, so this needs NO worker, NO +IDA and no .i64 -- it runs under any python with textual, in about a second. + +The app it boots is a bare `App` carrying `IdaTui.CSS` and the app theme, which +is exactly what the modals resolve their styles against. +""" +import asyncio +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from textual.app import App, ComposeResult # noqa: E402 +from textual.widgets import Static # noqa: E402 + +from idatui.app import ( # noqa: E402 + IDATUI_THEME, IdaTui, BusyScreen, ConfirmScreen, HelpScreen, LoadingScreen, + QuitScreen, StructEditor, XrefsScreen, +) +from idatui.domain import Struct # noqa: E402 +from idatui.rpc import screen_text # noqa: E402 +from idatui._sync import wait_for # noqa: E402 + + +class _StubProgram: + """Just enough Program for the struct editor to render.""" + + _STRUCTS = [ + Struct(name="timespec", size=0x10, members=2, is_union=False, ordinal=1), + Struct(name="stat", size=0x90, members=15, is_union=False, ordinal=2), + Struct(name="pthread_mutex_t", size=0x28, members=4, is_union=True, + ordinal=3), + ] + _SRC = ("struct timespec\n" + "{\n" + " __time_t tv_sec; /* seconds */\n" + " __syscall_slong_t tv_nsec;\n" + "};\n") + + def list_structs(self): + return list(self._STRUCTS) + + def struct_source(self, name): + return self._SRC.replace("timespec", name) + + +def _make(name: str): + """name -> a mounted-ready modal screen.""" + if name == "structs": + return StructEditor(_StubProgram()) + if name == "confirm": + return ConfirmScreen("Delete struct 'timespec' ?", + "This cannot be undone.") + if name == "quit": + return QuitScreen(["echo", "libc.so.6"]) + if name == "help": + return HelpScreen() + if name == "busy": + return BusyScreen("decompiling sub_2297\u2026") + if name == "loading": + return LoadingScreen("echo", "opening database\u2026") + if name == "xrefs": + return XrefsScreen(" xrefs to main", [ + (0x1234, " sub_2297+0x1c call main"), + (0x5678, " _start+0x21 mov rdi, main"), + ]) + raise SystemExit(f"unknown modal {name!r}; --list to see them") + + +NAMES = ["structs", "confirm", "quit", "help", "busy", "loading", "xrefs"] + + +class _Shot(App): + CSS = IdaTui.CSS + + def compose(self) -> ComposeResult: + # A little content underneath, so the modal's edge is visible against + # something rather than floating on an empty screen. + yield Static("\n".join(" .... the view behind the dialog ...." + for _ in range(60))) + + def on_mount(self) -> None: + self.register_theme(IDATUI_THEME) + self.theme = IDATUI_THEME.name + + +async def main() -> None: + args = [a for a in sys.argv[1:] if not a.startswith("--")] + if "--list" in sys.argv: + print(" ".join(NAMES)) + return + names = [args[0]] if args else NAMES + cols = int(args[1]) if len(args) > 1 else 100 + rows = int(args[2]) if len(args) > 2 else 32 + + for name in names: + app = _Shot() + async with app.run_test(size=(cols, rows)) as pilot: + app.push_screen(_make(name)) + await pilot.pause() + if name == "structs": + await wait_for( + lambda: bool(getattr(app.screen, "_structs", None)), + pilot.pause, 5) + app.screen.on_option_list_option_selected( + type("E", (), {"option_index": 0})()) + await wait_for(lambda: "{" in app.screen.query_one( + "#se-edit").text, pilot.pause, 5) + await pilot.pause() + print(f"\n=== {name} " + "=" * (cols - len(name) - 5)) + print(screen_text(app)["text"]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/idatui/app.py b/idatui/app.py index c331904..f8305be 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -3092,8 +3092,8 @@ class XrefsScreen(ModalScreen): self._preselect = preselect def compose(self) -> ComposeResult: - with Vertical(id="xref-box"): - yield Static(self._label, id="xref-title") + with Vertical(id="xref-box") as box: + box.border_title = Text(self._label.strip()) yield OptionList(*[Option(text) for _, text in self._items], id="xref-list") def on_mount(self) -> None: @@ -3173,8 +3173,8 @@ class SymbolPalette(ModalScreen): self._results: list[tuple] = [] def compose(self) -> ComposeResult: - with Vertical(id="pal-box"): - yield Static(" symbols", id="pal-title", markup=False) + with Vertical(id="pal-box") as box: + box.border_title = Text("symbols") yield Input(placeholder="fuzzy find symbol… ↑↓ select · Enter open · Esc close", id="pal-input") yield OptionList(id="pal-list") @@ -3253,8 +3253,8 @@ class SymbolPalette(ModalScreen): more = "+" if len(self._results) == cap else "" hint = " (F2: this binary)" if self._project_scope else ( " (F2: whole project)" if self._index is not None else "") - self.query_one("#pal-title", Static).update( - f" symbols [{scope}]: {len(self._results)}{more}{hint}") + 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) @@ -3319,8 +3319,8 @@ class StringsPalette(ModalScreen): self._results: list[tuple] = [] def compose(self) -> ComposeResult: - with Vertical(id="pal-box"): - yield Static(" strings", id="pal-title", markup=False) + with Vertical(id="pal-box") as box: + box.border_title = Text("strings") yield Input(placeholder="filter strings\u2026 \u2191\u2193 select \u00b7 " "Enter jump \u00b7 Esc close", id="pal-input") yield OptionList(id="pal-list") @@ -3393,8 +3393,8 @@ class StringsPalette(ModalScreen): more = "+" if len(rows) == cap else "" hint = " (F2: this binary)" if self._project_scope else ( " (F2: whole project)" if self._index is not None else "") - self.query_one("#pal-title", Static).update( - f" strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}") + 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) @@ -3513,8 +3513,8 @@ class QuitScreen(ModalScreen): def compose(self) -> ComposeResult: what = (f"{len(self._labels)} databases have unsaved changes" if len(self._labels) > 1 else "unsaved changes") - with Vertical(id="quit-box"): - yield Static(f"\u26a0 {what}", id="quit-title") + with Vertical(id="quit-box") as box: + box.border_title = Text(f"\u26a0 {what}") body = Text() for label in self._labels: body.append(f" \u2022 {label}\n", _S_LABEL) @@ -3546,8 +3546,8 @@ class HelpScreen(ModalScreen): avail = max(self.app.size.width - 6, 20) cols = self._columns(avail) per = -(-len(_HELP) // cols) # ceil, so the columns stay balanced - with Vertical(id="help-box"): - yield Static(" keys", id="help-title", markup=False) + with Vertical(id="help-box") as box: + box.border_title = Text("keys") # Still inside a scroll container, so a genuinely tiny terminal # degrades to scrolling rather than clipping — but spread across the # width it shouldn't come to that. @@ -3633,9 +3633,9 @@ class RegWriteScreen(ModalScreen): self._idx = idx def compose(self) -> ComposeResult: - with Vertical(id="pal-box"): - yield Static(f" registers at t={self._idx:,} \u2014 Enter seeks to the " - f"write, f seeks forward", id="pal-title", markup=False) + with Vertical(id="pal-box") as box: + box.border_title = Text(f"registers at t={self._idx:,}") + box.border_subtitle = Text("Enter seeks to the write \u00b7 f seeks forward") yield OptionList(id="pal-list") def on_mount(self) -> None: @@ -3857,9 +3857,8 @@ class LoadOptionsScreen(ModalScreen): def compose(self) -> ComposeResult: from .formats import PROCESSORS self._all = list(PROCESSORS) - with Vertical(id="pal-box"): - yield Static(" unrecognised file \u2014 how should IDA load it?", - id="pal-title", markup=False) + with Vertical(id="pal-box") as box: + box.border_title = Text("unrecognised file \u2014 how should IDA load it?") yield Static(f" {os.path.basename(self._path)} ({self._nbytes:,} bytes) " f"\u2014 no loader matched; without a processor IDA " f"assumes x86 at 0", id="load-note", markup=False) @@ -3922,8 +3921,8 @@ class LoadOptionsScreen(ModalScreen): ol.add_options(opts) if rows: ol.highlighted = 0 - self.query_one("#pal-title", Static).update( - f" unrecognised file \u2014 processor? ({len(rows)})") + 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) @@ -3980,8 +3979,8 @@ class ProjectPalette(ModalScreen): self._results: list[dict] = [] def compose(self) -> ComposeResult: - with Vertical(id="pal-box"): - yield Static(" binaries", id="pal-title", markup=False) + with Vertical(id="pal-box") as box: + box.border_title = Text("binaries") yield Input(placeholder="filter binaries\u2026 \u2191\u2193 select \u00b7 " "Enter switch \u00b7 Esc close", id="pal-input") yield OptionList(id="pal-list") @@ -4028,8 +4027,8 @@ class ProjectPalette(ModalScreen): # you are rather than at whatever sorts first. active = next((i for i, e in enumerate(rows) if e["active"]), 0) ol.highlighted = active - self.query_one("#pal-title", Static).update( - f" binaries: {len(rows)} of {len(self._entries)}") + 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) @@ -4071,7 +4070,8 @@ class ConfirmScreen(ModalScreen): self._note = note def compose(self) -> ComposeResult: - with Vertical(id="confirm-box"): + with Vertical(id="confirm-box") as box: + box.border_title = Text("\u26a0 confirm") yield Static(self._message, id="confirm-msg", markup=False) if self._note: yield Static(self._note, id="confirm-note", markup=False) @@ -4307,13 +4307,15 @@ class StructEditor(ModalScreen): then() def compose(self) -> ComposeResult: - with Vertical(id="se-box"): + with Vertical(id="se-box") as box: + box.border_title = Text("structs / types") + box.border_subtitle = Text("Esc close") with Horizontal(id="se-panes"): with Vertical(id="se-left"): - yield Static(" structs", id="se-title") + yield Static("structs", id="se-title") yield OptionList(id="se-list") with Vertical(id="se-right"): - yield Static(" C definition", id="se-hint") + yield Static("C definition", id="se-hint") yield CTextArea("", id="se-edit") yield Static( "Enter edit · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · d/Del delete · Esc close", @@ -4337,11 +4339,16 @@ class StructEditor(ModalScreen): self._structs = structs 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: 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(s.name, _S_LABEL) - label.append(f" {s.size:#x} {s.members}f {kw}", _S_DIM) + label.append(f"{name:<{width}}", _S_LABEL) + label.append(f"{s.size:>#7x}{s.members:>4}f {kw}", _S_DIM) ol.add_option(Option(label)) if structs: idx = 0 @@ -4647,15 +4654,39 @@ class IdaTui(App): width: 100%; height: 100%; content-align: center middle; background: $panel-darken-1; } + /* ---- modal chrome --------------------------------------------------- + One shape for every dialog: a THIN round border in a colour that + recedes, and the title set into the top border rather than painted as a + solid bar across the full width. The old `thick` frame plus an inverse + title bar was two heavy rectangles around content that is itself the + only thing worth looking at. Size stays per-box; everything else is + here, once. */ + #quit-box, #help-box, #xref-box, #pal-box, #se-box, #confirm-box, + #loading-box, #busy-box { + background: $panel; + border: round $panel-lighten-3; + border-title-color: $accent; + border-title-style: bold; + border-title-align: left; + border-subtitle-color: $text-muted; + border-subtitle-align: right; + } + /* The two that ask a question you can answer wrongly keep the warning hue. */ + #quit-box, #confirm-box { border: round $warning; + border-title-color: $warning; } + /* Lists inside a dialog are content, not another framed panel: drop the + stock OptionList border/background so the dialog has ONE edge. Focus + still reads from the highlight bar (bright when focused, dim when not). */ + #quit-box OptionList, #help-box OptionList, #xref-box OptionList, + #pal-box OptionList, #se-box OptionList { + border: none; background: transparent; padding: 0 1; + } QuitScreen { align: center middle; } - #quit-box { width: 64; height: auto; border: thick $warning; background: $panel; } - #quit-title { dock: top; height: 1; background: $warning; color: $background; text-style: bold; padding: 0 1; } - #quit-list { height: auto; padding: 1 2 0 2; } - #quit-help { height: 1; color: $text-muted; padding: 0 2; margin-top: 1; } + #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%; - border: thick $accent; background: $panel; } - #help-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; } + #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; } .help-col { height: auto; width: auto; margin-right: 1; } @@ -4663,8 +4694,7 @@ class IdaTui(App): 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; border: thick $accent; background: $panel; } - #xref-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; } + #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, @@ -4673,9 +4703,7 @@ class IdaTui(App): the input + results inherit this width (results is an overlay, so pin it). */ CommandPalette > Vertical { width: 80%; max-width: 120; } CommandPalette #--results { width: 100%; } - #pal-box { width: 96; max-width: 92%; height: auto; max-height: 80%; - border: thick $accent; background: $panel; } - #pal-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; } + #pal-box { width: 96; max-width: 92%; height: auto; max-height: 80%; } #pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; } #pal-list { height: auto; max-height: 24; } #trace-dock { dock: right; width: 34; background: $surface; border-left: solid $panel; } @@ -4690,32 +4718,34 @@ class IdaTui(App): LoadOptionsScreen #pal-list { max-height: 12; } #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; padding: 0 1; color: $text-muted; } + #confirm-note { height: auto; color: $text-muted; } StructEditor { align: center middle; } - #se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; } + #se-box { width: 90%; height: 84%; } #se-panes { height: 1fr; } - #se-left { width: 38; border-right: solid $accent; } + #se-left { width: 38; border-right: solid $panel-lighten-3; } #se-right { width: 1fr; } - #se-title, #se-hint { height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; } + /* 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; } #se-list { height: 1fr; } - #se-edit { height: 1fr; border: none; } - #se-status { height: 1; background: $panel-darken-2; color: $text-muted; padding: 0 1; } + #se-edit { height: 1fr; border: none; padding: 0 1; } + /* A footer, but a quiet one: a shade of the dialog's own background + 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; border: thick $warning; - background: $panel; padding: 1 2; } + #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; border: thick $accent; - background: $panel; padding: 1 2; } + #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; border: thick $accent; - background: $panel; padding: 1 2; } + #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; } """ |
