aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 16:08:36 +0200
committerblasty <blasty@local>2026-07-25 16:08:36 +0200
commit59d9aff1b9c216686fd7b8007aff00b974a17233 (patch)
treefdc9cfcacd5c07a9d7ce847dd05092d8e80e9a86
parentdocs: refresh PROJECTS.md phase 2/3 to match what shipped (diff)
downloadida-tui-59d9aff1b9c216686fd7b8007aff00b974a17233.tar.gz
ida-tui-59d9aff1b9c216686fd7b8007aff00b974a17233.tar.xz
ida-tui-59d9aff1b9c216686fd7b8007aff00b974a17233.zip
help: lay the cheatsheet out as fluid columns of cards
Five sections stacked vertically ran ~45 rows, so F1 scrolled on any terminal under ~50 lines. Each section is now its own bordered card (title in the border), and the cards flow into as many columns as the width allows. Textual CSS has no media queries, so the column count is computed in compose from the real app width. Sizing off the WIDEST section would let one long row (Move's "Ctrl+Home / Ctrl+End") inflate every column and cost a column that would otherwise fit, so _columns() measures the actual layout instead: chunk the sections, sum the per-chunk maxima, take the most columns that fit. Keys are right-aligned per card rather than globally, so a card of short keys stays narrow. 200x50 -> 3 cols 140x44 -> 2 cols 100x36 -> 1 col (scrolls) 160x44 -> 3 cols 120x40 -> 2 cols The scroll container stays, so a very small terminal degrades to scrolling instead of clipping — but at any normal size nothing scrolls. The box hugs its content (width:auto all the way down; VerticalScroll needed width:auto too or it filled to max-width) and stays centred. Tried a real CSS Grid first: it collapsed to zero height at narrow widths, and grid sizing from on_mount read a stale app width. Explicit columns are predictable. Also shortened one description that was pushing the widest card out. help scenario now asserts the five cards exist and that the content fits without a scrollbar. Suite 192/0.
-rw-r--r--idatui/app.py79
-rw-r--r--tests/test_scenarios.py13
2 files changed, 75 insertions, 17 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 3ef4ac8..dbe86e0 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -30,7 +30,7 @@ from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.command import DiscoveryHit, Hit, Provider
-from textual.containers import Horizontal, Vertical, VerticalScroll
+from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
from textual.geometry import Region, Size
from textual.message import Message
from textual.reactive import reactive
@@ -2348,7 +2348,7 @@ _HELP = (
)),
("Views", (
("Tab / F5", "disassembly \u21c4 pseudocode"),
- ("s", "split view (listing + pseudocode, synced)"),
+ ("s", "split view: listing + pseudocode"),
("Tab", "in split: switch the driving pane"),
("\\", "hex view"),
("o", "cycle the opcode-bytes column"),
@@ -2429,24 +2429,73 @@ class HelpScreen(ModalScreen):
BINDINGS = [Binding("escape,f1,q,question_mark", "close", "Close")]
+ #: widest cell content, +2 for the card's border, +2 for its padding
+ _CARD_PAD = 4
+
def compose(self) -> ComposeResult:
+ # Fluid: as many columns as the terminal can hold. Textual CSS has no
+ # media queries, so the split is computed here from the real width.
+ 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")
+ yield Static(" keys", id="help-title", markup=False)
+ # 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.
with VerticalScroll(id="help-body"):
- yield Static(self._cheatsheet(), id="help-text")
+ with Horizontal(id="help-cols"):
+ for c in range(cols):
+ chunk = _HELP[c * per:(c + 1) * per]
+ if not chunk:
+ continue
+ with Vertical(classes="help-col"):
+ for title, rows in chunk:
+ card = Static(self._card(rows),
+ classes="help-card", markup=False)
+ card.border_title = title
+ yield card
yield Static("Esc / F1 to close", id="help-foot")
@staticmethod
- def _cheatsheet() -> Text: # NB: not _render — that's a Widget internal
- width = max(len(k) for _, rows in _HELP for k, _ in rows)
+ def _section_widths() -> list[int]:
+ """Rendered width of each section's card (keys right-aligned per card)."""
+ out = []
+ for _, rows in _HELP:
+ kw = max(len(k) for k, _ in rows)
+ out.append(max(kw + 2 + len(d) for _, d in rows) + HelpScreen._CARD_PAD)
+ return out
+
+ @classmethod
+ def _columns(cls, avail: int) -> int:
+ """Most columns that actually fit in ``avail``.
+
+ Sizing off the widest section would let one long row (Move's
+ "Ctrl+Home / Ctrl+End") inflate every column and cost a column that would
+ otherwise fit. Each column hugs its own content, so measure the real
+ layout: chunk the sections and sum the per-chunk maxima.
+ """
+ ws = cls._section_widths()
+ n = len(ws)
+ for cols in range(min(n, 4), 1, -1):
+ per = -(-n // cols)
+ chunks = [ws[c * per:(c + 1) * per] for c in range(cols)]
+ total = sum(max(c) for c in chunks if c) + (cols - 1)
+ if total <= avail:
+ return cols
+ return 1
+
+ @staticmethod
+ def _card(rows) -> Text: # NB: not _render — that's a Widget internal
+ """One section's keys. The key column is sized per section, so a card of
+ short keys stays narrow instead of padding out to the global maximum."""
+ width = max(len(k) for k, _ in rows)
out = Text()
- for i, (title, rows) in enumerate(_HELP):
+ for i, (key, desc) in enumerate(rows):
if i:
out.append("\n")
- out.append(f"{title}\n", _S_FUNCHDR)
- for key, desc in rows:
- out.append(f" {key:>{width}}", _S_MNEM)
- out.append(f" {desc}\n", _S_INSN)
+ out.append(f"{key:>{width}}", _S_MNEM)
+ out.append(f" {desc}", _S_INSN)
return out
def action_close(self) -> None:
@@ -3006,10 +3055,14 @@ class IdaTui(App):
#quit-list { height: auto; padding: 1 2 0 2; }
#quit-help { height: 1; color: $text-muted; padding: 0 2; margin-top: 1; }
HelpScreen { align: center middle; }
- #help-box { width: 76; max-width: 92%; height: auto; max-height: 85%;
+ #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: $text; padding: 0 1; }
- #help-body { height: auto; max-height: 100%; padding: 1 2; }
+ #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; }
+ .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; border: thick $accent; background: $panel; }
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 49bb6b9..091ddc8 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -384,12 +384,17 @@ async def s_help(c: Ctx):
f"screen={type(app.screen).__name__}")
if not opened:
return
- txt = str(app.screen.query_one("#help-text", Static).render())
- c.check("it lists the key groups",
- all(s in txt for s in ("Navigate", "Views", "Move", "Edit", "Search")),
- txt[:70])
+ cards = app.screen.query(".help-card")
+ titles = {str(w.border_title) for w in cards}
+ txt = " ".join(str(w.render()) for w in cards)
+ c.check("each key group gets its own card",
+ titles == {"Navigate", "Views", "Move", "Edit", "Search"}, f"{titles}")
c.check("it documents real bindings",
"set type" in txt and "split view" in txt and "cross-references" in txt)
+ body = app.screen.query_one("#help-body")
+ c.check("the cards fit without a scrollbar at a normal size",
+ body.virtual_size.height <= body.size.height,
+ f"content={body.virtual_size.height} view={body.size.height}")
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
c.check("Esc closes it", not isinstance(app.screen, HelpScreen))