diff options
| author | blasty <blasty@local> | 2026-07-23 20:22:01 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-23 20:22:01 +0200 |
| commit | 73e8b6ca967c2f32b5586e0614727bcf4959272a (patch) | |
| tree | bd58822869aae23d04dc81e0cca767ef3f56f96f | |
| parent | listing: IDA-style function boundary banners in the unified view (diff) | |
| download | ida-tui-73e8b6ca967c2f32b5586e0614727bcf4959272a.tar.gz ida-tui-73e8b6ca967c2f32b5586e0614727bcf4959272a.tar.xz ida-tui-73e8b6ca967c2f32b5586e0614727bcf4959272a.zip | |
listing: two-level indent, address on headers, drop 'near', opcode cap+cycle
Polish per feedback: proc header drops the meaningless 'near' ('name proc');
the function-name/proc header now shows the address like every line; two-level
indent (function names at depth 0, opcode bytes + instruction text one level
deeper, matching IDA's label/instruction columns); 'o' now CYCLES the opcode
column off->limited->full and is shown in the footer; 'limited' mode caps long
runs at the first 8 bytes + ellipsis so 15-byte x86-64 insns don't blow out the
column (width capped to match).
_line_plain/render_line share the layout so cursor/search stay aligned.
Verified func_banners+disasm_nav/mouse/region_define/listing_view/
listing_struct_expand/listing_name_addr/search/continuous_view green. Needs a
supervisor restart.
| -rw-r--r-- | idatui/app.py | 72 | ||||
| -rw-r--r-- | server/patch_server.py | 2 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 6 |
3 files changed, 53 insertions, 27 deletions
diff --git a/idatui/app.py b/idatui/app.py index fd8c8c0..59f0af3 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -55,7 +55,10 @@ _S_DATA = Style(color="#c8a15a") # data items in the flat listing (db/dw/strin _S_UNK = Style(color="grey54", italic=True) # undefined bytes in the flat listing _S_MEMBER = Style(color="#9a8a6a") # struct field rows (expanded, indented) _S_SEP = Style(color="grey42") # function boundary separators / banners -_S_FUNCHDR = Style(color="#d7a021", bold=True) # 'name proc near'/'endp' headers +_S_FUNCHDR = Style(color="#d7a021", bold=True) # 'name proc'/'endp' headers + +_LST_INDENT = " " # one depth level: function names sit at level 0, code at 1 +_OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode # 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). @@ -911,7 +914,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru 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("o", "toggle_opcodes", "Opcodes"), Binding("c", "define_code", "Code", show=False), Binding("d", "make_data", "Data", show=False), Binding("a", "make_string", "Str", show=False), @@ -942,7 +945,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._term = "" self._matches: list[int] = [] self._ranges: dict[int, list[tuple[int, int]]] = {} - self._show_ops = True # opcode-bytes column (toggle 'o'), like disasm + self._op_mode = 1 # opcode column: 0=off, 1=limited, 2=full ('o' cycles) self._op_w = 0 # char width of the hex-bytes field (excl. gap) # -- text helpers ------------------------------------------------------ # @@ -953,23 +956,34 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru def _name_prefix(h: Head) -> str: return f"{h.name} " if h.name else "" + def _op_bytes_text(self, h: Head) -> str: + """Hex bytes for ``h``, truncated with an ellipsis in 'limited' mode so a + long x86-64 instruction doesn't blow out the column.""" + raw = h.raw or b"" + if self._op_mode == 1 and len(raw) > _OP_LIMIT: + return " ".join(f"{b:02X}" for b in raw[:_OP_LIMIT]) + "\u2026" + return " ".join(f"{b:02X}" for b in raw) + def _op_field(self, h: Head) -> str: - """The padded opcode-bytes column (empty when hidden / no bytes). Shared - format with the disasm view so cursor/search offsets line up.""" - if not self._show_ops or self._op_w <= 0: + """The padded opcode-bytes column (empty when hidden). Shared format so + cursor/search offsets line up.""" + if self._op_mode == 0 or self._op_w <= 0: return "" - raw = h.raw or b"" - return " ".join(f"{b:02X}" for b in raw).ljust(self._op_w) + " " + return self._op_bytes_text(h).ljust(self._op_w) + " " def _line_plain(self, idx: int) -> str | None: h = self._head(idx) if h is None: return None - if h.kind in ("sep", "funchdr"): - return h.text - indent = " " if h.kind == "member" else "" - return (f"{h.ea:08X} " + self._op_field(h) + indent - + self._name_prefix(h) + h.text) + # Function-name headers sit at depth 0 (with the address); everything + # else is indented one level, code/data opcode+text included. + if h.kind == "funchdr": + return f"{h.ea:08X} {h.text}" + base = f"{h.ea:08X} " + _LST_INDENT + if h.kind == "sep": + return base + h.text + extra = _LST_INDENT if h.kind == "member" else "" + return base + self._op_field(h) + extra + self._name_prefix(h) + h.text # -- public API -------------------------------------------------------- # def load(self, model: ListingModel, name: str, cursor: int = 0, @@ -1015,24 +1029,31 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self.post_message(ListingView.CursorMoved(self.cursor, self._cursor_ea())) def _update_op_w(self) -> bool: - """Recompute the opcode-column width from the widest code head seen. - Returns True if it changed (callers refresh + drop stale search index).""" + """Recompute the opcode-column width from the widest code head seen (capped + in 'limited' mode). Returns True if it changed.""" w = 0 - if self.model is not None and self._show_ops: + if self.model is not None and self._op_mode != 0: mx = self.model.max_raw_len() - w = max(mx * 3 - 1, 0) if mx > 0 else 0 + if mx > 0: + if self._op_mode == 1: # limited: cap bytes, +1 for the ellipsis + shown = min(mx, _OP_LIMIT) + w = shown * 3 - 1 + (1 if mx > _OP_LIMIT else 0) + else: # full + w = mx * 3 - 1 if w != self._op_w: self._op_w = w return True return False def action_toggle_opcodes(self) -> None: - self._show_ops = not self._show_ops + # cycle: off -> limited -> full -> off + self._op_mode = (self._op_mode + 1) % 3 self._update_op_w() self._ranges = {} # column layout changed -> stale match offsets self._clamp_x() self.refresh() - self._app_status("opcodes " + ("on" if self._show_ops else "off")) + self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)", + 2: "full"}[self._op_mode]) @work(thread=True, exclusive=True, group="listing-grow") def _grow(self) -> None: @@ -1097,16 +1118,21 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru if h is None: strip = Strip([Segment(f" {idx:>8} …", _S_DIM)]) elif h.kind == "sep": - strip = Strip([Segment(h.text, _S_SEP)]) + strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR), + Segment(_LST_INDENT + h.text, _S_SEP)]) elif h.kind == "funchdr": - strip = Strip([Segment(h.text, _S_FUNCHDR)]) + # depth-0: address + 'name proc'/'endp' (no indent) + strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR), + Segment(h.text, _S_FUNCHDR)]) else: - segs: list[Segment] = [Segment(f"{h.ea:08X} ", _S_ADDR)] + # depth-1: address, one indent, then opcode+text + segs: list[Segment] = [Segment(f"{h.ea:08X} ", _S_ADDR), + Segment(_LST_INDENT, _S_INSN)] op = self._op_field(h) if op: segs.append(Segment(op, _S_OPBYTES)) if h.kind == "member": - segs.append(Segment(" ", _S_MEMBER)) + segs.append(Segment(_LST_INDENT, _S_MEMBER)) if h.name: segs.append(Segment(f"{h.name} ", _S_LABEL)) if h.kind == "code": diff --git a/server/patch_server.py b/server/patch_server.py index 99cd584..73144fe 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -348,7 +348,7 @@ def _idatui_func_header_rows(ea): {"ea": hex(ea), "kind": "sep", "size": 0, "text": ""}, {"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + bar}, {"ea": hex(ea), "kind": "funchdr", "size": 0, - "text": name + " proc near", "name": name}, + "text": name + " proc", "name": name}, ] diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 73fc50b..f72e340 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1634,13 +1634,13 @@ async def s_func_banners(c: Ctx): f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}") c.check("a SUBROUTINE separator banner is present", any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads)) - c.check("a 'name proc near' header is present", - any(h.kind == "funchdr" and h.text.endswith("proc near") for h in heads)) + c.check("a 'name proc' header is present", + any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads)) c.check("a 'name endp' footer is present", any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads)) # the proc header for the origin function carries its name hdr = next((h for h in heads if h.kind == "funchdr" - and h.text == f"{fn.name} proc near"), None) + and h.text == f"{fn.name} proc"), None) c.check("the proc header names the function", hdr is not None, f"fn={fn.name}") |
