aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-26 14:36:12 +0200
committerblasty <blasty@local>2026-07-26 14:36:12 +0200
commitfd696d8cf080c37c1cbe7696f21fe8d5b8c468e4 (patch)
treed15d8a6bfad5726b0a99b5f0d8875d200ab78478
parentlisting: syntax-highlight assembly from IDA's own token tags (diff)
downloadida-tui-fd696d8cf080c37c1cbe7696f21fe8d5b8c468e4.tar.gz
ida-tui-fd696d8cf080c37c1cbe7696f21fe8d5b8c468e4.tar.xz
ida-tui-fd696d8cf080c37c1cbe7696f21fe8d5b8c468e4.zip
app: delete DisasmView, superseded by the unified listing
DisasmView was the function-scoped code view from before the unification. The app hasn't instantiated it since — compose() yields only ListingView, and even the test harness's Ctx.dis returns ListingView with a comment saying so. Its CursorMoved messages had no handler, so every one it posted went nowhere. 304 lines of rendering, search, cursor and navigation logic that never ran. It also cost real time this week: it made assembly highlighting look like a job that needed doing twice, and its isinstance branches in the follow and xrefs handlers were unreachable twins of the ListingView branch directly below them, which is exactly the kind of thing you read carefully before realising it can't execute. Gone with it: the dead branches (folded into the ListingView ones, keeping the fall-through-edge comment that was worth keeping), its CSS rule, and the comments that pointed at it as though it were a live alternative. DisasmModel STAYS — the domain still uses it to index a function's instructions (_do_edit_item resolves a row within a function that way). Only the widget was dead. 209/0 scenarios, 26/0 blob, 30/0 project UI, 36/0 index, 32/0 formats, 39/0 project, 27/0 pool. Smoke-tested a live pane afterwards: the listing renders, highlighted, and `drive where` answers.
-rw-r--r--docs/TEXTUAL_NOTES.md2
-rw-r--r--idatui/app.py339
-rw-r--r--tests/test_scenarios.py4
3 files changed, 14 insertions, 331 deletions
diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md
index 059b47c..c8b265c 100644
--- a/docs/TEXTUAL_NOTES.md
+++ b/docs/TEXTUAL_NOTES.md
@@ -15,7 +15,7 @@ Hard-won Textual behaviour and the patterns this app relies on. Pairs with
isn't recomputed until layout. Apply the scroll now AND again via
`call_after_refresh` with `refresh(layout=True)`; don't zero `virtual_size` on
load (snaps scroll to 0 → visible flash). See `ColumnCursor._apply_scroll`,
- `DisasmView._on_primed`, `DecompView.show`.
+ `ListingView._on_primed`, `DecompView.show`.
- **Scrolling on already-laid-out content (no reload) leaves a stale frame.** A
plain `scroll_to` moves `scroll_offset` but Textual only repaints on a
*rounded* scroll change, and with no virtual_size/content change there's no
diff --git a/idatui/app.py b/idatui/app.py
index 6506016..2190643 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -710,316 +710,12 @@ class SearchMixin:
# --------------------------------------------------------------------------- #
# Virtualized disassembly view
# --------------------------------------------------------------------------- #
-class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True):
- """A line-virtualized disassembly listing for a single function."""
-
- 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("o", "toggle_opcodes", "Opcodes", show=False),
- Binding("c", "define_code", "Code", show=False),
- Binding("p", "define_func", "Func", show=False),
- Binding("u", "undefine", "Undef", show=False),
- Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True),
- Binding("f5", "app.toggle_view", "Decompile", priority=True),
- Binding("L", "app.continuous_here", "Listing"),
- *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 disasm cursor moves; carries the instruction 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: DisasmModel | 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]]] = {}
- self._search_texts: list[str] | None = None
- self._show_ops = True
- self._op_w = 0 # char width of the hex-bytes field (excl. trailing gap)
-
- def _op_field(self, line) -> str: # type: ignore[no-untyped-def]
- """The padded opcode-bytes column (empty when hidden). Kept identical
- between the rendered strip and the plain text so cursor/search offsets
- line up."""
- if not self._show_ops or self._op_w <= 0:
- return ""
- raw = line.raw or b""
- return " ".join(f"{b:02X}" for b in raw).ljust(self._op_w) + " "
-
- def _line_plain(self, idx: int) -> str | None:
- if self.model is None:
- return None
- line = self.model.cached_line(idx)
- if line is None:
- return None
- s = f"{line.ea:08X} " + self._op_field(line)
- if line.label:
- s += f"{line.label}: "
- return s + line.text
-
- # -- public API -------------------------------------------------------- #
- def load(self, model: DisasmModel, 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
- # NB: don't zero virtual_size here — that snaps the scroll to 0 and
- # causes a visible jump before _on_primed restores the target scroll.
- self._matches = []
- self._ranges = {}
- self._search_texts = None
- self._prime()
-
- # -- search hooks ------------------------------------------------------ #
- def _fmt(self, line) -> str: # type: ignore[no-untyped-def]
- s = f"{line.ea:08X} " + self._op_field(line)
- if line.label:
- s += f"{line.label}: "
- return s + line.text
-
- def _search_line_count(self) -> int:
- return self.total
-
- def _search_line_text(self, i: int) -> str | None:
- t = self._search_texts
- return t[i] if t is not None and 0 <= i < len(t) else None
-
- def _search_ensure(self, done) -> None:
- if self._search_texts is not None:
- done()
- return
- self._app_status(f"/{self._term}/ indexing {self.total} lines…")
- self._index_for_search(done)
-
- @work(thread=True, exclusive=True, group="search-index")
- def _index_for_search(self, done) -> None:
- model = self.model
- if model is None:
- self.app.call_from_thread(done)
- return
- texts: list[str] = []
- off, total = 0, self.total
- # A freshly-loaded blob is one row per undefined byte, so "all lines" can
- # be millions of `db 4Ah` that nobody searches for. Index a bounded
- # prefix rather than hang; say so instead of silently finding nothing.
- LIMIT = 400_000
- capped = total > LIMIT
- total = min(total, LIMIT)
- while off < total:
- lines = model.lines(off, min(DisasmModel.BLOCK, total - off),
- prefetch=False)
- if not lines:
- break
- texts.extend(self._fmt(ln) for ln in lines)
- off += len(lines)
- self._search_texts = texts
- if capped:
- self.app.call_from_thread(
- self._app_status,
- f"search covers the first {LIMIT:,} lines of {self.total:,}")
- self.app.call_from_thread(done)
-
- @work(thread=True, exclusive=True, group="disasm-prime")
- def _prime(self) -> None:
- model = self.model
- if model is None:
- return
- total = model.total()
- height = max(self.size.height, 1)
- model.lines(0, min(total, height + DisasmModel.BLOCK), prefetch=True)
- if self.cursor:
- model.lines(max(self.cursor - 2, 0), height, prefetch=True)
- self.app.call_from_thread(self._on_primed, total)
-
- def _on_primed(self, total: int) -> None:
- self.total = total
- self.virtual_size = Size(0, total)
- self._update_op_w() # provisional width from the primed window
- self._scan_op_width() # settle it against the whole function
- self._clamp_x() # cursor line is now cached; keep the column in range
- 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()
-
- # -- 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)
- if y == 0: # once per refresh: warm the visible window + a little ahead
- self._ensure_window(top)
- idx = top + y
- if idx >= self.total:
- return Strip([Segment("".ljust(width), _S_INSN)])
- line = model.cached_line(idx)
- is_cursor = idx == self.cursor
- if line is None:
- strip = Strip([Segment(f" {idx:>8} …", _S_DIM)])
- else:
- segs: list[Segment] = [Segment(f"{line.ea:08X} ", _S_ADDR)]
- op = self._op_field(line)
- if op:
- segs.append(Segment(op, _S_OPBYTES))
- if line.label:
- segs.append(Segment(f"{line.label}: ", _S_LABEL))
- mnem, _, rest = line.text.partition(" ")
- segs.append(Segment(mnem, _S_MNEM))
- if rest:
- segs.append(Segment(" " + rest, _S_INSN))
- strip = Strip(segs)
- if idx in self._ranges:
- strip = _overlay_ranges(strip, self._ranges[idx], self._match_style(idx))
- if is_cursor:
- strip = _cursor_decorate(strip, self._line_plain(idx) or "", self.cursor_x)
- return strip.adjust_cell_length(width, _S_INSN)
-
- def _update_op_w(self) -> bool:
- """Recompute the opcode column width from the model's widest instruction.
- Returns True if it changed."""
- w = 0
- if self.model is not None and self._show_ops:
- mx = self.model.max_raw_len()
- w = max(mx * 3 - 1, 0) if mx > 0 else 0
- if w != self._op_w:
- self._op_w = w
- return True
- return False
-
- @work(thread=True, exclusive=True, group="disasm-opwidth")
- def _scan_op_width(self) -> None:
- model = self.model
- if model is None:
- return
- model.scan_bytes() # fetch all blocks -> stable widest instruction
- self.app.call_from_thread(self._settle_op_w)
-
- def _settle_op_w(self) -> None:
- if self._update_op_w():
- self._search_texts = None # layout changed -> stale offsets
- self.refresh()
-
- def action_toggle_opcodes(self) -> None:
- self._show_ops = not self._show_ops
- self._update_op_w()
- self._search_texts = None # column layout changed -> reindex on next search
- self._ranges = {}
- self._clamp_x()
- self.refresh()
- self._app_status("opcodes " + ("on" if self._show_ops else "off"))
-
- def _ensure_window(self, top: int) -> None:
- if self.model is None:
- return
- height = max(self.size.height, 1)
- start = max(top - DisasmModel.BLOCK, 0)
- count = height + 2 * DisasmModel.BLOCK
- if not self.model.is_cached(top, height):
- self._fetch_window(start, count)
- else:
- self.model.ensure_async(start, count) # warm neighbors
-
- @work(thread=True, exclusive=False, group="disasm-fetch")
- def _fetch_window(self, start: int, count: int) -> None:
- model = self.model
- if model is None:
- return
- model.lines(start, count, prefetch=True)
- self.app.call_from_thread(self.refresh)
-
- # -- 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() # scrolled: the whole viewport shifted
- else:
- _refresh_lines(self, old, self.cursor) # only the two changed rows
- self._refresh_hl()
- self.post_message(DisasmView.CursorMoved(self.cursor, self._cursor_ea()))
-
- def _after_cursor_move(self) -> None:
- self.post_message(DisasmView.CursorMoved(self.cursor, self._cursor_ea()))
-
- def _cursor_ea(self) -> int | None:
- if self.model is None:
- return None
- line = self.model.cached_line(self.cursor)
- return line.ea if line 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)
-
-
# --------------------------------------------------------------------------- #
# 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``
+ undefined heads interleaved (IDA's disassembly view), unlike ``DisasmModel``
which is bounded to one function. Backed by ``ListingModel`` (the ``heads``
server tool). Used for non-function regions and raw segment browsing.
"""
@@ -1742,7 +1438,7 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
best, best_ea = i, e
return best
- # -- navigation (mirrors DisasmView) ---------------------------------- #
+ # -- navigation -------------------------------------------------------- #
def _visible_height(self) -> int:
return max(self.size.height, 1)
@@ -3256,7 +2952,6 @@ class IdaTui(App):
#left { width: 30%; min-width: 42; max-width: 44; border-right: solid $panel; }
#func-table { height: 1fr; }
#func-filter { dock: top; }
- DisasmView { width: 1fr; padding: 0 1; }
DecompView { width: 1fr; }
ListingView { width: 1fr; padding: 0 1; }
#panes.split ListingView { border-right: tall $panel-lighten-2; }
@@ -3454,8 +3149,8 @@ class IdaTui(App):
fp = FunctionsPanel(id="left")
fp.display = False # overlay-first: reveal the docked pane with Ctrl+B
yield fp
- # The unified continuous listing is the one code view; DisasmView is
- # deprecated (kept only for DisasmModel, still used by the domain).
+ # The unified continuous listing is the one code view. (DisasmModel
+ # is still used by the domain to index a function's instructions.)
lst = ListingView()
yield lst
yield DecompView()
@@ -4653,17 +4348,12 @@ class IdaTui(App):
def on_follow_requested(self, msg: FollowRequested) -> None:
view = msg.view
word = view.word_under_cursor()
- if isinstance(view, DisasmView):
- ea = view._cursor_ea()
- # The instruction's ordinary fall-through edge (to the next line) is
- # an indistinguishable 'code' xref; pass it so follow can skip it and
- # land on a call/jump's real target instead of the next instruction.
- 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):
+ if isinstance(view, ListingView):
ea = view._cursor_ea()
if ea is not None:
+ # _next_ea() is the following item: follow uses it to skip the
+ # ordinary fall-through edge, which is an indistinguishable
+ # 'code' xref, and land on a call/jump's real target instead.
self._follow_disasm(ea, word, view._next_ea())
elif isinstance(view, DecompView) and view._texts:
self._follow_decomp(view._texts[view.cursor], word,
@@ -4674,17 +4364,11 @@ class IdaTui(App):
return
view = msg.view
word = view.word_under_cursor()
- if isinstance(view, DisasmView):
+ if isinstance(view, ListingView):
ea = view._cursor_ea()
if ea is not None:
# span = this instruction .. the next, to pre-select the dialog
# entry for the site we invoked xrefs from.
- nxt = view.model.cached_line(view.cursor + 1) if view.model else None
- self._push_busy("finding xrefs\u2026")
- 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._push_busy("finding xrefs\u2026")
self._xrefs_disasm(ea, word, ea, view._next_ea())
elif isinstance(view, DecompView) and view._texts:
@@ -5076,7 +4760,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, ListingView)):
+ if isinstance(view, ListingView):
return view._cursor_ea()
if isinstance(view, DecompView):
return view._line_ea(view.cursor)
@@ -5455,8 +5139,7 @@ class IdaTui(App):
# -- item structure edits (IDA c/p/u) --------------------------------- #
def on_edit_item_requested(self, msg: EditItemRequested) -> None:
view = msg.view
- ea = (view._cursor_ea()
- if isinstance(view, (DisasmView, ListingView)) else None)
+ ea = view._cursor_ea() if isinstance(view, ListingView) else None
if ea is None:
self._status("no address on this line to (re)define")
return
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index abffe3c..53dad91 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,
+ ConfirmScreen, DecompView, FunctionsPanel, HexView, IdaTui,
HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor,
SymbolPalette, XrefsScreen, _str_display, _word_occurrences,
)
@@ -1907,7 +1907,7 @@ async def s_region_define(c: Ctx):
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}")
- # 'p' on the entry head (re)creates the function and UPGRADES to DisasmView
+ # 'p' on the entry head (re)creates the function
c.lst.focus()
c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 0
await c.pause(0.05)