diff options
| -rw-r--r-- | TODO | 16 | ||||
| -rw-r--r-- | idatui/app.py | 287 | ||||
| -rw-r--r-- | idatui/rpc.py | 4 | ||||
| -rw-r--r-- | server/patch_server.py | 12 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 96 |
5 files changed, 378 insertions, 37 deletions
@@ -17,11 +17,17 @@ hard: [ ] deal with PLT stubs and such (oh god here we go) [~] how to deal with non-function-body regions? -> we want to be able to do data/type definitions in .data etc. - -> M0 done: navigating to a non-function EA opens a flat listing (region) - instead of refusing; c/p/u edit verbs on the disasm view (define code / - function / undefine) with bump_items() cache invalidation + region->func - upgrade. next: M1 server `heads` walker + ListingModel (address paging), - M2 ListingView (code+data render), M3 data typing in .data. + -> M0 done: navigating to a non-function EA opens a flat listing instead of + refusing; c/p/u edit verbs (define code/function/undefine) with + bump_items() cache invalidation + region->func upgrade. + -> M1 done: server `heads` walker (walks item-ends so undefined bytes show + as `db ?` and arbitrary addresses land exactly) + ListingModel. + -> M2 done: ListingView — virtualized flat listing (code+data+undefined, + kind-styled), wired into nav/follow/xrefs/hex/edit-verbs. region views + now use it (not the DisasmModel stopgap). + -> next: M3 data typing in .data (`d`/make_data with a type prompt, struct- + typed data, string/dup coalescing for big undefined runs); M4 optionally + unify the function disasm view as a filtered listing. crazy: [ ] multi/split view ala ghidra? diff --git a/idatui/app.py b/idatui/app.py index e1b26b8..ffef9b4 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -43,7 +43,7 @@ from textual.widgets.option_list import Option from .highlight import highlight_c from .client import IDAClient, IDAToolError -from .domain import DisasmModel, Func, Program, Struct +from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct # Styles for the disassembly listing. _S_ADDR = Style(color="grey58") @@ -51,6 +51,8 @@ _S_LABEL = Style(color="yellow", bold=True) _S_INSN = Style(color="white") _S_MNEM = Style(color="cyan") _S_OPBYTES = Style(color="grey50") # raw opcode bytes column +_S_DATA = Style(color="#c8a15a") # data items in the flat listing (db/dw/strings) +_S_UNK = Style(color="grey54", italic=True) # undefined bytes in the flat listing _S_CURSOR = Style(bgcolor="grey30") _S_DIM = Style(color="grey42", italic=True) _S_MATCH = Style(bgcolor="#7a5c00") # all search matches @@ -869,6 +871,207 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self._move(self.total) +# --------------------------------------------------------------------------- # +# Virtualized flat listing view (code + data + undefined, per segment) +# --------------------------------------------------------------------------- # +class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True): + """A line-virtualized *flat* listing over one segment: code, data and + undefined heads interleaved (IDA's disassembly view), unlike ``DisasmView`` + which is bounded to one function. Backed by ``ListingModel`` (the ``heads`` + server tool). Used for non-function regions and raw segment browsing. + """ + + BINDINGS = [ + Binding("j,down", "cursor_down", "Down", show=False), + Binding("k,up", "cursor_up", "Up", show=False), + Binding("ctrl+d", "half_page(1)", "½↓", show=False), + Binding("ctrl+u", "half_page(-1)", "½↑", show=False), + Binding("pagedown", "page(1)", "PgDn", show=False), + Binding("pageup", "page(-1)", "PgUp", show=False), + Binding("home", "goto_top", "Top", show=False), + Binding("G,end", "goto_bottom", "Bottom", show=False), + Binding("c", "define_code", "Code", show=False), + Binding("p", "define_func", "Func", show=False), + Binding("u", "undefine", "Undef", show=False), + *SearchMixin.SEARCH_BINDINGS, + *NavMixin.NAV_BINDINGS, + *ColumnCursor.COL_BINDINGS, + ] + + cursor = reactive(0, repaint=False) + cursor_x = reactive(0, repaint=False) + + class CursorMoved(Message): + """Posted when the listing cursor moves; carries the head ea.""" + + def __init__(self, index: int, ea: int | None) -> None: + super().__init__() + self.index = index + self.ea = ea + + def __init__(self) -> None: + super().__init__() + self.model: ListingModel | None = None + self.total = 0 + self._name = "" + self._pending_scroll_y: int | None = None + self._term = "" + self._matches: list[int] = [] + self._ranges: dict[int, list[tuple[int, int]]] = {} + + # -- text helpers ------------------------------------------------------ # + def _head(self, idx: int) -> Head | None: + return self.model.get(idx) if self.model is not None else None + + @staticmethod + def _name_prefix(h: Head) -> str: + return f"{h.name} " if h.name else "" + + def _line_plain(self, idx: int) -> str | None: + h = self._head(idx) + if h is None: + return None + return f"{h.ea:08X} " + self._name_prefix(h) + h.text + + # -- public API -------------------------------------------------------- # + def load(self, model: ListingModel, name: str, cursor: int = 0, + cursor_x: int = 0, scroll_y: int | None = None) -> None: + self.model = model + self._name = name + self.total = 0 + self.cursor = cursor + self.cursor_x = cursor_x + self._pending_scroll_y = scroll_y + self._matches = [] + self._ranges = {} + self._prime() + + @work(thread=True, exclusive=True, group="listing-prime") + def _prime(self) -> None: + model = self.model + if model is None: + return + model.load_all() # walk the whole segment's heads off the UI loop + self.app.call_from_thread(self._on_primed, len(model)) + + def _on_primed(self, total: int) -> None: + if self.model is None: + return + self.total = total + self.virtual_size = Size(0, total) + self.cursor = max(0, min(self.cursor, max(total - 1, 0))) + self._clamp_x() + if self._pending_scroll_y is not None and self._pending_scroll_y >= 0: + self._apply_scroll(min(self._pending_scroll_y, max(total - 1, 0))) + else: + self._scroll_cursor_into_view() + self._pending_scroll_y = None + self.refresh() + self.post_message(ListingView.CursorMoved(self.cursor, self._cursor_ea())) + + # -- search hooks (all heads are in memory after prime) --------------- # + def _search_line_count(self) -> int: + return self.total + + def _search_line_text(self, i: int) -> str | None: + return self._line_plain(i) + + def _search_ensure(self, done) -> None: + done() + + # -- rendering --------------------------------------------------------- # + def render_line(self, y: int) -> Strip: + model = self.model + width = self.size.width + if model is None or self.total == 0: + return Strip([Segment("".ljust(width), _S_DIM)]) + top = round(self.scroll_offset.y) + idx = top + y + if idx >= self.total: + return Strip([Segment("".ljust(width), _S_INSN)]) + h = model.get(idx) + if h is None: + strip = Strip([Segment(f" {idx:>8} …", _S_DIM)]) + else: + segs: list[Segment] = [Segment(f"{h.ea:08X} ", _S_ADDR)] + if h.name: + segs.append(Segment(f"{h.name} ", _S_LABEL)) + if h.kind == "code": + mnem, _, rest = h.text.partition(" ") + segs.append(Segment(mnem, _S_MNEM)) + if rest: + segs.append(Segment(" " + rest, _S_INSN)) + elif h.kind == "data": + segs.append(Segment(h.text, _S_DATA)) + else: + segs.append(Segment(h.text, _S_UNK)) + strip = Strip(segs) + if idx in self._ranges: + strip = _overlay_ranges(strip, self._ranges[idx], self._match_style(idx)) + if idx == self.cursor: + strip = _cursor_decorate(strip, self._line_plain(idx) or "", self.cursor_x) + return strip.adjust_cell_length(width, _S_INSN) + + # -- navigation -------------------------------------------------------- # + def _visible_height(self) -> int: + return max(self.size.height, 1) + + def _scroll_cursor_into_view(self) -> None: + height = self._visible_height() + top = round(self.scroll_offset.y) + if self.cursor < top: + self.scroll_to(y=self.cursor, animate=False) + elif self.cursor >= top + height: + self.scroll_to(y=max(self.cursor - height + 1, 0), animate=False) + + def _move(self, delta: int) -> None: + if self.total == 0: + return + old = self.cursor + before = round(self.scroll_offset.y) + self.cursor = max(0, min(self.total - 1, self.cursor + delta)) + self._clamp_x() + self._scroll_cursor_into_view() + if round(self.scroll_offset.y) != before: + self.refresh() + else: + _refresh_lines(self, old, self.cursor) + self.post_message(ListingView.CursorMoved(self.cursor, self._cursor_ea())) + + def _after_cursor_move(self) -> None: + self.post_message(ListingView.CursorMoved(self.cursor, self._cursor_ea())) + + def _cursor_ea(self) -> int | None: + h = self._head(self.cursor) + return h.ea if h else None + + def _next_ea(self) -> int | None: + h = self._head(self.cursor + 1) + return h.ea if h else None + + # -- item structure edits (IDA c/p/u) --------------------------------- # + def action_define_code(self) -> None: + self.post_message(EditItemRequested(self, "code")) + + def action_define_func(self) -> None: + self.post_message(EditItemRequested(self, "func")) + + def action_undefine(self) -> None: + self.post_message(EditItemRequested(self, "undef")) + + def action_cursor_down(self) -> None: + self._move(1) + + def action_cursor_up(self) -> None: + self._move(-1) + + def action_goto_top(self) -> None: + self._move(-self.total) + + def action_goto_bottom(self) -> None: + self._move(self.total) + + def _join(base: Style | None, style: Style) -> Style: return (base + style) if base is not None else style @@ -1741,6 +1944,7 @@ class IdaTui(App): #func-filter { dock: top; } DisasmView { width: 1fr; padding: 0 1; } DecompView { width: 1fr; } + ListingView { width: 1fr; padding: 0 1; } HexView { width: 1fr; padding: 0 1; } #search { height: 1; border: none; padding: 0 1; @@ -1846,6 +2050,9 @@ class IdaTui(App): dis.display = False yield dis yield DecompView() # pseudocode is the default view + lst = ListingView() + lst.display = False + yield lst hx = HexView() hx.display = False yield hx @@ -2114,9 +2321,14 @@ class IdaTui(App): if self._cur is None: return if self._active == "hex": - self._active = self._pref + self._active = self._code_mode() self._show_active() return + if self._active == "listing": + # A non-function region has no pseudocode to toggle to. + self._status(f"{self._cur.name} — flat listing (no function here); " + "'p' to define one") + return self._active = "decomp" if self._active == "disasm" else "disasm" self._pref = self._active # an explicit toggle sets the preference self._show_active() @@ -2127,11 +2339,13 @@ class IdaTui(App): if self._cur is None or self.program is None: return if self._active == "hex": - self._active = self._pref + self._active = self._code_mode() self._show_active() return if self._active == "disasm": ea = self.query_one(DisasmView)._cursor_ea() + elif self._active == "listing": + ea = self.query_one(ListingView)._cursor_ea() else: dec = self.query_one(DecompView) ea = dec._line_ea(dec.cursor) @@ -2201,6 +2415,10 @@ class IdaTui(App): nxt = view.model.cached_line(view.cursor + 1) if view.model else None if ea is not None: self._follow_disasm(ea, word, nxt.ea if nxt else None) + elif isinstance(view, ListingView): + ea = view._cursor_ea() + if ea is not None: + self._follow_disasm(ea, word, view._next_ea()) elif isinstance(view, DecompView) and view._texts: self._follow_decomp(view._texts[view.cursor], word, view._line_ea(view.cursor)) @@ -2215,6 +2433,10 @@ class IdaTui(App): # entry for the site we invoked xrefs from. nxt = view.model.cached_line(view.cursor + 1) if view.model else None self._xrefs_disasm(ea, word, ea, nxt.ea if nxt else None) + elif isinstance(view, ListingView): + ea = view._cursor_ea() + if ea is not None: + self._xrefs_disasm(ea, word, ea, view._next_ea()) elif isinstance(view, DecompView) and view._texts: here = view._line_ea(view.cursor) end = None @@ -2436,7 +2658,7 @@ class IdaTui(App): # -- comments ---------------------------------------------------------- # def _line_ea_for(self, view) -> int | None: # type: ignore[no-untyped-def] """Address of the line under the cursor in either code view.""" - if isinstance(view, DisasmView): + if isinstance(view, (DisasmView, ListingView)): return view._cursor_ea() if isinstance(view, DecompView): return view._line_ea(view.cursor) @@ -2680,7 +2902,8 @@ class IdaTui(App): # -- item structure edits (IDA c/p/u) --------------------------------- # def on_edit_item_requested(self, msg: EditItemRequested) -> None: - ea = msg.view._cursor_ea() if isinstance(msg.view, DisasmView) else None + ea = (msg.view._cursor_ea() + if isinstance(msg.view, (DisasmView, ListingView)) else None) if ea is None: self._status("no address on this line to (re)define") return @@ -2713,8 +2936,8 @@ class IdaTui(App): self._open_at, fn.addr, fn.name, idx, False, -1, 0, False) else: name = self.program.region_label(ea) - model = self.program.disasm(ea, name) - idx = max(model.index_of_ea(ea), 0) + lm = self.program.listing(ea) + idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0 self.app.call_from_thread( self._open_at, ea, name, idx, False, -1, 0, True) self.app.call_from_thread(self._edit_item_done, verb, ea) @@ -2755,9 +2978,10 @@ class IdaTui(App): # rather than refusing. This is where you land on code IDA didn't # mark, or on data — use c/p to define it (see _do_edit_item). name = self.program.region_label(ea) - self.program.disasm(ea, name) # warm the block cache off the UI loop + lm = self.program.listing(ea) + idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0 self.app.call_from_thread( - self._open_at, ea, name, 0, push, -1, 0, True) + self._open_at, ea, name, idx, push, -1, 0, True) return model = self.program.disasm(fn.addr, fn.name) idx = 0 if ea == fn.addr else model.index_of_ea(ea) @@ -2989,14 +3213,32 @@ class IdaTui(App): self._nav.append(entry) self._open_entry(entry, push=False) + def _code_mode(self) -> str: + """The code view to return to from hex: the flat listing for a region, + else the preferred function view (disasm/decomp).""" + if self._cur is not None and self._cur.is_region: + return "listing" + return self._pref + def _open_entry(self, entry: NavEntry, push: bool) -> None: if self.program is None: return self._cur = entry + # A region (not inside a function) has no pseudocode: show the flat + # segment listing (code + data + undefined) instead of the func views. + if entry.is_region: + self._active = "listing" + lm = self.program.listing(entry.ea) + sy = entry.scroll_y if entry.scroll_y >= 0 else None + if lm is not None: + self.query_one(ListingView).load( + lm, entry.name, cursor=entry.cursor, + cursor_x=entry.cursor_x, scroll_y=sy) + self._show_active() + return # Each open honours the preferred view; a decomp failure last time fell # back to disasm without changing the preference, so retry decomp here. - # A region (not inside a function) has no pseudocode -> force disasm. - self._active = "disasm" if entry.is_region else self._pref + self._active = self._pref model = self.program.disasm(entry.ea, entry.name) sy = entry.scroll_y if entry.scroll_y >= 0 else None self.query_one(DisasmView).load( @@ -3014,9 +3256,14 @@ class IdaTui(App): def _show_active(self) -> None: dis = self.query_one(DisasmView) dec = self.query_one(DecompView) + lst = self.query_one(ListingView) hx = self.query_one(HexView) - dis.display = dec.display = hx.display = False - if self._active == "disasm": + dis.display = dec.display = lst.display = hx.display = False + if self._active == "listing": + lst.display = True + lst.focus() + self._status_for_cur("listing") + elif self._active == "disasm": dis.display = True dis.focus() self._status_for_cur("disasm") @@ -3069,7 +3316,7 @@ class IdaTui(App): self._hex_status(msg.va) def on_hex_view_leave(self, msg: HexView.Leave) -> None: - self._active = self._pref + self._active = self._code_mode() self._show_active() def on_hex_view_to_code(self, msg: HexView.ToCode) -> None: @@ -3132,6 +3379,18 @@ class IdaTui(App): name = self._nav[-1].name if self._nav else "" self._status(f"{name} @ {ea:#x} (line {msg.index})") + def on_listing_view_cursor_moved(self, msg: ListingView.CursorMoved) -> None: + if self._nav: + self._nav[-1].cursor = msg.index + self._nav[-1].cursor_x = self.query_one(ListingView).cursor_x + if msg.index >= 0: + self._nav[-1].scroll_y = round(self.query_one(ListingView).scroll_offset.y) + ea = msg.ea + if ea is not None: + sec = self.program.section_of(ea) if self.program else None + self._status(f"{sec or '?'} @ {ea:#x} [listing] " + "(c code · p func · u undefine · Enter follow)") + # -- teardown ---------------------------------------------------------- # def on_unmount(self) -> None: if self._ka is not None: diff --git a/idatui/rpc.py b/idatui/rpc.py index e363ba3..5836057 100644 --- a/idatui/rpc.py +++ b/idatui/rpc.py @@ -28,7 +28,7 @@ from typing import Any from rich.console import Console from ._sync import drain, settle -from .app import DecompView, DisasmView, HexView +from .app import DecompView, DisasmView, HexView, ListingView PROTO_VERSION = 1 TYPE_DELAY_MS = 35 # default per-char delay for high-level typed ops (aesthetic) @@ -96,6 +96,8 @@ def _active_widget(app): return app.query_one(HexView) if app._active == "disasm": return app.query_one(DisasmView) + if app._active == "listing": + return app.query_one(ListingView) return app.query_one(DecompView) diff --git a/server/patch_server.py b/server/patch_server.py index a13e6cd..3e5193b 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -262,18 +262,26 @@ def heads( cursor = {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)} return {"addr": str(addr), "heads": rows, "cursor": cursor} + # Walk by item END (not next_head): next_head SKIPS undefined bytes, but a + # flat listing must show them (IDA renders undefined as `db ?` lines, and + # navigating to an unmarked address must land ON it). get_item_end steps by + # the item's size for code/data and by 1 through undefined bytes. + def _step(e): + nxt = ida_bytes.get_item_end(e) + return nxt if nxt > e else e + 1 + ea = ida_bytes.get_item_head(start) for _ in range(offset): if ea >= hi or ea == idaapi.BADADDR: break - ea = ida_bytes.next_head(ea, hi) + ea = _step(ea) more = False while ea != idaapi.BADADDR and ea < hi: if len(rows) >= count: more = True break rows.append(_idatui_head_row(ea)) - ea = ida_bytes.next_head(ea, hi) + ea = _step(ea) cursor = {"next": hex(ea)} if more else {"done": True} return {"addr": str(addr), "heads": rows, "cursor": cursor} ''' diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 97ee74b..2b9a9b8 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -25,7 +25,7 @@ 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, - StructEditor, SymbolPalette, XrefsScreen, + ListingView, StructEditor, SymbolPalette, XrefsScreen, ) from textual.widgets import ( # noqa: E402 DataTable, Footer, Input, OptionList, Static, TextArea, @@ -100,6 +100,10 @@ class Ctx: return self.app.query_one(DecompView) @property + def lst(self): + return self.app.query_one(ListingView) + + @property def hex(self): return self.app.query_one(HexView) @@ -1271,29 +1275,34 @@ async def s_region_define(c: Ctx): c.check("function removed by undefine", c.prog.function_of(addr) is None, "still a function") - # navigate there via the real 'g' prompt -> opens a REGION, not refused + # navigate there via the real 'g' prompt -> opens the flat LISTING view + # (a non-function region), not refused await c.goto_ui(hex(addr)) await c.wait(lambda: app._cur is not None and app._cur.ea == addr, 25) - c.check("goto to a non-function address opens a region (not refused)", + c.check("goto to a non-function address opens the listing view (not refused)", app._cur is not None and app._cur.is_region - and app._active == "disasm", + and app._active == "listing" and c.lst.display, f"cur={app._cur} active={app._active} status={c.status()!r}") - await c.wait(lambda: c.dis.total > 0 and c.dis._cursor_ea() is not None, 25) - c.check("region listing renders lines with a resolvable cursor address", - c.dis.total > 0 and c.dis._cursor_ea() == addr, - f"total={c.dis.total} cur_ea={c.dis._cursor_ea()}") + await c.wait(lambda: c.lst.total > 0 and c.lst._cursor_ea() is not None, 25) + c.check("listing renders heads and the cursor sits on the target address", + c.lst.total > 0 and c.lst._cursor_ea() == addr, + f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}") + # the flat listing spans the whole segment, not just this function + seg = c.prog.segment_bounds(addr) + c.check("listing spans the whole segment (more heads than one function)", + seg is not None and c.lst.total > 1, f"total={c.lst.total} seg={seg}") - # cursor on the entry line; 'p' (re)creates the function - c.dis.focus() - c.dis.cursor, c.dis.cursor_x = 0, 0 + # 'p' on the entry head (re)creates the function and UPGRADES to DisasmView + c.lst.focus() + c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 0 await c.pause(0.05) await c.press("p") await c.wait(lambda: c.prog.function_of(addr) is not None and app._cur is not None and not app._cur.is_region, 25) - c.check("'p' creates a function and upgrades the region to a function view", - c.prog.function_of(addr) is not None - and not app._cur.is_region and app._cur.ea == addr, - f"fn={c.prog.function_of(addr)} cur={app._cur}") + c.check("'p' creates a function and upgrades the listing to a function view", + c.prog.function_of(addr) is not None and not app._cur.is_region + and app._cur.ea == addr and app._active in ("disasm", "decomp"), + f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}") finally: # idempotency: guarantee the function is back even if a check failed if c.prog.function_of(addr) is None: @@ -1304,6 +1313,63 @@ async def s_region_define(c: Ctx): c.prog.bump_items() +@scenario("listing_view") +async def s_listing_view(c: Ctx): + """The flat listing view over a data segment: renders code AND data heads, + navigates, and reaches hex — without any function in play.""" + app = c.app + # find a data segment (has a non-code head somewhere) via the listing itself + data_ea = None + for start, end, name in c.prog.sections(): + if start >= end: + continue + lm = c.prog.listing(start) + if lm is None: + continue + lm.ensure(40) + if any(h.kind == "data" for h in lm.window(0, 40)): + data_ea = start + break + if data_ea is None: + c.check("found a segment with data items", False) + return + + await c.goto_ui(hex(data_ea)) + await c.wait(lambda: app._cur is not None and app._active == "listing" + and c.lst.total > 0, 25) + c.check("navigating to a data segment opens the listing view", + app._active == "listing" and c.lst.display and c.lst.total > 0, + f"active={app._active} total={c.lst.total}") + kinds = {h.kind for h in c.lst.model.window(0, 40)} + c.check("listing shows data heads (not just code)", "data" in kinds, str(kinds)) + + # a rendered data line carries the item text (e.g. db/dd/string) + c.lst.focus() + first_data = next((i for i in range(min(c.lst.total, 60)) + if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"), None) + c.check("a data head exists in the first screenful", first_data is not None, + f"total={c.lst.total}") + if first_data is not None: + c.lst.cursor = first_data + await c.pause(0.05) + plain = c.lst._line_plain(first_data) + c.check("data line renders its item text", bool(plain and plain.strip()), + f"plain={plain!r}") + c.check("listing cursor reports the head address", + c.lst._cursor_ea() == c.lst.model.get(first_data).ea, str(c.lst._cursor_ea())) + + # backslash from the listing opens hex at the cursor address; and back + cur_ea = c.lst._cursor_ea() + await c.press("backslash") + await c.wait(lambda: app._active == "hex" and c.hex.display, 15) + c.check("backslash from the listing opens the hex view", app._active == "hex", + f"active={app._active}") + await c.press("backslash") + await c.wait(lambda: app._active == "listing", 15) + c.check("returning from hex lands back on the listing (not a func view)", + app._active == "listing" and c.lst.display, f"active={app._active}") + + # --------------------------------------------------------------------------- # # Runner # --------------------------------------------------------------------------- # |
