aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-23 20:22:01 +0200
committerblasty <blasty@local>2026-07-23 20:22:01 +0200
commit73e8b6ca967c2f32b5586e0614727bcf4959272a (patch)
treebd58822869aae23d04dc81e0cca767ef3f56f96f
parentlisting: IDA-style function boundary banners in the unified view (diff)
downloadida-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.py72
-rw-r--r--server/patch_server.py2
-rw-r--r--tests/test_scenarios.py6
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}")