aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-07 00:07:22 +0200
committerblasty <blasty@local>2026-08-07 00:07:22 +0200
commitcefdeb88ab3271313db741c9c95677178b18d484 (patch)
tree4c44ffda644a0ca0f891b17d61d889e10114083b
parenttests: a guard that actually regresses on the resync storm (diff)
downloadida-tui-cefdeb88ab3271313db741c9c95677178b18d484.tar.gz
ida-tui-cefdeb88ab3271313db741c9c95677178b18d484.tar.xz
ida-tui-cefdeb88ab3271313db741c9c95677178b18d484.zip
app: the view mode is a type, and 'disasm' is gone
_active was a bare string with 49 comparisons across four modules and a fifth value nobody meant to keep. "disasm" was assigned on exactly one path -- a decompile that failed with nowhere to return to -- and named the same widget as "listing". Four sites understood it; five compared against "listing" alone and silently took the wrong branch: * Tab out of a failed decompile set "listing" instead of "decomp", so the first press appeared to do nothing. * rpc.py carried a workaround for a mode change that never arrived, keyed on being ALREADY in the ghost state -- so it fired in the rare case and not in the common one. Now keyed on LISTING, which is the case that happens. * drive.py asked the socket to show it "disasm", a value the app will now never report, and would have toggled twice and given up. ViewMode is a StrEnum on purpose: _active goes straight to drivers as cursor.kind and the pilot compares it to plain strings, so members being strings keeps every payload and comparison working. What it buys is one place that says which modes exist, and an AttributeError instead of silence on a typo. Read it through is_listing/is_decomp/is_hex/is_graph/in_code rather than ==. The bare comparisons are what let the ghost hide, and they are what the next mode would have to hunt down -- adding "graph" already cost one crash that way (_active_code_view returning None when a prompt closed). view_modes_all_handled walks the enum and asks the app the questions it asks itself. Verified it bites: adding a fifth unhandled member fails it twice. 746 checks, 142.3s.
-rw-r--r--idatui/app.py174
-rw-r--r--idatui/drive.py10
-rw-r--r--idatui/edit_ctl.py2
-rw-r--r--idatui/rpc.py46
-rw-r--r--idatui/trace_ctl.py4
-rw-r--r--tests/test_scenarios.py55
6 files changed, 202 insertions, 89 deletions
diff --git a/idatui/app.py b/idatui/app.py
index c66e39a..61f366e 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -22,6 +22,7 @@ import re
import subprocess
import time
from dataclasses import dataclass, field
+from enum import StrEnum
from rich.align import Align
from rich.segment import Segment
@@ -89,6 +90,42 @@ _OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode
_JUMP_CONTEXT = 4 # lines of context kept above a jump target (cursor stays on it)
_SPLIT_MIN_WIDTH = 100 # need room for two usable code panes side by side
+
+class ViewMode(StrEnum):
+ """Which pane is showing (in split: which one has focus). ``IdaTui._active``.
+
+ A ``StrEnum`` rather than an enum, deliberately: ``_active`` is handed
+ straight to drivers as ``cursor.kind`` over the RPC socket, and the pilot
+ suite compares it to plain strings. Members ARE strings, so every existing
+ comparison and every JSON payload keeps working -- what this buys is one
+ place that says which modes exist, and an AttributeError instead of silence
+ when one is misspelled.
+
+ There used to be a fifth value, ``"disasm"``, assigned on exactly one path
+ (a decompile that failed with nowhere to return to) and meaning the same
+ widget as ``LISTING``. Four sites handled it and five compared against
+ ``"listing"`` alone, so it silently took the wrong branch in half the app --
+ Tab out of a failed decompile flipped to the listing instead of retrying the
+ decompiler, and rpc.py carried a workaround for a mode change that never
+ arrived. It is gone; the failure path lands on LISTING like every other
+ route into the listing.
+
+ Adding a mode means auditing every ``_active`` comparison. Prefer the
+ predicates on IdaTui (``is_listing``/``in_code``/...) over bare ``==`` so
+ the next one has fewer places to reach.
+ """
+
+ LISTING = "listing" # the unified continuous listing (code + data)
+ DECOMP = "decomp" # Hex-Rays pseudocode
+ HEX = "hex" # the hex viewer
+ GRAPH = "graph" # the CFG graph view
+
+ #: The two that show a code view over a NavEntry, i.e. where a follow, an
+ #: xref or a rename makes sense.
+ @classmethod
+ def code_modes(cls) -> frozenset["ViewMode"]:
+ return frozenset({cls.LISTING, cls.DECOMP, cls.GRAPH})
+
# 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({
@@ -144,7 +181,7 @@ class BinaryState:
func_index: object | None = None
nav: list = field(default_factory=list)
cur: object | None = None
- active: str = "listing"
+ active: ViewMode = ViewMode.LISTING
split: bool = False
filter_term: str = ""
dirty: bool = False
@@ -4489,7 +4526,7 @@ class IdaTui(App):
# ONE notion of "which pane you're in": _active, kept in step with focus
# (on_descendant_focus does that while split). There used to be a second,
# _pref, but it was only ever assigned "listing" — see _code_mode().
- self._active = "listing" # currently shown view (in split: the focused pane)
+ self._active = ViewMode.LISTING # currently shown view (in split: the focused pane)
self._split = False # side-by-side listing + pseudocode
self._graph_sticky = False # stay in graph mode across navigations
self._split_eamap: list[list[int]] = [] # split: decomp line -> instr EAs
@@ -5424,7 +5461,7 @@ class IdaTui(App):
self._pool.set_active(label)
self._open_path = self._project.by_label(label).staged
self._title = os.path.basename(self._open_path)
- self._active = st.active if st else "listing"
+ self._active = st.active if st else ViewMode.LISTING
self._split = st.split if st else False
self._filter_term = st.filter_term if st else ""
self._dirty = st.dirty if st else False
@@ -5542,24 +5579,24 @@ class IdaTui(App):
return
if self._split:
# In split mode Tab/F5 just moves focus between the two panes.
- self._active = "decomp" if self._active == "listing" else "listing"
- (self.query_one(DecompView) if self._active == "decomp"
+ self._active = ViewMode.DECOMP if self.is_listing else ViewMode.LISTING
+ (self.query_one(DecompView) if self.is_decomp
else self.query_one(ListingView)).focus()
self._sync_split(self._active) # re-link from the new driver
self._status_for_cur("split")
return
- if self._active == "hex":
+ if self.is_hex:
self._active = self._code_mode()
self._show_active()
return
- if self._active == "graph":
+ if self.is_graph:
# F5/Tab out of the graph lands in the pseudocode at the block the
# cursor was on (Space is the key that returns to the listing).
gv = self.query_one(GraphView)
ea = gv._cursor_ea()
self._graph_sticky = False
if ea is None:
- self._active = "listing"
+ self._active = ViewMode.LISTING
self._show_active()
return
gv.display = False
@@ -5570,7 +5607,7 @@ class IdaTui(App):
self._status("decompiling…")
self._decomp_from_listing(ea)
return
- if self._active == "listing":
+ if self.is_listing:
# F5/Tab in the continuous listing: decompile the function under the
# cursor (IDA-style), if the cursor is inside a defined routine.
ea = self.query_one(ListingView)._cursor_ea()
@@ -5595,7 +5632,7 @@ class IdaTui(App):
self._decomp_return = None
if ret is not None:
self._cur = ret
- self._active = "listing"
+ self._active = ViewMode.LISTING
self._open_entry(ret, push=False)
elif self._cur is not None:
# No F5 snapshot (we arrived via a decomp navigation): show THIS entry
@@ -5606,7 +5643,7 @@ class IdaTui(App):
ea = dec._line_ea(dec.cursor)
self._toggle_to_listing(ea if ea is not None else self._cur.ea)
else:
- self._active = "listing"
+ self._active = ViewMode.LISTING
self._show_active()
@work(thread=True, group="nav")
@@ -5619,14 +5656,14 @@ class IdaTui(App):
def _apply_toggle_listing(self, idx: int) -> None:
cur = self._cur
if cur is None:
- self._active = "listing"
+ self._active = ViewMode.LISTING
self._show_active()
return
cur.view = "listing"
cur.cursor = idx
cur.cursor_x = 0
cur.scroll_y = -1 # derive a viewport (keeps the target in context)
- self._active = "listing"
+ self._active = ViewMode.LISTING
self._open_entry(cur, push=False)
@work(thread=True, group="nav")
@@ -5645,7 +5682,7 @@ class IdaTui(App):
# We optimistically raised the pseudocode overlay; drop back to the
# listing since there's nothing to decompile here.
self.query_one(DecompView).loading = False
- self._active = "listing"
+ self._active = ViewMode.LISTING
self._show_active()
self._status(msg)
@@ -5661,7 +5698,7 @@ class IdaTui(App):
entry = NavEntry(ea=fn_addr, name=fn_name, is_region=False,
dec_cursor=max(dec_idx, 0))
self._cur = entry
- self._active = "decomp"
+ self._active = ViewMode.DECOMP
self._show_active()
def action_toggle_split(self) -> None:
@@ -5677,7 +5714,7 @@ class IdaTui(App):
return
self._split = not self._split
if self._active not in ("listing", "decomp"):
- self._active = "listing"
+ self._active = ViewMode.LISTING
if self._split:
self._enter_split(self._cur.ea, self._cur.name)
else:
@@ -5692,7 +5729,7 @@ class IdaTui(App):
"""Space: swap the code view for the function's control-flow graph."""
if self._prompt_active():
return
- if self._active == "graph":
+ if self.is_graph:
self._graph_sticky = False
self._active = self._code_mode()
self._show_active()
@@ -5705,7 +5742,7 @@ class IdaTui(App):
ea = self._graph_target_ea()
if gv.loaded_ea is not None and gv.fc is not None \
and gv.fc.func_ea == self._cur.ea:
- self._active = "graph"
+ self._active = ViewMode.GRAPH
self._split = False
self._show_active()
if ea is not None:
@@ -5719,9 +5756,9 @@ class IdaTui(App):
"""The address the graph should land on: wherever the code view's cursor
is, so Space doesn't lose your place."""
try:
- if self._active in ("listing", "disasm"):
+ if self.is_listing:
return self.query_one(ListingView)._cursor_ea()
- if self._active == "decomp":
+ if self.is_decomp:
dv = self.query_one(DecompView)
return dv._line_ea(dv.cursor)
except Exception: # noqa: BLE001
@@ -5745,7 +5782,7 @@ class IdaTui(App):
err: str) -> None:
if self._cur is None or self._cur.ea != func_ea:
return # a newer navigation won
- if not self._graph_sticky and self._active != "graph":
+ if not self._graph_sticky and not self.is_graph:
# The load lost its race: the user has since left graph mode (or a
# rename's reload queued one behind their back). Forcing the view
# here drags them back into a graph they already dismissed.
@@ -5761,7 +5798,7 @@ class IdaTui(App):
return
gv = self.query_one(GraphView)
gv.set_graph(fc, want_ea)
- self._active = "graph"
+ self._active = ViewMode.GRAPH
self._split = False
self._show_active()
self._graph_status()
@@ -5808,19 +5845,19 @@ class IdaTui(App):
cursor; press again (or Tab/Esc) to return to the code view."""
if self._cur is None or self.program is None:
return
- if self._active == "hex":
+ if self.is_hex:
self._active = self._code_mode()
self._show_active()
return
- if self._active in ("listing", "disasm"):
+ if self.is_listing:
ea = self.query_one(ListingView)._cursor_ea()
- elif self._active == "graph":
+ elif self.is_graph:
ea = self.query_one(GraphView)._cursor_ea()
else:
dec = self.query_one(DecompView)
ea = dec._line_ea(dec.cursor)
self._hex_pending_ea = ea if ea is not None else self._cur.ea
- self._active = "hex"
+ self._active = ViewMode.HEX
self._show_active()
def action_filter(self) -> None:
@@ -5833,7 +5870,7 @@ class IdaTui(App):
def action_goto(self) -> None:
inp = self.query_one("#goto", Input)
- inp.placeholder = ("hex goto: 0xADDR or name — Enter" if self._active == "hex"
+ inp.placeholder = ("hex goto: 0xADDR or name — Enter" if self.is_hex
else "goto: name or 0xADDR — Enter")
inp.can_focus = True
inp.display = True
@@ -6256,7 +6293,7 @@ class IdaTui(App):
# If xrefs was invoked from the decompiler, land the jump back in the
# decompiler (when the target is decompilable) rather than the listing.
self._goto_ea(addr, push=True, focus_name=self._xref_focus_name,
- prefer_decomp=(self._active == "decomp"))
+ prefer_decomp=(self.is_decomp))
# -- database edits ---------------------------------------------------- #
# The bodies live in EditController (idatui/edit_ctl.py). What stays here is
@@ -6493,7 +6530,7 @@ class IdaTui(App):
# Snapshot where we jumped from so 'back' returns there. If that was
# the pseudocode (F5 makes a transient _cur not yet on the stack),
# record its position and push it as a decomp entry.
- if self._active == "decomp" and self._cur is not None:
+ if self.is_decomp and self._cur is not None:
dv = self.query_one(DecompView)
src = self._cur
src.view = "decomp"
@@ -6720,7 +6757,7 @@ class IdaTui(App):
self._end_search(cancel=True)
elif prompt.id == "goto":
self._end_goto()
- (self.query_one(HexView) if self._active == "hex"
+ (self.query_one(HexView) if self.is_hex
else (self._code_view() or self.query_one(ListingView))).focus()
else:
prompt.close()
@@ -6762,7 +6799,7 @@ class IdaTui(App):
return
if inp.id == "goto":
self._end_goto()
- (self.query_one(HexView) if self._active == "hex"
+ (self.query_one(HexView) if self.is_hex
else (self._code_view() or self.query_one(ListingView))).focus()
if value:
self._goto(value)
@@ -6791,6 +6828,30 @@ class IdaTui(App):
return view._line_ea(view.cursor)
return None
+ # Read _active through these rather than comparing strings. The bare
+ # comparisons are what let the old "disasm" value take the wrong branch in
+ # five places, and they are what the next new mode would have to hunt down.
+ @property
+ def is_listing(self) -> bool:
+ return self._active == ViewMode.LISTING
+
+ @property
+ def is_decomp(self) -> bool:
+ return self._active == ViewMode.DECOMP
+
+ @property
+ def is_hex(self) -> bool:
+ return self._active == ViewMode.HEX
+
+ @property
+ def is_graph(self) -> bool:
+ return self._active == ViewMode.GRAPH
+
+ @property
+ def in_code(self) -> bool:
+ """A code view over a NavEntry -- where follow/xrefs/rename mean something."""
+ return self._active in ViewMode.code_modes()
+
def _active_code_view(self): # type: ignore[no-untyped-def]
"""The currently-shown code widget (for reading the cursor address).
@@ -6799,11 +6860,11 @@ class IdaTui(App):
dereferences it crashes the app (which is exactly how graph mode
announced itself the first time it was driven).
"""
- if self._active in ("listing", "disasm"):
+ if self.is_listing:
return self.query_one(ListingView)
- if self._active == "decomp":
+ if self.is_decomp:
return self.query_one(DecompView)
- if self._active == "graph":
+ if self.is_graph:
return self.query_one(GraphView)
return None
@@ -6859,12 +6920,12 @@ class IdaTui(App):
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._status, f"goto: {e}")
return
- if self._active == "hex":
+ if self.is_hex:
self.app.call_from_thread(self._hex_goto, ea)
return
# A goto that lands inside the graph you're already looking at should
# move the cursor, not tear the picture down and build the same one.
- if self._active == "graph":
+ if self.is_graph:
gv = self.query_one(GraphView)
if gv.fc is not None and gv.fc.block_at(ea) is not None:
self.app.call_from_thread(gv.goto_ea, ea)
@@ -6911,9 +6972,9 @@ class IdaTui(App):
self._do_navigate(ea, push)
- def _code_mode(self) -> str:
+ def _code_mode(self) -> ViewMode:
"""The code view to return to from hex — always the unified listing."""
- return "listing"
+ return ViewMode.LISTING
def _open_entry(self, entry: NavEntry, push: bool) -> None:
if self.program is None:
@@ -6924,7 +6985,7 @@ class IdaTui(App):
if entry.view == "decomp":
# This entry was viewed in the decompiler (a jump from pseudocode, or
# a back/forward to one) — restore it there instead of the listing.
- self._active = "decomp"
+ self._active = ViewMode.DECOMP
dec = self.query_one(DecompView)
if dec.loaded_ea == entry.ea:
# already decompiled: reposition without a recompile
@@ -6953,7 +7014,7 @@ class IdaTui(App):
lst.load(
lm, entry.name, cursor=entry.cursor,
cursor_x=entry.cursor_x, scroll_y=sy, focus=focus)
- self._active = "listing"
+ self._active = ViewMode.LISTING
self._show_active()
# Graph mode is sticky: following a call from the graph should land in
# the callee's graph, not dump you back into the listing. The rebuild is
@@ -6983,7 +7044,7 @@ class IdaTui(App):
# Don't steal focus from an open prompt (search/rename/…) — a late async
# navigation completing here would otherwise pull it into the code view.
grab = not self._prompt_active()
- if self._split and self._active in ("listing", "decomp"):
+ if self._split and self._active in (ViewMode.LISTING, ViewMode.DECOMP):
# Side-by-side: listing (left) + pseudocode (right), one focused.
hx.display = gv.display = False
lst.display = dec.display = True
@@ -6995,7 +7056,7 @@ class IdaTui(App):
else:
dec.loading = False
if grab:
- (dec if self._active == "decomp" else lst).focus()
+ (dec if self.is_decomp else lst).focus()
if busy and self._cur is not None:
# Keep the in-flight message: the idle status used to overwrite
# it, so travelling history in split showed nothing at all while
@@ -7012,18 +7073,18 @@ class IdaTui(App):
self._split_ea2line = {}
self._split_range = None
dec.display = lst.display = hx.display = gv.display = False
- if self._active == "graph":
+ if self.is_graph:
gv.display = True
if grab:
gv.focus()
self._graph_status()
return
- if self._active in ("listing", "disasm"):
+ if self.is_listing:
lst.display = True
if grab:
lst.focus()
self._status_for_cur("listing")
- elif self._active == "hex":
+ elif self.is_hex:
hx.display = True
if grab:
hx.focus()
@@ -7064,7 +7125,7 @@ class IdaTui(App):
self._status("hex: no loaded segments")
return
hx.load(model, ea)
- if self._active == "hex":
+ if self.is_hex:
hx.focus()
def _hex_status(self, va: int) -> None:
@@ -7117,14 +7178,14 @@ class IdaTui(App):
detail = f" \u2014 {why}" if why else ""
# No pseudocode for this function: fall back to the code view rather
# than an error panel. If we came from the continuous listing (F5),
- # return there; otherwise show the disassembly.
+ # return to exactly where we left; otherwise just show the listing.
if self._cur is None or self._cur.ea != ea:
return # navigated away; stale result
if self._decomp_return is not None:
ret = self._decomp_return
self._decomp_return = None
self._cur = ret
- self._active = "listing"
+ self._active = ViewMode.LISTING
# Hand the reason over as a flash BEFORE reopening: going back
# to the listing reloads it, and the reload writes its own
# status afterwards — which is precisely how "F5 does nothing"
@@ -7133,11 +7194,14 @@ class IdaTui(App):
self._status(msg, priority=True)
self._open_entry(ret, push=False)
return
- self._active = "disasm"
+ # LISTING, not the old "disasm": it is the same widget, and a value
+ # only this path produced meant half the app took the wrong branch
+ # for it (see ViewMode).
+ self._active = ViewMode.LISTING
self._status(f"{name}: cannot decompile{detail}", priority=True)
self._show_active()
return
- if self._active == "decomp":
+ if self.is_decomp:
view.focus() # loading cover had blurred it; restore focus
# Restore the saved pseudocode position when returning to this function.
cur = self._cur
@@ -7231,11 +7295,11 @@ class IdaTui(App):
return top, 0
def on_listing_view_scrolled(self, msg: "ListingView.Scrolled") -> None:
- if self._split and self._active == "listing":
+ if self._split and self.is_listing:
self._sync_split("listing")
def on_decomp_view_scrolled(self, msg: "DecompView.Scrolled") -> None:
- if self._split and self._active == "decomp":
+ if self._split and self.is_decomp:
self._sync_split("decomp")
@work(thread=True, group="split-resync", exclusive=True)
@@ -7267,7 +7331,7 @@ class IdaTui(App):
"""A split-aware status line reflecting the focused pane + the link."""
if self._cur is None:
return
- if self._active == "decomp":
+ if self.is_decomp:
dec = self.query_one(DecompView)
ea = dec._line_ea(dec.cursor)
n = (len(self._split_eamap[dec.cursor])
@@ -7340,7 +7404,7 @@ class IdaTui(App):
self._nav[-1].dec_cursor = msg.index
self._nav[-1].dec_cursor_x = dv.cursor_x
if self._split:
- if self._active == "decomp":
+ if self.is_decomp:
self._sync_split("decomp")
self._split_status()
return
@@ -7366,7 +7430,7 @@ class IdaTui(App):
if msg.index >= 0:
self._nav[-1].scroll_y = round(lst.scroll_offset.y)
if self._split:
- if self._active == "listing":
+ if self.is_listing:
self._sync_split("listing")
self._split_status()
return
diff --git a/idatui/drive.py b/idatui/drive.py
index b83a8ba..1c7d8c2 100644
--- a/idatui/drive.py
+++ b/idatui/drive.py
@@ -64,7 +64,7 @@ def _fmt_where(st: dict) -> str:
ea = fn.get("ea")
loc = f"{name} @ {ea:#x}" if isinstance(ea, int) else "(none)"
extra = ""
- if cur.get("kind") in ("decomp", "disasm"):
+ if cur.get("kind") in ("decomp", "listing"):
extra = f" L{cur.get('line')} C{cur.get('col')} word={cur.get('word')!r}"
elif cur.get("kind") == "hex":
extra = f" va={cur.get('va'):#x}" if isinstance(cur.get("va"), int) else ""
@@ -87,10 +87,10 @@ def cmd_go(c, args):
def _show_view(c, want):
"""Make the requested code pane the visibly-active view (best effort).
- Tab toggles disasm<->decomp, and leaves hex back to the preferred code
+ Tab toggles listing<->decomp, and leaves hex back to the preferred code
view; so at most two toggles reach either code view from any state. If
- the decompiler fails for the current function the view falls back to
- disasm and we simply stop — the caller still returns its text as before.
+ the decompiler fails for the current function the view falls back to the
+ listing and we simply stop — the caller still returns its text as before.
"""
for _ in range(2):
if c.call("state").get("active") == want:
@@ -132,7 +132,7 @@ def cmd_dis(c, args):
# Drive the real UI so viewers see the disassembly, not just the driver.
if target is not None:
c.call("goto", target=target, delay_ms=0)
- _show_view(c, "disasm")
+ _show_view(c, "listing")
d = c.call("disassembly", target=target, max=n)
return "\n".join(f"{ln['ea']:#010x} {ln['text']}" for ln in d.get("lines", []))
diff --git a/idatui/edit_ctl.py b/idatui/edit_ctl.py
index 7364130..7803a7a 100644
--- a/idatui/edit_ctl.py
+++ b/idatui/edit_ctl.py
@@ -64,7 +64,7 @@ class EditController:
cur = app._cur
if cur is None:
return
- if app._active == "decomp":
+ if app.is_decomp:
# Snapshot the LIVE pseudocode position before forcing a recompile.
# dec_scroll_y isn't tracked on every move, so without this the reload
# falls into show()'s derive path (a bare scroll_to) and leaves a
diff --git a/idatui/rpc.py b/idatui/rpc.py
index 90cfd3c..b4ca80e 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, GraphView, HexView, ListingView
+from .app import DecompView, GraphView, HexView, ListingView, ViewMode
PROTO_VERSION = 1
TYPE_DELAY_MS = 35 # default per-char delay for high-level typed ops (aesthetic)
@@ -128,11 +128,11 @@ _MOVE_KEYS = {
# --------------------------------------------------------------------------- #
def _active_widget(app):
"""The currently *shown* code widget (mirrors app._active)."""
- if app._active == "hex":
+ if app.is_hex:
return app.query_one(HexView)
- if app._active == "graph":
+ if app.is_graph:
return app.query_one(GraphView)
- if app._active in ("listing", "disasm"):
+ if app.is_listing:
return app.query_one(ListingView)
return app.query_one(DecompView)
@@ -142,11 +142,11 @@ def graph_info(app, blocks: bool = True) -> dict[str, Any]:
rather than the box-drawing characters it is rendered as."""
gv = app.query_one(GraphView)
if gv.fc is None or gv.lay is None:
- return {"open": app._active == "graph", "loaded": False,
+ return {"open": app.is_graph, "loaded": False,
"note": "press space (or graph {action:'open'}) on a function"}
lay, fc = gv.lay, gv.fc
out: dict[str, Any] = {
- "open": app._active == "graph",
+ "open": app.is_graph,
"loaded": True,
"func": {"name": fc.name, "ea": fc.func_ea, "entry": fc.entry},
"zoom": gv.ZOOMS[gv._zoom],
@@ -631,20 +631,20 @@ class RpcServer:
if action == "show":
return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
if action in ("open", "toggle", "close"):
- if action == "open" and app._active == "graph":
+ if action == "open" and app.is_graph:
return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
- if action == "close" and app._active != "graph":
+ if action == "close" and not app.is_graph:
return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
want = "graph" if action in ("open", "toggle") and \
- app._active != "graph" else None
+ not app.is_graph else None
res = await self._press(
["space"],
- (lambda: app._active == "graph") if want else
- (lambda: app._active != "graph"),
+ (lambda: app.is_graph) if want else
+ (lambda: not app.is_graph),
timeout, f"graph {action}")
return {**res, "graph": graph_info(app, blocks=want_blocks)}
- if app._active != "graph":
+ if not app.is_graph:
raise ValueError(f"graph {action}: the graph is not open "
f"(graph {{action:'open'}} first)")
if action == "zoom":
@@ -795,7 +795,7 @@ class RpcServer:
ea = app.program.resolve(target)
except Exception: # noqa: BLE001 — unknown name; caller falls back to generic
return None
- if app._active == "hex":
+ if app.is_hex:
return lambda: app.query_one(HexView).cursor_va() == ea
fn = app.program.function_of(ea)
want = fn.addr if fn else ea
@@ -1025,17 +1025,17 @@ class RpcServer:
await self._fill_prompt("g", "goto", str(target), delay,
clear=False)
await settle(app, timeout=timeout)
- if app._active == "hex":
+ if app.is_hex:
# backslash leaves hex for the code view (which may be decomp).
await self._press(["backslash"],
- lambda: app._active != "hex", timeout,
+ lambda: not app.is_hex, timeout,
"leave the hex view")
- if app._active == "decomp":
+ if app.is_decomp:
# These bindings live on the listing; in the decompiler the key
# would be swallowed or do something else entirely.
- await self._press(["tab"], lambda: app._active == "listing",
+ await self._press(["tab"], lambda: app.is_listing,
timeout, "switch to the listing")
- if app._active != "listing":
+ if not app.is_listing:
raise RuntimeError(
f"define needs the listing view, but the active pane is "
f"{app._active!r}")
@@ -1054,8 +1054,8 @@ class RpcServer:
await self._fill_prompt("g", "goto", str(target), delay,
clear=False)
await settle(app, timeout=timeout)
- if app._active == "hex":
- await self._press(["backslash"], lambda: app._active != "hex",
+ if app.is_hex:
+ await self._press(["backslash"], lambda: not app.is_hex,
timeout, "leave the hex view")
view = _active_widget(app)
if isinstance(view, HexView):
@@ -1122,13 +1122,13 @@ class RpcServer:
if app._active != before:
return True
# Fallback case: a tab toward pseudocode on a function Hex-Rays
- # can't decompile snaps `_active` back to disasm (see
+ # can't decompile lands back on the LISTING (see
# App._apply_decomp), so `_active` never changes and the naive
# `_active != before` predicate would block for the full
# timeout. Treat "requested decomp but it's known-failed" as
# settled (the decompile is cached, so this is cheap).
cur = app._cur
- if before == "disasm" and cur is not None:
+ if before == ViewMode.LISTING and cur is not None:
try:
return app.program.decompile(cur.ea).failed
except Exception: # noqa: BLE001
@@ -1137,7 +1137,7 @@ class RpcServer:
return await self._press(["tab"], _toggled, timeout, "toggle_view")
if method == "hex":
- return await self._press(["backslash"], lambda: app._active == "hex",
+ return await self._press(["backslash"], lambda: app.is_hex,
timeout, "hex")
if method == "graph":
return await self._graph(params, timeout)
diff --git a/idatui/trace_ctl.py b/idatui/trace_ctl.py
index 5f17e49..46d4b17 100644
--- a/idatui/trace_ctl.py
+++ b/idatui/trace_ctl.py
@@ -143,7 +143,7 @@ class TraceController:
# from the pseudocode navigates to an address, which opens the listing —
# so stepping through C threw you out of C on the first keypress.
app._goto_ea(pc, push=False,
- prefer_decomp=(app._active == "decomp"))
+ prefer_decomp=(app.is_decomp))
def seek_split(self, pc: int) -> bool:
"""Put BOTH panes on ``pc``. True if handled.
@@ -342,7 +342,7 @@ class TraceController:
if not self._need_trace():
return
t = self.trace
- if app._active == "hex":
+ if app.is_hex:
hx = app._try_view(M.HexView)
va = hx.cursor_va() if hx is not None else None
if va is None:
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 6c22c7c..9934462 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -632,6 +632,55 @@ async def s_strings(c: Ctx):
await c.press("escape")
+@scenario("view_modes_all_handled")
+async def s_view_modes_all_handled(c: Ctx):
+ """Every ViewMode must be handled by every switch that reads _active.
+
+ Adding "graph" meant auditing each `_active ==` in the app, and the one that
+ was missed -- _active_code_view returning None -- crashed the app the first
+ time a prompt closed in graph mode. There used to be a fifth value,
+ "disasm", assigned on one path and understood by four sites out of nine.
+
+ So: walk the enum, and for each member show it and ask the app the questions
+ it asks itself. Cheap (no worker calls, just mode switches) and it fails on
+ the next mode that forgets to appear somewhere.
+ """
+ from idatui.app import ViewMode
+ app = c.app
+ fn = await c.open_biggest("listing")
+ try:
+ for mode in ViewMode:
+ app._active = mode
+ app._show_active() # must not raise for any member
+ await c.pause(0.05)
+ view = app._active_code_view()
+ if mode in ViewMode.code_modes():
+ c.check(f"{mode.value}: _active_code_view resolves a widget",
+ view is not None, f"{mode.value} -> None")
+ c.check(f"{mode.value}: exactly one predicate is true",
+ sum((app.is_listing, app.is_decomp,
+ app.is_hex, app.is_graph)) == 1,
+ f"{mode.value}: listing={app.is_listing} "
+ f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}")
+ c.check(f"{mode.value}: in_code agrees with code_modes()",
+ app.in_code == (mode in ViewMode.code_modes()),
+ f"in_code={app.in_code} for {mode.value}")
+ c.check("every mode is a plain string over the wire",
+ all(isinstance(m, str) and m == m.value for m in ViewMode),
+ str([repr(m) for m in ViewMode]))
+ c.check("'disasm' is not a mode any more",
+ "disasm" not in {m.value for m in ViewMode},
+ str([m.value for m in ViewMode]))
+ finally:
+ # Restore through a real navigation, not by poking _active back.
+ # _show_active() tears down split state and re-points the panes as a
+ # side effect, and reset() doesn't rebuild any of that -- leaving it
+ # half-torn-down made split_view fail two scenarios later with an empty
+ # listing model, which reads as split_view's bug and isn't.
+ app._active = ViewMode.LISTING
+ await c.open(fn.addr, "listing")
+
+
@scenario("split_view")
async def s_split_view(c: Ctx):
app, lst, dec = c.app, c.lst, c.dec
@@ -841,7 +890,7 @@ async def s_fallback(c: Ctx):
c.check("F5/Tab on an undecompilable function says so", landed,
f"active={app._active} status={c.status()!r}")
c.check("F5/Tab on an undecompilable function falls back to a code view",
- app._active in ("listing", "disasm") and c.dis.display,
+ app.is_listing and c.dis.display,
f"active={app._active} status={c.status()!r}")
# a decompilable function F5s into pseudocode
await c.open("main", "decomp")
@@ -1359,7 +1408,7 @@ async def s_follow_xrefs(c: Ctx):
await c.press("tab")
landed = await c.wait(
lambda: (app._active == "decomp" and dec.loaded_ea == xref.fn_addr)
- or (app._active in ("listing", "disasm")
+ or (app.is_listing
and _CANNOT_DECOMP in c.status().lower()), 25)
if app._active == "decomp":
c.check("F5 at the xref site decompiles the referencing function",
@@ -2335,7 +2384,7 @@ async def s_continuous_view(c: Ctx):
c.lst.focus()
await c.press("tab")
await c.wait(lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea)
- or (app._active in ("listing", "disasm")
+ or (app.is_listing
and _CANNOT_DECOMP in c.status().lower()), 25)
if app._active == "decomp":
c.check("F5/Tab decompiles the function under the cursor",