aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 12:38:17 +0200
committerblasty <blasty@local>2026-07-25 12:38:17 +0200
commit9df788b5c55fa3445cd86a7768d7a7e6e34ba871 (patch)
tree036a0f316a2c218650e7f08e71f820684c04ea91
parentdecomp: Home/End move along the line instead of scrolling to top/bottom (diff)
downloadida-tui-9df788b5c55fa3445cd86a7768d7a7e6e34ba871.tar.gz
ida-tui-9df788b5c55fa3445cd86a7768d7a7e6e34ba871.tar.xz
ida-tui-9df788b5c55fa3445cd86a7768d7a7e6e34ba871.zip
ui: drop the footer cheatsheet; F1 opens a key reference instead
The permanent Footer spent a screen row on a truncated, always-visible key list. Remove it (the status line now owns the bottom row) and put the full cheatsheet behind F1 — grouped by task (Navigate / Views / Move / Edit / Search) rather than by widget, which is what makes it readable. Esc, F1 or q closes it; there's also a "Keyboard shortcuts" command-palette entry. F1 rather than '?' because '?' is already search-backwards in the code views. Textual leaves F1 unbound (App only claims ctrl+q/ctrl+c), so it traps cleanly. Gotcha worth recording: the helper that builds the cheatsheet was first called _render, which collides with Widget._render — Textual invoked ours internally and got a rich Text where it wanted a Visual, so the whole screen failed to paint ("'Text' object has no attribute 'render_strips'"). Renamed to _cheatsheet. The search scenario's "rendered above the footer" check now asserts the search input owns the bottom row instead. New `help` scenario: footer gone, F1 opens it, groups + real bindings present, Esc closes (5 checks). Suite note: view_toggle fails in a full run ONLY as collateral from the standing `filter` flake, which runs immediately before it — when filter leaves the table empty, view_toggle's open_biggest has nothing to open. Verified: view_toggle passes alone (13/13) and directly after `help` (18/18), and reproduces as a cascade with --only filter,view_toggle. Not a regression from this change.
-rw-r--r--idatui/app.py104
-rw-r--r--tests/test_scenarios.py38
2 files changed, 132 insertions, 10 deletions
diff --git a/idatui/app.py b/idatui/app.py
index f79d04d..037e244 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
+from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.geometry import Region, Size
from textual.message import Message
from textual.reactive import reactive
@@ -38,7 +38,7 @@ from textual.screen import ModalScreen
from textual.scroll_view import ScrollView
from textual.strip import Strip
from textual.widgets import (
- DataTable, Footer, Input, OptionList, Static, TextArea,
+ DataTable, Input, OptionList, Static, TextArea,
)
from textual.widgets.option_list import Option
@@ -2246,6 +2246,90 @@ class StringsPalette(ModalScreen):
self.dismiss(None)
+#: The keyboard cheatsheet (F1). Grouped by task rather than by widget, which is
+#: what makes it readable; keep it in step with the BINDINGS above it.
+_HELP = (
+ ("Navigate", (
+ ("Enter", "follow the symbol under the cursor"),
+ ("Esc", "back (navigation history)"),
+ ("g", "goto address or symbol"),
+ ("Ctrl+N", "find symbol (fuzzy)"),
+ ("\"", "strings browser"),
+ ("x", "cross-references to the symbol"),
+ ("L", "continuous listing at the cursor"),
+ ("Ctrl+O", "switch binary (projects)"),
+ )),
+ ("Views", (
+ ("Tab / F5", "disassembly \u21c4 pseudocode"),
+ ("s", "split view (listing + pseudocode, synced)"),
+ ("Tab", "in split: switch the driving pane"),
+ ("\\", "hex view"),
+ ("o", "cycle the opcode-bytes column"),
+ ("Ctrl+B", "show/hide the names pane"),
+ ("Ctrl+T", "structs / types editor"),
+ ("Ctrl+P", "command palette"),
+ )),
+ ("Move", (
+ ("j / k", "down / up"),
+ ("Ctrl+D / Ctrl+U", "half page down / up"),
+ ("PgDn / PgUp", "page down / up"),
+ ("Ctrl+Home / Ctrl+End", "top / bottom (G also)"),
+ ("Home / End", "start / end of line"),
+ ("Shift+Home", "start of the instruction / code"),
+ ("h / l", "column left / right"),
+ ("w / b", "word forward / back"),
+ )),
+ ("Edit", (
+ ("n", "rename"),
+ ("y", "set type (prototype, local or global)"),
+ (";", "comment"),
+ ("c", "make code"),
+ ("p", "make function"),
+ ("d", "make data"),
+ ("a", "make string"),
+ ("u", "undefine"),
+ ("Ctrl+S", "save the database"),
+ )),
+ ("Search", (
+ ("/", "search forward (repeat to continue)"),
+ ("?", "search backward"),
+ ("N", "previous match"),
+ ("Ctrl+Y", "copy the current line"),
+ ("F1", "this cheatsheet"),
+ ("q", "quit"),
+ )),
+)
+
+
+class HelpScreen(ModalScreen):
+ """F1: the keyboard cheatsheet, replacing the permanent footer."""
+
+ BINDINGS = [Binding("escape,f1,q,question_mark", "close", "Close")]
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="help-box"):
+ yield Static(" keys", id="help-title")
+ with VerticalScroll(id="help-body"):
+ yield Static(self._cheatsheet(), id="help-text")
+ 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)
+ out = Text()
+ for i, (title, rows) in enumerate(_HELP):
+ 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)
+ return out
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
class ProjectPalette(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)."""
@@ -2696,6 +2780,7 @@ class IdaCommands(Provider):
app.action_strings),
("Switch binary…", "another binary in the project (Ctrl+O)",
app.action_switch_binary),
+ ("Keyboard shortcuts", "the key cheatsheet (F1)", app.action_help),
("Follow symbol under cursor", "jump to the referenced symbol (Enter)",
lambda: va("follow")),
("Show xrefs to symbol", "cross-references to the cursor symbol (x)",
@@ -2789,6 +2874,12 @@ class IdaTui(App):
width: 100%; height: 100%; content-align: center middle;
background: $panel-darken-1;
}
+ HelpScreen { align: center middle; }
+ #help-box { width: 76; max-width: 92%; height: auto; max-height: 85%;
+ 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-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: $text; padding: 0 1; }
@@ -2840,6 +2931,7 @@ class IdaTui(App):
Binding("s", "toggle_split", "Split", show=False),
Binding("quotation_mark,shift+f12", "strings", "Strings", show=False),
Binding("ctrl+o", "switch_binary", "Binaries", show=False),
+ Binding("f1", "help", "Keys", show=False),
Binding("g", "goto", "Goto"),
Binding("slash", "filter", "Filter", show=False),
Binding("ctrl+b", "toggle_functions", "Names", show=False),
@@ -2947,7 +3039,6 @@ class IdaTui(App):
# on, a single-word marker parses as a style tag and is silently eaten —
# which is why [listing] and [pseudocode] never actually rendered.
yield Static("connecting\u2026", id="status", markup=False)
- yield Footer()
def on_mount(self) -> None:
# Keep the hidden command input out of the focus chain until summoned.
@@ -3327,6 +3418,13 @@ class IdaTui(App):
self._open_function(addr, f.name if f else hex(addr))
# -- projects: switching between the binaries of one target ------------ #
+ def action_help(self) -> None:
+ """F1: the keyboard cheatsheet (there's no permanent footer any more)."""
+ if self._prompt_active():
+ return
+ if not isinstance(self.screen, HelpScreen):
+ self.push_screen(HelpScreen())
+
def action_switch_binary(self) -> None:
"""Ctrl+O: pick another binary from the project (Ghidra-style)."""
if self._pool is None:
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 1a55f23..c294a9f 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -25,11 +25,11 @@ import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.app import ( # noqa: E402
ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui,
- ListingView, StringsPalette, StructEditor, SymbolPalette, XrefsScreen,
- _str_display, _word_occurrences,
+ HelpScreen, ListingView, StringsPalette, StructEditor, SymbolPalette,
+ XrefsScreen, _str_display, _word_occurrences,
)
from textual.widgets import ( # noqa: E402
- DataTable, Footer, Input, OptionList, Static, TextArea,
+ DataTable, Input, OptionList, Static, TextArea,
)
from rich.text import Text # noqa: E402
from idatui._sync import wait_for # noqa: E402
@@ -340,6 +340,30 @@ async def s_command_palette(c: Ctx):
await c.wait(lambda: app._active != "hex", 5)
+@scenario("help")
+async def s_help(c: Ctx):
+ app = c.app
+ st = app.query_one("#status", Static)
+ c.check("the status line owns the bottom row (no footer cheatsheet)",
+ st.region.y + st.region.height == app.size.height,
+ f"status={st.region} screen={app.size}")
+ await c.press("f1")
+ opened = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10)
+ c.check("F1 opens the key cheatsheet", opened,
+ 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])
+ c.check("it documents real bindings",
+ "set type" in txt and "split view" in txt and "cross-references" in txt)
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
+ c.check("Esc closes it", not isinstance(app.screen, HelpScreen))
+
+
@scenario("strings")
async def s_strings(c: Ctx):
app = c.app
@@ -933,10 +957,10 @@ async def s_search(c: Ctx):
await c.pause(0.1)
c.check("search bar visible, status hidden (no overlap)",
si.display and not status.display, f"si={si.display} status={status.display}")
- footer = app.query_one(Footer)
- c.check("search input is rendered above the footer (not overlapping)",
- si.region.height >= 1 and si.region.y < footer.region.y,
- f"search={si.region} footer={footer.region}")
+ c.check("search input owns the bottom row (nothing overlaps it)",
+ si.region.height >= 1
+ and si.region.y + si.region.height == app.size.height,
+ f"search={si.region} screen={app.size}")
for ch in term:
await c.press(ch)
await c.pause(0.05)