aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--TODO16
-rw-r--r--idatui/app.py287
-rw-r--r--idatui/rpc.py4
-rw-r--r--server/patch_server.py12
-rw-r--r--tests/test_scenarios.py96
5 files changed, 378 insertions, 37 deletions
diff --git a/TODO b/TODO
index a1ff814..92ecfa1 100644
--- a/TODO
+++ b/TODO
@@ -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
# --------------------------------------------------------------------------- #