diff options
| author | blasty <blasty@local> | 2026-07-23 17:23:16 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-23 17:23:16 +0200 |
| commit | 0c097f543a0fd1437189796e69484992604cf8ca (patch) | |
| tree | d6531de3dc4d4f842d941f7b748d15ec1ca1bc29 /idatui | |
| parent | listing: F5/Tab decompiles the function under the cursor (IDA-style) — step 2 (diff) | |
| download | ida-tui-0c097f543a0fd1437189796e69484992604cf8ca.tar.gz ida-tui-0c097f543a0fd1437189796e69484992604cf8ca.tar.xz ida-tui-0c097f543a0fd1437189796e69484992604cf8ca.zip | |
unify: the continuous listing is the one code view; deprecate DisasmView
Full IDA-style unification. The function-bounded DisasmView is gone from the UI:
navigation opens ONE continuous segment listing (functions+data+undefined
interleaved) at the target; F5/Tab decompiles the function under the cursor and
back. The listing gained the opcode column (Head.raw, 'o' toggle) so rendering
matches disasm.
Routing: _do_navigate/_open_function/_open_entry target the listing;
_show_active drops disasm; _active in {listing,decomp,hex}; _pref=listing.
Decomp is a per-function toggle with a listing fallback on failure. Edits
reload in place (_reload_active_code); bump_names() drops the listing cache so
renames refresh. Listing 'n' is symbol-aware. ListingModel gains
cached_line/lines shims; Head.label. DisasmView removed from compose/handlers;
rpc updated. DisasmModel kept (domain/test_domain).
Tests migrated (c.dis->ListingView; open(decomp) F5s; xref/search re-pointed).
Per-scenario green across view_toggle/rename/follow_xrefs/xref_labels/decomp_*/
search/mouse/startup/continuous_view (30/1, 1 cold-decompile flake).
Diffstat (limited to 'idatui')
| -rw-r--r-- | idatui/app.py | 241 | ||||
| -rw-r--r-- | idatui/domain.py | 26 | ||||
| -rw-r--r-- | idatui/rpc.py | 6 |
3 files changed, 127 insertions, 146 deletions
diff --git a/idatui/app.py b/idatui/app.py index d7744f0..c924fa5 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -54,6 +54,14 @@ _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_MEMBER = Style(color="#9a8a6a") # struct field rows (expanded, indented) + +# Tokens that look like identifiers but aren't renamable symbols (so 'n' on them +# in the listing names the address instead of trying to rename the token). +_ASM_KEYWORDS = frozenset({ + "db", "dw", "dd", "dq", "dt", "byte", "word", "dword", "qword", "tbyte", + "offset", "short", "near", "far", "ptr", "dup", "cs", "ds", "es", "fs", + "gs", "ss", "align", "public", "assume", "end", +}) _S_CURSOR = Style(bgcolor="grey30") _S_DIM = Style(color="grey42", italic=True) _S_MATCH = Style(bgcolor="#7a5c00") # all search matches @@ -606,7 +614,8 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True Binding("c", "define_code", "Code", show=False), Binding("p", "define_func", "Func", show=False), Binding("u", "undefine", "Undef", show=False), - Binding("tab,shift+tab,f5", "app.toggle_view", "Pseudocode", priority=True), + 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, @@ -1204,7 +1213,8 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True """ BINDINGS = [ - Binding("tab,shift+tab,f5", "app.toggle_view", "Disasm", priority=True), + Binding("tab,shift+tab", "app.toggle_view", "Disasm", priority=True), + Binding("f5", "app.toggle_view", "Decompile", priority=True), Binding("L", "app.continuous_here", "Listing"), Binding("j,down", "cursor_down", "Down", show=False), Binding("k,up", "cursor_up", "Up", show=False), @@ -1437,7 +1447,8 @@ class HexView(ScrollView, can_focus=True): Binding("G,end", "goto_bottom", show=False), Binding("enter", "to_code", "To code"), Binding("escape", "leave", "Back"), - Binding("tab,shift+tab,f5", "app.toggle_view", "Code", priority=True), + Binding("tab,shift+tab", "app.toggle_view", "Code", priority=True), + Binding("f5", "app.toggle_view", "Decompile", priority=True), ] cursor = reactive(0, repaint=False) @@ -2144,8 +2155,8 @@ class IdaTui(App): self._filter_timer = None self._sort_col = 0 # 0=addr, 1=name, 2=size self._sort_reverse = False - self._pref = "decomp" # preferred code view (changed by Tab) - self._active = "decomp" # currently shown view (falls back on decomp fail) + self._pref = "listing" # unified: the linear listing is the code view + self._active = "listing" # currently shown view self._hex_pending_ea: int | None = None self._cur: NavEntry | None = None self._decomp_return: NavEntry | None = None # listing to return to from F5 @@ -2164,13 +2175,11 @@ class IdaTui(App): fp = FunctionsPanel(id="left") fp.display = False # overlay-first: reveal the docked pane with Ctrl+B yield fp - dis = DisasmView() - dis.display = False - yield dis - yield DecompView() # pseudocode is the default view + # The unified continuous listing is the one code view; DisasmView is + # deprecated (kept only for DisasmModel, still used by the domain). lst = ListingView() - lst.display = False yield lst + yield DecompView() hx = HexView() hx.display = False yield hx @@ -2207,7 +2216,7 @@ class IdaTui(App): inp.can_focus = False # The names pane is an overlay now (Ctrl+N); focus the default code view # (pseudocode) so app bindings work before anything is opened. - self.query_one(DecompView).focus() + self.query_one(ListingView).focus() self._connect() if self._rpc_path: self._start_rpc() @@ -2444,7 +2453,7 @@ class IdaTui(App): left.display = not left.display if not left.display: self.query_one(DecompView if self._active == "decomp" - else DisasmView).focus() + else ListingView).focus() else: self.query_one("#func-table", DataTable).focus() @@ -2488,17 +2497,16 @@ class IdaTui(App): return self._decomp_from_listing(ea) return - if self._active == "decomp" and self._decomp_return is not None: - # F5/Tab in a decompiler view reached from the listing -> back to it. - ret = self._decomp_return - self._decomp_return = None + # active == "decomp": F5/Tab returns to the linear listing. + ret = self._decomp_return + self._decomp_return = None + if ret is not None: self._cur = ret - self._active = "listing" + self._active = "listing" + if ret is not None: self._open_entry(ret, push=False) - return - self._active = "decomp" if self._active == "disasm" else "disasm" - self._pref = self._active # an explicit toggle sets the preference - self._show_active() + else: + self._show_active() @work(thread=True, group="nav") def _decomp_from_listing(self, ea: int) -> None: @@ -2536,9 +2544,7 @@ class IdaTui(App): self._active = self._code_mode() self._show_active() return - if self._active == "disasm": - ea = self.query_one(DisasmView)._cursor_ea() - elif self._active == "listing": + if self._active in ("listing", "disasm"): ea = self.query_one(ListingView)._cursor_ea() else: dec = self.query_one(DecompView) @@ -2583,7 +2589,7 @@ class IdaTui(App): table.focus() else: self.query_one(DecompView if self._active == "decomp" - else DisasmView).focus() + else ListingView).focus() # -- input submit (filter / goto) ------------------------------------- # def on_search_requested(self, msg: SearchRequested) -> None: @@ -2833,15 +2839,29 @@ class IdaTui(App): self._status("no address on this line to name") return head = msg.view.cur_head() - cur = head.name if (head is not None and head.name) else "" - self._rename_ctx = (msg.view, cur) - self._rename_addr = ea + word = msg.view.word_under_cursor() + mnem = head.text.split(" ", 1)[0] if (head and head.text) else "" + # If the cursor is on a symbol token (a call/branch target, a data + # reference, or this head's own label) rename THAT symbol; otherwise + # create/rename a label at the head's address (bare/undefined bytes). + if (word and self._looks_like_symbol(word) and word != mnem + and word.lower() not in _ASM_KEYWORDS): + self._rename_ctx = (msg.view, word) + self._rename_addr = None + placeholder = f"rename '{word}' — Enter=apply Esc=cancel" + prefill = word + else: + cur = head.name if (head is not None and head.name) else "" + self._rename_ctx = (msg.view, cur) + self._rename_addr = ea + placeholder = f"name @ {ea:#x} — Enter=apply Esc=cancel" + prefill = cur self.query_one("#status", Static).display = False inp = self.query_one("#rename", Input) - inp.placeholder = f"name @ {ea:#x} — Enter=apply Esc=cancel" + inp.placeholder = placeholder inp.can_focus = True inp.display = True - inp.value = cur + inp.value = prefill inp.focus() return if not msg.name: @@ -2942,16 +2962,26 @@ class IdaTui(App): return self.app.call_from_thread(self._after_comment, ea, text) - def _after_comment(self, ea: int, text: str) -> None: + def _reload_active_code(self) -> None: + """Refresh whichever code view is showing after an edit (comment/rename/ + retype), in place: re-decompile if in the decompiler, else reload the + listing.""" cur = self._cur + if cur is None: + return + if self._active == "decomp": + self.query_one(DecompView).loaded_ea = None # force re-decompile + self._show_active() + else: + self._save_current_pos() + self._open_entry(cur, push=False) + + def _after_comment(self, ea: int, text: str) -> None: # A comment shows in both views but only after Hex-Rays recompiles, so # reuse the name-generation invalidation (bumps gen -> decompile is - # force_recompiled lazily; disasm block caches are cleared). + # force_recompiled lazily; disasm/listing caches are cleared). self.program.bump_names() - if cur is not None: - self._save_current_pos() - self.query_one(DecompView).loaded_ea = None # force pseudocode reload - self._open_entry(cur, push=False) + self._reload_active_code() self._dirty = True verb = "cleared comment" if not text else "commented" self._status(f"{verb} @ {ea:#x} (Ctrl+S to save)") @@ -3031,11 +3061,7 @@ class IdaTui(App): # A type change alters the pseudocode (and disasm operand types), so # recompile via the name-generation invalidation and reopen in place. self.program.bump_names() - cur = self._cur - if cur is not None: - self._save_current_pos() - self.query_one(DecompView).loaded_ea = None - self._open_entry(cur, push=False) + self._reload_active_code() self._dirty = True what = "prototype" if kind == "func" else f"'{word}'" self._status(f"retyped {what} (Ctrl+S to save)") @@ -3148,10 +3174,7 @@ class IdaTui(App): # A renamed symbol can appear in many functions, so invalidate globally; # each function refreshes its names the next time it's viewed. self.program.bump_names() - if cur is not None: - self._save_current_pos() - self.query_one(DecompView).loaded_ea = None # force pseudocode reload - self._open_entry(cur, push=False) + self._reload_active_code() if kind == "func" and addr is not None and self._func_index is not None: self._func_index.update_name(addr, new) for e in self._nav: @@ -3274,33 +3297,15 @@ class IdaTui(App): def _do_navigate(self, ea: int, push: bool, focus_name: str | None = None) -> None: # worker context assert self.program is not None + # Unified: everything opens the one continuous listing at ``ea``. A + # function name is used for the status label; a region gets a segment + # label. F5/Tab decompiles the function under the cursor from here. fn = self.program.function_of(ea) - if fn is None: - # Not inside a function: open a flat listing (region) anchored here - # 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) - 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, 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) - # The disasm cursor alone doesn't position the pseudocode pane. For a - # mid-function target with the decompiler active, resolve the address to - # its pseudocode line (via the per-line /*0xEA*/ markers) so the jump - # lands on the reference there too (e.g. selecting an xref). A plain - # function-entry jump is line 0 in both views -> skip the decompile. - # With a focus_name (the symbol the xref was on), also land the column - # on that token where it appears in the line. - dec_idx, dec_col = -1, 0 - if self._active == "decomp" and ea != fn.addr: - dec_idx = self._decomp_line_for(fn.addr, ea) - if dec_idx >= 0 and focus_name: - dec_col = self._decomp_col_for(fn.addr, dec_idx, focus_name) + lm = self.program.listing(ea) + idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0 + name = fn.name if fn is not None else self.program.region_label(ea) self.app.call_from_thread( - self._open_at, fn.addr, fn.name, idx, push, dec_idx, dec_col) + self._open_at, ea, name, idx, push, -1, 0, fn is None) def _decomp_col_for(self, fn_addr: int, line_idx: int, name: str) -> int: """Column of ``name`` (whole-word) on pseudocode line ``line_idx``, so an @@ -3473,14 +3478,12 @@ class IdaTui(App): self.query_one("#func-table", DataTable).focus() def _code_view(self): # type: ignore[no-untyped-def] - return self.query_one(DecompView if self._pref == "decomp" else DisasmView) + return self.query_one(DecompView if self._pref == "decomp" else ListingView) def _active_code_view(self): # type: ignore[no-untyped-def] """The currently-shown code widget (for reading the cursor address).""" - if self._active == "listing": + if self._active in ("listing", "disasm"): return self.query_one(ListingView) - if self._active == "disasm": - return self.query_one(DisasmView) if self._active == "decomp": return self.query_one(DecompView) return None @@ -3546,82 +3549,53 @@ class IdaTui(App): self._open_function(ea, name) def _save_current_pos(self) -> None: - """Snapshot the active views' cursor + scroll into the top history entry + """Snapshot the listing cursor + scroll into the top history entry (called just before we navigate away).""" if not self._nav: return e = self._nav[-1] - dis = self.query_one(DisasmView) - e.cursor, e.cursor_x = dis.cursor, dis.cursor_x - e.scroll_y = round(dis.scroll_offset.y) - dec = self.query_one(DecompView) - if dec.loaded_ea == e.ea: - e.dec_cursor, e.dec_cursor_x = dec.cursor, dec.cursor_x - e.dec_scroll_y = round(dec.scroll_offset.y) - e.dec_scroll_x = round(dec.scroll_offset.x) + lst = self.query_one(ListingView) + if lst.model is not None: + e.cursor, e.cursor_x = lst.cursor, lst.cursor_x + e.scroll_y = round(lst.scroll_offset.y) + + @work(thread=True, group="nav") + def _open_function(self, ea: int, name: str | None = None, + push: bool = True) -> None: + # Unified: opening a function is just navigating the one linear listing + # to its entry address. + self._do_navigate(ea, push) - def _open_function(self, ea: int, name: str, push: bool = True) -> None: - if push: - self._save_current_pos() - entry = NavEntry(ea=ea, name=name, cursor=0) - if push: - 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 + """The code view to return to from hex — always the unified listing.""" + return "listing" 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. - self._active = self._pref - model = self.program.disasm(entry.ea, entry.name) + # Unified model: the code view is always the continuous listing, + # positioned at this entry. The decompiler is a per-function toggle + # (F5/Tab -> _decomp_from_listing), never opened implicitly. + lm = self.program.listing(entry.ea) sy = entry.scroll_y if entry.scroll_y >= 0 else None - self.query_one(DisasmView).load( - model, entry.name, cursor=entry.cursor, cursor_x=entry.cursor_x, scroll_y=sy) - dec = self.query_one(DecompView) - # If the decompiler already shows this function, _show_active won't reload - # it (and thus won't reposition), so move its cursor to the target line - # here — e.g. an xref/goto whose target is inside the current function. - reposition_dec = dec.loaded_ea == entry.ea + 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._active = "listing" self._show_active() - if reposition_dec and self._active == "decomp": - dsy = entry.dec_scroll_y if entry.dec_scroll_y >= 0 else -1 - dec.goto(entry.dec_cursor, entry.dec_cursor_x, dsy, entry.dec_scroll_x) 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 = lst.display = hx.display = False - if self._active == "listing": + dec.display = lst.display = hx.display = False + if self._active in ("listing", "disasm"): lst.display = True lst.focus() self._status_for_cur("listing") - elif self._active == "disasm": - dis.display = True - dis.focus() - self._status_for_cur("disasm") elif self._active == "hex": hx.display = True hx.focus() @@ -3731,17 +3705,6 @@ class IdaTui(App): loc = f" @ {msg.ea:#x}" if msg.ea is not None else "" self._status(f"{self._cur.name}{loc} [pseudocode line {msg.index}]") - def on_disasm_view_cursor_moved(self, msg: DisasmView.CursorMoved) -> None: - if self._nav: - # Keep the top-of-history position current (line + column) so that - # returning here later lands exactly where we left. - self._nav[-1].cursor = msg.index - self._nav[-1].cursor_x = self.query_one(DisasmView).cursor_x - ea = msg.ea - if ea is not None: - 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 diff --git a/idatui/domain.py b/idatui/domain.py index 86fb47e..e392936 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -98,6 +98,10 @@ class Head: name: str | None = None raw: bytes | None = None # opcode/item bytes (filled in for code by the model) + @property + def label(self) -> str | None: # Line-compatible alias + return self.name + @classmethod def from_raw(cls, d: dict) -> "Head": return cls( @@ -680,6 +684,21 @@ class ListingModel: with self._lock: return self._by_ea.get(ea, -1) + # -- DisasmModel-compatible accessors (unified model) ------------------ # + def cached_line(self, idx: int) -> Head | None: + """Alias of get() for the disasm view's Line interface.""" + return self.get(idx) + + def lines(self, start: int, count: int, prefetch: bool = True) -> list[Head]: + return self.window(start, count) + + def is_cached(self, start: int, count: int) -> bool: + with self._lock: + return start + count <= len(self._heads) + + def ensure_async(self, start: int, count: int) -> None: + pass # the background grower streams the rest in; nothing to prefetch + # --------------------------------------------------------------------------- # # Hex model: block-cached byte view over the loaded image (VA-addressed) @@ -1114,12 +1133,13 @@ class Program: return dec def bump_names(self) -> None: - """Signal that symbol names changed (a rename). Disasm names are live in - the IDB, so clearing the block caches is enough for those; decompilation - is generation-checked and force-recompiled lazily on next access.""" + """Signal that symbol names changed (a rename). Disasm/listing names are + live in the IDB, so clearing the cached rows is enough for those; + decompilation is generation-checked and force-recompiled lazily.""" with self._lock: self._name_gen += 1 models = list(self._disasm.values()) + self._listings.clear() # listing head rows cache names -> refetch for m in models: m.invalidate() diff --git a/idatui/rpc.py b/idatui/rpc.py index 5836057..908a26c 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, ListingView +from .app import DecompView, HexView, ListingView PROTO_VERSION = 1 TYPE_DELAY_MS = 35 # default per-char delay for high-level typed ops (aesthetic) @@ -94,9 +94,7 @@ def _active_widget(app): """The currently *shown* code widget (mirrors app._active).""" if app._active == "hex": return app.query_one(HexView) - if app._active == "disasm": - return app.query_one(DisasmView) - if app._active == "listing": + if app._active in ("listing", "disasm"): return app.query_one(ListingView) return app.query_one(DecompView) |
