aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/app.py
diff options
context:
space:
mode:
Diffstat (limited to 'idatui/app.py')
-rw-r--r--idatui/app.py3976
1 files changed, 3002 insertions, 974 deletions
diff --git a/idatui/app.py b/idatui/app.py
index ea68620..3f67756 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -10,8 +10,8 @@ Design notes:
without ever materializing 52k lines in a widget.
* All network/domain work runs in Textual worker threads; the UI never blocks.
* An address-history stack backs Enter (follow) / Esc (back), IDA-style.
-* On startup we bump the worker idle-TTL and run a keepalive heartbeat so the
- session never gets reaped while we chill.
+* Database lifecycle is lease-based through ida_codemode: matching GUI sessions
+ are reused, otherwise a shared managed idalib worker is opened on demand.
"""
from __future__ import annotations
@@ -20,7 +20,10 @@ import asyncio
import os
import re
import subprocess
+import sys
+import time
from dataclasses import dataclass, field
+from enum import StrEnum
from rich.align import Align
from rich.segment import Segment
@@ -30,7 +33,7 @@ from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.command import DiscoveryHit, Hit, Provider
-from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
+from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.geometry import Region, Size
from textual.message import Message
from textual.reactive import reactive
@@ -43,11 +46,18 @@ from textual.widgets import (
)
from textual.widgets.option_list import Option
-from .highlight import highlight_c
+from . import graph
+from . import kittygfx
+from .edit_ctl import EditController
+from .prompt import PromptBar
+from .trace_ctl import TraceController
+from . import findings, search
+from .highlight import CTextArea, highlight_c
+from .journal import Journal
from .errors import IDAToolError, IDAConnectionError
-from .worker_client import WorkerClient
-from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct
+from .codemode_client import CodeModeClient, registered_database
+from .domain import Func, Head, ListingModel, Program, Struct
# Styles for the disassembly listing.
_S_ADDR = Style(color="#6b7684")
@@ -83,6 +93,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({
@@ -91,11 +137,27 @@ _ASM_KEYWORDS = frozenset({
"gs", "ss", "align", "public", "assume", "end",
})
_S_CURSOR = Style(bgcolor="#2a313c")
+#: Execution trails. Deliberately faint: they sit UNDER the code palette and
+#: must not compete with it — the trail says "you came through here", the text
+#: still has to be readable as code. Now is the loudest because there is exactly
+#: one of it.
+_S_TRAIL_NOW = Style(bgcolor="#3f3410")
+_S_TRAIL_PAST = Style(bgcolor="#2b1c17") # warm: behind you
+_S_TRAIL_FUTURE = Style(bgcolor="#152230") # cool: ahead of you
+#: Hex with a trace loaded: bytes the trace SAW at this timestamp vs bytes we're
+#: still showing from the file. The distinction matters more than the values —
+#: one is evidence, the other is an assumption.
+_S_HEX_LIVE = Style(color="#9ece6a")
+_S_HEX_STALE = Style(color="#5e6875")
_S_DIM = Style(color="#7c8b9e", italic=True)
_S_MATCH = Style(bgcolor="#7a5c00") # all search matches
_S_MATCH_CUR = Style(bgcolor="#d0a215", color="#12161c") # the current match
_S_NAME_MATCH = Style(bgcolor="#d0a215", color="#12161c") # filter match in a name
_S_WORD = Style(bgcolor="#2a3f5f") # identifier under the cursor
+#: The operand/literal under the cursor — what `o` would reformat. Distinct from
+#: _S_WORD (which marks every occurrence of an identifier): this marks ONE span,
+#: the thing a keypress acts on, so it reads as a selection rather than a match.
+_S_OPERAND = Style(bgcolor="#3a3560", underline=True)
_S_CELL = Style(reverse=True) # the block cursor cell
_S_LINENO = Style(color="#626c7a") # pseudocode line-number gutter
_S_LINENO_CUR = Style(color="#c3cad3", bold=True) # gutter on the cursor line
@@ -114,15 +176,15 @@ _ADDR_MARK_STRIP_RE = re.compile(r"\s*/\*\s*0x[0-9A-Fa-f]+\s*\*/")
@dataclass
class BinaryState:
"""Everything that makes one project binary's session resumable across a
- switch. Addresses outlive the worker, so nav history survives eviction; the
- Program/index only survive while that worker is still resident."""
+ switch. Addresses outlive a database lease, so nav history survives eviction;
+ the Program/index only survive while that lease remains resident."""
label: str
program: object | None = None
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
@@ -237,6 +299,17 @@ class MakeDataRequested(Message):
self.view = view
+class OpFormatRequested(Message):
+ """A code view asks to change how the literal under the cursor is DISPLAYED
+ (IDA's 'o'): hex, decimal, binary, character, offset. ``mode`` is 'cycle',
+ 'back', or a format by name."""
+
+ def __init__(self, view, mode: str = "cycle") -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+ self.mode = mode
+
+
class NavMixin:
"""Follow / xrefs actions shared by the code views (bindings live on each
view since Textual only merges BINDINGS from DOMNode subclasses)."""
@@ -522,13 +595,22 @@ class ColumnCursor:
self._page_scroll(direction * (self._visible_height() // 2))
def _apply_scroll(self, y: int, x: int = 0) -> None:
- """Set the scroll offset reliably after a (re)load. Applied now and again
- after the next refresh — when the view was just shown its size isn't
- computed yet, so an immediate scroll_to clamps to 0; the deferred pass
- re-applies it and forces a repaint so the pane never shows a stale frame.
+ """Set the scroll offset reliably after a (re)load. Applied now and, if
+ that didn't take, again after the next refresh — when the view was just
+ shown its size isn't computed yet, so an immediate scroll_to clamps to 0;
+ the deferred pass re-applies it and forces a repaint so the pane never
+ shows a stale frame.
+
+ The deferred pass is only scheduled when the scroll actually clamped.
+ ``refresh(layout=True)`` re-arranges the whole screen, and paying that on
+ every scroll that already landed cost ~25% of the time it takes to move
+ through a view.
"""
y = max(0, y)
self.scroll_to(x=x, y=y, animate=False)
+ off = self.scroll_offset
+ if round(off.y) == y and round(off.x) == x:
+ return # max_scroll was current, the offset is already where we want
def _fix(yy: int = y, xx: int = x) -> None:
self.scroll_to(x=xx, y=yy, animate=False)
@@ -537,6 +619,73 @@ class ColumnCursor:
self.call_after_refresh(_fix)
+class _MatchRanges:
+ """Which lines matched, plus where in each line — the *where* computed lazily.
+
+ Searching a big segment for one character matches most of it: `c` over bash
+ hits 177 000 lines at 310 000 places. Building a range list for every one of
+ them costs more than finding them did, and all but the forty on screen are
+ thrown away unread.
+
+ So the line set is eager (search needs it to count and to jump) and the
+ offsets within a line are worked out when that line is painted, or when the
+ cursor lands on it, and cached from then on.
+
+ Quacks like the ``{line: [(start, end), ...]}`` dict it replaced: ``in``,
+ ``get``, ``[]``, ``items``, ``len``. Assigning a plain ``{}`` to reset stays
+ valid, because every reader only uses that same subset.
+ """
+
+ __slots__ = ("_lines", "_needle", "_n", "_ci", "_text", "_cache")
+
+ def __init__(self, lines, needle: str, n: int, ci: bool, text) -> None:
+ self._lines = lines # set[int]
+ self._needle = needle # already case-folded when ci
+ self._n = n # len(term); the needle may be folded
+ self._ci = ci
+ self._text = text # callable: line index -> str | None
+ self._cache: dict[int, list[tuple[int, int]]] = {}
+
+ def _find(self, i: int) -> list[tuple[int, int]]:
+ s = self._text(i)
+ if not s:
+ return []
+ hay = s.lower() if self._ci else s
+ needle, n = self._needle, self._n
+ out = []
+ j = hay.find(needle)
+ while j >= 0:
+ out.append((j, j + n))
+ j = hay.find(needle, j + n)
+ return out
+
+ def __contains__(self, i) -> bool:
+ return i in self._lines
+
+ def __len__(self) -> int:
+ return len(self._lines)
+
+ def __iter__(self):
+ return iter(self._lines)
+
+ def get(self, i, default=None):
+ if i not in self._lines:
+ return default
+ got = self._cache.get(i)
+ if got is None:
+ got = self._cache[i] = self._find(i)
+ return got
+
+ def __getitem__(self, i):
+ got = self.get(i)
+ if got is None:
+ raise KeyError(i)
+ return got
+
+ def items(self):
+ return ((i, self.get(i)) for i in sorted(self._lines))
+
+
class SearchMixin:
"""Vim-style in-view search shared by the disasm and pseudocode views.
@@ -559,6 +708,80 @@ class SearchMixin:
_matches: list[int]
_ranges: dict[int, list[tuple[int, int]]]
+ #: What ``_matches`` was last computed from: (term, case-fold, row count,
+ #: id(line source)). Lets an as-you-type search narrow the previous hits
+ #: instead of rescanning the segment; see :meth:`_compute_matches`. Any of
+ #: those changing under us discards it.
+ _matched_key: tuple | None = None
+
+ #: The searchable body as ONE string (see :meth:`_search_haystack`), its
+ #: case-folded twin, the character offset each line starts at, and the key
+ #: they were built for. Dropped when the search ends.
+ _hay_key: tuple | None = None
+ _hay: tuple | None = None
+
+ def _search_source_id(self) -> int:
+ """Identity of whatever supplies the line text. Changes when the view is
+ pointed at a different model/body, which invalidates a narrowing."""
+ return id(getattr(self, "model", None) or getattr(self, "_texts", None))
+
+ def _reset_search_cache(self, body: bool = False) -> None:
+ """Forget the narrowing key, and with ``body=True`` the joined body too.
+
+ Every place that resets ``_matches``/``_ranges`` must call this: a stale
+ prefix would make the next search narrow from an empty list.
+
+ The body is a different question. It is keyed by (row count, line source)
+ so it invalidates itself when the view is pointed somewhere else or more
+ rows stream in — which means ending a search does NOT have to throw it
+ away, and the next `/` over the same segment is then instant instead of
+ re-joining a quarter of a million lines. It DOES have to go when the
+ plain text of a row changes without either of those moving, which is
+ exactly what toggling the opcode-bytes column does.
+ """
+ self._matched_key = None
+ if body:
+ self._hay_key = None
+ self._hay = None
+
+ def _search_haystack(self, count: int, src: int):
+ """``(starts, blob, blob_folded)`` for the whole body, or None.
+
+ Every line joined with newlines, so finding a term is one C-level
+ ``str.find`` walk over the segment instead of a python loop that rebuilds
+ and case-folds 200 000 lines per keystroke. ``starts[i]`` is where line
+ ``i`` begins; a term can never straddle a line because it cannot contain
+ a newline.
+
+ Returns None (and the caller falls back to the per-line loop) if folding
+ changes the length — a couple of unicode codepoints grow when lowered,
+ and then every offset after them would be wrong.
+ """
+ key = (count, src)
+ if self._hay_key == key:
+ return self._hay
+ starts: list[int] = []
+ parts: list[str] = []
+ pos = 0
+ chunk = 4096
+ for base in range(0, count, chunk):
+ for s in self._search_line_texts(base, min(chunk, count - base)):
+ if not s:
+ s = ""
+ starts.append(pos)
+ parts.append(s)
+ pos += len(s) + 1
+ while len(starts) < count: # a short window: keep the indices lined up
+ starts.append(pos)
+ parts.append("")
+ pos += 1
+ blob = "\n".join(parts)
+ folded = blob.lower()
+ hay = None if len(folded) != len(blob) else (starts, blob, folded)
+ self._hay_key = key
+ self._hay = hay
+ return hay
+
# --- hooks a subclass implements ---
def _search_line_count(self) -> int:
raise NotImplementedError
@@ -566,6 +789,12 @@ class SearchMixin:
def _search_line_text(self, i: int) -> str | None:
raise NotImplementedError
+ def _search_line_texts(self, start: int, count: int) -> list:
+ """``count`` line texts from ``start``. Overridable so a view whose rows
+ come from a locked model can fetch a window in one go."""
+ text_of = self._search_line_text
+ return [text_of(i) for i in range(start, start + count)]
+
def _search_ensure(self, done) -> None:
"""Ensure all line texts are available, then call ``done()`` on the UI
thread. Default: assume ready."""
@@ -590,6 +819,7 @@ class SearchMixin:
self._term = ""
self._matches = []
self._ranges = {}
+ self._reset_search_cache()
self.cursor = getattr(self, "_search_origin", self.cursor)
self.cursor_x = getattr(self, "_search_origin_x", self.cursor_x)
self.refresh()
@@ -628,6 +858,7 @@ class SearchMixin:
self._term = ""
self._matches = []
self._ranges = {}
+ self._reset_search_cache()
self.cursor = getattr(self, "_search_origin", self.cursor)
self.cursor_x = getattr(self, "_search_origin_x", self.cursor_x)
self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False)
@@ -645,26 +876,70 @@ class SearchMixin:
def _compute_matches(self) -> None:
term = self._term
- needle = term.lower() if getattr(self, "_ci", True) else term
+ ci = getattr(self, "_ci", True)
+ needle = term.lower() if ci else term
+ count = self._search_line_count()
+ src = self._search_source_id()
+ # Typing forward can only ever REMOVE lines: a line holding "mov" holds
+ # "mo". So when the term just grew (and nothing else moved -- same
+ # case-folding, same body, same number of rows) rescan only the previous
+ # hits. Search is driven a keystroke at a time, and a segment of bash is
+ # 224k rows; this is the difference between rescanning all of them per
+ # keypress and looking at a few thousand.
+ #
+ # The row count is part of the key because the listing streams in behind
+ # the search: rows that arrived after the last pass have never been
+ # looked at, and narrowing would silently never find them.
+ n = len(term)
matches: list[int] = []
- ranges: dict[int, list[tuple[int, int]]] = {}
- for i in range(self._search_line_count()):
- s = self._search_line_text(i)
- if not s:
- continue
- hay = s.lower() if getattr(self, "_ci", True) else s
- pos, rs = 0, []
- while True:
- j = hay.find(needle, pos)
- if j < 0:
- break
- rs.append((j, j + len(term)))
- pos = j + len(term)
- if rs:
+ ranges: object = {}
+ hay = self._search_haystack(count, src)
+ if hay is not None:
+ starts, blob, folded = hay
+ body = folded if ci else blob
+ nlines = len(starts)
+ blen = len(body)
+ line = 0
+ j = body.find(needle)
+ while j >= 0:
+ # find() walks forward, so the line only ever advances.
+ while line + 1 < nlines and starts[line + 1] <= j:
+ line += 1
+ matches.append(line)
+ # Only the LINE matters here; the offsets inside it are worked
+ # out on demand. So skip the rest of this line rather than
+ # finding every further occurrence in it.
+ nxt = starts[line + 1] if line + 1 < nlines else blen
+ j = body.find(needle, nxt)
+ ranges = _MatchRanges(set(matches), needle, n, ci,
+ self._search_line_text)
+ else:
+ # Typing forward can only ever REMOVE lines: a line holding "mov"
+ # holds "mo". So when the term just grew (and nothing else moved --
+ # same case-folding, same body, same number of rows) rescan only the
+ # previous hits.
+ #
+ # The row count is part of the key because the listing streams in
+ # behind the search: rows that arrived after the last pass have never
+ # been looked at, and narrowing would silently never find them.
+ rows: object = range(count)
+ prev = self._matched_key
+ if (prev is not None and prev[2] == count and prev[3] == src
+ and prev[1] == ci and term.startswith(prev[0]) and prev[0]):
+ rows = self._matches
+ text_of = self._search_line_text
+ for i in rows:
+ s = text_of(i)
+ if not s:
+ continue
+ h = s.lower() if ci else s
+ if h.find(needle) < 0:
+ continue
matches.append(i)
- ranges[i] = rs
+ ranges = _MatchRanges(set(matches), needle, n, ci, text_of)
self._matches = matches
self._ranges = ranges
+ self._matched_key = (term, ci, count, src)
def search_repeat(self, direction: int, include_current: bool = False) -> None:
if not getattr(self, "_term", ""):
@@ -698,6 +973,7 @@ class SearchMixin:
self._term = ""
self._matches = []
self._ranges = {}
+ self._reset_search_cache()
self.refresh()
def _match_style(self, idx: int) -> Style:
@@ -734,7 +1010,11 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
Binding("end", "col_end", "eol", show=False),
Binding("G,ctrl+end", "goto_bottom", "Bottom", show=False),
Binding("ctrl+home", "goto_top", "Top", show=False),
- Binding("o", "toggle_opcodes", "Opcodes"),
+ # `o` is IDA's operand-format key, and that muscle memory is worth more
+ # than the opcode column's old claim on it (moved to B, for bytes).
+ Binding("o", "op_format('cycle')", "Format"),
+ Binding("O", "op_format('back')", "Format \u2190", show=False),
+ Binding("B", "toggle_opcodes", "Bytes", show=False),
Binding("c", "define_code", "Code", show=False),
Binding("d", "make_data", "Data", show=False),
Binding("a", "make_string", "Str", show=False),
@@ -769,6 +1049,9 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._name = ""
self._pending_scroll_y: int | None = None
self._pending_focus: str | None = None
+ #: Operand index to put the cursor column back on once rows land — a
+ #: reformat can change an operand's width, moving the ones after it.
+ self._pending_op: int | None = None
self._term = ""
self._matches: list[int] = []
self._ranges: dict[int, list[tuple[int, int]]] = {}
@@ -777,6 +1060,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._search_loading = False
self._search_pending: list = [] # done-callbacks awaiting the load
self._link_rows: set[int] = set() # split-view: linked instruction rows
+ #: {address: 'now'|'past'|'future'} painted under the code (trace mode).
+ self.trail: dict[int, str] = {}
# -- text helpers ------------------------------------------------------ #
def _head(self, idx: int) -> Head | None:
@@ -788,19 +1073,24 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
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."""
+ long x86-64 instruction doesn't blow out the column.
+
+ ``bytes.hex(" ")`` rather than a per-byte f-string generator: this is
+ called for every row of every plain line, and search builds the plain
+ line for the whole segment.
+ """
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)
+ return raw[:_OP_LIMIT].hex(" ").upper() + "\u2026"
+ return raw.hex(" ").upper()
@staticmethod
def _span_segments(h: Head, fallback: Style):
"""Segments for a row's disassembly text.
- Uses IDA's own token classification when the worker supplied it; falls
- back to the old mnemonic/rest split so an older worker (or a row whose
- spans didn't match the text) still renders.
+ Uses IDA's own token classification when Code Mode supplies it; falls
+ back to the mnemonic/rest split when spans are absent or disagree with
+ the plain text.
"""
if h.spans:
return [Segment(t, _S_SPAN.get(k, fallback)) for k, t in h.spans]
@@ -819,8 +1109,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
return ""
return self._op_bytes_text(h).ljust(self._op_w) + " "
- def _line_plain(self, idx: int) -> str | None:
- h = self._head(idx)
+ def _plain_of(self, h: Head | None) -> str | None:
if h is None:
return None
# Function headers and code labels sit at depth 0 (with the address);
@@ -833,6 +1122,22 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
extra = _LST_INDENT if h.kind == "member" else ""
return base + self._op_field(h) + extra + self._name_prefix(h) + h.text
+ def _line_plain(self, idx: int) -> str | None:
+ return self._plain_of(self._head(idx))
+
+ def _search_line_texts(self, start: int, count: int) -> list:
+ """A window of plain lines in one model call.
+
+ Building the search body row by row took the model's lock and bisected
+ its row table a quarter of a million times; ``window`` does both once
+ for the whole window.
+ """
+ model = self.model
+ if model is None:
+ return []
+ plain = self._plain_of
+ return [plain(h) for h in model.window(start, count)]
+
def _insn_col(self, idx: int) -> int:
"""Column where the instruction/content text begins, past the address +
opcode-bytes gutter — the shift+home target. Mirrors ``_line_plain``'s
@@ -860,15 +1165,19 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
def load(self, model: ListingModel, name: str, cursor: int = 0,
cursor_x: int = 0, scroll_y: int | None = None,
focus: str | None = None) -> None:
- self.model = model
+ previous, self.model = self.model, model
self._name = name
self.total = 0
self.cursor = cursor
self.cursor_x = cursor_x
self._pending_scroll_y = scroll_y
self._pending_focus = focus # token to land the cursor column on
+ self._pending_op = None
self._matches = []
self._ranges = {}
+ # Navigating inside the same segment reuses the same model, and the
+ # searchable body with it; only a different model invalidates it.
+ self._reset_search_cache(body=model is not previous)
self._prime()
@work(thread=True, exclusive=True, group="listing-prime")
@@ -876,11 +1185,21 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
model = self.model
if model is None:
return
- # Load just enough to render the viewport around the cursor, so the
- # listing appears immediately even on a huge segment; the rest streams
- # in via _grow. (load_all here would blank the pane for seconds.)
+ # One call gets the WHOLE row index -- every row's address, kind and
+ # size, and the page boundaries -- so the scrollbar is right immediately
+ # and _grow has nothing left to stream. The rows arrive text-less and
+ # materialise a page at a time as they are read.
+ #
+ # It is an optimisation, not a contract: an older or unhappy backend
+ # returns nothing usable and we stream exactly as before.
height = max(self.size.height, 1)
- model.ensure(self.cursor + height + 2 * ListingModel.PAGE)
+ if model.build_from_index():
+ # Render the viewport HERE, on this worker thread. Reading a
+ # skeleton page fetches it, and doing that lazily from render_line
+ # would put an RPC on the UI loop for the first paint.
+ model.window(max(self.cursor - height, 0), height * 3)
+ else:
+ model.ensure(self.cursor + height + 2 * ListingModel.PAGE)
self.app.call_from_thread(self._on_primed, len(model), model.complete)
if not model.complete:
self._grow()
@@ -901,6 +1220,16 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
if occ:
self.cursor_x = occ[0][0]
self._pending_focus = None
+ if self._pending_op is not None:
+ # Stay on the operand that was just reformatted: its text can change
+ # width, which moves every operand after it out from under the
+ # cursor (and the next press would then hit a different one).
+ h = self._head(self.cursor)
+ for lo, _hi, n in (h.ops if h is not None else None) or ():
+ if n == self._pending_op:
+ self.cursor_x = self._insn_col(self.cursor) + lo
+ break
+ self._pending_op = None
self._clamp_x()
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)))
@@ -933,6 +1262,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._op_mode = (self._op_mode + 1) % 3
self._update_op_w()
self._ranges = {} # column layout changed -> stale match offsets
+ # ...and which rows match at all: the opcode hex is searchable text.
+ self._reset_search_cache(body=True)
self._clamp_x()
self.refresh()
self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)",
@@ -941,13 +1272,23 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
@work(thread=True, exclusive=True, group="listing-grow")
def _grow(self) -> None:
"""Stream the rest of the segment's heads in the background, growing the
- virtual size as they land so the scrollbar/paging catch up."""
+ virtual size as they land so the scrollbar/paging catch up.
+
+ SKELETON pages: this loop only exists to find out how many rows the
+ segment has, and it used to render every one of them to do it -- 227k
+ rows for a 1.2MB bash, ~9s, essentially all never displayed. A skeleton
+ page has the same rows at the same addresses and no text, is 2.8x
+ cheaper and costs one round trip instead of two. The first read of one
+ materialises it through the same path a rename uses, so only what is
+ actually shown ever gets rendered. _prime (the viewport) still loads
+ real pages, so what you are looking at is never a skeleton.
+ """
model = self.model
if model is None:
return
since = 0
while not model.complete:
- if model.load_next_page() == 0:
+ if model.load_next_page(text=False) == 0:
break
if self.model is not model: # a new load() replaced us
return
@@ -1045,6 +1386,12 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
linked = idx in self._link_rows
if linked:
strip = strip.apply_style(_S_LINK) # split-view companion band
+ if self.trail and h is not None:
+ kind = self.trail.get(h.ea)
+ if kind is not None:
+ strip = strip.apply_style(
+ _S_TRAIL_NOW if kind == "now" else
+ _S_TRAIL_PAST if kind == "past" else _S_TRAIL_FUTURE)
plain = self._line_plain(idx) if (self._hl_word or idx == self.cursor) else None
if idx in self._ranges:
strip = _overlay_ranges(strip, self._ranges[idx], self._match_style(idx))
@@ -1054,8 +1401,30 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
strip = _overlay_ranges(strip, occ, _S_WORD)
if idx == self.cursor:
strip = _cursor_decorate(strip, plain or "", self.cursor_x)
+ # The operand 'o' would reformat, marked before you press it. A line
+ # can hold several literals and the cursor picks one; showing which
+ # is the difference between an edit you chose and one you got.
+ # Drawn LAST on purpose: _cursor_decorate paints the word under the
+ # cursor, and that word is usually the literal itself — so painting
+ # this first just loses to it.
+ span = self._cursor_operand(idx)
+ if span is not None:
+ strip = _overlay_over(strip, [span], _S_OPERAND)
return strip.adjust_cell_length(width, _S_LINK if linked else _S_INSN)
+ def _cursor_operand(self, idx: int) -> tuple[int, int] | None:
+ """Screen columns of the operand under the cursor on row ``idx``, or None.
+
+ The extents come from the worker (IDA's own operand markers) and are
+ offsets into the head's text, so they shift by the same gutter the
+ cursor column is measured against."""
+ h = self._head(idx)
+ if h is None or not h.ops:
+ return None
+ base = self._insn_col(idx)
+ got = h.op_at(self.cursor_x - base)
+ return (base + got[0], base + got[1]) if got else None
+
# -- split-view link highlight ---------------------------------------- #
def set_link(self, rows) -> None:
rows = set(rows) if rows else set()
@@ -1142,6 +1511,18 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
def action_make_string(self) -> None:
self.post_message(EditItemRequested(self, "string"))
+ # -- literal display format (IDA 'o') --------------------------------- #
+ def action_op_format(self, mode: str = "cycle") -> None:
+ self.post_message(OpFormatRequested(self, mode))
+
+ def op_col(self) -> int:
+ """Cursor column inside the head's OWN text — the same string the worker
+ renders, so it can say which operand the cursor is standing on. -1 when
+ the cursor is left of it (in the address/opcode gutter), which means "you
+ didn't pick one, take the first literal"."""
+ base = self._insn_col(self.cursor)
+ return self.cursor_x - base if self.cursor_x >= base else -1
+
def cur_head(self) -> Head | None:
return self._head(self.cursor)
@@ -1225,6 +1606,10 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
Binding("end", "col_end", "eol", show=False),
Binding("ctrl+home", "goto_top", "Top", show=False),
Binding("G,ctrl+end", "goto_bottom", "Bottom", show=False),
+ # Hex-Rays keeps number formats of its own, so `o` works here too — on
+ # the C literal under the cursor, not on the instruction's operand.
+ Binding("o", "op_format('cycle')", "Format"),
+ Binding("O", "op_format('back')", "Format \u2190", show=False),
*SearchMixin.SEARCH_BINDINGS,
*NavMixin.NAV_BINDINGS,
*ColumnCursor.COL_BINDINGS,
@@ -1253,13 +1638,44 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self._gutter = 0 # line-number gutter width (cells)
self._line_eas: list[int | None] = [] # per-line address (marker stripped)
self._link_line: int | None = None # split-view: linked pseudocode line
+ #: {line index: 'now'|'past'|'future'} — the execution trail, mapped from
+ #: instructions onto pseudocode via decomp_map.
+ self.trail: dict[int, str] = {}
self._term = ""
self._matches: list[int] = []
self._ranges: dict[int, list[tuple[int, int]]] = {}
+ #: {line: [(x0, x1, value, ea, opnum)]} — where the number literals are,
+ #: so the one under the cursor can be marked (Hex-Rays keeps formats per
+ #: literal, and a C line often has several).
+ self._nums: dict[int, list[tuple[int, int, str, int, int]]] = {}
+ #: (ea, opnum) to put the cursor back on once the nums land — a reformat
+ #: reflows the line, so the old column points at the wrong literal.
+ self._keep_lit: tuple[int, int] | None = None
def _line_plain(self, idx: int) -> str | None:
return self._texts[idx] if 0 <= idx < len(self._texts) else None
+ def set_nums(self, nums: dict) -> None:
+ self._nums = nums or {}
+ keep, self._keep_lit = self._keep_lit, None
+ if keep is not None:
+ # Land the cursor back on the literal that was just reformatted:
+ # `48` becoming `0x30` moves everything after it, so holding the
+ # column would put the next press on a different literal.
+ for x0, _x1, _v, ea, opnum in self._nums.get(self.cursor, ()):
+ if (ea, opnum) == keep:
+ self.cursor_x = x0
+ self._hscroll()
+ break
+ self.refresh()
+
+ def _cursor_literal(self, idx: int) -> tuple[int, int] | None:
+ """Columns of the number literal under the cursor on ``idx``, or None."""
+ for x0, x1, _v, _ea, _op in self._nums.get(idx, ()):
+ if x0 <= self.cursor_x < x1:
+ return (x0, x1)
+ return None
+
def _col_offset(self) -> int:
return self._gutter
@@ -1293,6 +1709,10 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self.cursor_x = cursor_x
self._matches = []
self._ranges = {}
+ # A whole new body: drop the joined haystack outright rather than trust
+ # id(self._texts) to differ, since the list it replaces is freed here and
+ # its address can be handed straight back.
+ self._reset_search_cache(body=True)
# Gutter wide enough for the largest line number + a trailing space.
self._gutter = (len(str(total)) + 1) if total else 0
maxw = max((s.cell_length for s in self._strips), default=0)
@@ -1386,6 +1806,11 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
base = self._strips[idx]
if linked:
base = base.apply_style(_S_LINK) # split-view companion band
+ kind = self.trail.get(idx) if self.trail else None
+ if kind is not None:
+ base = base.apply_style(
+ _S_TRAIL_NOW if kind == "now" else
+ _S_TRAIL_PAST if kind == "past" else _S_TRAIL_FUTURE)
if idx in self._ranges:
base = _overlay_ranges(base, self._ranges[idx], self._match_style(idx))
if self._hl_word:
@@ -1394,6 +1819,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
base = _overlay_ranges(base, occ, _S_WORD)
if idx == self.cursor:
base = _cursor_decorate(base, self._texts[idx], self.cursor_x)
+ span = self._cursor_literal(idx) # the literal `o` would reformat
+ if span is not None: # (last: see ListingView)
+ base = _overlay_over(base, [span], _S_OPERAND)
code_w = max(width - gw, 0)
code = base.crop(x, x + code_w).adjust_cell_length(
code_w, _S_LINK if linked else None)
@@ -1430,6 +1858,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
if round(old_value) != round(new_value):
self.post_message(DecompView.Scrolled())
+ def action_op_format(self, mode: str = "cycle") -> None:
+ self.post_message(OpFormatRequested(self, mode))
+
def action_col_code_home(self) -> None:
"""shift+home: first non-blank column — past the C indentation, the
pseudocode analogue of the listing's skip-the-address-gutter."""
@@ -1538,6 +1969,10 @@ class HexView(ScrollView, can_focus=True):
super().__init__(id="hex")
self.model = None
self.total = 0
+ #: Trace to read memory from, and the timestamp to read it at. When set,
+ #: the dump shows what memory HELD then rather than what the file holds.
+ self.trace = None
+ self.trace_idx = 0
self._internal_top: int | None = None # scroll target we set ourselves
# -- public API -------------------------------------------------------- #
@@ -1590,7 +2025,11 @@ class HexView(ScrollView, can_focus=True):
self.refresh(layout=True)
self.scroll_to(y=y, animate=False)
- self.call_after_refresh(_fix)
+ # Only re-apply when the scroll clamped (the view's size wasn't computed
+ # yet). See ColumnCursor._apply_scroll: the deferred pass drags a full
+ # layout with it, which is far too expensive to do on every scroll.
+ if round(self.scroll_offset.y) != y:
+ self.call_after_refresh(_fix)
def _scroll_to_cursor(self, center: bool = False) -> None:
height = self._visible_height()
@@ -1724,6 +2163,15 @@ class HexView(ScrollView, can_focus=True):
return Strip([Segment("".ljust(width), _S_HEX)])
va, data = model.row(r)
cur_row, cur_col = self.cursor // 16, self.cursor % 16
+ # With a trace loaded the row shows what memory HELD at the current
+ # timestamp, not what the file contains. Only the bytes the trace
+ # actually saw are overlaid: the rest stay the database's, dimmed, so
+ # you can always tell evidence from the file's idea of the world.
+ tmem = tknown = None
+ if self.trace is not None and data is not None:
+ tmem, tknown = self.trace.memory(va, len(data), self.trace_idx)
+ if not any(tknown):
+ tmem = tknown = None
fo = model.file_offset(va)
fo_str = f"{fo:08X}" if fo is not None else "--------"
segs: list[Segment] = [
@@ -1733,28 +2181,940 @@ class HexView(ScrollView, can_focus=True):
if data is None:
segs.append(Segment("… fetching", _S_DIM))
else:
+ # Emit RUNS, not one segment per byte. A row is 32 cells whose style
+ # almost never changes (one cursor cell, or a trace boundary), and a
+ # segment per cell made every hex frame 1540 segments for the
+ # compositor to cut and merge again.
n = len(data)
+ run: list[str] = []
+ run_st = None
+
+ def flush(st=None, _segs=segs) -> None:
+ nonlocal run, run_st
+ if run:
+ _segs.append(Segment("".join(run), run_st))
+ run = []
+ run_st = st
+
+ def put(text: str, st) -> None:
+ nonlocal run_st
+ if st is not run_st:
+ flush(st)
+ run.append(text)
+
+ run_st = _S_HEX
for i in range(16):
if i == 8:
- segs.append(Segment(" ", _S_HEX))
+ put(" ", _S_HEX)
if i < n:
- st = _S_CELL if (r == cur_row and i == cur_col) else _S_HEX
- segs.append(Segment(f"{data[i]:02X} ", st))
+ live = tknown is not None and tknown[i]
+ val = tmem[i] if live else data[i]
+ if r == cur_row and i == cur_col:
+ st = _S_CELL
+ elif tknown is None:
+ st = _S_HEX
+ else:
+ st = _S_HEX_LIVE if live else _S_HEX_STALE
+ put(f"{val:02X} ", st)
else:
- segs.append(Segment(" ", _S_HEX))
- segs.append(Segment(" |", _S_DIM))
+ put(" ", _S_HEX)
+ put(" |", _S_DIM)
for i in range(16):
if i < n:
- ch = chr(data[i]) if 32 <= data[i] < 127 else "."
- st = _S_CELL if (r == cur_row and i == cur_col) else _S_ASCII
+ live = tknown is not None and tknown[i]
+ val = tmem[i] if live else data[i]
+ ch = chr(val) if 32 <= val < 127 else "."
+ if r == cur_row and i == cur_col:
+ st = _S_CELL
+ elif tknown is None:
+ st = _S_ASCII
+ else:
+ st = _S_HEX_LIVE if live else _S_HEX_STALE
else:
ch, st = " ", _S_ASCII
- segs.append(Segment(ch, st))
- segs.append(Segment("|", _S_DIM))
+ put(ch, st)
+ put("|", _S_DIM)
+ flush()
return Strip(segs).adjust_cell_length(width, _S_HEX)
# --------------------------------------------------------------------------- #
+# Graph view
+# --------------------------------------------------------------------------- #
+_S_GBORDER = Style(color="#4b5565") # box border, idle
+_S_GBORDER_CUR = Style(color="#7aa2f7", bold=True) # box border, cursor block
+_S_GLABEL = Style(color="#7aa2f7", bold=True) # loc_XXXX in the border
+_S_GLABEL_CUR = Style(color="#c0caf5", bold=True)
+_S_GDIM = Style(color="#5e6875")
+_S_GENTRY = Style(color="#9ece6a", bold=True) # the entry block's label
+#: Edge colours follow IDA's convention: green = branch taken, red = falls
+#: through, blue = the block's only successor, purple = loops back.
+_S_EDGE = {
+ graph.E_TRUE: Style(color="#5fbf5f"),
+ graph.E_FALSE: Style(color="#cf5f5f"),
+ graph.E_UNCOND: Style(color="#5f87d7"),
+ graph.E_SWITCH: Style(color="#c9a227"),
+ graph.E_BACK: Style(color="#a06fd0"),
+}
+#: Same hues, brightened: the edges touching the block you're on.
+_S_EDGE_HOT = {
+ graph.E_TRUE: Style(color="#8ff08f", bold=True),
+ graph.E_FALSE: Style(color="#ff8f8f", bold=True),
+ graph.E_UNCOND: Style(color="#9fc4ff", bold=True),
+ graph.E_SWITCH: Style(color="#ffd75f", bold=True),
+ graph.E_BACK: Style(color="#d0a0ff", bold=True),
+}
+_S_MINI_BG = Style(bgcolor="#161b22", color="#3b4453")
+_S_MINI_NODE = Style(bgcolor="#161b22", color="#5f7799")
+_S_MINI_CUR = Style(bgcolor="#161b22", color="#9ece6a", bold=True)
+_S_MINI_VIEW = Style(bgcolor="#233044", color="#c0caf5")
+_S_MINI_EDGE = Style(bgcolor="#161b22", color="#2f3945")
+
+_GPAD = 1 # columns of padding inside a box
+_MINI_W, _MINI_H = 30, 14
+
+
+#: Memo for ``Style + Style``. Rich rebuilds a whole Style on every ``+``, and
+#: the graph merges an overlay (cursor band, trail, highlight) over a run of
+#: cells that share only a handful of base styles -- so the same pair is
+#: recombined hundreds of times per frame.
+_STYLE_SUM: dict[tuple, Style] = {}
+
+
+class _CellRow:
+ """A row of (char, style) cells that coalesces into a Strip.
+
+ The graph is drawn per screen row from three independent sources -- edge
+ cells, boxes, then the minimap -- which overwrite each other. Composing into
+ a flat cell array and coalescing once at the end is both simpler and cheaper
+ than splicing Strips three times.
+ """
+
+ __slots__ = ("ch", "st", "width")
+
+ def __init__(self, width: int, base: Style) -> None:
+ self.width = max(width, 0)
+ self.ch = [" "] * self.width
+ self.st = [base] * self.width
+
+ def put(self, i: int, ch: str, style: Style) -> None:
+ if 0 <= i < self.width:
+ self.ch[i] = ch
+ self.st[i] = style
+
+ def text(self, i: int, s: str, style: Style) -> None:
+ """Write ``s`` at cell ``i``, clipped to the row.
+
+ Slice assignment rather than a call per character: a graph row is drawn
+ from box borders, instruction text and the minimap, and doing it a cell
+ at a time made painting one frame thousands of bound-method calls.
+ Assigning a str to a list slice expands it to characters in C.
+ """
+ if not s:
+ return
+ a = i if i > 0 else 0
+ b = i + len(s)
+ if b > self.width:
+ b = self.width
+ if b <= a:
+ return
+ self.ch[a:b] = s[a - i:b - i]
+ self.st[a:b] = [style] * (b - a)
+
+ def restyle(self, a: int, b: int, style: Style) -> None:
+ """Merge ``style`` over the cells in [a, b) (keeps the characters)."""
+ if a < 0:
+ a = 0
+ if b > self.width:
+ b = self.width
+ st = self.st
+ combine = _STYLE_SUM
+ for i in range(a, b):
+ base = st[i]
+ key = (base, style)
+ got = combine.get(key)
+ if got is None:
+ got = combine[key] = base + style
+ st[i] = got
+
+ def strip(self) -> Strip:
+ segs: list[Segment] = []
+ if not self.width:
+ return Strip([])
+ run_start = 0
+ cur = self.st[0]
+ for i in range(1, self.width):
+ if self.st[i] is not cur and self.st[i] != cur:
+ segs.append(Segment("".join(self.ch[run_start:i]), cur))
+ run_start, cur = i, self.st[i]
+ segs.append(Segment("".join(self.ch[run_start:]), cur))
+ return Strip(segs)
+
+
+class GraphView(NavMixin, ScrollView, can_focus=True):
+ """IDA-style control-flow graph of one function, in character cells.
+
+ The layout comes from ``idatui.graph`` (pure, offline-testable); this class
+ is only presentation, navigation and hit-testing. Nothing is pre-painted: a
+ big function is millions of cells, so each screen row is composed on demand
+ from the edge index plus whichever boxes cover that row -- the same
+ discipline as ``ListingView.render_line``.
+
+ Box contents are the SAME ``Head`` rows the listing renders, so IDA's own
+ colour tags, names and operand text come along for free instead of this
+ growing a second disassembler renderer.
+ """
+
+ BINDINGS = [
+ Binding("j,down", "cursor_down", "Down", show=False),
+ Binding("k,up", "cursor_up", "Up", show=False),
+ Binding("h,left", "cursor_left", "Left", show=False),
+ Binding("l,right", "cursor_right", "Right", show=False),
+ Binding("J", "succ_block", "Next block", show=False),
+ Binding("K", "pred_block", "Prev block", show=False),
+ Binding("w", "next_block", "Block →", show=False),
+ Binding("b", "prev_block", "Block ←", show=False),
+ Binding("0", "goto_entry", "Entry", show=False),
+ Binding("z", "zoom", "Zoom"),
+ Binding("m", "minimap", "Minimap", show=False),
+ Binding("e", "engine", "Engine", show=False),
+ Binding("f", "center", "Centre", show=False),
+ Binding("ctrl+d", "pan(12)", "½↓", show=False),
+ Binding("ctrl+u", "pan(-12)", "½↑", show=False),
+ Binding("pagedown", "pan(24)", "PgDn", show=False),
+ Binding("pageup", "pan(-24)", "PgUp", show=False),
+ Binding("home", "col_home", "bol", show=False),
+ Binding("end", "col_end", "eol", show=False),
+ *NavMixin.NAV_BINDINGS,
+ ]
+
+ cursor_node = reactive(-1, repaint=False)
+ cursor_row = reactive(0, repaint=False)
+ cursor_x = reactive(0, repaint=False)
+
+ ZOOMS = ("full", "compact", "collapsed")
+
+ class CursorMoved(Message):
+ """Posted when the graph cursor lands on a new address."""
+
+ def __init__(self, ea: int | None, block: int) -> None:
+ super().__init__()
+ self.ea = ea
+ self.block = block
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.fc = None # domain.Flowchart
+ self.lay: graph.Layout | None = None
+ self.loaded_ea: int | None = None
+ self._blocks: dict[int, object] = {}
+ self._zoom = 0
+ self._show_minimap = True
+ #: layout backend; "auto" prefers triskel where it is installed and the
+ #: function is small enough for it. Cycled with `e`.
+ self._engine = "auto"
+ self._mini_cache: tuple | None = None
+ self._drag: tuple[int, int, float, float] | None = None
+ self._drag_map = False # the drag started on the minimap
+ self._hl_word = ""
+ self.trail: dict[int, str] | None = None
+
+ # -- content ---------------------------------------------------------- #
+ def set_graph(self, fc, ea: int | None = None) -> None:
+ """Install a flowchart and lay it out at the current zoom."""
+ self.fc = fc
+ self._blocks = {b.id: b for b in fc.blocks} if fc else {}
+ self.loaded_ea = fc.func_ea if fc else None
+ self._relayout()
+ if fc:
+ blk = fc.block_at(ea) if ea is not None else None
+ self.cursor_node = blk.id if blk else fc.entry
+ self.cursor_row = 0
+ if blk is not None and ea is not None:
+ rows = self._rows(blk.id)
+ for i, h in enumerate(rows):
+ if h is not None and h.ea == ea:
+ self.cursor_row = i
+ break
+ self.cursor_x = 0
+ self._center_cursor()
+ self.refresh(layout=True)
+
+ def _relayout(self) -> None:
+ self._mini_cache = None
+ if not self.fc or not self.fc.blocks:
+ self.lay = None
+ self.virtual_size = Size(0, 0)
+ return
+ blocks = [graph.Block(id=b.id, start=b.start, end=b.end,
+ succs=list(b.succs)) for b in self.fc.blocks]
+ self.lay = graph.layout(blocks, self._sizer, entry=self.fc.entry,
+ engine=self._engine)
+ self.virtual_size = Size(self.lay.width + 2, self.lay.height + 1)
+
+ def _rows(self, nid: int):
+ """The Head rows shown inside block ``nid`` at the current zoom."""
+ b = self._blocks.get(nid)
+ if b is None:
+ return []
+ if self._zoom == 2:
+ return [None] # one synthetic summary row
+ return b.rows
+
+ def _row_plain(self, nid: int, i: int) -> str:
+ b = self._blocks.get(nid)
+ if b is None:
+ return ""
+ if self._zoom == 2:
+ n = len(b.rows)
+ return f"{n} instruction{'s' if n != 1 else ''}"
+ rows = b.rows
+ if not (0 <= i < len(rows)):
+ return ""
+ h = rows[i]
+ if self._zoom == 0:
+ return f"{h.ea:08X} {self._head_text(h)}"
+ return self._head_text(h)
+
+ @staticmethod
+ def _head_text(h) -> str:
+ return (f"{h.name} {h.text}" if h.name else h.text)
+
+ def _sizer(self, b: graph.Block) -> tuple[int, int]:
+ nid = b.id
+ rows = self._rows(nid)
+ n = max(len(rows), 1)
+ label = f"loc_{b.start:X}"
+ widest = max([len(label) + 4]
+ + [len(self._row_plain(nid, i)) for i in range(n)])
+ return (widest + 2 * _GPAD + 2, n + 2)
+
+ # -- geometry --------------------------------------------------------- #
+ def _cur_node(self) -> graph.Node | None:
+ if self.lay is None:
+ return None
+ return self.lay.by_id.get(self.cursor_node)
+
+ def cur_head(self):
+ rows = self._rows(self.cursor_node)
+ if self._zoom == 2 or not rows:
+ b = self._blocks.get(self.cursor_node)
+ return b.rows[0] if (b and b.rows) else None
+ i = max(0, min(self.cursor_row, len(rows) - 1))
+ return rows[i]
+
+ def _cursor_ea(self) -> int | None:
+ h = self.cur_head()
+ return h.ea if h is not None else None
+
+ def _next_ea(self) -> int | None:
+ """The address after the cursor's instruction -- what ``follow`` uses to
+ skip the fall-through edge and land on a real branch target."""
+ b = self._blocks.get(self.cursor_node)
+ if b is None:
+ return None
+ rows = b.rows
+ i = max(0, min(self.cursor_row, len(rows) - 1)) if rows else 0
+ if rows and i + 1 < len(rows):
+ return rows[i + 1].ea
+ return b.end
+
+ def _line_plain(self, _idx=None) -> str:
+ return self._row_plain(self.cursor_node, self.cursor_row)
+
+ def word_under_cursor(self) -> str:
+ plain = self._line_plain()
+ if not plain:
+ return ""
+ a, b = _word_bounds(plain, self.cursor_x)
+ return plain[a:b] if b > a else ""
+
+ def set_highlight(self, word: str) -> None:
+ if word != self._hl_word:
+ self._hl_word = word
+ self.refresh()
+
+ def set_trail(self, trail: dict[int, str] | None) -> None:
+ self.trail = trail
+ self.refresh()
+
+ # -- cursor motion ---------------------------------------------------- #
+ def _clamp_cursor(self) -> None:
+ if self.lay is None or not self.lay.nodes:
+ return
+ if self.cursor_node not in self.lay.by_id:
+ self.cursor_node = self.lay.nodes[0].id
+ rows = self._rows(self.cursor_node)
+ self.cursor_row = max(0, min(self.cursor_row, max(len(rows) - 1, 0)))
+ plain = self._line_plain()
+ self.cursor_x = max(0, min(self.cursor_x, max(len(plain) - 1, 0)))
+
+ def _order(self) -> list[int]:
+ return [n.id for n in self.lay.nodes] if self.lay else []
+
+ def _moved(self) -> None:
+ self._clamp_cursor()
+ self._scroll_to_cursor()
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+
+ def action_cursor_down(self) -> None:
+ rows = self._rows(self.cursor_node)
+ if self.cursor_row + 1 < len(rows):
+ self.cursor_row += 1
+ else:
+ order = self._order()
+ if self.cursor_node in order:
+ i = order.index(self.cursor_node)
+ if i + 1 < len(order):
+ self.cursor_node = order[i + 1]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_cursor_up(self) -> None:
+ if self.cursor_row > 0:
+ self.cursor_row -= 1
+ else:
+ order = self._order()
+ if self.cursor_node in order:
+ i = order.index(self.cursor_node)
+ if i > 0:
+ self.cursor_node = order[i - 1]
+ self.cursor_row = max(len(self._rows(self.cursor_node)) - 1, 0)
+ self._moved()
+
+ def action_cursor_left(self) -> None:
+ self.cursor_x = max(0, self.cursor_x - 1)
+ self._moved()
+
+ def action_cursor_right(self) -> None:
+ self.cursor_x = min(len(self._line_plain()), self.cursor_x + 1)
+ self._moved()
+
+ def action_col_home(self) -> None:
+ self.cursor_x = 0
+ self._moved()
+
+ def action_col_end(self) -> None:
+ self.cursor_x = max(len(self._line_plain()) - 1, 0)
+ self._moved()
+
+ def _hop(self, table: dict) -> None:
+ tgt = table.get(self.cursor_node) or []
+ if not tgt:
+ self.app._status("no edge that way")
+ return
+ self.cursor_node = tgt[0][0]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_succ_block(self) -> None:
+ if self.lay:
+ self._hop(self.lay.succ)
+
+ def action_pred_block(self) -> None:
+ if self.lay:
+ self._hop(self.lay.pred)
+
+ def _step_order(self, delta: int) -> None:
+ order = self._order()
+ if not order:
+ return
+ i = order.index(self.cursor_node) if self.cursor_node in order else 0
+ self.cursor_node = order[max(0, min(len(order) - 1, i + delta))]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_next_block(self) -> None:
+ self._step_order(1)
+
+ def action_prev_block(self) -> None:
+ self._step_order(-1)
+
+ def action_goto_entry(self) -> None:
+ if self.fc:
+ self.cursor_node = self.fc.entry
+ self.cursor_row = 0
+ self._moved()
+ self._center_cursor()
+
+ def action_pan(self, rows: int) -> None:
+ self.scroll_to(y=max(0, self.scroll_offset.y + rows), animate=False)
+ self._snap_into_view()
+
+ def _viewport_has_block(self) -> bool:
+ if self.lay is None:
+ return False
+ y0 = int(self.scroll_offset.y)
+ x0 = int(self.scroll_offset.x)
+ y1, x1 = y0 + self.size.height, x0 + self.size.width
+ return any(n.y <= y1 and y0 <= n.bottom and n.x <= x1 and x0 <= n.right
+ for n in self.lay.nodes)
+
+ def _snap_into_view(self) -> None:
+ """After a pan, if the viewport holds no block at all, ease to the
+ nearest one.
+
+ Blocks cover a few percent of a laid-out graph -- 4.6% on an 87-block
+ function, under 1% on a 424-block one -- the rest being the padding that
+ keeps edges apart. Panning therefore lands in empty space more often
+ than not, and an empty screen gives you nothing to navigate back by.
+ Only fires when nothing is visible, so it never fights a deliberate pan.
+ """
+ if self.lay is None or self._viewport_has_block():
+ return
+ cy = self.scroll_offset.y + self.size.height / 2
+ cx = self.scroll_offset.x + self.size.width / 2
+ n = self._nearest_node(cy, cx)
+ if n is not None:
+ self._center_on(n, defer=False)
+ self.refresh()
+
+ def action_zoom(self) -> None:
+ self._zoom = (self._zoom + 1) % len(self.ZOOMS)
+ self._relayout()
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh(layout=True)
+ self.app._graph_status() # keeps the function name; names the zoom
+
+ def action_minimap(self) -> None:
+ self._show_minimap = not self._show_minimap
+ self.refresh()
+ self.app._status(f"graph: minimap {'on' if self._show_minimap else 'off'}")
+
+ def action_engine(self) -> None:
+ """Cycle the layout engine and redraw the same function with it.
+
+ The two engines disagree about shape more than about correctness --
+ native draws wide and short, triskel narrow and tall with far fewer
+ crossings -- and which one reads better genuinely depends on the
+ function. Cheaper to look than to argue.
+ """
+ from . import graph_triskel
+ choices = ["auto", "native"] + (["triskel"] if graph_triskel.available()
+ else [])
+ self._engine = choices[(choices.index(self._engine) + 1) % len(choices)]
+ self._relayout()
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh(layout=True)
+ got = self.lay.stats["engine"] if self.lay else "?"
+ # Name the interpreter. The launcher runs $IDATUI_PYTHON (default
+ # ~/ida-venv), which is NOT the repo .venv the tests use, so "not
+ # installed" on its own sends people to check the wrong python.
+ note = ("" if graph_triskel.available()
+ else f" (no pytriskel in {sys.executable})")
+ # A fallback with no reason is a bug report nobody can file.
+ if self.lay and self.lay.stats.get("engine_error"):
+ note = f" \u2014 {self.lay.stats['engine_error']}"
+ self.app._status(f"graph: engine {self._engine} \u2192 {got}{note}")
+
+ def action_center(self) -> None:
+ self._center_cursor()
+ self.refresh()
+
+ def action_copy_line(self) -> None:
+ plain = self._line_plain()
+ if not plain:
+ return
+ n = self.app._copy(plain)
+ self.app._status(f"copied line ({n} chars) to clipboard")
+
+ def goto_ea(self, ea: int) -> bool:
+ """Put the cursor on ``ea`` if it lives in this graph."""
+ if not self.fc:
+ return False
+ b = self.fc.block_at(ea)
+ if b is None:
+ return False
+ self.cursor_node = b.id
+ self.cursor_row = 0
+ for i, h in enumerate(self._rows(b.id)):
+ if h is not None and h.ea == ea:
+ self.cursor_row = i
+ break
+ self.cursor_x = 0
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh()
+ return True
+
+ # -- scrolling -------------------------------------------------------- #
+ def _cursor_cell(self) -> tuple[int, int] | None:
+ n = self._cur_node()
+ if n is None:
+ return None
+ row = n.y + 1 + max(0, min(self.cursor_row, n.h - 3))
+ col = n.x + 1 + _GPAD + self.cursor_x
+ return (row, col)
+
+ def _scroll_to_cursor(self) -> None:
+ cell = self._cursor_cell()
+ if cell is None:
+ return
+ row, col = cell
+ w, h = self.size.width, self.size.height
+ if w <= 0 or h <= 0:
+ return
+ y, x = self.scroll_offset.y, self.scroll_offset.x
+ if row < y:
+ y = row
+ elif row >= y + h:
+ y = row - h + 1
+ if col < x + 2:
+ x = max(0, col - 2)
+ elif col >= x + w - 2:
+ x = col - w + 3
+ if (y, x) != (self.scroll_offset.y, self.scroll_offset.x):
+ self.scroll_to(y=max(0, y), x=max(0, x), animate=False)
+
+ def _center_on(self, n: graph.Node, defer: bool = True) -> None:
+ """Bring block ``n`` into the middle of the viewport.
+
+ ``defer=False`` during a drag: layout is already valid then, and
+ queueing a callback per mouse-move makes the scrub lag behind.
+ """
+ if n is None or self.size.width <= 0:
+ return
+ y = max(0, n.y - max(self.size.height // 2 - n.h // 2, 0))
+ x = max(0, int(n.cx) - self.size.width // 2)
+ self.scroll_to(y=y, x=x, animate=False)
+ if not defer:
+ return
+ # Setting virtual_size then scrolling immediately clamps to 0 (max_scroll
+ # isn't recomputed until layout), so apply it again after the refresh.
+ def _again() -> None:
+ self.scroll_to(y=y, x=x, animate=False)
+ self.call_after_refresh(_again)
+
+ def _center_cursor(self) -> None:
+ n = self._cur_node()
+ if n is not None:
+ self._center_on(n)
+
+ # -- minimap hit-testing ----------------------------------------------- #
+ def _minimap_rect(self) -> tuple[int, int, int, int] | None:
+ """(left, top, w, h) of the minimap in CONTENT coordinates, or None.
+
+ The minimap is pinned to the viewport, not the canvas, so these are
+ screen-relative and the scroll offset must NOT be added. Must agree with
+ _draw_minimap_row, which is why both take the inset from here.
+ """
+ if not self._show_minimap or self.lay is None:
+ return None
+ w, h = self.size.width, self.size.height
+ if w < _MINI_W + 10 or h < _MINI_H + 2:
+ return None
+ return (w - _MINI_W - 2, 0, _MINI_W, _MINI_H)
+
+ def _nearest_node(self, row: float, col: float) -> graph.Node | None:
+ """The block nearest a canvas point (distance 0 if the point is inside).
+
+ Cells are about twice as tall as they are wide, so the column distance
+ is halved -- otherwise "nearest" means nearest in cells, which does not
+ look nearest on screen.
+ """
+ if self.lay is None:
+ return None
+ best, best_d = None, None
+ for n in self.lay.nodes:
+ dx = 0.0 if n.x <= col <= n.right else min(abs(col - n.x),
+ abs(col - n.right))
+ dy = 0.0 if n.y <= row <= n.bottom else min(abs(row - n.y),
+ abs(row - n.bottom))
+ d = (dx * 0.5) ** 2 + dy ** 2
+ if best_d is None or d < best_d:
+ best, best_d = n, d
+ return best
+
+ def _minimap_seek(self, x: int, y: int, defer: bool = True) -> bool:
+ """Treat (x, y) as a point on the minimap and go to the block there.
+
+ Deliberately snaps to the NEAREST BLOCK rather than scrolling to the raw
+ coordinate. Most of a laid-out graph is the padding that keeps edges
+ apart, so a coordinate-accurate jump usually parks the viewport in empty
+ space -- and the cursor, which only moved when the point landed exactly
+ on a block, stayed behind. Snapping means every click lands on something
+ and the keyboard carries on from there.
+
+ Returns False if the point isn't on the minimap, so the caller can fall
+ through to ordinary canvas hit-testing.
+ """
+ rect = self._minimap_rect()
+ if rect is None or self.lay is None:
+ return False
+ left, top, _w, _h = rect
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ c, r = x - left - 1, y - top - 1 # inside the border
+ if not (0 <= c < gw and 0 <= r < gh):
+ return False
+ lay = self.lay
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ cx, cy = (c + 0.5) * sx, (r + 0.5) * sy # centre of that mini-cell
+ n = self._nearest_node(cy, cx)
+ if n is None:
+ self.scroll_to(x=max(0, int(cx - self.size.width / 2)),
+ y=max(0, int(cy - self.size.height / 2)),
+ animate=False)
+ return True
+ if n.id == self.cursor_node:
+ return True # already there; don't churn while dragging
+ self.cursor_node = n.id
+ self.cursor_row = 0
+ self.cursor_x = 0
+ self._clamp_cursor()
+ self._center_on(n, defer=defer)
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+ return True
+
+ # -- mouse ------------------------------------------------------------- #
+ def on_mouse_down(self, event) -> None: # type: ignore[no-untyped-def]
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ if self._minimap_seek(off.x, off.y):
+ self._drag = None
+ self._drag_map = True # keep scrubbing while the button is held
+ return
+ self._drag_map = False
+ self._drag = (off.x, off.y, self.scroll_offset.x, self.scroll_offset.y)
+
+ def on_mouse_up(self, event) -> None: # type: ignore[no-untyped-def]
+ was_pan = self._drag is not None and not self._drag_map
+ self._drag = None
+ self._drag_map = False
+ if was_pan:
+ self._snap_into_view() # don't leave them adrift in the padding
+
+ def on_mouse_move(self, event) -> None: # type: ignore[no-untyped-def]
+ if not event.button:
+ return
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ if self._drag_map:
+ # drag = scrub block to block through the overview
+ self._minimap_seek(off.x, off.y, defer=False)
+ return
+ if self._drag is None:
+ return
+ x0, y0, sx, sy = self._drag
+ self.scroll_to(x=max(0, sx + (x0 - off.x)), y=max(0, sy + (y0 - off.y)),
+ animate=False)
+
+ def on_click(self, event) -> None: # type: ignore[no-untyped-def]
+ if self.lay is None:
+ return
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ # The minimap floats over the canvas, so it has to be tested FIRST --
+ # otherwise a click on it is read as canvas coordinates and drops the
+ # cursor into whatever block happens to lie underneath.
+ if self._minimap_seek(off.x, off.y):
+ self.focus()
+ return
+ row = off.y + int(self.scroll_offset.y)
+ col = off.x + int(self.scroll_offset.x)
+ n = self.lay.node_at(row, col)
+ if n is None or n.block is None:
+ return
+ self.focus()
+ self.cursor_node = n.id
+ self.cursor_row = max(0, min(row - n.y - 1,
+ max(len(self._rows(n.id)) - 1, 0)))
+ self.cursor_x = max(0, col - n.x - 1 - _GPAD)
+ self._clamp_cursor()
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+ if getattr(event, "chain", 1) >= 2:
+ self.post_message(FollowRequested(self))
+
+ # -- rendering -------------------------------------------------------- #
+ def _edge_styles(self) -> tuple[dict, set]:
+ hot = self.lay.incident.get(self.cursor_node, set()) if self.lay else set()
+ return (_S_EDGE, hot)
+
+ def render_line(self, y: int) -> Strip:
+ width = self.size.width
+ if self.lay is None or not self.lay.nodes:
+ return Strip([Segment("".ljust(width), _S_GDIM)])
+ row = int(self.scroll_offset.y) + y
+ col0 = int(self.scroll_offset.x)
+ out = _CellRow(width, _S_INSN)
+ base, hot = self._edge_styles()
+
+ # 1. edge cells (an index query, never a painted canvas)
+ for col, (ch, kind, eid) in self.lay.painting.cells_at_row(
+ row, col0, col0 + width).items():
+ st = (_S_EDGE_HOT if eid in hot else base).get(kind, _S_GDIM)
+ out.put(col - col0, ch, st)
+
+ # 2. boxes covering this row (they win over edges: nothing routes inside)
+ for n in self.lay.nodes_at_row(row):
+ self._draw_node_row(out, n, row, col0)
+
+ # 3. minimap, last, over everything
+ if self._show_minimap:
+ self._draw_minimap_row(out, y, width)
+ return out.strip().adjust_cell_length(width, _S_INSN)
+
+ def _draw_node_row(self, out: _CellRow, n: graph.Node, row: int,
+ col0: int) -> None:
+ cur = n.id == self.cursor_node
+ bs = _S_GBORDER_CUR if cur else _S_GBORDER
+ left = n.x - col0
+ w = n.w
+ b = self._blocks.get(n.id)
+ if row == n.y:
+ # top border carries the label: ┌─ loc_1234 ─────┐
+ label = f"loc_{n.block.start:X}" if n.block else ""
+ if b is not None and b.rows and b.rows[0].name:
+ label = b.rows[0].name
+ out.text(left, graph.BOX["tl"] + graph.BOX["h"] * (w - 2)
+ + graph.BOX["tr"], bs)
+ tag = f" {label} "
+ if len(tag) <= w - 4:
+ st = _S_GENTRY if (self.fc and n.id == self.fc.entry) else (
+ _S_GLABEL_CUR if cur else _S_GLABEL)
+ out.text(left + 2, tag, st)
+ if n.block is not None and n.block.selfloop:
+ out.put(left + w - 2, "↺", _S_EDGE[graph.E_BACK])
+ return
+ if row == n.y + n.h - 1:
+ out.text(left, graph.BOX["bl"] + graph.BOX["h"] * (w - 2)
+ + graph.BOX["br"], bs)
+ return
+ out.put(left, graph.BOX["v"], bs)
+ out.put(left + w - 1, graph.BOX["v"], bs)
+ out.text(left + 1, " " * (w - 2), _S_INSN)
+ i = row - n.y - 1
+ rows = self._rows(n.id)
+ if not (0 <= i < len(rows)):
+ return
+ text_col = left + 1 + _GPAD
+ h = rows[i]
+ plain = self._row_plain(n.id, i)
+ if h is None: # collapsed summary
+ out.text(text_col, plain, _S_GDIM)
+ else:
+ c = text_col
+ if self._zoom == 0:
+ out.text(c, f"{h.ea:08X} ", _S_ADDR)
+ c += 10
+ if h.name:
+ out.text(c, f"{h.name} ", _S_LABEL)
+ c += len(h.name) + 2
+ fallback = {"code": _S_INSN, "data": _S_DATA}.get(h.kind, _S_UNK)
+ if h.spans:
+ for kind, t in h.spans:
+ out.text(c, t, _S_SPAN.get(kind, fallback))
+ c += len(t)
+ else:
+ out.text(c, h.text, fallback)
+ inner_a, inner_b = left + 1, left + w - 1
+ # execution trail (a loaded Tenet trace), same palette as the listing
+ if self.trail is not None and h is not None:
+ k = self.trail.get(h.ea)
+ if k is not None:
+ out.restyle(inner_a, inner_b,
+ _S_TRAIL_NOW if k == "now" else
+ _S_TRAIL_PAST if k == "past" else _S_TRAIL_FUTURE)
+ if self._hl_word and plain:
+ for a, bb in _word_occurrences(plain, self._hl_word):
+ out.restyle(text_col + a, text_col + bb, _S_WORD)
+ if cur and i == self.cursor_row:
+ out.restyle(inner_a, inner_b, _S_CURSOR)
+ x = max(0, min(self.cursor_x, max(len(plain) - 1, 0)))
+ wa, wb = _word_bounds(plain, x)
+ if wb > wa:
+ out.restyle(text_col + wa, text_col + wb, _S_WORD)
+ if self.has_focus:
+ out.restyle(text_col + x, text_col + x + 1, _S_CELL)
+
+ # -- minimap ---------------------------------------------------------- #
+ def _minimap(self) -> list[list[int]]:
+ """A coarse occupancy grid of the whole graph: 0 empty, 1 edge, 2 block,
+ 3 the cursor's block. Cached per (layout, zoom, cursor block)."""
+ key = (id(self.lay), self._zoom, self.cursor_node)
+ if self._mini_cache and self._mini_cache[0] == key:
+ return self._mini_cache[1]
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ grid = [[0] * gw for _ in range(gh)]
+ lay = self.lay
+ if lay is not None and lay.width and lay.height:
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ for lo, hi, col, _kind, _eid in lay.painting.vruns:
+ c = min(int(col / sx), gw - 1)
+ for r in range(min(int(lo / sy), gh - 1),
+ min(int(hi / sy), gh - 1) + 1):
+ if not grid[r][c]:
+ grid[r][c] = 1
+ for n in lay.nodes:
+ mark = 3 if n.id == self.cursor_node else 2
+ r0, r1 = int(n.y / sy), int((n.y + n.h - 1) / sy)
+ c0, c1 = int(n.x / sx), int(n.right / sx)
+ for r in range(max(r0, 0), min(r1, gh - 1) + 1):
+ for c in range(max(c0, 0), min(c1, gw - 1) + 1):
+ if grid[r][c] < mark:
+ grid[r][c] = mark
+ self._mini_cache = (key, grid)
+ return grid
+
+ def _draw_minimap_row(self, out: _CellRow, y: int, width: int) -> None:
+ # Inset by two: a ScrollView paints its vertical scrollbar over the last
+ # column, which otherwise eats the minimap's right border.
+ if self._minimap_rect() is None or not (0 <= y < _MINI_H):
+ return
+ left = self._minimap_rect()[0] # one source of truth with the hit-test
+ grid = self._minimap()
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ lay = self.lay
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ vy0 = int(self.scroll_offset.y / sy)
+ vy1 = int((self.scroll_offset.y + self.size.height) / sy)
+ vx0 = int(self.scroll_offset.x / sx)
+ vx1 = int((self.scroll_offset.x + width) / sx)
+
+ if y == 0:
+ out.put(left, graph.BOX["tl"], _S_MINI_BG)
+ for i in range(1, _MINI_W - 1):
+ out.put(left + i, graph.BOX["h"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["tr"], _S_MINI_BG)
+ tag = f" {len(lay.nodes)} blocks "
+ out.text(left + 2, tag, _S_MINI_BG)
+ return
+ if y == _MINI_H - 1:
+ out.put(left, graph.BOX["bl"], _S_MINI_BG)
+ for i in range(1, _MINI_W - 1):
+ out.put(left + i, graph.BOX["h"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["br"], _S_MINI_BG)
+ return
+ r = y - 1
+ out.put(left, graph.BOX["v"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["v"], _S_MINI_BG)
+ for c in range(gw):
+ v = grid[r][c] if r < len(grid) else 0
+ inview = vy0 <= r <= vy1 and vx0 <= c <= vx1
+ if v == 3:
+ ch, st = "█", _S_MINI_CUR
+ elif v == 2:
+ ch, st = "█", _S_MINI_NODE
+ elif v == 1:
+ ch, st = "·", _S_MINI_EDGE
+ else:
+ ch, st = " ", _S_MINI_BG
+ if inview and v != 3:
+ st = _S_MINI_VIEW if v == 0 else st + Style(bgcolor="#233044")
+ out.put(left + 1 + c, ch, st)
+
+
+# --------------------------------------------------------------------------- #
# Function list panel
# --------------------------------------------------------------------------- #
class FunctionsPanel(Vertical):
@@ -1770,6 +3130,58 @@ class FunctionsPanel(Vertical):
# --------------------------------------------------------------------------- #
+# Shared list navigation for the overlays
+# --------------------------------------------------------------------------- #
+class OptionListNav:
+ """Cursor + paging for an overlay whose filter ``Input`` keeps the focus.
+
+ These screens focus a filter box, not the list, so the ``OptionList``'s own
+ bindings never fire -- the Input sees every key first. These actions forward
+ to the list on its behalf.
+
+ Paging delegates to the widget's OWN ``action_page_up``/``action_page_down``
+ rather than reimplementing it: those know the live viewport height, skip
+ disabled options and clamp at both ends. A hand-rolled "move by N" here
+ would have to guess the height and would drift from the list that DOES have
+ focus (``XrefsScreen``, or the struct list), which pages natively.
+
+ Textual only merges ``BINDINGS`` from ``DOMNode`` subclasses, so a plain
+ mixin's are silently dropped -- every screen must splat ``*NAV_BINDINGS``
+ (or list its own keys) explicitly. Same trap as ``SearchMixin``.
+ """
+
+ #: The common key set. ``StructEditor`` deliberately does NOT use this: it
+ #: binds ctrl+n to "new type", so it lists bare up/down itself.
+ NAV_BINDINGS = [
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("pagedown", "page_down", show=False),
+ Binding("pageup", "page_up", show=False),
+ ]
+
+ def _nav_list(self):
+ """The list to drive, or None when there is nothing to move through."""
+ ol = self.query_one(OptionList)
+ return ol if ol.option_count else None
+
+ def action_cursor_down(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_page_down(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.action_page_down()
+
+ def action_page_up(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.action_page_up()
+
+
+# --------------------------------------------------------------------------- #
# Xrefs popup
# --------------------------------------------------------------------------- #
class XrefsScreen(ModalScreen):
@@ -1787,8 +3199,8 @@ class XrefsScreen(ModalScreen):
self._preselect = preselect
def compose(self) -> ComposeResult:
- with Vertical(id="xref-box"):
- yield Static(self._label, id="xref-title")
+ with Vertical(id="xref-box") as box:
+ box.border_title = Text(self._label.strip())
yield OptionList(*[Option(text) for _, text in self._items], id="xref-list")
def on_mount(self) -> None:
@@ -1841,13 +3253,12 @@ def _fuzzy(name: str, q: str):
return (score, tuple(pos))
-class SymbolPalette(ModalScreen):
+class SymbolPalette(OptionListNav, ModalScreen):
"""A command-palette overlay: type to fuzzy-find a symbol, Enter opens it."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
# F2, not ctrl+a: the focused Input binds "home,ctrl+a" so it would never
# reach us. Function keys are untouched by Input.
Binding("f2", "scope", "This binary / whole project", show=False),
@@ -1868,8 +3279,8 @@ class SymbolPalette(ModalScreen):
self._results: list[tuple] = []
def compose(self) -> ComposeResult:
- with Vertical(id="pal-box"):
- yield Static(" symbols", id="pal-title", markup=False)
+ with Vertical(id="pal-box") as box:
+ box.border_title = Text("symbols")
yield Input(placeholder="fuzzy find symbol… ↑↓ select · Enter open · Esc close",
id="pal-input")
yield OptionList(id="pal-list")
@@ -1948,18 +3359,8 @@ class SymbolPalette(ModalScreen):
more = "+" if len(self._results) == cap else ""
hint = " (F2: this binary)" if self._project_scope else (
" (F2: whole project)" if self._index is not None else "")
- self.query_one("#pal-title", Static).update(
- f" symbols [{scope}]: {len(self._results)}{more}{hint}")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ self.query_one("#pal-box").border_title = Text(
+ f"symbols [{scope}]: {len(self._results)}{more}{hint}")
def action_choose(self) -> None:
ol = self.query_one(OptionList)
@@ -1986,14 +3387,178 @@ def _str_display(text: str, limit: int = 200) -> str:
return out[:limit] + ("\u2026" if len(out) > limit else "")
-class StringsPalette(ModalScreen):
+class SearchPalette(OptionListNav, ModalScreen):
+ """Ctrl+F: search the whole database, by text or by bytes.
+
+ Unlike every other palette here this does NOT filter as you type: each
+ search walks the image or the listing in the database process, so it runs
+ on Enter. That gives Enter two jobs, which is fine as long as it is never
+ ambiguous: while the query differs from what was last searched, Enter
+ searches; once the results on screen belong to the query in the box, Enter
+ opens the highlighted one. The title says which it will do.
+
+ Mode is guessed from the query (idatui/search.py) because asking first is a
+ tax on every search; F2 overrides the guess and a `hex:`/`text:` prefix
+ settles it outright.
+ """
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ *OptionListNav.NAV_BINDINGS,
+ Binding("f2", "mode", "Text / bytes", show=False),
+ ]
+ LIMIT = 500
+
+ def __init__(self, program, initial: str = "") -> None:
+ super().__init__()
+ self._program = program
+ self._initial = initial
+ self._forced: str | None = None # F2: pin the mode
+ self._hits: list = []
+ self._searched: tuple[str, str] | None = None # (mode, query) on screen
+ self._busy = False
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="pal-box") as box:
+ box.border_title = Text("search")
+ yield Input(placeholder="text, or bytes like 48 8b ?? c3 \u00b7 "
+ "Enter search \u00b7 F2 mode \u00b7 Esc close",
+ id="pal-input")
+ yield OptionList(id="pal-list")
+
+ def on_mount(self) -> None:
+ inp = self.query_one("#pal-input", Input)
+ inp.value = self._initial
+ inp.focus()
+ self._retitle()
+ if self._initial:
+ self._run()
+
+ # -- mode / title ------------------------------------------------------- #
+ def _query(self) -> str:
+ return self.query_one("#pal-input", Input).value.strip()
+
+ def _mode_query(self) -> tuple[str, str]:
+ return search.classify(self._query(), self._forced)
+
+ def _retitle(self, note: str = "") -> None:
+ mode, q = self._mode_query()
+ pinned = "" if self._forced is None else "*"
+ state = note
+ if not state:
+ if self._busy:
+ state = "searching\u2026"
+ elif self._searched == (mode, q) and q:
+ n = len(self._hits)
+ state = f"{n} hit{'' if n == 1 else 's'} \u2014 Enter opens"
+ elif q:
+ state = "Enter searches"
+ self.query_one("#pal-box").border_title = Text(
+ f"search [{mode}{pinned}]" + (f": {state}" if state else ""))
+
+ def action_mode(self) -> None:
+ mode, _ = self._mode_query()
+ self._forced = search.TEXT if mode == search.BYTES else search.BYTES
+ self._searched = None # the results on screen are for the old mode
+ self._retitle()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop() # modal inputs bubble to the app's own #search handler
+ self._retitle()
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ mode, q = self._mode_query()
+ if self._searched == (mode, q) and self._hits:
+ self.action_choose()
+ else:
+ self._run()
+
+ # -- searching ---------------------------------------------------------- #
+ def _run(self) -> None:
+ mode, q = self._mode_query()
+ if not q:
+ return
+ if mode == search.BYTES:
+ problem = search.pattern_problem(q)
+ if problem:
+ # Refuse here rather than round-tripping: IDA's own message for
+ # a bad pattern is empty about half the time.
+ self._hits, self._searched = [], None
+ self.query_one(OptionList).clear_options()
+ self._retitle(problem)
+ return
+ q = search.normalise_pattern(q)
+ self._busy = True
+ self._retitle()
+ self._search(mode, q)
+
+ @work(thread=True, exclusive=True, group="dbsearch")
+ def _search(self, mode: str, query: str) -> None:
+ try:
+ hits, err, truncated = self._program.search(query, mode,
+ limit=self.LIMIT)
+ except Exception as e: # noqa: BLE001 -- a search must not kill the app
+ hits, err, truncated = [], str(e), False
+ self.app.call_from_thread(self._present, mode, query, hits, err, truncated)
+
+ def _present(self, mode: str, query: str, hits: list, err: str | None,
+ truncated: bool) -> None:
+ self._busy = False
+ self._hits = hits
+ # Remember what these results ARE, not what the box says now: the user
+ # may have typed on while the search ran, and then Enter must search
+ # again rather than open a hit from the previous query.
+ self._searched = (mode, query) if err is None else None
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for h in hits:
+ label = Text()
+ label.append(f"{h.addr:08X} ", _S_ADDR)
+ label.append(f"{(h.func or h.seg or ''):<22.22} ", _S_DIM)
+ body = Text(h.line or "")
+ if mode == search.TEXT and query:
+ low, ql = (h.line or "").lower(), query.lower()
+ at = low.find(ql)
+ if at >= 0:
+ body.stylize(_S_NAME_MATCH, at, at + len(query))
+ label.append_text(body)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if hits:
+ ol.highlighted = 0
+ if err:
+ self._retitle(err)
+ elif not hits:
+ self._retitle("no match")
+ else:
+ n = len(hits)
+ self._retitle(f"{n}{'+' if truncated else ''} "
+ f"hit{'' if n == 1 else 's'} \u2014 Enter opens")
+
+ # -- moving / choosing --------------------------------------------------- #
+ def action_choose(self) -> None:
+ ol = self.query_one(OptionList)
+ i = ol.highlighted
+ if i is not None and 0 <= i < len(self._hits):
+ self.dismiss(self._hits[i])
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ if 0 <= event.option_index < len(self._hits):
+ self.dismiss(self._hits[event.option_index])
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+class StringsPalette(OptionListNav, ModalScreen):
"""Every string in the binary (IDA's Shift+F12), filterable; Enter jumps to
it in the unified listing."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("f2", "scope", "This binary / whole project", show=False),
]
LIMIT = 500
@@ -2014,8 +3579,8 @@ class StringsPalette(ModalScreen):
self._results: list[tuple] = []
def compose(self) -> ComposeResult:
- with Vertical(id="pal-box"):
- yield Static(" strings", id="pal-title", markup=False)
+ with Vertical(id="pal-box") as box:
+ box.border_title = Text("strings")
yield Input(placeholder="filter strings\u2026 \u2191\u2193 select \u00b7 "
"Enter jump \u00b7 Esc close", id="pal-input")
yield OptionList(id="pal-list")
@@ -2088,18 +3653,8 @@ class StringsPalette(ModalScreen):
more = "+" if len(rows) == cap else ""
hint = " (F2: this binary)" if self._project_scope else (
" (F2: whole project)" if self._index is not None else "")
- self.query_one("#pal-title", Static).update(
- f" strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ self.query_one("#pal-box").border_title = Text(
+ f"strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}")
def action_choose(self) -> None:
ol = self.query_one(OptionList)
@@ -2132,12 +3687,15 @@ _HELP = (
)),
("Views", (
("Tab / F5", "disassembly \u21c4 pseudocode"),
+ ("Space", "control-flow graph \u21c4 text"),
("s", "split view: listing + pseudocode"),
("Tab", "in split: switch the driving pane"),
("\\", "hex view"),
- ("o", "cycle the opcode-bytes column"),
+ ("B", "cycle the opcode-bytes column"),
("Ctrl+B", "show/hide the names pane"),
("Ctrl+T", "structs / types editor"),
+ ("Ctrl+F", "search the database: text or bytes"),
+ ("Ctrl+E", "export findings as markdown"),
("Ctrl+P", "command palette"),
)),
("Move", (
@@ -2159,6 +3717,7 @@ _HELP = (
("d", "make data"),
("a", "make string"),
("u", "undefine"),
+ ("o / O", "literal format: hex/dec/bin/char/offset"),
("Ctrl+S", "save the database"),
)),
("Search", (
@@ -2166,19 +3725,37 @@ _HELP = (
("?", "search backward"),
("N", "previous match"),
("Ctrl+Y", "copy the current line"),
- ("F1", "this cheatsheet"),
+ ("F1 / H", "this cheatsheet"),
("q", "quit"),
)),
+ ("Graph (Space)", (
+ ("j / k", "line up/down, crossing blocks"),
+ ("h / l", "column left / right"),
+ ("J / K", "follow an edge to a successor / predecessor"),
+ ("w / b", "next / previous block in layout order"),
+ ("0", "jump to the entry block"),
+ ("z", "zoom: full \u2192 compact \u2192 collapsed"),
+ ("m", "show/hide the minimap"),
+ ("e", "layout engine: auto \u2192 native \u2192 triskel"),
+ ("f", "centre on the current block"),
+ ("Enter", "follow (stays in the graph if it lands here)"),
+ ("drag / click", "pan / put the cursor in a block"),
+ ("click minimap", "jump the view there (drag to scrub)"),
+ )),
)
class QuitScreen(ModalScreen):
- """Asked before exiting with unsaved database changes. Dismisses with
- "save", "discard" or None (stay)."""
+ """Asked before exiting with unsaved database changes.
+
+ Code Mode clients cannot roll a shared database back. The ``d`` choice means
+ "do not explicitly save": a GUI keeps the changes dirty, while a managed
+ idalib worker may persist them when its final lease closes.
+ """
BINDINGS = [
Binding("s", "save", "Save & quit"),
- Binding("d", "discard", "Discard & quit"),
+ Binding("d", "discard", "Leave & quit"),
Binding("escape,c", "cancel", "Cancel"),
]
@@ -2189,13 +3766,13 @@ class QuitScreen(ModalScreen):
def compose(self) -> ComposeResult:
what = (f"{len(self._labels)} databases have unsaved changes"
if len(self._labels) > 1 else "unsaved changes")
- with Vertical(id="quit-box"):
- yield Static(f"\u26a0 {what}", id="quit-title")
+ with Vertical(id="quit-box") as box:
+ box.border_title = Text(f"\u26a0 {what}")
body = Text()
for label in self._labels:
body.append(f" \u2022 {label}\n", _S_LABEL)
yield Static(body, id="quit-list")
- yield Static("s save & quit d discard & quit Esc cancel",
+ yield Static("s save & quit d leave as-is & quit Esc cancel",
id="quit-help")
def action_save(self) -> None:
@@ -2211,7 +3788,7 @@ class QuitScreen(ModalScreen):
class HelpScreen(ModalScreen):
"""F1: the keyboard cheatsheet, replacing the permanent footer."""
- BINDINGS = [Binding("escape,f1,q,question_mark", "close", "Close")]
+ BINDINGS = [Binding("escape,f1,H,q,question_mark", "close", "Close")]
#: widest cell content, +2 for the card's border, +2 for its padding
_CARD_PAD = 4
@@ -2222,8 +3799,8 @@ class HelpScreen(ModalScreen):
avail = max(self.app.size.width - 6, 20)
cols = self._columns(avail)
per = -(-len(_HELP) // cols) # ceil, so the columns stay balanced
- with Vertical(id="help-box"):
- yield Static(" keys", id="help-title", markup=False)
+ with Vertical(id="help-box") as box:
+ box.border_title = Text("keys")
# Still inside a scroll container, so a genuinely tiny terminal
# degrades to scrolling rather than clipping — but spread across the
# width it shouldn't come to that.
@@ -2239,7 +3816,7 @@ class HelpScreen(ModalScreen):
classes="help-card", markup=False)
card.border_title = title
yield card
- yield Static("Esc / F1 to close", id="help-foot")
+ yield Static("Esc · F1 · H to close", id="help-foot")
@staticmethod
def _section_widths() -> list[int]:
@@ -2286,7 +3863,211 @@ class HelpScreen(ModalScreen):
self.dismiss(None)
-class LoadOptionsScreen(ModalScreen):
+class RegWriteScreen(OptionListNav, ModalScreen):
+ """Registers, and the instruction that set each one.
+
+ "Which instruction set this register to its current value?" is the question
+ a trace exists to answer, and seeking backwards to it is a single keypress
+ here rather than a manual walk. Forward is offered too, but backward is what
+ people actually want — you notice a bad value after it has been used.
+ """
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ *OptionListNav.NAV_BINDINGS,
+ Binding("enter", "choose", show=False, priority=True),
+ Binding("f", "choose_forward", show=False),
+ ]
+
+ def __init__(self, rows, idx: int) -> None:
+ super().__init__()
+ self._rows = rows # (name, value, last_write, next_write)
+ self._idx = idx
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="pal-box") as box:
+ box.border_title = Text(f"registers at t={self._idx:,}")
+ box.border_subtitle = Text("Enter seeks to the write \u00b7 f seeks forward")
+ yield OptionList(id="pal-list")
+
+ def on_mount(self) -> None:
+ ol = self.query_one(OptionList)
+ opts = []
+ for name, val, last, nxt in self._rows:
+ label = Text()
+ label.append(f" {name:>4} ", _S_MNEM)
+ label.append(f"{val:#018x} " if val > 0xFFFFFFFF else f"{val:#010x} ",
+ _S_INSN)
+ if last is None:
+ label.append("never written in this trace", _S_DIM)
+ elif last == self._idx:
+ label.append("set by THIS instruction", _S_DATA)
+ else:
+ label.append(f"set at t={last:,}", _S_LABEL)
+ label.append(f" ({self._idx - last:,} steps back)", _S_DIM)
+ if nxt is not None:
+ label.append(f" next t={nxt:,}", _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ ol.highlighted = 0
+ ol.focus()
+
+ def _pick(self, forward: bool) -> None:
+ i = self.query_one(OptionList).highlighted
+ if i is None or not (0 <= i < len(self._rows)):
+ self.dismiss(None)
+ return
+ _name, _val, last, nxt = self._rows[i]
+ self.dismiss(nxt if forward else last)
+
+ def action_choose(self) -> None:
+ self._pick(False)
+
+ def action_choose_forward(self) -> None:
+ self._pick(True)
+
+ def on_option_list_option_selected(self, event) -> None: # type: ignore[no-untyped-def]
+ self._pick(False)
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+class TraceDock(Vertical):
+ """Registers and a timeline for the loaded execution trace, docked right.
+
+ Persistent rather than a modal: a trace turns every other view into "state
+ at time T", so the time and the registers are context you read WHILE looking
+ at code, not something you open and dismiss.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(id="trace-dock")
+ self.trace = None
+ self.idx = 0
+
+ def compose(self) -> ComposeResult:
+ yield Static("", id="trace-head", markup=False)
+ yield Static("", id="trace-regs", markup=False)
+ yield Static("", id="trace-stack", markup=False)
+ yield TraceTimeline(id="trace-timeline")
+
+ def show(self, trace, idx: int) -> None:
+ self.trace = trace
+ self.idx = idx
+ tl = self.query_one(TraceTimeline)
+ tl.trace, tl.idx = trace, idx
+ self.refresh_state()
+
+ def refresh_state(self) -> None:
+ t = self.trace
+ if t is None:
+ return
+ n = max(t.length, 1)
+ pct = (self.idx + 1) * 100.0 / n
+ head = Text()
+ head.append(f" {self.idx:,}", _S_MNEM)
+ head.append(f" / {t.length - 1:,} ", _S_DIM)
+ head.append(f"{pct:5.1f}%\n", _S_ADDR)
+ # Register values are machine state and stay as the trace recorded them,
+ # but everything else on screen is in database addresses. Showing both
+ # here explains the relationship once, where it's read, instead of
+ # leaving "pc 0x2aed" next to "rip 0x7ffff6faaaed" to be puzzled over.
+ head.append(f" pc {t.ip(self.idx):#x}", _S_LABEL)
+ if t.slide:
+ head.append(f" (trace {t.raw_ip(self.idx):#x})", _S_DIM)
+ self.query_one("#trace-head", Static).update(head)
+
+ # Registers, with the ones THIS instruction wrote called out: that
+ # difference is the entire reason a delta trace is readable.
+ changed = t.changed(self.idx)
+ body = Text()
+ pc = t.pc_name
+ for name in t.registers:
+ v = t.register(name, self.idx)
+ if v is None:
+ continue
+ hot = name in changed
+ body.append(f" {name:>4} ", _S_MNEM if hot else _S_DIM)
+ body.append(f"{v:#018x}\n" if v > 0xFFFFFFFF else f"{v:#010x}\n",
+ _S_DATA if hot else (_S_LABEL if name == pc else _S_INSN))
+ self.query_one("#trace-regs", Static).update(body)
+ self._render_stack(t)
+ tl = self.query_one(TraceTimeline)
+ tl.idx = self.idx
+ tl.refresh()
+
+
+ STACK_WORDS = 8
+
+ def _render_stack(self, t) -> None: # type: ignore[no-untyped-def]
+ """The stack as of this instant, read out of the trace.
+
+ This is where a trace's memory actually is: on two real traces, NONE of
+ the accesses fell inside the image — every one was stack or heap. A
+ memory view that could only address the image would have nothing to show.
+
+ Bytes the trace never saw are printed as '??' rather than zeros. A trace
+ knows what it observed and nothing else, and quietly rendering unseen
+ memory as zero would invent facts.
+ """
+ sp_name = next((r for r in ("rsp", "esp", "sp") if r in t.reg_at), "")
+ sp = t.register(sp_name, self.idx) if sp_name else None
+ out = Text()
+ if sp is None:
+ self.query_one("#trace-stack", Static).update(out)
+ return
+ width = 8 if (t.info and "64" in (t.info.arch or "")) else 4
+ out.append(f" stack ({sp_name})\n", _S_DIM)
+ for k in range(self.STACK_WORDS):
+ a = sp + k * width
+ data, known = t.memory_raw(a, width, self.idx)
+ out.append(" \u25b8" if k == 0 else " ", _S_MNEM)
+ out.append(f"{a:012x} ", _S_ADDR)
+ if all(known):
+ v = int.from_bytes(data, "little")
+ out.append(f"{v:0{width * 2}x}\n", _S_DATA if k == 0 else _S_INSN)
+ elif any(known):
+ out.append("".join(f"{b:02x}" if known[i] else "??"
+ for i, b in enumerate(data)) + "\n", _S_INSN)
+ else:
+ out.append("?" * (width * 2) + "\n", _S_SEP)
+ self.query_one("#trace-stack", Static).update(out)
+
+
+class TraceTimeline(Static):
+ """The trace as a vertical bar: where you are, and where you've been.
+
+ Tenet's timeline is a Qt widget you scroll and drag to zoom. A terminal
+ column can't do that, but it can do the part that matters — show the shape
+ of the trace and your position in it — with one row per N timestamps.
+ """
+
+ def __init__(self, **kw) -> None:
+ super().__init__("", **kw)
+ self.trace = None
+ self.idx = 0
+
+ def render(self) -> Text:
+ t = self.trace
+ out = Text()
+ h = max(self.size.height - 1, 1)
+ if t is None or not t.length:
+ return out
+ out.append(" timeline\n", _S_DIM)
+ h = max(h - 1, 1)
+ per = max(t.length / h, 1.0)
+ here = int(self.idx / per)
+ for row in range(h):
+ if row == here:
+ out.append(" \u25b6", _S_MNEM)
+ out.append(f" {int(row * per):>10,}\n", _S_ADDR)
+ else:
+ out.append(" \u2502\n", _S_SEP if row % 5 else _S_ADDR)
+ return out
+
+
+class LoadOptionsScreen(OptionListNav, ModalScreen):
"""Ask how to load a file no loader recognised.
IDA's own answer to an unidentified file is a dialog; ours is this. Without
@@ -2301,8 +4082,7 @@ class LoadOptionsScreen(ModalScreen):
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("enter", "choose", show=False, priority=True),
]
@@ -2318,9 +4098,8 @@ class LoadOptionsScreen(ModalScreen):
def compose(self) -> ComposeResult:
from .formats import PROCESSORS
self._all = list(PROCESSORS)
- with Vertical(id="pal-box"):
- yield Static(" unrecognised file \u2014 how should IDA load it?",
- id="pal-title", markup=False)
+ with Vertical(id="pal-box") as box:
+ box.border_title = Text("unrecognised file \u2014 how should IDA load it?")
yield Static(f" {os.path.basename(self._path)} ({self._nbytes:,} bytes) "
f"\u2014 no loader matched; without a processor IDA "
f"assumes x86 at 0", id="load-note", markup=False)
@@ -2383,18 +4162,8 @@ class LoadOptionsScreen(ModalScreen):
ol.add_options(opts)
if rows:
ol.highlighted = 0
- self.query_one("#pal-title", Static).update(
- f" unrecognised file \u2014 processor? ({len(rows)})")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ self.query_one("#pal-box").border_title = Text(
+ f"unrecognised file \u2014 processor? ({len(rows)})")
def action_choose(self) -> None:
ol = self.query_one(OptionList)
@@ -2425,14 +4194,13 @@ class LoadOptionsScreen(ModalScreen):
self.dismiss({})
-class ProjectPalette(ModalScreen):
+class ProjectPalette(OptionListNav, ModalScreen):
"""The project's binaries; Enter switches to one. Shows which are resident
(a live worker, so switching is instant) vs cold (needs an open)."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
]
def __init__(self, entries: list[dict]) -> None:
@@ -2441,8 +4209,8 @@ class ProjectPalette(ModalScreen):
self._results: list[dict] = []
def compose(self) -> ComposeResult:
- with Vertical(id="pal-box"):
- yield Static(" binaries", id="pal-title", markup=False)
+ with Vertical(id="pal-box") as box:
+ box.border_title = Text("binaries")
yield Input(placeholder="filter binaries\u2026 \u2191\u2193 select \u00b7 "
"Enter switch \u00b7 Esc close", id="pal-input")
yield OptionList(id="pal-list")
@@ -2489,18 +4257,8 @@ class ProjectPalette(ModalScreen):
# you are rather than at whatever sorts first.
active = next((i for i, e in enumerate(rows) if e["active"]), 0)
ol.highlighted = active
- self.query_one("#pal-title", Static).update(
- f" binaries: {len(rows)} of {len(self._entries)}")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ self.query_one("#pal-box").border_title = Text(
+ f"binaries: {len(rows)} of {len(self._entries)}")
def action_choose(self) -> None:
i = self.query_one(OptionList).highlighted
@@ -2532,7 +4290,8 @@ class ConfirmScreen(ModalScreen):
self._note = note
def compose(self) -> ComposeResult:
- with Vertical(id="confirm-box"):
+ with Vertical(id="confirm-box") as box:
+ box.border_title = Text("\u26a0 confirm")
yield Static(self._message, id="confirm-msg", markup=False)
if self._note:
yield Static(self._note, id="confirm-note", markup=False)
@@ -2545,8 +4304,43 @@ class ConfirmScreen(ModalScreen):
self.dismiss(False)
-_LOGO_PATH = os.path.join(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logo.ans")
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_LOGO_PATH = os.path.join(_REPO_ROOT, "logo.ans")
+#: The same artwork as a real image, for terminals that can draw one. logo.ans
+#: is half-blocks (two pixels per cell); this is a transparent PNG at 768px.
+LOGO_PNG = os.path.join(_REPO_ROOT, "logo.png")
+_LOGO_BOX = (60, 33) # the most room the splash will give the art
+#: Rows the loading box spends on everything that is not the artwork: border 2,
+#: padding 2, the art's margin 1, title 1, note 1 + margin 1, help 1 + margin 1.
+LOGO_CHROME_ROWS = 10
+#: Below this the image is a postage stamp; show the text splash instead.
+LOGO_MIN_ROWS = 8
+_logo_cells: tuple[int, int] | None = None
+
+
+def logo_cells(max_rows: int | None = None) -> tuple[int, int]:
+ """Cell footprint for the image, derived from the artwork and the terminal's
+ real cell size rather than hardcoded.
+
+ Cells are nowhere near square (9x22 px here, 1:2.44), so a fixed box picked
+ for one aspect ratio stretches any other. Recomputing means the art can be
+ replaced without anyone remembering to edit a constant.
+
+ ``max_rows`` shrinks it to the room actually available. The terminal scales
+ the image into whatever cell box we place it in, so there is no reason for
+ the splash to be all-or-nothing -- and it WAS all-or-nothing: a 31-row pane
+ is one row short of the natural size, so the logo silently disappeared
+ rather than being drawn a little smaller.
+ """
+ global _logo_cells
+ if _logo_cells is None:
+ px = kittygfx.png_size(LOGO_PNG)
+ _logo_cells = kittygfx.fit(px, *_LOGO_BOX) if px else _LOGO_BOX
+ if max_rows is None or max_rows >= _logo_cells[1]:
+ return _logo_cells
+ px = kittygfx.png_size(LOGO_PNG)
+ return (kittygfx.fit(px, _LOGO_BOX[0], max(max_rows, 1)) if px
+ else (_LOGO_BOX[0], max(max_rows, 1)))
_logo_cache: object = False # False == not yet loaded (None == absent/unreadable)
@@ -2577,17 +4371,45 @@ class LoadingScreen(ModalScreen):
super().__init__()
self._title = title
self._note = note
+ self._image = False # drawing the real image, not the block art
+ self._cells: tuple[int, int] | None = None # image size, in cells
+ self._last_place = 0.0 # throttles re-anchoring after a repaint
+
+ def _room(self) -> int:
+ """Rows left for artwork once the box's own furniture is paid for."""
+ return self.app.size.height - LOGO_CHROME_ROWS
+
+ def _fits(self, rows: int) -> bool:
+ """Room for art of exactly ``rows`` (the block art cannot be resized)."""
+ return self._room() >= rows and self.app.size.width >= 64
def compose(self) -> ComposeResult:
with Vertical(id="loading-box"):
- logo = _load_logo()
- # Only show the splash art when the terminal can fit it plus the
- # title/note/help + box chrome; otherwise fall back to a text-only
- # overlay so nothing important is clipped off a small screen.
- if logo is not None:
- sz = self.app.size
- n = len(logo.split("\n"))
- if sz.height >= n + 9 and sz.width >= 64:
+ # A terminal that can draw a real image gets one: same artwork,
+ # same cell footprint, ~10x the linear resolution of the block art.
+ # The image is anchored to screen cells rather than composited by
+ # Textual (no unicode-placeholder support here), so the widget is
+ # only reserved blank space -- see _place_logo.
+ # Scale the image to the room there is, rather than demanding its
+ # natural size and vanishing when one row is missing.
+ room = self._room()
+ cols, rows = logo_cells(room)
+ self._cells = (cols, rows)
+ kittygfx.log(f"compose: supported={kittygfx.supported()} "
+ f"app.size={self.app.size} room={room} "
+ f"cells={cols}x{rows} natural={logo_cells()}")
+ if (kittygfx.supported() and self.app.size.width >= 64
+ and room >= LOGO_MIN_ROWS):
+ self._image = True
+ blank = Static("\n" * (rows - 1), id="loading-image")
+ blank.styles.height = rows
+ yield blank
+ else:
+ logo = _load_logo()
+ # Only show the splash art when the terminal can fit it plus
+ # the title/note/help + box chrome; otherwise fall back to a
+ # text-only overlay so nothing important is clipped.
+ if logo is not None and self._fits(len(logo.split("\n"))):
# Align.center, not the box's align-horizontal: the 1fr
# title/note siblings make the child group span the full
# width, so container alignment has nothing left to centre.
@@ -2602,6 +4424,61 @@ class LoadingScreen(ModalScreen):
self.query_one("#loading-note", Static).update(text)
except Exception: # noqa: BLE001 -- not mounted yet / already gone
pass
+ # Textual doesn't know the image is there, so a repaint can drop it.
+ # Re-anchoring is one short escape with no image data; throttled so a
+ # chatty progress callback can't turn it into a flicker.
+ if self._image:
+ now = time.monotonic()
+ if now - self._last_place > 0.2:
+ self._last_place = now
+ self._place_logo()
+
+ # -- the image, which Textual knows nothing about ---------------------- #
+ def _place_logo(self) -> None:
+ """Anchor the image over the blank cells reserved for it.
+
+ Deferred to after a refresh because a widget has no screen region until
+ it has been laid out, and re-run on resize because the region moves.
+ """
+ if not self._image:
+ return
+ try:
+ region = self.query_one("#loading-image", Static).region
+ except Exception as e: # noqa: BLE001 -- gone already
+ kittygfx.log(f"place_logo: no widget ({e})")
+ return
+ kittygfx.log(f"place_logo: region={region}")
+ if not region.width or not region.height:
+ return
+ # The reserved region is the truth about how much room there is; the
+ # image is scaled into exactly it, so a resize needs no relayout.
+ cols, rows = self._cells or logo_cells()
+ rows = min(rows, region.height)
+ col = region.x + max((region.width - cols) // 2, 0) # centre it
+ kittygfx.place(region.y, col, min(cols, region.width), rows)
+
+ def on_mount(self) -> None:
+ if not self._image:
+ return
+ # Upload HERE, not from the launcher: Textual is on the alternate screen
+ # by now, and an image uploaded to the primary screen cannot be placed
+ # from the alternate one -- the placement reports success and draws
+ # nothing at all.
+ if not kittygfx.upload(LOGO_PNG):
+ self._image = False
+ return
+ self.call_after_refresh(self._place_logo)
+
+ def on_resize(self) -> None:
+ if self._image:
+ kittygfx.clear()
+ self.call_after_refresh(self._place_logo)
+
+ def on_unmount(self) -> None:
+ # The image is anchored to the screen, not owned by the compositor, so
+ # it would sit there over the disassembly forever if we didn't say so.
+ if self._image:
+ kittygfx.clear()
def action_hide(self) -> None:
self.dismiss()
@@ -2637,14 +4514,27 @@ class StructEditor(ModalScreen):
selected struct; Ctrl+S declares (creates or updates) it; Ctrl+N starts a
new one; Delete removes the highlighted struct; Esc returns to the list then
closes.
+
+ ``/`` from the list opens a fuzzy filter over the struct names -- the same
+ key and the same matcher as the code views' search and the symbol palette,
+ rather than a third way to find something by typing.
"""
BINDINGS = [
Binding("ctrl+s", "save", "Save", priority=True),
Binding("ctrl+n", "new", "New", priority=True),
Binding("ctrl+y", "copy", "Copy", priority=True),
+ Binding("slash", "filter", "Filter", show=False),
Binding("delete,d", "delete", "Delete", show=False),
Binding("escape", "close", "Close"),
+ # Only ever reached while the FILTER has focus: a focused OptionList
+ # consumes these itself, so they move its highlight from the prompt
+ # (type to narrow, arrow to pick, exactly like the symbol palette).
+ # NOT OptionListNav.NAV_BINDINGS: ctrl+n is "new type" here.
+ Binding("up", "cursor_up", "Up", show=False),
+ Binding("down", "cursor_down", "Down", show=False),
+ Binding("pageup", "page_up", show=False),
+ Binding("pagedown", "page_down", show=False),
]
NEW_TEMPLATE = "struct NewStruct\n{\n int field;\n};\n"
@@ -2654,7 +4544,9 @@ class StructEditor(ModalScreen):
def __init__(self, program: Program) -> None:
super().__init__()
self._program = program
- self._structs: list[Struct] = []
+ self._all: list[Struct] = [] # every struct the database has
+ self._structs: list[Struct] = [] # the VISIBLE rows (== _all when unfiltered)
+ self._filter = ""
self._loaded: str | None = None # name currently in the editor
self._loaded_src: str | None = None # its source, to detect unsaved edits
@@ -2677,16 +4569,23 @@ class StructEditor(ModalScreen):
then()
def compose(self) -> ComposeResult:
- with Vertical(id="se-box"):
+ with Vertical(id="se-box") as box:
+ box.border_title = Text("structs / types")
+ box.border_subtitle = Text("Esc close")
with Horizontal(id="se-panes"):
with Vertical(id="se-left"):
- yield Static(" structs", id="se-title")
+ yield Static("structs", id="se-title")
+ yield Input(placeholder="fuzzy filter\u2026 \u2191\u2193 pick \u00b7 "
+ "Enter edit \u00b7 Esc clear",
+ id="se-filter")
yield OptionList(id="se-list")
with Vertical(id="se-right"):
- yield Static(" C definition", id="se-hint")
- yield TextArea("", id="se-edit")
+ yield Static("C definition", id="se-hint")
+ yield CTextArea("", id="se-edit")
+ # Esc is on the border subtitle; repeating it here cost the row.
yield Static(
- "Enter edit · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · d/Del delete · Esc close",
+ "Enter edit · / filter · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · "
+ "d/Del delete",
id="se-status")
def on_mount(self) -> None:
@@ -2704,26 +4603,153 @@ class StructEditor(ModalScreen):
self.app.call_from_thread(self._populate, structs, select)
def _populate(self, structs: list[Struct], select: str | None) -> None:
- self._structs = structs
+ self._all = structs
+ self._show(select)
+
+ def _show(self, select: str | None = None) -> None:
+ """Rebuild the list from ``_all`` through the current filter.
+
+ ``_structs`` stays the VISIBLE rows, because every action here indexes
+ it by the list's highlighted row -- load, delete and the confirm dialogs
+ would all address the wrong struct if the two ever drifted apart.
+ """
+ q = self._filter.strip()
+ # A struct we were told to select but the filter would hide (a rename, or
+ # one just created under a stale filter) beats the filter -- otherwise a
+ # successful save looks like the struct vanished.
+ if q and select is not None and _fuzzy(select, q) is None:
+ inp = self.query_one("#se-filter", Input)
+ inp.value = ""
+ inp.display = False
+ self._filter = q = ""
+ rows: list[tuple[Struct, tuple[int, ...]]] = []
+ if q:
+ scored = []
+ for s in self._all:
+ m = _fuzzy(s.name, q)
+ if m is not None:
+ scored.append((m[0], m[1], s))
+ scored.sort(key=lambda t: (-t[0], t[2].name))
+ rows = [(s, pos) for _, pos, s in scored]
+ else:
+ rows = [(s, ()) for s in self._all]
+ self._structs = [s for s, _ in rows]
+
ol = self.query_one("#se-list", OptionList)
ol.clear_options()
- for s in structs:
+ # Ragged rows read as noise, so the name column is padded to the widest
+ # name actually present (capped, so one monstrous C++ mangling can't
+ # push the size/count columns off the pane).
+ width = min(max((len(s.name) for s, _ in rows), default=0), 22)
+ opts = []
+ for s, pos in rows:
kw = "union" if s.is_union else "struct"
+ name = s.name if len(s.name) <= width else s.name[:width - 1] + "\u2026"
label = Text()
- label.append(s.name, _S_LABEL)
- label.append(f" {s.size:#x} {s.members}f {kw}", _S_DIM)
- ol.add_option(Option(label))
- if structs:
+ nm = Text(f"{name:<{width}}", style=_S_LABEL)
+ for p in pos:
+ if p < len(name):
+ nm.stylize(_S_NAME_MATCH, p, p + 1)
+ label.append_text(nm)
+ label.append(f"{s.size:>#7x}{s.members:>4}f {kw}", _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if rows:
idx = 0
if select is not None:
- idx = next((i for i, s in enumerate(structs) if s.name == select), 0)
+ idx = next((i for i, s in enumerate(self._structs)
+ if s.name == select), 0)
ol.highlighted = idx
+ cap = "structs"
+ if q:
+ cap = f"structs {len(rows)}/{len(self._all)}"
+ self.query_one("#se-title", Static).update(cap)
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
i = event.option_index
if 0 <= i < len(self._structs):
self._confirm_discard(lambda n=self._structs[i].name: self._load(n))
+ # -- filter ("/" from the list) ----------------------------------------- #
+ def action_filter(self) -> None:
+ """'/': fuzzy-filter the struct list, as you type."""
+ # A '/' typed into the definition is a division operator, and one typed
+ # into the filter is the Input's business; only the list opens this.
+ if self.focused is not self.query_one("#se-list", OptionList):
+ return
+ inp = self.query_one("#se-filter", Input)
+ inp.display = True
+ inp.value = self._filter
+ inp.focus()
+
+ def _clear_filter(self, focus_list: bool = True) -> None:
+ inp = self.query_one("#se-filter", Input)
+ inp.value = ""
+ inp.display = False
+ self._filter = ""
+ self._show(select=self._loaded)
+ if focus_list:
+ self.query_one("#se-list", OptionList).focus()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ # Modal Input messages bubble to the App (which owns #func-filter and
+ # the search prompt) -- stop them here or they drive the main screen.
+ if event.input.id != "se-filter":
+ return
+ event.stop()
+ self._filter = event.value
+ self._show()
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ if event.input.id != "se-filter":
+ return
+ event.stop()
+ # Enter on a filtered list loads the highlighted struct, like Enter on
+ # the list itself -- typing a name and pressing Enter should open it.
+ ol = self.query_one("#se-list", OptionList)
+ i = ol.highlighted
+ if i is not None and 0 <= i < len(self._structs):
+ self._confirm_discard(lambda n=self._structs[i].name: self._load(n))
+ else:
+ ol.focus()
+
+ def _filter_focused(self) -> bool:
+ return self.focused is self.query_one("#se-filter", Input)
+
+ def action_cursor_up(self) -> None:
+ self._move_highlight(-1)
+
+ def action_cursor_down(self) -> None:
+ self._move_highlight(1)
+
+ def action_page_up(self) -> None:
+ self._page(-1)
+
+ def action_page_down(self) -> None:
+ self._page(1)
+
+ def _list_from_filter(self):
+ """The type list, but only while the FILTER holds focus.
+
+ When the list itself has focus it handles these keys natively, and
+ forwarding as well would move the highlight twice.
+ """
+ if not self._filter_focused():
+ return None
+ ol = self.query_one("#se-list", OptionList)
+ return ol if ol.option_count else None
+
+ def _move_highlight(self, delta: int) -> None:
+ ol = self._list_from_filter()
+ if ol is not None:
+ ol.highlighted = max(0, min((ol.highlighted or 0) + delta,
+ ol.option_count - 1))
+
+ def _page(self, direction: int) -> None:
+ ol = self._list_from_filter()
+ if ol is not None:
+ (ol.action_page_down if direction > 0 else ol.action_page_up)()
+
@work(thread=True, exclusive=True, group="se-load")
def _load(self, name: str) -> None:
try:
@@ -2782,6 +4808,8 @@ class StructEditor(ModalScreen):
self._set_status(f"save failed — {msg}", error=True)
return
self._loaded = name
+ if name:
+ self.app.journal.record("type", None, name)
if formatted:
# Reformat the editor to IDA's canonical layout (keeps cursor at top).
ta = self.query_one("#se-edit", TextArea)
@@ -2833,6 +4861,7 @@ class StructEditor(ModalScreen):
return
if getattr(self.app, "_dirty", None) is not None:
self.app._dirty = True
+ self.app.journal.record("del_type", None, name)
self._refresh()
self._set_status(f"deleted {name}")
@@ -2848,10 +4877,15 @@ class StructEditor(ModalScreen):
self._set_status(f"copied {what} ({n} chars) to clipboard")
def action_close(self) -> None:
- # A stray Esc while editing returns to the list instead of discarding.
+ # Esc backs out one level at a time: out of the definition, then out of
+ # the filter, and only then out of the dialog. Closing the editor from
+ # under a half-typed filter is the kind of thing you only do once.
if self.focused is self.query_one("#se-edit", TextArea):
self.query_one("#se-list", OptionList).focus()
return
+ if self._filter_focused() or self._filter:
+ self._clear_filter()
+ return
self._confirm_discard(lambda: self.dismiss(None),
"Discard unsaved changes and close?")
@@ -2902,7 +4936,12 @@ class IdaCommands(Provider):
app.action_strings),
("Switch binary…", "another binary in the project (Ctrl+O)",
app.action_switch_binary),
- ("Keyboard shortcuts", "the key cheatsheet (F1)", app.action_help),
+ ("Search database…", "disassembly text or a byte pattern with "
+ "wildcards (Ctrl+F)", app.action_find),
+ ("Export findings…", "your comments, names and types as markdown "
+ "(Ctrl+E)", app.action_export),
+ ("Keyboard shortcuts", "the key cheatsheet (F1 or H)",
+ app.action_help),
("Follow symbol under cursor", "jump to the referenced symbol (Enter)",
lambda: va("follow")),
("Show xrefs to symbol", "cross-references to the cursor symbol (x)",
@@ -2915,6 +4954,14 @@ class IdaCommands(Provider):
("Hex view", "raw bytes at the cursor (\\)", app.action_hex),
("Split view (listing ⇄ pseudocode)",
"side-by-side synced views (s)", app.action_toggle_split),
+ ("Graph view (control flow)",
+ "the function's basic blocks as a graph (Space)",
+ app.action_toggle_graph),
+ ("Graph: cycle zoom",
+ "full → compact → collapsed (z, in the graph)",
+ lambda: va("zoom")),
+ ("Graph: toggle minimap",
+ "the overview box (m, in the graph)", lambda: va("minimap")),
("Rename symbol…", "rename the symbol under the cursor (n)",
lambda: va("rename")),
("Set type / prototype…", "retype the symbol under the cursor (y)",
@@ -2929,7 +4976,19 @@ class IdaCommands(Provider):
lambda: va("make_string")),
("Undefine", "undefine the item at the cursor (u)",
lambda: va("undefine")),
- ("Toggle opcode bytes", "cycle the opcode-bytes column (o)",
+ ("Literal format: next", "cycle the literal under the cursor (o)",
+ lambda: va("op_format", "cycle")),
+ ("Literal format: previous", "the other way round (O)",
+ lambda: va("op_format", "back")),
+ *((f"Literal format: {label}", f"show the literal as {label} ({fmt})",
+ (lambda f=fmt: va("op_format", f)))
+ for fmt, label in (("hex", "hexadecimal"), ("dec", "decimal"),
+ ("oct", "octal"), ("bin", "binary"),
+ ("char", "a character"),
+ ("offset", "an offset (reference)"),
+ ("stack", "a stack variable"),
+ ("default", "IDA's own choice"))),
+ ("Toggle opcode bytes", "cycle the opcode-bytes column (B)",
lambda: va("toggle_opcodes")),
("Structs / types editor", "view + edit local types (Ctrl+T)",
app.action_structs),
@@ -2966,6 +5025,7 @@ class IdaTui(App):
ListingView { width: 1fr; padding: 0 1; }
#panes.split ListingView { border-right: tall $panel-lighten-2; }
HexView { width: 1fr; padding: 0 1; }
+ GraphView { width: 1fr; padding: 0 1; }
#search {
height: 1; border: none; padding: 0 1;
background: $primary-darken-2; color: $text;
@@ -2990,42 +5050,77 @@ class IdaTui(App):
height: 1; border: none; padding: 0 1;
background: $primary-darken-3; color: $text;
}
+ #export {
+ height: 1; border: none; padding: 0 1;
+ background: $success-darken-3; color: $text;
+ }
#status { height: 1; background: $panel; color: $text; padding: 0 1; }
.decomp-loading {
width: 100%; height: 100%; content-align: center middle;
background: $panel-darken-1;
}
- QuitScreen { align: center middle; }
- #quit-box { width: 64; height: auto; border: thick $warning; background: $panel; }
- #quit-title { dock: top; height: 1; background: $warning; color: $background; text-style: bold; padding: 0 1; }
- #quit-list { height: auto; padding: 1 2 0 2; }
- #quit-help { height: 1; color: $text-muted; padding: 0 2; margin-top: 1; }
- HelpScreen { align: center middle; }
- #help-box { width: auto; max-width: 98%; height: auto; max-height: 90%;
- border: thick $accent; background: $panel; }
- #help-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
+ /* ---- modal chrome ---------------------------------------------------
+ One shape for every dialog: a THIN round border in a colour that
+ recedes, and the title set into the top border rather than painted as a
+ solid bar across the full width. The old `thick` frame plus an inverse
+ title bar was two heavy rectangles around content that is itself the
+ only thing worth looking at. Size stays per-box; everything else is
+ here, once. */
+ #quit-box, #help-box, #xref-box, #pal-box, #se-box, #confirm-box,
+ #loading-box, #busy-box {
+ background: $panel;
+ border: round $panel-lighten-3;
+ border-title-color: $accent;
+ border-title-style: bold;
+ border-title-align: left;
+ border-subtitle-color: $text-muted;
+ border-subtitle-align: right;
+ }
+ /* The two that ask a question you can answer wrongly keep the warning hue. */
+ #quit-box, #confirm-box { border: round $warning;
+ border-title-color: $warning; }
+ /* Lists inside a dialog are content, not another framed panel: drop the
+ stock OptionList border/background so the dialog has ONE edge. Focus
+ still reads from the highlight bar (bright when focused, dim when not). */
+ #quit-box OptionList, #help-box OptionList, #xref-box OptionList,
+ #pal-box OptionList, #se-box OptionList {
+ border: none; background: transparent; padding: 0 1;
+ }
+ #quit-box { width: 64; height: auto; padding: 1 1 0 1; }
+ #quit-list { height: auto; padding: 0 1; }
+ #quit-help { height: 1; color: $text-muted; padding: 0 1; margin-top: 1; }
+ #help-box { width: auto; max-width: 98%; height: auto; max-height: 90%; }
#help-body { height: auto; max-height: 100%; width: auto; padding: 1 1; }
#help-cols { height: auto; width: auto; }
.help-col { height: auto; width: auto; margin-right: 1; }
.help-card { height: auto; width: auto; padding: 0 1;
border: round $panel-lighten-2; }
#help-foot { dock: bottom; height: 1; color: $text-muted; padding: 0 2; }
- XrefsScreen { align: center middle; }
- #xref-box { width: 84; max-height: 70%; height: auto; border: thick $accent; background: $panel; }
- #xref-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
+ #xref-box { width: 84; max-height: 70%; height: auto; padding: 0 0; }
#xref-list { height: auto; max-height: 100%; }
- /* every #pal-box palette centres, not just the symbol one */
- SymbolPalette, StringsPalette, ProjectPalette,
- LoadOptionsScreen { align: center middle; }
+ /* A dialog centres because it is a dialog — not because someone remembered
+ to add it to a list. That list is how SearchPalette shipped pinned to the
+ top of the screen, and the comment that used to sit here ("every #pal-box
+ palette centres, not just the symbol one") was itself the second time.
+ ModalScreen matches subclasses, so every modal below inherits this and
+ the next one gets it for free; `modal_centering` in the pilot suite
+ fails if one ever opts out by accident. Textual's own Ctrl+P palette is a
+ ModalScreen too and wants its stock top alignment, so it opts out here,
+ deliberately and visibly. */
+ ModalScreen { align: center middle; }
+ CommandPalette { align: center top; }
/* Give the stock Ctrl+P command palette side padding instead of full width;
the input + results inherit this width (results is an overlay, so pin it). */
CommandPalette > Vertical { width: 80%; max-width: 120; }
CommandPalette #--results { width: 100%; }
- #pal-box { width: 96; max-width: 92%; height: auto; max-height: 80%;
- border: thick $accent; background: $panel; }
- #pal-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
+ #pal-box { width: 96; max-width: 92%; height: auto; max-height: 80%; }
#pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; }
#pal-list { height: auto; max-height: 24; }
+ #trace-dock { dock: right; width: 34; background: $surface; border-left: solid $panel; }
+ #trace-head { height: 2; padding: 0 1; background: $panel; }
+ #trace-regs { height: auto; padding: 1 0 0 0; }
+ #trace-stack { height: auto; padding: 1 0 0 0; }
+ #trace-timeline { height: 1fr; padding: 1 0 0 0; }
#load-note { height: 2; padding: 1 1 0 1; color: $text-muted; }
/* Cap the processor list so the ADDRESS FIELD is always on screen: with the
palette default (24) the box outgrew the terminal and the field you need
@@ -3033,31 +5128,33 @@ class IdaTui(App):
LoadOptionsScreen #pal-list { max-height: 12; }
#load-base { border: none; height: 1; margin: 1 1 0 1; background: $panel; color: $text; }
#load-help { height: 1; padding: 0 1; color: $text-muted; }
- #confirm-note { height: auto; padding: 0 1; color: $text-muted; }
- StructEditor { align: center middle; }
- #se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; }
+ #confirm-note { height: auto; color: $text-muted; }
+ #se-box { width: 90%; height: 84%; }
#se-panes { height: 1fr; }
- #se-left { width: 38; border-right: solid $accent; }
+ #se-left { width: 38; border-right: solid $panel-lighten-3; }
#se-right { width: 1fr; }
- #se-title, #se-hint { height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
+ /* Column captions, not title bars: the dialog already has one title. */
+ #se-title, #se-hint { height: 1; color: $text-muted; text-style: bold;
+ padding: 0 1; }
+ /* Hidden until '/'; same one-row prompt shape as the app's own prompts. */
+ #se-filter { display: none; height: 1; border: none; padding: 0 1;
+ background: $primary-darken-2; color: $text; }
#se-list { height: 1fr; }
- #se-edit { height: 1fr; border: none; }
- #se-status { height: 1; background: $panel-darken-2; color: $text-muted; padding: 0 1; }
- ConfirmScreen { align: center middle; }
- #confirm-box { width: 60; height: auto; border: thick $warning;
- background: $panel; padding: 1 2; }
+ #se-edit { height: 1fr; border: none; padding: 0 1; }
+ /* A footer, but a quiet one: a shade of the dialog's own background
+ instead of the old near-black bar. */
+ #se-status { height: 1; background: $panel-darken-1; color: $text-muted;
+ padding: 0 1; }
+ #confirm-box { width: 60; height: auto; padding: 1 2; }
#confirm-msg { height: auto; }
#confirm-help { height: 1; color: $text-muted; margin-top: 1; }
- LoadingScreen { align: center middle; }
- #loading-box { width: 72; height: auto; border: thick $accent;
- background: $panel; padding: 1 2; }
+ #loading-box { width: 72; height: auto; padding: 1 2; }
#loading-logo { width: 100%; height: auto; margin-bottom: 1; }
+ #loading-image { width: 100%; margin-bottom: 1; }
#loading-title { width: 1fr; height: 1; text-style: bold; }
#loading-note { height: auto; color: $text-muted; margin-top: 1; }
#loading-help { height: auto; color: $text-muted; margin-top: 1; }
- BusyScreen { align: center middle; }
- #busy-box { width: auto; min-width: 26; height: auto; border: thick $accent;
- background: $panel; padding: 1 2; }
+ #busy-box { width: auto; min-width: 26; height: auto; padding: 1 2; }
#busy-msg { height: 1; text-style: bold; }
#busy-help { height: 1; color: $text-muted; margin-top: 1; }
"""
@@ -3066,12 +5163,32 @@ class IdaTui(App):
Binding("q", "quit", "Quit"),
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
+ Binding("ctrl+f", "find", "Find"),
+ Binding("ctrl+e", "export", "Export", show=False),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
+ # IDA's own key for text/graph. Graph mode is opt-in and self-contained:
+ # with it off nothing else in the app does any extra work.
+ Binding("space", "toggle_graph", "Graph", show=False),
Binding("quotation_mark,shift+f12", "strings", "Strings", show=False),
Binding("ctrl+o", "switch_binary", "Binaries", show=False),
Binding("ctrl+l", "load_options", "Reload as…", show=False),
- Binding("f1", "help", "Keys", show=False),
+ # Trace stepping. ] / [ move one instruction, } / { step over a
+ # call by following the stack pointer.
+ # Seeking, as opposed to stepping: jump to the next/previous time THIS
+ # thing was touched, where "this thing" is whatever the focused view
+ # addresses — an instruction in the code views, a byte in hex.
+ Binding("greater_than_sign", "seek_next_hit", "Next hit", show=False),
+ Binding("less_than_sign", "seek_prev_hit", "Prev hit", show=False),
+ Binding("W", "seek_reg_write", "Reg writes", show=False),
+ Binding("right_square_bracket", "step_fwd", "Step", show=False),
+ Binding("left_square_bracket", "step_back", "Step back", show=False),
+ Binding("right_curly_bracket", "step_over_fwd", "Step over", show=False),
+ Binding("left_curly_bracket", "step_over_back", "Step over back", show=False),
+ # H as well as F1: terminals and multiplexers swallow function keys all
+ # the time (and the one that does it is upstream of us, so there is
+ # nothing to fix on this side), which left the cheatsheet unreachable.
+ Binding("f1,H", "help", "Keys", show=False),
Binding("g", "goto", "Goto"),
Binding("slash", "filter", "Filter", show=False),
Binding("ctrl+b", "toggle_functions", "Names", show=False),
@@ -3082,7 +5199,7 @@ class IdaTui(App):
def __init__(self, open_path: str | None = None, keepalive: bool = True,
rpc_path: str | None = None, ttl: int = 1800,
- project=None, load_args: str = "") -> None:
+ project=None, load_args: str = "", trace_path: str = "") -> None:
super().__init__()
# Project mode is additive: with no project this is the plain
# single-binary app, unchanged.
@@ -3099,25 +5216,34 @@ class IdaTui(App):
self._flash_until = 0.0 # ...until this monotonic time
self._pending_switch = None # switch waiting on that answer
self._nav_seq = 0 # bumped per navigation; drops stale ones
+ #: Literal positions for the decompilation being loaded (worker thread
+ #: -> the view, handed over when the pseudocode is applied).
+ self._pending_nums: dict = {}
# None = teardown wasn't an explicit quit (crash/kill): save defensively.
# False = the user chose discard, or we already saved on the way out.
self._save_on_exit: bool | None = None
self._index = None # project-wide symbol/string index
if project is not None:
from .index import ProjectIndex
- from .pool import WorkerPool
- self._pool = WorkerPool(project, ttl=ttl)
+ from .pool import DatabasePool
+ self._pool = DatabasePool(project, ttl=ttl)
self._index = ProjectIndex(
os.path.join(project.index_dir, "project.db"))
self._binary = project.refs[0].label
open_path = project.refs[0].staged
self._open_path = open_path
self._ttl = ttl
- self._load_args = load_args or "" # IDA switches for a headerless blob
+ self._load_args = load_args or "" # first-open options for a headerless blob
+ self._new_database = False # Ctrl+L asks Code Mode for a fresh IDB
+ self._title = (os.path.basename(open_path) if open_path else "")
+ #: Where we are in the execution trace, and everything that moves us.
+ #: Owns the trace state; the _trace/_t/_trail_* properties below
+ #: forward to it.
+ self.trace_ctl = TraceController(self, trace_path or "")
self._do_keepalive = keepalive
self._rpc_path = rpc_path
self._rpc = None
- self.client: WorkerClient | None = None
+ self.client: CodeModeClient | None = None
self.program: Program | None = None
self._loading_screen: LoadingScreen | None = None
self._ka = None
@@ -3132,8 +5258,9 @@ 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
self._split_ea2line: dict[int, int] = {} # split: instr EA -> decomp line
self._split_range: tuple[int, int] | None = None # decomp'd fn ea span
@@ -3146,11 +5273,15 @@ class IdaTui(App):
self._conn_screen: LoadingScreen | None = None
self._reconnecting = False # a reconnect attempt is in flight
self._search_ctx: tuple[object | None, int] = (None, 1)
- self._rename_ctx: tuple[object | None, str] = (None, "")
- self._rename_addr: int | None = None # set for address-based (listing) naming
- self._comment_ctx: tuple[object | None, int, str] = (None, 0, "")
- self._retype_ctx: tuple[object | None, str, int, str] = (None, "", 0, "")
- self._makedata_ctx: tuple[object | None, int] = (None, 0)
+ #: The one-line prompts above the footer. Each holds its own context
+ #: for exactly as long as it is on screen; see idatui/prompt.py.
+ self.prompts = PromptBar(self, "search", "rename", "comment",
+ "retype", "makedata", "goto", "export")
+ #: Everything that writes to the database (idatui/edit_ctl.py).
+ self.edits = EditController(self)
+ #: What those writes were, so the findings export can say which
+ #: comments and names are YOURS -- the database cannot (journal.py).
+ self.journal = Journal()
self._xref_focus_name: str | None = None
self._dirty = False
@@ -3168,6 +5299,14 @@ class IdaTui(App):
hx = HexView()
hx.display = False
yield hx
+ gv = GraphView()
+ gv.display = False
+ yield gv
+ # Docked right and only shown once a trace is loaded, so a normal
+ # session looks exactly as it did.
+ td = TraceDock()
+ td.display = False
+ yield td
si = Input(id="search")
si.display = False
si.can_focus = False
@@ -3192,6 +5331,10 @@ class IdaTui(App):
gi.display = False
gi.can_focus = False
yield gi
+ xi = Input(id="export")
+ xi.display = False
+ xi.can_focus = False
+ yield xi
# markup=False: the status is plain text full of [listing]/[split]/[label]
# markers and symbol names that may contain brackets. With Textual markup
# on, a single-word marker parses as a style tag and is silently eaten —
@@ -3210,8 +5353,8 @@ class IdaTui(App):
if self._rpc_path:
self._start_rpc()
# A file no loader recognises has to be described before it can be
- # opened, so ask BEFORE the worker starts — once IDA has made a database
- # the answer is baked in and changing it means deleting the .i64.
+ # opened, so ask BEFORE Code Mode creates it — once IDA has made a database
+ # the answer is baked in and changing it requires a fresh-IDB reopen.
if self._project is not None:
ref = self._pending_load_ref()
if ref is not None:
@@ -3242,6 +5385,11 @@ class IdaTui(App):
if os.path.exists(self._open_path + ".i64") or os.path.exists(
os.path.splitext(self._open_path)[0] + ".i64"):
return False
+ try:
+ if registered_database(self._open_path):
+ return False
+ except Exception:
+ pass # connect() will surface registry failures with full diagnostics
return needs_load_options(self._open_path)
def action_load_options(self) -> None:
@@ -3255,7 +5403,11 @@ class IdaTui(App):
forward.
"""
if not self._can_reload():
- self._status("nothing to reload")
+ if self.client is not None and self.client.backend == "gui":
+ self._status(
+ "reload unavailable for a GUI-owned database — reopen it in IDA")
+ else:
+ self._status("nothing to reload")
return
n = len(self._func_index) if self._func_index else 0
note = ("this image has no functions, so nothing is lost"
@@ -3274,10 +5426,13 @@ class IdaTui(App):
ref = self._project.by_label(self._binary)
if ref is not None:
path, label = ref.source, ref.label
- # Drop the worker first: it holds the database open, and the .i64 can't
- # be removed (or rebuilt) underneath a live one.
- self._release_worker()
- self._drop_database()
+ # Release our lease first. Code Mode waits for a managed worker's final
+ # lease grace, then creates the replacement IDB atomically. A GUI-backed
+ # database is rejected by _can_reload(): the TUI must never close it.
+ self._release_database()
+ self._new_database = True
+ if label is not None and self._pool is not None:
+ self._pool.recreate_on_next_open(label)
self._reset_for_reload()
self._load_args = ""
if label is not None and self._project is not None:
@@ -3289,7 +5444,9 @@ class IdaTui(App):
self._pending_switch = None
self._ask_load_options(path, label=label)
- def _release_worker(self) -> None:
+ def _release_database(self) -> None:
+ if self.program is not None:
+ self.program.close()
if self._pool is not None and self._binary is not None:
try:
self._pool.evict(self._binary, save=False)
@@ -3303,23 +5460,6 @@ class IdaTui(App):
self.client = None
self.program = None
- def _drop_database(self) -> None:
- """Remove the .i64 (and any unpacked scratch) so the next open re-reads
- the raw image with new options."""
- base = self._open_path
- if self._project is not None and self._binary is not None:
- ref = self._project.by_label(self._binary)
- if ref is not None:
- base = ref.staged
- if not base:
- return
- for suffix in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"):
- for cand in (base + suffix, os.path.splitext(base)[0] + suffix):
- try:
- os.remove(cand)
- except OSError:
- pass
-
def _reset_for_reload(self) -> None:
self._no_functions = False
self._func_index = None
@@ -3363,6 +5503,11 @@ class IdaTui(App):
if os.path.exists(ref.db) or os.path.exists(
os.path.splitext(ref.staged)[0] + ".i64"):
return None # already analysed: the .i64 records how
+ try:
+ if registered_database(ref.staged, output_database=ref.db):
+ return None
+ except Exception:
+ pass
from .formats import needs_load_options
return ref if needs_load_options(ref.source) else None
@@ -3416,10 +5561,6 @@ class IdaTui(App):
asyncio.get_running_loop().create_task(_serve())
- async def on_unmount(self) -> None:
- if self._rpc is not None:
- await self._rpc.stop()
-
# -- status helper ----------------------------------------------------- #
def _status(self, text: str, priority: bool = False) -> None:
"""Write the status bar. ``priority`` marks the RESULT of something the
@@ -3438,8 +5579,12 @@ class IdaTui(App):
self._flash_until = _time.monotonic() + 8.0
elif self._flash and _time.monotonic() < self._flash_until:
text = self._flash
- if self._binary: # project mode: always say which binary you're in
- text = f"[{self._binary}] {text}"
+ # Always say WHICH file this is. In project mode that's the binary's
+ # label; otherwise the filename we opened. Cheap on purpose — _module()
+ # asks the worker, and this runs on every status write.
+ tag = self._binary or self._title
+ if tag:
+ text = f"[{tag}] {text}"
# An image with no functions at all is nearly always a blob described
# wrongly, and that stays true as you scroll around — so it belongs in
# the status bar, not in a one-off message the next write clobbers.
@@ -3480,9 +5625,10 @@ class IdaTui(App):
# -- connection loss / recovery --------------------------------------- #
def _handle_exception(self, error: BaseException) -> None:
- """Intercept a lost-connection error from any worker so the whole app
- doesn't die when the analysis server goes away (it can idle out, be
- killed, or the box can sleep). Everything else crashes as usual."""
+ """Intercept a lost Code Mode lease so the app can rediscover the DB.
+
+ Everything unrelated to database connectivity crashes as usual.
+ """
from textual.worker import WorkerFailed
orig = error.error if isinstance(error, WorkerFailed) else error
if isinstance(orig, IDAConnectionError):
@@ -3514,15 +5660,15 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="reconnect")
def _reconnect(self) -> None:
- # The worker died (segfault -> dropped socket). Respawn it: it re-opens
- # and re-analyzes the binary in a fresh process, then we rebuild.
+ # The registered instance disappeared. Rediscover it; Code Mode may find
+ # a GUI/replacement worker, then we rebuild caches against the new handle.
try:
if self._open_path is None:
self.app.call_from_thread(self._reconnect_failed,
"no binary to reopen")
return
- client = WorkerClient(self._open_path, ttl=self._ttl,
- load_args=self._load_args)
+ client = CodeModeClient(self._open_path, ttl=self._ttl,
+ load_args=self._load_args)
client.connect(progress=lambda m: self.app.call_from_thread(
self._conn_note, m))
except Exception as e: # noqa: BLE001
@@ -3530,7 +5676,7 @@ class IdaTui(App):
return
self.app.call_from_thread(self._after_reconnect, client, Program(client))
- def _after_reconnect(self, client: "WorkerClient", program: "Program") -> None:
+ def _after_reconnect(self, client: "CodeModeClient", program: "Program") -> None:
self.client = client
self.program = program
self._reconnecting = False
@@ -3550,13 +5696,13 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="connect")
def _connect(self) -> None:
try:
- client = self._open_worker_client()
+ client = self._open_database_client()
if client is None:
return # the opener already reported + dismissed the overlay
module = client.health().get("module", "?")
if self._do_keepalive:
- # Keep the session warm while we run; don't make it immortal, so
- # it's reclaimed after the TUI closes. (No-op for the worker.)
+ # Compatibility shim: DatabaseHandle's SSE lease already owns
+ # liveness and heartbeat behavior.
self._ka = client.keepalive(interval=120.0).start()
program = Program(client)
except Exception as e: # noqa: BLE001
@@ -3571,31 +5717,33 @@ class IdaTui(App):
return
self.client = client
self.program = program
- self.app.call_from_thread(self._status, f"{module} — loading functions…")
+ self._new_database = False
+ self.app.call_from_thread(
+ self._status, f"{module} [{client.backend}] — loading functions…")
self._load_functions()
- def _open_worker_client(self): # type: ignore[no-untyped-def]
- """Our idalib-worker path: spawn the worker (it opens + analyzes the
- binary in its own process) and connect. Returns the client, or None."""
- from .worker_client import WorkerClient
- if self._pool is not None: # project mode: the pool owns the workers
+ def _open_database_client(self): # type: ignore[no-untyped-def]
+ """Attach through Code Mode, reusing a GUI or managed idalib database."""
+ if self._pool is not None: # project mode: the pool owns the leases
label = self._binary or self._project.refs[0].label
client = self._pool.get(label, progress=lambda m:
self.app.call_from_thread(self._status, m))
self._binary = label
self._pool.set_active(label)
self._open_path = self._project.by_label(label).staged
+ self._title = os.path.basename(self._open_path)
return client
if not self._open_path:
self.app.call_from_thread(
- self._status, "the worker backend needs a binary path")
+ self._status, "Code Mode needs a database or executable path")
self.app.call_from_thread(self._dismiss_loading)
return None
base = os.path.basename(self._open_path)
self.app.call_from_thread(
- self._status, f"starting worker — initial auto-analysis of {base}…")
- client = WorkerClient(self._open_path, ttl=self._ttl,
- load_args=self._load_args)
+ self._status, f"discovering Code Mode database for {base}…")
+ client = CodeModeClient(self._open_path, ttl=self._ttl,
+ load_args=self._load_args,
+ new_database=self._new_database)
client.connect(progress=lambda m: self.app.call_from_thread(
self._status, m))
return client
@@ -3607,7 +5755,6 @@ class IdaTui(App):
self._func_index = idx
self.app.call_from_thread(lambda: self.query_one("#func-table", DataTable).clear())
last = 0
- module = self._module()
while not idx.complete:
idx.load_next_page()
rows = idx.window(last, len(idx) - last)
@@ -3615,18 +5762,20 @@ class IdaTui(App):
if rows:
self.app.call_from_thread(self._append_rows, rows)
self.app.call_from_thread(
- self._status, f"{module} — {last} functions…"
+ self._status, f"{last} functions…"
)
# If a filter is active (typed during load), re-apply it over the full set.
if self._filter_term:
self.app.call_from_thread(self._apply_filter, self._filter_term)
else:
self.app.call_from_thread(
- self._status, f"{module} — {len(idx)} functions (Ctrl+N: find symbol)")
+ self._status, f"{len(idx)} functions (Ctrl+N: find symbol)")
# Land somewhere useful instead of an empty pane: main() if present,
# otherwise pop the fuzzy symbol picker.
self.app.call_from_thread(self._auto_land)
self._index_binary() # project mode: keep the cross-binary index fresh
+ if self.trace_ctl.armed:
+ self._load_trace() # needs the index above: rebasing reads it
@work(thread=True, exclusive=True, group="prewarm")
def _prewarm_provider(self) -> None:
@@ -3666,7 +5815,7 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="index")
def _index_binary(self) -> None:
"""Fold this binary's symbols + strings into the project index, so it can
- be searched later even when its worker is gone."""
+ be searched later even when its Code Mode lease is gone."""
if self._index is None or self._project is None or self._binary is None:
return
ref = self._project.by_label(self._binary)
@@ -3684,7 +5833,7 @@ class IdaTui(App):
imps, exps = self.program.linkage()
entries += [(KIND_IMPORT, i.addr, i.name) for i in imps]
entries += [(KIND_EXPORT, e.addr, e.name) for e in exps]
- except Exception: # noqa: BLE001 -- an old worker has no list_linkage
+ except Exception: # noqa: BLE001 -- indexing is best-effort
pass
try:
n = self._index.reindex(self._binary, entries, source=ref.source)
@@ -3726,7 +5875,19 @@ class IdaTui(App):
if fn is not None:
self._open_function(fn.addr, fn.name)
elif len(self._func_index):
- self.action_symbols()
+ # No main(): land on the first function rather than pushing the
+ # symbol palette. A modal as the *startup* state leaves a human
+ # staring at a picker over an empty pane, and silently swallows
+ # every keystroke an RPC driver injects while `ping` still says
+ # ready:true. Landing somewhere real is better for both; Ctrl+N is
+ # one keypress away.
+ first = self._func_index.get(0)
+ if first is not None:
+ self._open_function(first.addr, first.name)
+ self._status(f"no entry function — opened {first.name} "
+ "(Ctrl+N: find symbol)")
+ else:
+ self.action_symbols()
else:
self._land_without_functions()
@@ -3757,7 +5918,13 @@ class IdaTui(App):
cursor=0, push=True, is_region=True)
def _can_reload(self) -> bool:
- """Whether we're able to re-open this binary with different options."""
+ """Whether Code Mode can replace this IDB with different options.
+
+ A GUI database is owned by the user and has no remote close/rollback
+ route. Managed idalib databases can be released and reopened fresh.
+ """
+ if self.client is not None and self.client.backend == "gui":
+ return False
if self._project is not None and self._binary is not None:
return True
return bool(self._open_path)
@@ -3837,7 +6004,7 @@ class IdaTui(App):
if term:
self._status(f"filter '{term}': {len(matched)}/{total}")
else:
- self._status(f"{self._module()} — {total} functions")
+ self._status(f"{total} functions")
def _apply_pending_filter(self) -> None:
self._filter_timer = None
@@ -3872,8 +6039,7 @@ class IdaTui(App):
left = self.query_one("#left", FunctionsPanel)
left.display = not left.display
if not left.display:
- self.query_one(DecompView if self._active == "decomp"
- else ListingView).focus()
+ self._focus_code_view()
else:
self.query_one("#func-table", DataTable).focus()
@@ -3947,6 +6113,9 @@ class IdaTui(App):
def _on_quit_choice(self, choice: str | None) -> None:
if choice == "discard":
+ # Code Mode has no rollback/close-without-save operation. For GUI
+ # sessions this leaves changes dirty in IDA; a managed worker owns
+ # its final save policy and may persist them on final lease release.
self._save_on_exit = False
self.exit()
elif choice == "save":
@@ -3963,7 +6132,7 @@ class IdaTui(App):
if self._pool is not None:
self._pool.close_all(save=True) # saves each resident worker
elif self.program is not None:
- self.program.client.call("idb_save", timeout=600.0)
+ self.program.client.save_database()
except Exception as e: # noqa: BLE001 -- still exit, but say so
self.app.call_from_thread(self._status, f"save failed: {e}")
self.app.call_from_thread(self._finish_exit)
@@ -4003,7 +6172,7 @@ class IdaTui(App):
self._ask_load_options(ref.source, label=label)
return
# Snapshot what we're leaving so coming back restores the view, then let
- # the pool hand us a worker (spawning + evicting as the budget dictates).
+ # the pool hand us a lease (attaching + evicting as the budget dictates).
if self._binary is not None:
self._states[self._binary] = BinaryState(
label=self._binary, program=self.program,
@@ -4024,8 +6193,8 @@ class IdaTui(App):
self.app.call_from_thread(self._switch_failed, label, str(e))
return
st = self._states.get(label)
- # The Program (and its caches) only survive while that worker does; a
- # binary that was evicted comes back with a fresh one. Either way the nav
+ # The Program (and its caches) only survive while that lease does; an
+ # evicted binary reattaches. Either way the nav
# history is just addresses, so it always survives.
reuse = (st is not None and st.program is not None
and getattr(st.program, "client", None) is client)
@@ -4039,7 +6208,8 @@ class IdaTui(App):
self._binary = label
self._pool.set_active(label)
self._open_path = self._project.by_label(label).staged
- self._active = st.active if st else "listing"
+ self._title = os.path.basename(self._open_path)
+ 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
@@ -4061,7 +6231,7 @@ class IdaTui(App):
self._did_auto_land = False
self._auto_land()
return
- # Cold (first visit, or the worker was evicted): rebuild the index, then
+ # Cold (first visit, or the lease was evicted): rebuild the index, then
# land back where we were via _pending_restore.
self._cur = None
self._func_index = None
@@ -4109,6 +6279,35 @@ class IdaTui(App):
binary=self._binary),
self._on_string_chosen)
+ def action_find(self) -> None:
+ """Ctrl+F: search the whole database — disassembly text, or bytes."""
+ if self.program is None:
+ self._status("not connected yet")
+ return
+ if self._prompt_active():
+ return
+ # Seed it with the word under the cursor: the search you want is
+ # usually about the thing you are looking at.
+ seed = ""
+ view = self._active_code_view()
+ if view is not None:
+ try:
+ seed = view.word_under_cursor() or ""
+ except Exception: # noqa: BLE001 -- a seed is a nicety, never a
+ seed = "" # reason not to open the search
+ self.push_screen(SearchPalette(self.program, seed), self._on_hit_chosen)
+
+ def _on_hit_chosen(self, hit) -> None: # type: ignore[no-untyped-def]
+ if hit is None:
+ return
+ # Navigate to the ITEM, not the matched byte: a pattern can start in
+ # the middle of an instruction, and there is nothing to put a cursor on
+ # there. The status names the exact address so it isn't lost.
+ self._goto_ea(hit.head, push=True)
+ if hit.addr != hit.head:
+ self._status(f"match at {hit.addr:#x} (inside {hit.head:#x})",
+ priority=True)
+
def _on_string_chosen(self, choice) -> None: # type: ignore[no-untyped-def]
if choice is None:
return
@@ -4118,28 +6317,6 @@ class IdaTui(App):
return
self._goto_ea(addr, push=True) # land on the literal in the listing
- def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def]
- """Keep ``_active`` in step with focus while split.
-
- Tab moves both together, but focus also moves on its own — a click, or a
- pane focusing itself after a load — and then ``_active`` still names the
- pane you're NOT in. Everything downstream trusts ``_active``: follow
- resolves the word under that pane's cursor and pushes history for it, so
- Enter in the pseudocode would follow something from the listing and the
- next Esc got spent undoing it.
- """
- if not self._split:
- return
- w = self.focused
- mode = ("decomp" if isinstance(w, DecompView)
- else "listing" if isinstance(w, ListingView) else None)
- if mode is None or mode == self._active:
- return
- self._active = mode
- self._sync_split(mode) # re-link the band from the new driver
- if not self.query_one(DecompView).loading:
- self._status_for_cur("split") # never clobber "decompiling…"
-
def action_toggle_view(self) -> None:
"""Tab: switch the code pane between disassembly and pseudocode (or leave
the hex view back to the preferred code view)."""
@@ -4157,17 +6334,35 @@ 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 == "listing":
+ 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 = ViewMode.LISTING
+ self._show_active()
+ return
+ gv.display = False
+ dec = self.query_one(DecompView)
+ dec.display = True
+ dec.loading = True
+ dec.focus()
+ self._status("decompiling…")
+ self._decomp_from_listing(ea)
+ return
+ 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()
@@ -4192,7 +6387,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
@@ -4203,7 +6398,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")
@@ -4216,14 +6411,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")
@@ -4242,7 +6437,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)
@@ -4258,7 +6453,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:
@@ -4274,12 +6469,115 @@ 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:
self._show_active()
+ # -- graph mode -------------------------------------------------------- #
+ #: Above this, a CFG graph stops being a picture and becomes a hairball --
+ #: IDA's own is unreadable at this size too. Refusing beats rendering soup.
+ GRAPH_MAX_BLOCKS = 400
+
+ def action_toggle_graph(self) -> None:
+ """Space: swap the code view for the function's control-flow graph."""
+ if self._prompt_active():
+ return
+ if self.is_graph:
+ self._graph_sticky = False
+ self._active = self._code_mode()
+ self._show_active()
+ return
+ if self._cur is None or self.program is None:
+ self._status("open a function first")
+ return
+ self._graph_sticky = True
+ gv = self.query_one(GraphView)
+ 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 = ViewMode.GRAPH
+ self._split = False
+ self._show_active()
+ if ea is not None:
+ gv.goto_ea(ea)
+ self._graph_status()
+ return
+ self._status(f"{self._cur.name} — building graph…")
+ self._load_graph(self._cur.ea, ea)
+
+ def _graph_target_ea(self) -> int | None:
+ """The address the graph should land on: wherever the code view's cursor
+ is, so Space doesn't lose your place."""
+ try:
+ if self.is_listing:
+ return self.query_one(ListingView)._cursor_ea()
+ if self.is_decomp:
+ dv = self.query_one(DecompView)
+ return dv._line_ea(dv.cursor)
+ except Exception: # noqa: BLE001
+ pass
+ return self._cur.ea if self._cur else None
+
+ @work(thread=True, exclusive=True, group="graph")
+ def _load_graph(self, func_ea: int, want_ea: int | None) -> None:
+ assert self.program is not None
+ err = ""
+ fc = None
+ try:
+ fc = self.program.flowchart(func_ea)
+ except IDAConnectionError:
+ raise
+ except Exception as e: # noqa: BLE001
+ err = f"{type(e).__name__}: {e}"
+ self.app.call_from_thread(self._apply_graph, func_ea, want_ea, fc, err)
+
+ def _apply_graph(self, func_ea: int, want_ea: int | None, fc, # type: ignore[no-untyped-def]
+ 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 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.
+ return
+ if fc is None:
+ self._status(err or "no control-flow graph for this function "
+ "(is it a thunk or an import?)")
+ return
+ if len(fc.blocks) > self.GRAPH_MAX_BLOCKS:
+ self._status(
+ f"{fc.name}: {len(fc.blocks)} blocks — too many to graph "
+ f"(limit {self.GRAPH_MAX_BLOCKS}); staying in the listing")
+ return
+ gv = self.query_one(GraphView)
+ gv.set_graph(fc, want_ea)
+ self._active = ViewMode.GRAPH
+ self._split = False
+ self._show_active()
+ self._graph_status()
+
+ def _graph_status(self) -> None:
+ gv = self.query_one(GraphView)
+ if gv.lay is None or gv.fc is None:
+ return
+ s = gv.lay.stats
+ loops = f", {s['back']} loop{'s' if s['back'] != 1 else ''}" if s["back"] else ""
+ eng = "" if s.get("engine") == "native" else f", {s.get('engine')}"
+ self._status(
+ f"{gv.fc.name} @ {gv.fc.func_ea:#x} [graph: {s['blocks']} blocks, "
+ f"{s['edges']} edges{loops}{eng}] "
+ f"z=zoom({gv.ZOOMS[gv._zoom]}) m=map J/K=edge space=text")
+
+ def on_graph_view_cursor_moved(self, msg: "GraphView.CursorMoved") -> None:
+ gv = self.query_one(GraphView)
+ gv.set_highlight(gv.word_under_cursor())
+ if msg.ea is not None and gv.fc is not None:
+ b = gv.fc.block_at(msg.ea)
+ extra = f" block {b.start:#x}" if b else ""
+ self._status(f"{gv.fc.name} @ {msg.ea:#x}{extra} [graph]")
+
@work(thread=True, group="split")
def _enter_split(self, ea: int, name: str) -> None:
# Load the listing for the current function (bg) then reveal both panes.
@@ -4303,17 +6601,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.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:
@@ -4324,9 +6624,53 @@ class IdaTui(App):
inp.value = self._filter_term
inp.focus()
+ def action_export(self) -> None:
+ """Ctrl+E: write what you have worked out to a markdown report.
+
+ Prefilled with a path beside the binary, because the common case is
+ "just write it" and the rare case is worth one edit.
+ """
+ if self.program is None:
+ self._status("no database open", priority=True)
+ return
+ inp = self.query_one("#export", Input)
+ inp.placeholder = "export findings to… (markdown) — Enter"
+ inp.can_focus = True
+ inp.display = True
+ inp.value = findings.default_path(self._open_path or "findings")
+ self.query_one("#status", Static).display = False
+ inp.focus()
+
+ def _end_export(self) -> None:
+ inp = self.query_one("#export", Input)
+ inp.display = False
+ inp.can_focus = False
+ self.query_one("#status", Static).display = True
+
+ def export_findings(self, path: str | None = None) -> None:
+ """Gather + write the report off the UI thread (it walks the database)."""
+ self._status("exporting findings…", priority=True)
+ self._export_worker(path)
+
+ @work(thread=True, exclusive=True, group="export")
+ def _export_worker(self, path: str | None) -> None:
+ try:
+ self.journal.load(self.program)
+ self.journal.flush(self.program)
+ out, f = findings.export(self.program, self._open_path or "", path,
+ journal=self.journal)
+ except Exception as e: # noqa: BLE001 -- a bad path is a message, not a crash
+ self.call_from_thread(self._status, f"export failed: {e}", True)
+ return
+ n_named = len(findings._user_names(f))
+ self.call_from_thread(
+ self._status,
+ f"exported {len(f.comments)} comments, {n_named} names, "
+ f"{len(f.types)} types → {out}", True)
+
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
@@ -4362,8 +6706,7 @@ class IdaTui(App):
elif self.query_one("#left", FunctionsPanel).display:
table.focus()
else:
- self.query_one(DecompView if self._active == "decomp"
- else ListingView).focus()
+ self._focus_code_view()
# -- input submit (filter / goto) ------------------------------------- #
def on_search_requested(self, msg: SearchRequested) -> None:
@@ -4381,6 +6724,19 @@ class IdaTui(App):
def on_follow_requested(self, msg: FollowRequested) -> None:
view = msg.view
word = view.word_under_cursor()
+ if isinstance(view, GraphView):
+ ea = view._cursor_ea()
+ if ea is None:
+ return
+ # Inside the graph, a jump to a block of THIS function should move
+ # the cursor, not navigate away and rebuild the whole picture.
+ tgt = self._graph_local_target(view, ea, word)
+ if tgt is not None:
+ view.goto_ea(tgt)
+ self.post_message(GraphView.CursorMoved(tgt, view.cursor_node))
+ return
+ self._follow_disasm(ea, word, view._next_ea())
+ return
if isinstance(view, ListingView):
ea = view._cursor_ea()
if ea is not None:
@@ -4392,11 +6748,39 @@ class IdaTui(App):
self._follow_decomp(view._texts[view.cursor], word,
view._line_ea(view.cursor))
+ def _graph_local_target(self, view: "GraphView", ea: int,
+ word: str) -> int | None:
+ """If the cursor's instruction branches somewhere inside this same
+ graph, return that address."""
+ if view.fc is None or self.program is None:
+ return None
+ try:
+ if word and self._looks_like_symbol(word):
+ t = self.program.resolve(word)
+ if t and view.fc.block_at(t) is not None:
+ return t
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ for xr in self.program.xrefs_from(ea):
+ t = xr.to
+ if t and t != view._next_ea() and view.fc.block_at(t) is not None:
+ return t
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
def on_xrefs_requested(self, msg: XrefsRequested) -> None:
if self._xref_active: # one gather at a time; ignore a second 'x'
return
view = msg.view
word = view.word_under_cursor()
+ if isinstance(view, GraphView):
+ ea = view._cursor_ea()
+ if ea is not None:
+ self._push_busy("finding xrefs…")
+ self._xrefs_disasm(ea, word, ea, view._next_ea())
+ return
if isinstance(view, ListingView):
ea = view._cursor_ea()
if ea is not None:
@@ -4486,7 +6870,7 @@ class IdaTui(App):
def _cross_binary_impl(self, name: str) -> tuple[str, int] | None:
"""``(binary, addr)`` of a project binary that EXPORTS ``name``.
- Reads the on-disk index, so a provider resolves even when its worker was
+ Reads the on-disk index, so a provider resolves even when its lease was
evicted — the whole reason the index exists.
"""
if self._index is None or self._project is None or not name:
@@ -4659,7 +7043,7 @@ class IdaTui(App):
def _foreign_importers(self, subj: int, subj_name, fn): # type: ignore[no-untyped-def]
"""Project binaries that IMPORT the symbol at ``subj`` — the other half
of the phase-3 join, read from the on-disk index so a caller shows up
- whether or not its worker is resident.
+ whether or not its database lease is resident.
Only for a symbol this binary actually exports: a local name that
happens to collide with another binary's import isn't a caller of ours.
@@ -4709,595 +7093,141 @@ 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))
- # -- rename ------------------------------------------------------------ #
- @staticmethod
- def _is_pseudocode_label(view, name: str) -> bool:
- """True if ``name`` is a Hex-Rays goto label in ``view``. The rename tool
- has no label category (only func/global/local/stack), so renaming one
- fails with a misleading 'local variable not found'; detect it up front
- and explain instead. A label is the default ``LABEL_n`` or any token used
- as a ``goto`` target."""
- if not isinstance(view, DecompView):
- return False
- if re.fullmatch(r"LABEL_\d+", name):
- return True
- body = "\n".join(getattr(view, "_texts", []) or [])
- return re.search(rf"\bgoto\s+{re.escape(name)}\b", body) is not None
+ # -- database edits ---------------------------------------------------- #
+ # The bodies live in EditController (idatui/edit_ctl.py). What stays here is
+ # what Textual insists on owning: on_<Message> handlers, which it dispatches
+ # by name on the DOMNode, and @work entry points, whose worker machinery
+ # wants a DOMNode host. Both are one-line delegates.
def on_rename_requested(self, msg: RenameRequested) -> None:
- # In the flat listing, 'n' names the ADDRESS under the cursor (create a
- # label), not a symbol-by-name. This is what lets you name a bare/
- # undefined byte — e.g. the free byte at addr+1 after shrinking a u16 to
- # a u8 — which the word-under-cursor path can't do (no symbol to rename).
- if isinstance(msg.view, ListingView):
- ea = msg.view._cursor_ea()
- if ea is None:
- self._status("no address on this line to name")
- return
- head = msg.view.cur_head()
- 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 = placeholder
- inp.can_focus = True
- inp.display = True
- inp.value = prefill
- inp.focus()
- return
- if not msg.name:
- self._status("nothing to rename under the cursor")
- return
- if self._is_pseudocode_label(msg.view, msg.name):
- self._status(
- f"can't rename pseudocode label '{msg.name}' "
- "(Hex-Rays goto labels aren't renamable via the API)")
+ self.edits.request_rename(msg)
+
+ def on_comment_requested(self, msg: CommentRequested) -> None:
+ self.edits.request_comment(msg)
+
+ def on_retype_requested(self, msg: RetypeRequested) -> None:
+ if self._cur is None or self.program is None:
return
- self._rename_ctx = (msg.view, msg.name)
- self._rename_addr = None
- self.query_one("#status", Static).display = False
- inp = self.query_one("#rename", Input)
- inp.placeholder = f"rename '{msg.name}' — Enter=apply Esc=cancel"
- inp.can_focus = True
- inp.display = True
- inp.value = msg.name
- inp.focus()
+ self._prepare_retype(msg.view, msg.name)
- def _end_rename(self) -> None:
- inp = self.query_one("#rename", Input)
- inp.display = False
- inp.can_focus = False
- self._rename_addr = None
- self.query_one("#status", Static).display = True
- view, _ = self._rename_ctx
- if view is not None:
- view.focus()
+ def on_make_data_requested(self, msg: MakeDataRequested) -> None:
+ self.edits.request_make_data(msg)
- # -- 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, ListingView):
- return view._cursor_ea()
- if isinstance(view, DecompView):
- return view._line_ea(view.cursor)
- return None
+ def on_op_format_requested(self, msg: OpFormatRequested) -> None:
+ self.edits.request_op_format(msg)
- @staticmethod
- def _existing_comment(view) -> str:
- """Current line comment (for prefill), parsed from the rendered text. In
- pseudocode a comment is `// text` before the trailing /*0xEA*/ markers;
- C has no `//` operator, so the last `//` is unambiguously the comment."""
- if isinstance(view, DecompView) and 0 <= view.cursor < len(view._texts):
- s = re.sub(r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "", view._texts[view.cursor])
- i = s.rfind("//")
- return s[i + 2:].strip() if i >= 0 else ""
- return ""
+ def on_edit_item_requested(self, msg: EditItemRequested) -> None:
+ self.edits.request_edit_item(msg)
- def on_comment_requested(self, msg: CommentRequested) -> None:
- ea = self._line_ea_for(msg.view)
- # Signature / local-declaration lines carry no address; fall back to the
- # function's entry ea so commenting the header annotates the function.
- func_level = ea is None
- if func_level:
- ea = self._cur.ea if self._cur else None
- if ea is None:
- self._status("no address on this line to comment")
- return
- existing = "" if func_level else self._existing_comment(msg.view)
- self._comment_ctx = (msg.view, ea, existing)
- self.query_one("#status", Static).display = False
- inp = self.query_one("#comment", Input)
- inp.placeholder = (
- f"function comment @ {ea:#x} — Enter=apply (empty=clear) Esc=cancel"
- if func_level else
- f"comment @ {ea:#x} — Enter=apply (empty=clear) Esc=cancel")
- inp.can_focus = True
- inp.display = True
- inp.value = existing
- inp.focus()
+ @work(thread=True, exclusive=True, group="rename")
+ def _do_rename(self, view, old: str, new: str) -> None: # type: ignore[no-untyped-def]
+ self.edits.do_rename(view, old, new)
- def _end_comment(self) -> None:
- inp = self.query_one("#comment", Input)
- inp.display = False
- inp.can_focus = False
- self.query_one("#status", Static).display = True
- view, _, _ = self._comment_ctx
- if view is not None:
- view.focus()
+ @work(thread=True, exclusive=True, group="rename")
+ def _do_name_addr(self, addr: int, name: str) -> None:
+ self.edits.do_name_addr(addr, name)
@work(thread=True, exclusive=True, group="comment")
def _do_comment(self, ea: int, text: str) -> None:
- assert self.program is not None
- # The prompt is single-line, so a literal '\n' (backslash-n) means a real
- # newline — Hex-Rays renders each as its own '//' line. Lets long notes
- # wrap instead of running off the right edge and clipping.
- text = text.replace("\\n", "\n")
- try:
- res = self.program.set_comment(ea, text)
- except IDAToolError as e:
- self.app.call_from_thread(self._status, f"comment failed: {e.message}")
- return
- data = res.get("result") if isinstance(res, dict) else None
- if isinstance(data, list) and data and isinstance(data[0], dict) and data[0].get("error"):
- self.app.call_from_thread(self._status, f"comment failed: {data[0]['error']}")
- return
- self.app.call_from_thread(self._after_comment, ea, text)
+ self.edits.do_comment(ea, text)
- 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":
- # 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
- # stale frame until the next cursor move; capturing the real scroll
- # makes show() take the robust _apply_scroll path and repaint now.
- dec = self.query_one(DecompView)
- if cur.ea == dec.loaded_ea:
- cur.dec_cursor = dec.cursor
- cur.dec_cursor_x = dec.cursor_x
- cur.dec_scroll_y = round(dec.scroll_offset.y)
- cur.dec_scroll_x = round(dec.scroll_offset.x)
- dec.loaded_ea = None # force re-decompile
- self._show_active()
- else:
- # Capture the LIVE position from the widget (the source of truth)
- # rather than trusting nav-entry tracking, which goes stale. Capture
- # it as ADDRESSES via the anchor: bump_names() discards the segment
- # model so the reload rebuilds it, and an edit that changes how many
- # rows an item takes makes the old indices point somewhere else.
- # Index capture is CORRECT here and an anchor is not: a rename or
- # comment doesn't change how many rows anything takes, and the model
- # this rebuilds is constructed empty — index_of_ea on it returns -1
- # until pages load, so an anchor would resolve to nothing while
- # costing an extra model build on the UI thread. Address anchoring is
- # for the edit paths that DO change row structure (see _do_edit_item).
- lst = self.query_one(ListingView)
- cur.view = "listing"
- if lst.model is not None:
- cur.cursor = lst.cursor
- cur.cursor_x = lst.cursor_x
- cur.scroll_y = round(lst.scroll_offset.y)
- self._open_entry(cur, push=False)
+ @work(thread=True, exclusive=True, group="retype")
+ def _prepare_retype(self, view, word: str | None) -> None: # type: ignore[no-untyped-def]
+ self.edits.prepare_retype(view, word)
- 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/listing caches are cleared).
- self.program.bump_names()
- 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)")
+ @work(thread=True, exclusive=True, group="retype-apply")
+ def _do_retype(self, kind: str, subject: int, word: str, new: str) -> None:
+ self.edits.do_retype(kind, subject, word, new)
- # -- retype (set type, IDA 'y') --------------------------------------- #
- def on_retype_requested(self, msg: RetypeRequested) -> None:
- if self._cur is None or self.program is None:
- return
- self._prepare_retype(msg.view, msg.name)
+ @work(thread=True, exclusive=True, group="makedata")
+ def _do_make_data(self, ea: int, type_decl: str,
+ anchor: ViewAnchor | None = None) -> None:
+ self.edits.do_make_data(ea, type_decl, anchor)
- @work(thread=True, exclusive=True, group="retype")
- def _prepare_retype(self, view, word: str | None) -> None: # type: ignore[no-untyped-def]
- """Work out whether the cursor is on a local variable or a function, and
- fetch the current type/prototype to prefill the prompt."""
- assert self.program is not None and self._cur is not None
- ft = self.program.func_types(self._cur.ea)
- kind: str | None = None
- subject: int = self._cur.ea
- prefill = ""
- # 1) a local variable (or arg) of the current function
- if word and ft is not None:
- lv = next((v for v in ft.lvars if v.name == word), None)
- if lv is not None:
- kind, prefill = "lvar", lv.type
- # 2) a symbol under the cursor: a function (retype its prototype) or a
- # global/data item (retype the variable). Without the data case a
- # global fell through to (3) and silently retyped the ENCLOSING
- # function's prototype instead.
- if kind is None and self._looks_like_symbol(word):
- try:
- tgt = self.program.resolve(word)
- except Exception: # noqa: BLE001
- tgt = None
- if tgt is not None:
- tft = self.program.func_types(tgt)
- if tft is not None:
- kind, subject, prefill = "func", tgt, tft.prototype
- else:
- dt = self.program.data_type(tgt)
- if dt is not None and not dt.get("is_func"):
- kind, subject = "data", tgt
- prefill = dt.get("type") or self._guess_data_type(
- dt.get("size") or 0)
- # 3) fall back to the current function itself
- if kind is None and ft is not None:
- kind, subject, prefill = "func", self._cur.ea, ft.prototype
- if kind is None:
- self.app.call_from_thread(self._status, "nothing to retype under the cursor")
- return
- self.app.call_from_thread(self._open_retype, view, kind, subject, word or "", prefill)
+ @work(thread=True, exclusive=True, group="opformat")
+ def _do_op_format(self, mode: str, where: str, ea: int, col: int,
+ line: int = -1) -> None:
+ self.edits.do_op_format(mode, where, ea, col, line)
- @staticmethod
- def _guess_data_type(size: int) -> str:
- """A sensible prefill when a global carries no type yet."""
- return {1: "unsigned __int8", 2: "unsigned __int16",
- 4: "unsigned __int32", 8: "unsigned __int64"}.get(
- size, f"char[{size}]" if size > 0 else "void *")
+ @work(thread=True, exclusive=True, group="edititem")
+ def _do_edit_item(self, kind: str, ea: int,
+ anchor: ViewAnchor | None = None) -> None:
+ self.edits.do_edit_item(kind, ea, anchor)
- def _open_retype(self, view, kind: str, subject: int, word: str, # type: ignore[no-untyped-def]
- prefill: str) -> None:
- self._retype_ctx = (view, kind, subject, word)
- self.query_one("#status", Static).display = False
- inp = self.query_one("#retype", Input)
- label = "prototype" if kind == "func" else f"type for '{word}'"
- inp.placeholder = f"{label} — Enter=apply Esc=cancel"
- inp.can_focus = True
- inp.display = True
- inp.value = prefill
- inp.focus()
+ def _reload_active_code(self) -> None:
+ self.edits.reload_active_code()
- def _end_retype(self) -> None:
- inp = self.query_one("#retype", Input)
- inp.display = False
- inp.can_focus = False
- self.query_one("#status", Static).display = True
- view = self._retype_ctx[0]
- if view is not None:
- view.focus()
+ # -- trace ------------------------------------------------------------- #
+ # Everything below delegates to TraceController (idatui/trace_ctl.py). The
+ # keys stay declared here because Textual only merges BINDINGS from DOMNode
+ # subclasses, and the @work entry points stay here because Textual's worker
+ # machinery wants a DOMNode host.
- @work(thread=True, exclusive=True, group="retype-apply")
- def _do_retype(self, kind: str, subject: int, word: str, new: str) -> None:
- assert self.program is not None
- if kind == "func":
- err = self.program.set_function_type(subject, new)
- elif kind == "data": # a global / data item referenced in the body
- err = self.program.set_data_type(subject, new)
- else: # lvar of the current function
- err = self.program.set_lvar_type(self._cur.ea, word, new)
- if err:
- self.app.call_from_thread(self._status, f"retype failed: {err}")
- return
- self.app.call_from_thread(self._after_retype, kind, word)
+ # Read-only views onto the controller's state. The pilot suite and the RPC
+ # layer read the trace position by these names; they are properties rather
+ # than attributes so there is exactly one owner and no copy to drift.
+ @property
+ def _trace(self): # type: ignore[no-untyped-def]
+ return self.trace_ctl.trace
- def _after_retype(self, kind: str, word: str) -> None:
- # 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()
- self._reload_active_code()
- self._dirty = True
- what = "prototype" if kind == "func" else f"'{word}'"
- self._status(f"retyped {what} (Ctrl+S to save)")
+ @property
+ def _t(self) -> int:
+ return self.trace_ctl.t
- # -- typed data definition (make_data, IDA 'd') ----------------------- #
- @staticmethod
- def _default_data_type(head) -> str: # type: ignore[no-untyped-def]
- """A sensible prefill C type for defining data over ``head``."""
- sz = getattr(head, "size", 0) or 0
- return {1: "unsigned __int8", 2: "unsigned __int16",
- 4: "unsigned __int32", 8: "unsigned __int64"}.get(
- sz, f"char[{sz}]" if sz > 0 else "unsigned __int8")
+ @property
+ def _trail_map(self) -> list:
+ return self.trace_ctl.trail_map
- def on_make_data_requested(self, msg: MakeDataRequested) -> None:
- view = msg.view
- ea = view._cursor_ea() if isinstance(view, ListingView) else None
- if ea is None:
- self._status("no address on this line to define data")
- return
- self._makedata_ctx = (view, ea)
- self.query_one("#status", Static).display = False
- inp = self.query_one("#makedata", Input)
- inp.placeholder = (f"data type @ {ea:#x} (e.g. int, char[16], my_struct)"
- " — Enter=apply Esc=cancel")
- inp.can_focus = True
- inp.display = True
- head = view.cur_head() if isinstance(view, ListingView) else None
- inp.value = self._default_data_type(head) if head is not None else "int"
- inp.focus()
+ @property
+ def _trail_map_ea(self): # type: ignore[no-untyped-def]
+ return self.trace_ctl.trail_map_ea
- def _end_makedata(self) -> None:
- inp = self.query_one("#makedata", Input)
- inp.display = False
- inp.can_focus = False
- self.query_one("#status", Static).display = True
- view = self._makedata_ctx[0]
- if view is not None:
- view.focus()
+ @property
+ def _trail_line_of(self) -> dict:
+ return self.trace_ctl.trail_line_of
- @work(thread=True, exclusive=True, group="makedata")
- def _do_make_data(self, ea: int, type_decl: str,
- anchor: ViewAnchor | None = None) -> None: # worker context
- assert self.program is not None
- try:
- self.program.make_data(ea, type_decl)
- except Exception as e: # noqa: BLE001
- self.app.call_from_thread(self._status, f"make data: {e}")
- return
- self.program.bump_items()
- anchor = anchor or ViewAnchor()
- anchor.flash = f"data ({type_decl}) @ {ea:#x} (Ctrl+S to save)"
- 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
- _cur, top = self._anchor_rows(anchor, lm, ea)
- self.app.call_from_thread(
- self._open_at, ea, name, idx, False, -1, 0, True, None, top)
- self.app.call_from_thread(self._edit_done, anchor)
+ @work(thread=True, exclusive=True, group="load-trace")
+ def _load_trace(self) -> None:
+ self.trace_ctl.load()
- @work(thread=True, exclusive=True, group="rename")
- def _do_rename(self, view, old: str, new: str) -> None: # type: ignore[no-untyped-def]
- assert self.program is not None
- prog, cur = self.program, self._cur
- kind = "data"
- addr: int | None = None
- batch: dict = {"data": {"old": old, "new": new}}
- resolved: int | None = None
- try:
- resolved = prog.resolve(old)
- except Exception: # noqa: BLE001
- resolved = None
- if resolved is not None:
- fn = prog.function_of(resolved)
- if fn is not None and fn.addr == resolved:
- kind, addr = "func", resolved
- batch = {"func": {"addr": hex(resolved), "name": new}}
- else:
- kind, batch = "data", {"data": {"old": old, "new": new}}
- elif isinstance(view, DecompView) and cur is not None:
- dec = prog.decompile(cur.ea)
- ref = next((r for r in dec.refs if r.name == old), None)
- if ref is not None:
- fn = prog.function_of(ref.addr)
- if fn is not None and fn.addr == ref.addr:
- kind, addr = "func", ref.addr
- batch = {"func": {"addr": hex(ref.addr), "name": new}}
- else:
- kind, batch = "data", {"data": {"old": old, "new": new}}
- else:
- kind = "local"
- batch = {"local": {"func_addr": hex(cur.ea), "old": old, "new": new}}
- elif cur is not None: # disasm view
- if old.startswith(("var_", "arg_")):
- kind = "stack"
- batch = {"stack": {"func_addr": hex(cur.ea), "old": old, "new": new}}
- try:
- res = prog.client.call("rename", batch=batch)
- except IDAToolError as e:
- self.app.call_from_thread(self._status, f"rename failed: {e.message}")
- return
- summary = res.get("summary", {}) if isinstance(res, dict) else {}
- if not (summary.get("ok", 0) > 0 and summary.get("failed", 0) == 0):
- msg = "rename failed"
- for catk in ("func", "data", "local", "stack"):
- items = res.get(catk) if isinstance(res, dict) else None
- if isinstance(items, list) and items and items[0].get("error"):
- msg = f"rename failed: {items[0]['error']}"
- self.app.call_from_thread(self._status, msg)
- return
- self.app.call_from_thread(self._after_rename, kind, addr, old, new)
+ def _seek(self, idx: int, follow: bool = True) -> None:
+ self.trace_ctl.seek(idx, follow)
- def _after_rename(self, kind: str, addr: int | None, old: str, new: str) -> None:
- cur = self._cur
- # 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()
- 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:
- if e.ea == addr:
- e.name = new
- # Update the one cell in place (a full rebuild would race the
- # initial streaming load and duplicate row keys).
- table = self.query_one("#func-table", DataTable)
- try:
- name_col = list(table.columns.keys())[1]
- table.update_cell(str(addr), name_col, new)
- except Exception: # noqa: BLE001 -- row filtered out / not yet streamed
- pass
- self._dirty = True
- self._status(f"renamed {old} → {new} (Ctrl+S to save)")
+ def _step(self, delta: int) -> None:
+ self.trace_ctl.step(delta)
- @work(thread=True, exclusive=True, group="rename")
- def _do_name_addr(self, addr: int, name: str) -> None: # worker context
- """Set a label at ``addr`` (listing 'n'). Works on a bare/undefined byte
- — unlike the symbol-by-name path, this names the address directly."""
- assert self.program is not None
- try:
- res = self.program.client.call(
- "rename", batch={"data": {"addr": hex(addr), "new": name}})
- except IDAToolError as e:
- self.app.call_from_thread(self._status, f"name failed: {e.message}")
- return
- summary = res.get("summary", {}) if isinstance(res, dict) else {}
- if not (summary.get("ok", 0) > 0 and summary.get("failed", 0) == 0):
- err = "name failed"
- items = res.get("data") if isinstance(res, dict) else None
- if isinstance(items, list) and items and items[0].get("error"):
- err = f"name failed: {items[0]['error']}"
- self.app.call_from_thread(self._status, err)
- return
- # The label shows in the listing's head rows -> invalidate + reopen.
- self.program.bump_items()
- lm = self.program.listing(addr)
- label = self.program.region_label(addr)
- idx = max(lm.ensure_ea(addr), 0) if lm is not None else 0
- self.app.call_from_thread(self._open_at_named, label, addr, idx, name)
+ def _step_over(self, direction: int) -> None:
+ self.trace_ctl.step_over(direction)
- def _open_at_named(self, label: str, addr: int, idx: int, name: str) -> None:
- self._open_at(addr, label, idx, False, -1, 0, True)
- self._dirty = True
- self._status(f"named {addr:#x} → {name} (Ctrl+S to save)")
+ def _paint_trail(self) -> None:
+ self.trace_ctl.paint_trail()
- # -- 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, ListingView) else None
- if ea is None:
- self._status("no address on this line to (re)define")
- return
- self._do_edit_item(msg.kind, ea, self._anchor())
+ @work(thread=True, exclusive=True, group="split-resync")
+ def _resync_decomp_async(self, ea: int) -> None:
+ self._resync_decomp(ea)
- @work(thread=True, exclusive=True, group="edititem")
- def _do_edit_item(self, kind: str, ea: int,
- anchor: ViewAnchor | None = None) -> None: # worker context
- assert self.program is not None
- verb = {"code": "defined code", "func": "created function",
- "undef": "undefined", "string": "made string",
- "thumb": "switched decoding", "thumbscan": "scanned"}[kind]
- try:
- if kind == "code":
- # Keep going until something stops it: one instruction is rarely
- # what you want, and on a raw image it means pressing `c` once
- # per opcode for the length of a function.
- r = self.program.define_code_run(ea)
- n, why = int(r.get("count", 0)), r.get("stopped", "")
- if n == 0 and why == "defined":
- # Already code/data here — a no-op, not a failure. Saying
- # "failed to create instruction" for it would be a lie.
- self.app.call_from_thread(
- self._status, f"already defined @ {ea:#x}")
- return
- if n == 0:
- raise IDAToolError("define_code",
- f"@ {ea:#x}: Failed to create instruction")
- end = int(str(r.get("end", hex(ea))), 0)
- reason = {"undecodable": "hit bytes that don't decode",
- "flow": "control flow ends here",
- "defined": "ran into existing code/data",
- "segment": "end of segment",
- "limit": "instruction limit"}.get(why, why)
- verb = (f"defined {n} instruction{'s' if n != 1 else ''} "
- f"({ea:#x}\u2013{end:#x}) \u2014 {reason}")
- elif kind == "thumbscan":
- # A vector table is a list of Thumb entry points that IDA won't
- # follow on a headerless image, because nothing tells it those
- # words are pointers. Scan from the cursor.
- anchor.refresh_functions = True
- r = self.program.thumb_scan(ea, ea + 0x400)
- n, applied = int(r.get("n", 0)), int(r.get("applied", 0))
- if not n:
- verb = (f"no Thumb entry pointers in {ea:#x}\u2013{ea+0x400:#x}"
- " (odd words pointing into the image)")
- else:
- verb = (f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
- f"{applied} disassembled")
- elif kind == "thumb":
- # Switch the mode, then disassemble in it: flipping T and
- # leaving the bytes undefined shows nothing, and the reason you
- # flipped it was to read the code.
- r = self.program.set_thumb(ea)
- run = self.program.define_code_run(ea)
- n = int(run.get("count", 0))
- mode = "Thumb" if r.get("thumb") else "ARM"
- verb = f"{mode} @ {ea:#x}"
- if r.get("forced_32bit"):
- verb += " (segment set to 32-bit; Thumb needs ARM32)"
- if r.get("db_64bit"):
- # Disassembly will look right and F5 will never work.
- verb += (" \u26a0 this database is 64-bit, so Hex-Rays "
- "won't decompile it \u2014 Ctrl+L and pick "
- "arm:ARMv7-A")
- verb += (f" \u2014 {n} instruction{'s' if n != 1 else ''}"
- if n else " \u2014 still doesn't decode")
- # falls through to the shared reload: same cache bump, same
- # anchor restore, same flash. That is the whole point of having
- # one path.
- elif kind == "func":
- anchor.refresh_functions = True
- r = self.program.define_func(ea)
- if r.get("start") and r.get("end"):
- verb = (f"created function {r['start']}\u2013{r['end']}"
- + (" (end worked out from the code)"
- if r.get("how") == "explicit-end" else ""))
- elif kind == "string":
- s = self.program.make_string(ea)
- verb = f"made string ({s[:24]!r})" if s else verb
- else:
- # Undefining can destroy a function as easily as `p` creates one.
- anchor.refresh_functions = True
- self.program.undefine(ea)
- except Exception as e: # noqa: BLE001 -- surface soft/hard tool errors
- self.app.call_from_thread(self._status, f"{kind}: {e}")
- return
- # Structure changed everywhere: drop all item/function/decomp caches.
- self.program.bump_items()
- # Re-resolve: a define_func upgrades the region to a real function view;
- # anything else re-reads the (still function-less) listing in place.
- anchor = anchor or ViewAnchor()
- anchor.flash = f"{verb} @ {ea:#x} (Ctrl+S to save)"
- fn = self.program.function_of(ea)
- if fn is not None:
- model = self.program.disasm(fn.addr, fn.name)
- idx = 0 if ea == fn.addr else model.index_of_ea(ea)
- _cur, top = self._anchor_rows(anchor, model, ea)
- self.app.call_from_thread(
- self._open_at, fn.addr, fn.name, idx, False, -1, 0, False,
- None, top)
- else:
- 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
- _cur, top = self._anchor_rows(anchor, lm, ea)
- self.app.call_from_thread(
- self._open_at, ea, name, idx, False, -1, 0, True, None, top)
- self.app.call_from_thread(self._edit_done, anchor)
+ def action_seek_next_hit(self) -> None:
+ self.trace_ctl.seek_hit(1)
- def _edit_done(self, anchor: ViewAnchor) -> None:
- """One place where an edit's aftermath is settled.
+ def action_seek_prev_hit(self) -> None:
+ self.trace_ctl.seek_hit(-1)
- The reload this edit triggered will write its own status when it lands —
- after this — so the message is handed over as a flash rather than
- written and lost.
- """
- self._dirty = True
- if anchor.flash:
- self._status(anchor.flash, priority=True)
- if anchor.refresh_functions:
- # Creating (or destroying) a function changes the index that the
- # names pane, Ctrl+N and the "no functions" hint all read. Without
- # this, `p` gave you a function the rest of the app couldn't see.
- self._reindex_functions()
+ def action_seek_reg_write(self) -> None:
+ self.trace_ctl.seek_reg_write()
+
+ def action_step_fwd(self) -> None:
+ self.trace_ctl.step(1)
+
+ def action_step_back(self) -> None:
+ self.trace_ctl.step(-1)
+
+ def action_step_over_fwd(self) -> None:
+ self.trace_ctl.step_over(1)
+
+ def action_step_over_back(self) -> None:
+ self.trace_ctl.step_over(-1)
@work(thread=True, exclusive=True, group="load-funcs")
def _reindex_functions(self) -> None:
@@ -5326,7 +7256,8 @@ class IdaTui(App):
def _save(self) -> None:
assert self.program is not None
try:
- self.program.client.call("idb_save", timeout=300.0)
+ self.journal.flush(self.program) # ride along into the .i64
+ self.program.client.save_database()
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._status, f"save failed: {e}")
return
@@ -5381,7 +7312,8 @@ class IdaTui(App):
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, ea, name, idx, push, -1, 0, fn is None, focus_name)
+ self._open_at_if_current, seq, ea, name, idx, push, fn is None,
+ focus_name)
def _open_decomp_entry(self, fn_addr: int, fn_name: str, dec_idx: int,
dec_cursor_x: int, push: bool,
@@ -5399,7 +7331,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"
@@ -5554,6 +7486,18 @@ class IdaTui(App):
cur = row_of(fallback_ea)
return (cur, row_of(a.top_ea))
+ def _open_at_if_current(self, seq: int, ea: int, name: str, cursor: int,
+ push: bool, is_region: bool,
+ focus_name: str | None) -> None:
+ """Apply a navigation result only if it's still the one being awaited.
+
+ The decompiler path has had this since 756589a; the listing path hadn't,
+ so a slow navigation could still land on top of a newer one.
+ """
+ if seq != self._nav_seq:
+ return
+ self._open_at(ea, name, cursor, push, -1, 0, is_region, focus_name)
+
def _open_at(self, ea: int, name: str, cursor: int, push: bool,
dec_cursor: int = -1, dec_cursor_x: int = 0,
is_region: bool = False, focus_name: str | None = None,
@@ -5602,37 +7546,22 @@ class IdaTui(App):
self._flash = None
if event.key != "escape":
return
- if self.query_one("#search", Input).display:
- event.stop()
- event.prevent_default()
- self._end_search(cancel=True)
- return
- if self.query_one("#rename", Input).display:
+ # Esc closes whichever prompt is up. This used to be one copy of these
+ # four lines per prompt, which is how they drifted apart -- search
+ # cancelled its highlight, goto restored focus, the edit prompts did
+ # neither consistently.
+ prompt = self.prompts.active()
+ if prompt is not None:
event.stop()
event.prevent_default()
- self._end_rename()
- return
- if self.query_one("#comment", Input).display:
- event.stop()
- event.prevent_default()
- self._end_comment()
- return
- if self.query_one("#retype", Input).display:
- event.stop()
- event.prevent_default()
- self._end_retype()
- return
- if self.query_one("#makedata", Input).display:
- event.stop()
- event.prevent_default()
- self._end_makedata()
- return
- if self.query_one("#goto", Input).display:
- event.stop()
- event.prevent_default()
- self._end_goto()
- (self.query_one(HexView) if self._active == "hex"
- else self._code_view()).focus()
+ if prompt.id == "search":
+ self._end_search(cancel=True)
+ elif prompt.id in ("goto", "export"):
+ self._end_goto() if prompt.id == "goto" else self._end_export()
+ (self.query_one(HexView) if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))).focus()
+ else:
+ prompt.close()
return
fi = self.query_one("#func-filter", Input)
if fi.display:
@@ -5657,42 +7586,32 @@ class IdaTui(App):
view.repeat_last(direction)
self._end_search()
return
- if inp.id == "rename":
- view, old = self._rename_ctx
- addr = self._rename_addr # capture before _end_rename clears it
- self._end_rename()
- if addr is not None: # listing: name this address (create a label)
- if value and value != old:
- self._do_name_addr(addr, value)
- return
- if view is not None and value and value != old:
- self._do_rename(view, old, value)
- return
- if inp.id == "comment":
- view, ea, existing = self._comment_ctx
- self._end_comment()
- if view is not None and value != existing: # empty value clears it
- self._do_comment(ea, value)
- return
- if inp.id == "retype":
- view, kind, subject, word = self._retype_ctx
- self._end_retype()
- if view is not None and value:
- self._do_retype(kind, subject, word, value)
- return
- if inp.id == "makedata":
- view, ea = self._makedata_ctx
- self._end_makedata()
- if view is not None and value:
- self._do_make_data(ea, value, self._anchor())
+ # The edit prompts all submit the same way: take the context the prompt
+ # was holding (close() hands it over, so it can't be read twice or go
+ # stale) and let the controller decide what to do with it.
+ submit = {"rename": self.edits.submit_rename,
+ "comment": self.edits.submit_comment,
+ "retype": self.edits.submit_retype,
+ "makedata": self.edits.submit_make_data}.get(inp.id or "")
+ if submit is not None:
+ ctx = self.prompts[inp.id].close()
+ if ctx is not None:
+ submit(ctx, value)
return
if inp.id == "goto":
self._end_goto()
- (self.query_one(HexView) if self._active == "hex"
- else self._code_view()).focus()
+ (self.query_one(HexView) if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))).focus()
if value:
self._goto(value)
return
+ if inp.id == "export":
+ self._end_export()
+ (self.query_one(HexView) if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))).focus()
+ if value:
+ self.export_findings(value)
+ return
# filter mode: already applied incrementally; Enter just confirms + closes.
self._apply_filter(value)
self.query_one("#func-table", DataTable).focus()
@@ -5705,15 +7624,63 @@ class IdaTui(App):
"""
return self._active_code_view()
+ def _line_ea_for(self, view) -> int | None: # type: ignore[no-untyped-def]
+ """Address of the line under the cursor in either code view.
+
+ Shared, not an edit helper: the comment prompt, the goto prompt's
+ readback and rpc.py's `where` all ask the same question.
+ """
+ if isinstance(view, ListingView):
+ return view._cursor_ea()
+ if isinstance(view, DecompView):
+ 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)."""
- if self._active in ("listing", "disasm"):
+ """The currently-shown code widget (for reading the cursor address).
+
+ Everything downstream trusts ``_active``, so a new mode that forgets to
+ appear here doesn't degrade -- it returns None and the first caller that
+ dereferences it crashes the app (which is exactly how graph mode
+ announced itself the first time it was driven).
+ """
+ if self.is_listing:
return self.query_one(ListingView)
- if self._active == "decomp":
+ if self.is_decomp:
return self.query_one(DecompView)
+ if self.is_graph:
+ return self.query_one(GraphView)
return None
- def _palette_action(self, name: str) -> None:
+ def _focus_code_view(self) -> None:
+ """Put focus back on whichever code view is showing."""
+ (self._active_code_view() or self.query_one(ListingView)).focus()
+
+ def _palette_action(self, name: str, *args) -> None:
"""Run a cursor-scoped code-view action (rename/xrefs/follow/…) picked from
the command palette against the active code view."""
view = self._active_code_view()
@@ -5725,7 +7692,7 @@ class IdaTui(App):
if fn is None:
self._status(f"'{name}' isn't available in this view")
return
- fn()
+ fn(*args)
def action_continuous_here(self) -> None:
"""'L': open the continuous segment listing at the cursor — one long
@@ -5761,9 +7728,16 @@ 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.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)
+ return
# Navigate to the containing function at the right line (handles both a
# function name and a mid-function address).
self._do_navigate(ea, push=True)
@@ -5806,9 +7780,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:
@@ -5819,7 +7793,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
@@ -5848,13 +7822,19 @@ 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
+ # async and re-checks _cur, so a fast second jump just drops the stale one.
+ if self._graph_sticky and entry.ea:
+ self._load_graph(entry.ea, entry.ea)
# Prompt overlays that own the keyboard while visible; a background
# navigation must not yank focus out from under them (else typed keys leak
# into a code view as destructive verbs — e.g. 'u' = undefine).
- _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "func-filter")
+ _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "export",
+ "func-filter")
def _prompt_active(self) -> bool:
for iid in self._PROMPT_IDS:
@@ -5869,12 +7849,13 @@ class IdaTui(App):
dec = self.query_one(DecompView)
lst = self.query_one(ListingView)
hx = self.query_one(HexView)
+ gv = self.query_one(GraphView)
# 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 = False
+ hx.display = gv.display = False
lst.display = dec.display = True
self.query_one("#panes").set_class(True, "split")
busy = self._cur is not None and dec.loaded_ea != self._cur.ea
@@ -5884,7 +7865,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
@@ -5900,13 +7881,19 @@ class IdaTui(App):
self._split_eamap = []
self._split_ea2line = {}
self._split_range = None
- dec.display = lst.display = hx.display = False
- if self._active in ("listing", "disasm"):
+ dec.display = lst.display = hx.display = gv.display = False
+ if self.is_graph:
+ gv.display = True
+ if grab:
+ gv.focus()
+ self._graph_status()
+ return
+ 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()
@@ -5947,7 +7934,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:
@@ -5977,6 +7964,12 @@ class IdaTui(App):
assert self.program is not None
dec = self.program.decompile(ea)
why = ""
+ if not dec.failed:
+ # Where the number literals are, fetched in the same worker as the
+ # decompile (it is one call and the answer is cached with it) so the
+ # view can mark the one under the cursor without a round trip per
+ # keypress.
+ self._pending_nums = self.program.pc_nums(ea)
if dec.failed:
# Ask Hex-Rays why, in the same worker: the plain tool reports
# "Decompilation failed at 0x0" and drops the only useful part.
@@ -5994,14 +7987,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"
@@ -6010,11 +8003,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
@@ -6025,6 +8021,7 @@ class IdaTui(App):
sx = cur.dec_scroll_x if same else 0
note = " (truncated)" if dec.truncated else ""
view.show(ea, dec.code or "", cursor=c, cursor_x=cx, scroll_y=sy, scroll_x=sx)
+ view.set_nums(self._pending_nums)
if self._split:
self._sync_split(self._active) # crude link now
self._load_split_map(ea) # then upgrade to the region map
@@ -6033,11 +8030,22 @@ class IdaTui(App):
self._status(
f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
- def _sync_split(self, source: str) -> None:
+ def _sync_split(self, source: str, resync: bool = True) -> None:
"""Split view: highlight (+ scroll into view) the companion pane's
location for the focused pane's cursor. The companion only gets a band +
scroll (its cursor never moves), so there is no echo/ping-pong. Uses the
- rich per-line ea map (decomp_map) when loaded, else the single marker."""
+ rich per-line ea map (decomp_map) when loaded, else the single marker.
+
+ ``resync=False`` means "the decompiler has just been re-pointed for this
+ anchor, don't ask again". Without it this recursed forever: _split_range
+ is the min/max of the decomp_map's addresses, which does NOT cover every
+ address in the function (Hex-Rays doesn't attribute them all), so an
+ anchor inside the loaded function but outside that span asked for a
+ resync, got back the function already loaded, re-entered here, and asked
+ again -- one worker and one lookup_funcs round trip per iteration, for as
+ long as the cursor sat there. 21,156 calls for a single address in one
+ scenario, and in the live app an idle split view pegging the worker.
+ """
if not self._split or self.program is None:
return
lst = self.query_one(ListingView)
@@ -6070,7 +8078,7 @@ class IdaTui(App):
dec.set_link(None)
return
rng = self._split_range
- if rng is not None and not (rng[0] <= ea <= rng[1]):
+ if resync and rng is not None and not (rng[0] <= ea <= rng[1]):
# left the decompiled function — follow to whatever function is
# under the anchor (the unified view spans many functions).
self._resync_decomp(ea)
@@ -6096,11 +8104,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)
@@ -6120,7 +8128,10 @@ class IdaTui(App):
return
self._cur.ea, self._cur.name = fn.addr, fn.name
if dec.loaded_ea == fn.addr: # already decompiled (scrolled back): just relink
- self._sync_split("listing")
+ # resync=False: we ARE the resync. Re-entering the resync branch is
+ # how this looped forever for an address the decomp_map's span
+ # doesn't cover.
+ self._sync_split("listing", resync=False)
return
dec.loading = True
self._load_decomp(fn.addr, fn.name) # -> _apply_decomp -> map -> re-sync
@@ -6129,7 +8140,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])
@@ -6167,8 +8178,18 @@ class IdaTui(App):
self.app.call_from_thread(self._apply_split_map, ea, m)
def _apply_split_map(self, ea: int, m: list) -> None:
- if not self._split or self._cur is None or self._cur.ea != ea:
- return # left split / navigated away
+ """Index the per-line instruction map for the decompiled function.
+
+ Keyed to what the DECOMPILER holds, not to _cur, and not conditional on
+ split being on. The old guard dropped the result whenever _cur had moved
+ while the fetch was in flight — during trace stepping that is almost
+ always — leaving the split view working from the map of the function you
+ just left. _cur follows the cursor; this map describes the pseudocode on
+ screen, and those are different things.
+ """
+ dec = self._try_view(DecompView)
+ if dec is not None and dec.loaded_ea is not None and ea != dec.loaded_ea:
+ return # a stale fetch for a function we no longer show
self._split_eamap = m
self._split_ea2line = {}
alleas = []
@@ -6179,7 +8200,12 @@ class IdaTui(App):
# ea span of the decompiled function: when the listing cursor leaves it,
# _sync_split re-points the decomp to the function under the cursor.
self._split_range = (min(alleas), max(alleas)) if alleas else None
- self._sync_split(self._active) # re-link with the region map
+ # ONE index, shared with the trace path: it used to keep a parallel copy
+ # of exactly this, fetched separately and keyed differently, which is how
+ # the two ended up describing different functions.
+ self.trace_ctl.adopt_map(ea, m, self._split_ea2line, self._split_range)
+ if self._split:
+ self._sync_split(self._active) # re-link with the region map
def on_decomp_view_cursor_moved(self, msg: DecompView.CursorMoved) -> None:
dv = self._try_view(DecompView)
@@ -6187,7 +8213,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
@@ -6213,7 +8239,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
@@ -6224,7 +8250,9 @@ class IdaTui(App):
"(c code · p func · u undefine · Enter follow)")
# -- teardown ---------------------------------------------------------- #
- def on_unmount(self) -> None:
+ async def on_unmount(self) -> None:
+ if self._rpc is not None:
+ await self._rpc.stop()
if self._ka is not None:
self._ka.stop()
if self.program is not None:
@@ -6236,7 +8264,7 @@ class IdaTui(App):
elif self.client is not None:
if self._save_on_exit is None and self._dirty:
try: # unexpected teardown with edits: don't drop them
- self.client.call("idb_save", timeout=600.0)
+ self.client.save_database()
except Exception: # noqa: BLE001
pass
self.client.close()