From 02d02417800184fb76cd0245cdaa94c437aa4081 Mon Sep 17 00:00:00 2001 From: blasty Date: Fri, 21 Aug 2026 12:14:46 +0200 Subject: reformat: ruff format + import sort, mechanically (see ruff.toml) No behavior. Listed in .git-blame-ignore-revs (next commit). --- idatui/app.py | 1957 +++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 1282 insertions(+), 675 deletions(-) (limited to 'idatui/app.py') diff --git a/idatui/app.py b/idatui/app.py index 1ee7459..75dc9d1 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -37,26 +37,28 @@ from textual.containers import Horizontal, Vertical, VerticalScroll from textual.geometry import Region, Size from textual.message import Message from textual.reactive import reactive -from textual.theme import Theme from textual.screen import ModalScreen from textual.scroll_view import ScrollView from textual.strip import Strip +from textual.theme import Theme from textual.widgets import ( - DataTable, Input, OptionList, Static, TextArea, + DataTable, + Input, + OptionList, + Static, + TextArea, ) from textual.widgets.option_list import Option -from . import diag, graph, kittygfx +from . import diag, findings, graph, kittygfx, search +from .domain import Func, Head, ListingModel, Program, Struct from .edit_ctl import EditController -from .prompt import PromptBar -from .trace_ctl import TraceController -from . import findings, search +from .errors import IDAConnectionError from .highlight import CTextArea, highlight_c from .journal import Journal - -from .errors import IDAConnectionError from .nexus_client import NexusClient, registered_database -from .domain import Func, Head, ListingModel, Program, Struct +from .prompt import PromptBar +from .trace_ctl import TraceController # Styles for the disassembly listing. _S_ADDR = Style(color="#6b7684") @@ -68,28 +70,28 @@ _S_INSN = Style(color="#c3cad3") #: text), HUES only where they mean something (numbers, strings, symbols), #: structure recedes so brackets and commas stop competing with operands. _S_SPAN = { - "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive - "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight - "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets - "str": Style(color="#9ece6a"), # 9.9:1 string literals - "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets - "seg": Style(color="#93aee0"), # 8.1:1 segment names - "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1 - "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/- - "err": Style(color="#c9762f"), # IDA's own error marker - "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified + "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive + "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight + "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets + "str": Style(color="#9ece6a"), # 9.9:1 string literals + "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets + "seg": Style(color="#93aee0"), # 8.1:1 segment names + "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1 + "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/- + "err": Style(color="#c9762f"), # IDA's own error marker + "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified } _S_MNEM = Style(color="#e8ecf2") _S_OPBYTES = Style(color="#5e6875") # raw opcode bytes column _S_DATA = Style(color="#d8a657") _S_UNK = Style(color="#7c8b9e", italic=True) # undefined bytes in the flat listing _S_MEMBER = Style(color="#93aee0") -_S_SEP = Style(color="#5e6875") # function boundary separators / banners +_S_SEP = Style(color="#5e6875") # function boundary separators / banners _S_FUNCHDR = Style(color="#7aa2f7", bold=True) # 'name proc'/'endp' headers -_LST_INDENT = " " # one depth level: function names sit at level 0, code at 1 -_OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode -_JUMP_CONTEXT = 4 # lines of context kept above a jump target (cursor stays on it) +_LST_INDENT = " " # one depth level: function names sit at level 0, code at 1 +_OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode +_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 @@ -117,10 +119,10 @@ class ViewMode(StrEnum): 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 + 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. @@ -128,21 +130,47 @@ class ViewMode(StrEnum): 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({ - "db", "dw", "dd", "dq", "dt", "byte", "word", "dword", "qword", "tbyte", - "offset", "short", "near", "far", "ptr", "dup", "cs", "ds", "es", "fs", - "gs", "ss", "align", "public", "assume", "end", -}) +_ASM_KEYWORDS = frozenset( + { + "db", + "dw", + "dd", + "dq", + "dt", + "byte", + "word", + "dword", + "qword", + "tbyte", + "offset", + "short", + "near", + "far", + "ptr", + "dup", + "cs", + "ds", + "es", + "fs", + "gs", + "ss", + "align", + "public", + "assume", + "end", + } +) _S_CURSOR = Style(bgcolor="#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 +_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. @@ -157,12 +185,12 @@ _S_WORD = Style(bgcolor="#2a3f5f") # identifier under the cursor #: _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_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 -_S_DECOMP_SPIN = Style(color="#d0a215", bold=True) # 'decompiling' spinner glyph +_S_DECOMP_SPIN = Style(color="#d0a215", bold=True) # 'decompiling' spinner glyph _S_DECOMP_WAIT = Style(color="#7c8b9e", italic=True) # 'decompiling' label -_S_DECOMP_DOTS = Style(color="#626c7a") # trailing ellipsis +_S_DECOMP_DOTS = Style(color="#626c7a") # trailing ellipsis _S_LINK = Style(bgcolor="#233044") # split view: rows linked to the other pane's cursor # Hex-Rays appends a `/*0xEA*/` address marker to each pseudocode line (we fetch @@ -206,8 +234,8 @@ class ViewAnchor: """ view: str = "listing" - ea: int | None = None # cursor address - top_ea: int | None = None # first visible address + ea: int | None = None # cursor address + top_ea: int | None = None # first visible address cursor_x: int = 0 flash: str | None = None #: The edit changed which functions exist, so the index must be rebuilt. @@ -218,12 +246,12 @@ class ViewAnchor: class NavEntry: ea: int name: str - cursor: int = 0 # disasm line (instruction index) - cursor_x: int = 0 # disasm column - scroll_y: int = -1 # disasm viewport top (-1 = derive from cursor) - dec_cursor: int = 0 # pseudocode line + cursor: int = 0 # disasm line (instruction index) + cursor_x: int = 0 # disasm column + scroll_y: int = -1 # disasm viewport top (-1 = derive from cursor) + dec_cursor: int = 0 # pseudocode line dec_cursor_x: int = 0 # pseudocode column - dec_scroll_y: int = -1 # pseudocode viewport top (-1 = derive) + dec_scroll_y: int = -1 # pseudocode viewport top (-1 = derive) dec_scroll_x: int = 0 # pseudocode horizontal scroll is_region: bool = False # not inside a function (flat listing view) view: str = "listing" # which code view to restore this entry in @@ -417,7 +445,9 @@ def _overlay_over(strip: Strip, ranges: list[tuple[int, int]], style: Style) -> if a > pos: parts.append(strip.crop(pos, a)) mid = strip.crop(a, b) - parts.append(Strip([Segment(s.text, (s.style or Style()) + style) for s in mid])) + parts.append( + Strip([Segment(s.text, (s.style or Style()) + style) for s in mid]) + ) pos = b if pos < total: parts.append(strip.crop(pos, total)) @@ -638,11 +668,11 @@ class _MatchRanges: __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._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._text = text # callable: line index -> str | None self._cache: dict[int, list[tuple[int, int]]] = {} def _find(self, i: int) -> list[tuple[int, int]]: @@ -770,7 +800,7 @@ class SearchMixin: starts.append(pos) parts.append(s) pos += len(s) + 1 - while len(starts) < count: # a short window: keep the indices lined up + while len(starts) < count: # a short window: keep the indices lined up starts.append(pos) parts.append("") pos += 1 @@ -831,8 +861,11 @@ class SearchMixin: def _after_incremental(self) -> None: self._compute_matches() self.refresh() - self._jump_from(getattr(self, "_search_origin", 0), - getattr(self, "_search_dir", 1), include_current=True) + self._jump_from( + getattr(self, "_search_origin", 0), + getattr(self, "_search_dir", 1), + include_current=True, + ) n = len(self._matches) self._app_status(f"/{self._term} {n} match{'' if n == 1 else 'es'}") @@ -840,13 +873,23 @@ class SearchMixin: if not self._matches: return if direction >= 0: - nxt = next((m for m in self._matches - if (m >= origin if include_current else m > origin)), - self._matches[0]) + nxt = next( + ( + m + for m in self._matches + if (m >= origin if include_current else m > origin) + ), + self._matches[0], + ) else: - nxt = next((m for m in reversed(self._matches) - if (m <= origin if include_current else m < origin)), - self._matches[-1]) + nxt = next( + ( + m + for m in reversed(self._matches) + if (m <= origin if include_current else m < origin) + ), + self._matches[-1], + ) self._goto_line(nxt) def search_commit(self) -> None: @@ -860,7 +903,9 @@ class SearchMixin: 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) + self.scroll_to( + y=max(self.cursor - self._visible_height() // 2, 0), animate=False + ) self.refresh() def repeat_last(self, direction: int) -> None: @@ -870,8 +915,13 @@ class SearchMixin: return self._term = term self._ci = term.islower() - self._search_ensure(lambda: (self._compute_matches(), self.refresh(), - self.search_repeat(direction))) + self._search_ensure( + lambda: ( + self._compute_matches(), + self.refresh(), + self.search_repeat(direction), + ) + ) def _compute_matches(self) -> None: term = self._term @@ -910,8 +960,7 @@ class SearchMixin: # 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) + 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 -- @@ -923,8 +972,14 @@ class SearchMixin: # 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]): + 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: @@ -948,10 +1003,18 @@ class SearchMixin: return cur = self.cursor if direction >= 0: - nxt = next((m for m in self._matches - if (m >= cur if include_current else m > cur)), self._matches[0]) + nxt = next( + ( + m + for m in self._matches + if (m >= cur if include_current else m > cur) + ), + self._matches[0], + ) else: - nxt = next((m for m in reversed(self._matches) if m < cur), self._matches[-1]) + nxt = next( + (m for m in reversed(self._matches) if m < cur), self._matches[-1] + ) self._goto_line(nxt) k = self._matches.index(nxt) + 1 self._app_status(f"/{self._term}/ {k}/{len(self._matches)} line {nxt}") @@ -963,7 +1026,9 @@ class SearchMixin: ranges = self._ranges.get(self.cursor) if ranges: self.cursor_x = ranges[0][0] - self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False) + self.scroll_to( + y=max(self.cursor - self._visible_height() // 2, 0), animate=False + ) self._hscroll() # bring the match column into horizontal view self.refresh() self._refresh_hl() @@ -1054,8 +1119,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._term = "" self._matches: list[int] = [] self._ranges: dict[int, list[tuple[int, int]]] = {} - self._op_mode = 1 # opcode column: 0=off, 1=limited, 2=full ('o' cycles) - self._op_w = 0 # char width of the hex-bytes field (excl. gap) + self._op_mode = 1 # opcode column: 0=off, 1=limited, 2=full ('o' cycles) + self._op_w = 0 # char width of the hex-bytes field (excl. gap) self._search_loading = False self._search_pending: list = [] # done-callbacks awaiting the load self._link_rows: set[int] = set() # split-view: linked instruction rows @@ -1161,9 +1226,15 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._refresh_hl() # -- public API -------------------------------------------------------- # - def load(self, model: ListingModel, name: str, cursor: int = 0, - cursor_x: int = 0, scroll_y: int | None = None, - focus: str | None = None) -> None: + def load( + self, + model: ListingModel, + name: str, + cursor: int = 0, + cursor_x: int = 0, + scroll_y: int | None = None, + focus: str | None = None, + ) -> None: previous, self.model = self.model, model self._name = name self.total = 0 @@ -1265,8 +1336,10 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._reset_search_cache(body=True) self._clamp_x() self.refresh() - self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)", - 2: "full"}[self._op_mode]) + self._app_status( + "opcodes: " + + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)", 2: "full"}[self._op_mode] + ) @work(thread=True, exclusive=True, group="listing-grow") def _grow(self) -> None: @@ -1355,20 +1428,28 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru if h is None: strip = Strip([Segment(f" {idx:>8} …", _S_DIM)]) elif h.kind == "sep": - strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR), - Segment(_LST_INDENT + h.text, _S_SEP)]) + strip = Strip( + [ + Segment(f"{h.ea:08X} ", _S_ADDR), + Segment(_LST_INDENT + h.text, _S_SEP), + ] + ) elif h.kind == "funchdr": # depth-0: address + 'name proc'/'endp' (no indent) - strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR), - Segment(h.text, _S_FUNCHDR)]) + strip = Strip( + [Segment(f"{h.ea:08X} ", _S_ADDR), Segment(h.text, _S_FUNCHDR)] + ) elif h.kind == "label": # depth-0: address + 'loc_XXX:' on its own line - strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR), - Segment(h.text, _S_LABEL)]) + strip = Strip( + [Segment(f"{h.ea:08X} ", _S_ADDR), Segment(h.text, _S_LABEL)] + ) else: # depth-1: address, one indent, then opcode+text - segs: list[Segment] = [Segment(f"{h.ea:08X} ", _S_ADDR), - Segment(_LST_INDENT, _S_INSN)] + segs: list[Segment] = [ + Segment(f"{h.ea:08X} ", _S_ADDR), + Segment(_LST_INDENT, _S_INSN), + ] op = self._op_field(h) if op: segs.append(Segment(op, _S_OPBYTES)) @@ -1389,8 +1470,12 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru 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) + _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)) @@ -1686,8 +1771,15 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True elif self.cursor_x >= sx + width: self.scroll_to(x=self.cursor_x - width + 1, animate=False) - def show(self, ea: int, text: str, cursor: int = 0, cursor_x: int = 0, - scroll_y: int = -1, scroll_x: int = 0) -> None: + def show( + self, + ea: int, + text: str, + cursor: int = 0, + cursor_x: int = 0, + scroll_y: int = -1, + scroll_x: int = 0, + ) -> None: # Pull each line's `/*0xEA*/` marker into _line_eas, then strip it from # the displayed text (clutter) before highlighting. Stripping only edits # within lines, so line indices still align with the domain's raw code. @@ -1725,8 +1817,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self._hscroll() self.refresh() - def goto(self, cursor: int, cursor_x: int = 0, scroll_y: int = -1, - scroll_x: int = 0) -> None: + def goto( + self, cursor: int, cursor_x: int = 0, scroll_y: int = -1, scroll_x: int = 0 + ) -> None: """Move the cursor/scroll on the already-loaded text (no re-highlight). Used to jump to a target inside the function already displayed, e.g. an xref/goto that resolves to this same function. @@ -1778,7 +1871,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True return self._line_eas[idx] if 0 <= idx < len(self._line_eas) else None def _after_cursor_move(self) -> None: - self.post_message(DecompView.CursorMoved(self.cursor, self._line_ea(self.cursor))) + self.post_message( + DecompView.CursorMoved(self.cursor, self._line_ea(self.cursor)) + ) @property def total(self) -> int: @@ -1808,8 +1903,12 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True 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) + _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: @@ -1818,12 +1917,13 @@ 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) + 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) + code_w, _S_LINK if linked else None + ) if gw <= 0: return code style = _S_LINENO_CUR if idx == self.cursor else _S_LINENO @@ -2104,16 +2204,16 @@ class HexView(ScrollView, can_focus=True): panes. Matches ``render_line``'s layout: addr(9) + file-offset(10) + 16 hex cells of 3 cols (with a 1-col gap before byte 8), then ' |' + ASCII.""" HEX, ASCII = 19, 70 - if x < HEX: # clicked the address/offset gutter -> row start + if x < HEX: # clicked the address/offset gutter -> row start return 0 - if x < HEX + 49: # hex byte region + if x < HEX + 49: # hex byte region rel = x - HEX - if rel >= 24: # collapse the 1-col gap between the two halves + if rel >= 24: # collapse the 1-col gap between the two halves rel -= 1 return min(rel // 3, 15) - if x < ASCII: # the ' |' separator -> last byte of the row + if x < ASCII: # the ' |' separator -> last byte of the row return 15 - return min(x - ASCII, 15) # ASCII pane (and anything past it) + return min(x - ASCII, 15) # ASCII pane (and anything past it) def on_click(self, event) -> None: # type: ignore[no-untyped-def] if self.model is None or self.model.size == 0: @@ -2240,12 +2340,12 @@ class HexView(ScrollView, can_focus=True): # --------------------------------------------------------------------------- # # 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_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 +_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 = { @@ -2269,7 +2369,7 @@ _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 +_GPAD = 1 # columns of padding inside a box _MINI_W, _MINI_H = 30, 14 @@ -2317,7 +2417,7 @@ class _CellRow: b = self.width if b <= a: return - self.ch[a:b] = s[a - i:b - i] + 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: @@ -2403,7 +2503,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): def __init__(self) -> None: super().__init__() - self.fc = None # domain.Flowchart + self.fc = None # domain.Flowchart self.lay: graph.Layout | None = None self.loaded_ea: int | None = None self._blocks: dict[int, object] = {} @@ -2414,7 +2514,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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._drag_map = False # the drag started on the minimap self._hl_word = "" self.trail: dict[int, str] | None = None @@ -2445,10 +2545,13 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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) + 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): @@ -2457,7 +2560,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): if b is None: return [] if self._zoom == 2: - return [None] # one synthetic summary row + return [None] # one synthetic summary row return b.rows def _row_plain(self, nid: int, i: int) -> str: @@ -2477,15 +2580,16 @@ class GraphView(NavMixin, ScrollView, can_focus=True): @staticmethod def _head_text(h) -> str: - return (f"{h.name} {h.text}" if h.name else h.text) + 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)]) + widest = max( + [len(label) + 4] + [len(self._row_plain(nid, i)) for i in range(n)] + ) return (widest + 2 * _GPAD + 2, n + 2) # -- geometry --------------------------------------------------------- # @@ -2647,8 +2751,10 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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) + 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 @@ -2675,7 +2781,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): self._clamp_cursor() self._center_cursor() self.refresh(layout=True) - self.app._graph_status() # keeps the function name; names the zoom + self.app._graph_status() # keeps the function name; names the zoom def action_minimap(self) -> None: self._show_minimap = not self._show_minimap @@ -2691,8 +2797,10 @@ class GraphView(NavMixin, ScrollView, can_focus=True): function. Cheaper to look than to argue. """ from . import graph_triskel - choices = ["auto", "native"] + (["triskel"] if graph_triskel.available() - else []) + + choices = ["auto", "native"] + ( + ["triskel"] if graph_triskel.available() else [] + ) self._engine = choices[(choices.index(self._engine) + 1) % len(choices)] self._relayout() self._clamp_cursor() @@ -2702,8 +2810,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True): # 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})") + 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']}" @@ -2781,10 +2890,12 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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: @@ -2818,11 +2929,17 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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 + 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 @@ -2845,21 +2962,23 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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 + 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 + 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) + 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 + return True # already there; don't churn while dragging self.cursor_node = n.id self.cursor_row = 0 self.cursor_x = 0 @@ -2876,7 +2995,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): return if self._minimap_seek(off.x, off.y): self._drag = None - self._drag_map = True # keep scrubbing while the button is held + 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) @@ -2886,7 +3005,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): self._drag = None self._drag_map = False if was_pan: - self._snap_into_view() # don't leave them adrift in the padding + 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: @@ -2901,8 +3020,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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) + 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: @@ -2923,8 +3043,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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_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() @@ -2948,7 +3067,8 @@ class GraphView(NavMixin, ScrollView, can_focus=True): # 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(): + 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) @@ -2961,8 +3081,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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: + 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 @@ -2973,19 +3092,24 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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) + 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) + 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) + 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) @@ -2997,7 +3121,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): text_col = left + 1 + _GPAD h = rows[i] plain = self._row_plain(n.id, i) - if h is None: # collapsed summary + if h is None: # collapsed summary out.text(text_col, plain, _S_GDIM) else: c = text_col @@ -3019,9 +3143,15 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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) + 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) @@ -3049,8 +3179,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True): 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): + 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: @@ -3069,7 +3200,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True): # 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 + 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 @@ -3118,7 +3249,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True): # --------------------------------------------------------------------------- # class FunctionsPanel(Vertical): def compose(self) -> ComposeResult: - self._filter = Input(placeholder="filter (glob, e.g. sub_*) — Enter to apply", id="func-filter") + self._filter = Input( + placeholder="filter (glob, e.g. sub_*) — Enter to apply", id="func-filter" + ) self._filter.display = False yield self._filter table = DataTable(id="func-table", cursor_type="row", zebra_stripes=True) @@ -3188,8 +3321,9 @@ class XrefsScreen(ModalScreen): BINDINGS = [Binding("escape", "close", "Close")] - def __init__(self, label: str, items: list[tuple[object, str]], - preselect: int = 0) -> None: + def __init__( + self, label: str, items: list[tuple[object, str]], preselect: int = 0 + ) -> None: # payload is an int address, or (binary, address) for a caller in another # project binary; dismiss() hands it back untouched. super().__init__() @@ -3271,8 +3405,8 @@ class SymbolPalette(OptionListNav, ModalScreen): def __init__(self, funcs: list[Func], index=None, binary=None) -> None: super().__init__() self._funcs = funcs - self._index = index # ProjectIndex, when this is a project - self._binary = binary # label of the binary we're currently in + self._index = index # ProjectIndex, when this is a project + self._binary = binary # label of the binary we're currently in self._project_scope = False #: (binary|None, addr, name) — binary is None for a local hit self._results: list[tuple] = [] @@ -3280,8 +3414,10 @@ class SymbolPalette(OptionListNav, ModalScreen): def compose(self) -> ComposeResult: 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 Input( + placeholder="fuzzy find symbol… ↑↓ select · Enter open · Esc close", + id="pal-input", + ) yield OptionList(id="pal-list") def on_mount(self) -> None: @@ -3306,13 +3442,17 @@ class SymbolPalette(OptionListNav, ModalScreen): # rows: (binary|None, addr, name, match positions) if self._project_scope and self._index is not None: from .index import KIND_FUNC + # The trigram index already guarantees every hit CONTAINS the query, # so ranking only has to order them — an exact-substring rank (match # position, then name length) costs a find() per row instead of a # full fuzzy pass, and fetching 3x the display limit rather than 10x # keeps the per-keystroke work down on a big project. - hits = self._index.search(query, kind=KIND_FUNC, - limit=self.PROJECT_LIMIT * 3) if query else [] + hits = ( + self._index.search(query, kind=KIND_FUNC, limit=self.PROJECT_LIMIT * 3) + if query + else [] + ) q = query.lower() scored = [] for h in hits: @@ -3322,9 +3462,15 @@ class SymbolPalette(OptionListNav, ModalScreen): # on (position, length, text), and a bare sort() would then fall # through to comparing Hit objects, which aren't orderable. scored.sort(key=lambda t: (t[0], t[1], t[2], t[3].binary, t[3].addr)) - rows = [(h.binary, h.addr, h.text, - tuple(range(at, at + len(q))) if at < (1 << 30) else ()) - for at, _, _, h in scored[:self.PROJECT_LIMIT]] + rows = [ + ( + h.binary, + h.addr, + h.text, + tuple(range(at, at + len(q))) if at < (1 << 30) else (), + ) + for at, _, _, h in scored[: self.PROJECT_LIMIT] + ] elif query: scored = [] for f in self._funcs: @@ -3332,9 +3478,9 @@ class SymbolPalette(OptionListNav, ModalScreen): if m is not None: scored.append((m[0], m[1], f)) scored.sort(key=lambda t: (-t[0], t[2].name)) - rows = [(None, f.addr, f.name, pos) for _, pos, f in scored[:self.LIMIT]] + rows = [(None, f.addr, f.name, pos) for _, pos, f in scored[: self.LIMIT]] else: - rows = [(None, f.addr, f.name, ()) for f in self._funcs[:self.LIMIT]] + rows = [(None, f.addr, f.name, ()) for f in self._funcs[: self.LIMIT]] self._results = [(b, a, n) for b, a, n, _ in rows] ol = self.query_one(OptionList) ol.clear_options() @@ -3356,10 +3502,14 @@ class SymbolPalette(OptionListNav, ModalScreen): scope = "project" if self._project_scope else "this binary" cap = self.PROJECT_LIMIT if self._project_scope else self.LIMIT 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 "") + hint = ( + " (F2: this binary)" + if self._project_scope + else (" (F2: whole project)" if self._index is not None else "") + ) self.query_one("#pal-box").border_title = Text( - f"symbols [{scope}]: {len(self._results)}{more}{hint}") + f"symbols [{scope}]: {len(self._results)}{more}{hint}" + ) def action_choose(self) -> None: ol = self.query_one(OptionList) @@ -3380,8 +3530,12 @@ class SymbolPalette(OptionListNav, ModalScreen): def _str_display(text: str, limit: int = 200) -> str: """One-line, printable rendering of a string literal for the browser: escape the common control chars, drop the rest, and clip long bodies.""" - out = (text.replace("\\", "\\\\").replace("\n", "\\n") - .replace("\r", "\\r").replace("\t", "\\t")) + out = ( + text.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) out = "".join(ch if ch.isprintable() else "." for ch in out) return out[:limit] + ("\u2026" if len(out) > limit else "") @@ -3412,7 +3566,7 @@ class SearchPalette(OptionListNav, ModalScreen): super().__init__() self._program = program self._initial = initial - self._forced: str | None = None # F2: pin the mode + 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 @@ -3420,9 +3574,11 @@ class SearchPalette(OptionListNav, ModalScreen): 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 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: @@ -3453,16 +3609,17 @@ class SearchPalette(OptionListNav, ModalScreen): elif q: state = "Enter searches" self.query_one("#pal-box").border_title = Text( - f"search [{mode}{pinned}]" + (f": {state}" if state else "")) + 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._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 + event.stop() # modal inputs bubble to the app's own #search handler self._retitle() def on_input_submitted(self, event: Input.Submitted) -> None: @@ -3495,14 +3652,14 @@ class SearchPalette(OptionListNav, ModalScreen): @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) + 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: + 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 @@ -3533,8 +3690,10 @@ class SearchPalette(OptionListNav, ModalScreen): 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") + self._retitle( + f"{n}{'+' if truncated else ''} " + f"hit{'' if n == 1 else 's'} \u2014 Enter opens" + ) # -- moving / choosing --------------------------------------------------- # def action_choose(self) -> None: @@ -3569,9 +3728,10 @@ class StringsPalette(OptionListNav, ModalScreen): super().__init__() # Pre-render + pre-lower once: filtering runs on every keystroke and a # big binary has tens of thousands of strings. - self._rows = [(s, d, d.lower()) - for s in strings for d in (_str_display(s.text),)] - self._index = index # ProjectIndex, when this is a project + self._rows = [ + (s, d, d.lower()) for s in strings for d in (_str_display(s.text),) + ] + self._index = index # ProjectIndex, when this is a project self._binary = binary self._project_scope = False #: (binary|None, addr, display text) — binary is None for a local hit @@ -3580,8 +3740,11 @@ class StringsPalette(OptionListNav, ModalScreen): def compose(self) -> ComposeResult: 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 Input( + placeholder="filter strings\u2026 \u2191\u2193 select \u00b7 " + "Enter jump \u00b7 Esc close", + id="pal-input", + ) yield OptionList(id="pal-list") def on_mount(self) -> None: @@ -3607,19 +3770,23 @@ class StringsPalette(OptionListNav, ModalScreen): # rows: (binary|None, addr, length, display text, match offset) if self._project_scope and self._index is not None: from .index import KIND_STRING - hits = self._index.search(query, kind=KIND_STRING, - limit=self.PROJECT_LIMIT * 3) if query else [] + + hits = ( + self._index.search( + query, kind=KIND_STRING, limit=self.PROJECT_LIMIT * 3 + ) + if query + else [] + ) rows = [] for h in hits: disp = _str_display(h.text) - rows.append((h.binary, h.addr, len(h.text), disp, - disp.lower().find(q))) + rows.append((h.binary, h.addr, len(h.text), disp, disp.lower().find(q))) # the index already guarantees a match, so ranking only orders them: # earliest match, then shortest, with a stable (binary, addr) tiebreak # (a literal shared by two binaries would otherwise be unordered). - rows.sort(key=lambda r: (r[4] if r[4] >= 0 else 1 << 30, - r[2], r[0], r[1])) - rows = rows[:self.PROJECT_LIMIT] + rows.sort(key=lambda r: (r[4] if r[4] >= 0 else 1 << 30, r[2], r[0], r[1])) + rows = rows[: self.PROJECT_LIMIT] else: rows = [] for s, disp, low in self._rows: @@ -3650,10 +3817,14 @@ class StringsPalette(OptionListNav, ModalScreen): scope = "project" if self._project_scope else "this binary" cap = self.PROJECT_LIMIT if self._project_scope else self.LIMIT 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 "") + hint = ( + " (F2: this binary)" + if self._project_scope + else (" (F2: whole project)" if self._index is not None else "") + ) self.query_one("#pal-box").border_title = Text( - f"strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}") + f"strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}" + ) def action_choose(self) -> None: ol = self.query_one(OptionList) @@ -3674,74 +3845,92 @@ class StringsPalette(OptionListNav, ModalScreen): #: The keyboard cheatsheet (F1). Grouped by task rather than by widget, which is #: what makes it readable; keep it in step with the BINDINGS above it. _HELP = ( - ("Navigate", ( - ("Enter", "follow the symbol under the cursor"), - ("Esc", "back (navigation history)"), - ("g", "goto address or symbol"), - ("Ctrl+N", "find symbol (fuzzy)"), - ("\"", "strings browser"), - ("x", "cross-references to the symbol"), - ("L", "continuous listing at the cursor"), - ("Ctrl+O", "switch binary (projects)"), - )), - ("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"), - ("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+R", "refresh the current view in place"), - ("Ctrl+E", "export findings as markdown"), - ("Ctrl+P", "command palette"), - )), - ("Move", ( - ("j / k", "down / up"), - ("Ctrl+D / Ctrl+U", "half page down / up"), - ("PgDn / PgUp", "page down / up"), - ("Ctrl+Home / Ctrl+End", "top / bottom (G also)"), - ("Home / End", "start / end of line"), - ("Shift+Home", "start of the instruction / code"), - ("h / l", "column left / right"), - ("w / b", "word forward / back"), - )), - ("Edit", ( - ("n", "rename"), - ("y", "set type (prototype, local or global)"), - (";", "comment"), - ("c", "make code"), - ("p", "make function"), - ("d", "make data"), - ("a", "make string"), - ("u", "undefine"), - ("o / O", "literal format: hex/dec/bin/char/offset"), - ("Ctrl+S", "save the database"), - )), - ("Search", ( - ("/", "search forward (repeat to continue)"), - ("?", "search backward"), - ("N", "previous match"), - ("Ctrl+Y", "copy the current line"), - ("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)"), - )), + ( + "Navigate", + ( + ("Enter", "follow the symbol under the cursor"), + ("Esc", "back (navigation history)"), + ("g", "goto address or symbol"), + ("Ctrl+N", "find symbol (fuzzy)"), + ('"', "strings browser"), + ("x", "cross-references to the symbol"), + ("L", "continuous listing at the cursor"), + ("Ctrl+O", "switch binary (projects)"), + ), + ), + ( + "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"), + ("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+R", "refresh the current view in place"), + ("Ctrl+E", "export findings as markdown"), + ("Ctrl+P", "command palette"), + ), + ), + ( + "Move", + ( + ("j / k", "down / up"), + ("Ctrl+D / Ctrl+U", "half page down / up"), + ("PgDn / PgUp", "page down / up"), + ("Ctrl+Home / Ctrl+End", "top / bottom (G also)"), + ("Home / End", "start / end of line"), + ("Shift+Home", "start of the instruction / code"), + ("h / l", "column left / right"), + ("w / b", "word forward / back"), + ), + ), + ( + "Edit", + ( + ("n", "rename"), + ("y", "set type (prototype, local or global)"), + (";", "comment"), + ("c", "make code"), + ("p", "make function"), + ("d", "make data"), + ("a", "make string"), + ("u", "undefine"), + ("o / O", "literal format: hex/dec/bin/char/offset"), + ("Ctrl+S", "save the database"), + ), + ), + ( + "Search", + ( + ("/", "search forward (repeat to continue)"), + ("?", "search backward"), + ("N", "previous match"), + ("Ctrl+Y", "copy the current line"), + ("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)"), + ), + ), ) @@ -3764,19 +3953,24 @@ class QuitScreen(ModalScreen): self._labels = labels def compose(self) -> ComposeResult: - what = (f"{len(self._labels)} databases have unsaved changes" - if len(self._labels) > 1 else "unsaved changes") + what = ( + f"{len(self._labels)} databases have unsaved changes" + if len(self._labels) > 1 + else "unsaved changes" + ) 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) body.append( - "\nFinal managed leases discard; shared/GUI sessions stay open.", - _S_DIM) + "\nFinal managed leases discard; shared/GUI sessions stay open.", _S_DIM + ) yield Static(body, id="quit-list") - yield Static("s save & quit d discard / leave & quit Esc cancel", - id="quit-help") + yield Static( + "s save & quit d discard / leave & quit Esc cancel", + id="quit-help", + ) def action_save(self) -> None: self.dismiss("save") @@ -3810,13 +4004,14 @@ class HelpScreen(ModalScreen): with VerticalScroll(id="help-body"): with Horizontal(id="help-cols"): for c in range(cols): - chunk = _HELP[c * per:(c + 1) * per] + chunk = _HELP[c * per : (c + 1) * per] if not chunk: continue with Vertical(classes="help-col"): for title, rows in chunk: - card = Static(self._card(rows), - classes="help-card", markup=False) + card = Static( + self._card(rows), classes="help-card", markup=False + ) card.border_title = title yield card yield Static("Esc · F1 · H to close", id="help-foot") @@ -3843,7 +4038,7 @@ class HelpScreen(ModalScreen): n = len(ws) for cols in range(min(n, 4), 1, -1): per = -(-n // cols) - chunks = [ws[c * per:(c + 1) * per] for c in range(cols)] + chunks = [ws[c * per : (c + 1) * per] for c in range(cols)] total = sum(max(c) for c in chunks if c) + (cols - 1) if total <= avail: return cols @@ -3884,13 +4079,15 @@ class RegWriteScreen(OptionListNav, ModalScreen): def __init__(self, rows, idx: int) -> None: super().__init__() - self._rows = rows # (name, value, last_write, next_write) + 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") + box.border_subtitle = Text( + "Enter seeks to the write \u00b7 f seeks forward" + ) yield OptionList(id="pal-list") def on_mount(self) -> None: @@ -3899,8 +4096,9 @@ class RegWriteScreen(OptionListNav, ModalScreen): 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) + 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: @@ -3992,15 +4190,16 @@ class TraceDock(Vertical): 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)) + 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] @@ -4031,8 +4230,13 @@ class TraceDock(Vertical): 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) + 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) @@ -4100,18 +4304,27 @@ class LoadOptionsScreen(OptionListNav, ModalScreen): def compose(self) -> ComposeResult: from .formats import PROCESSORS + self._all = list(PROCESSORS) 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) + 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, + ) yield Input(placeholder="filter processors\u2026", id="pal-input") yield OptionList(id="pal-list") - yield Input(placeholder="load address, e.g. 0x8000000 (blank = 0)", - id="load-base") - yield Static(" Enter accept \u00b7 Tab base address \u00b7 " - "Esc load as IDA would", id="load-help", markup=False) + yield Input( + placeholder="load address, e.g. 0x8000000 (blank = 0)", id="load-base" + ) + yield Static( + " Enter accept \u00b7 Tab base address \u00b7 Esc load as IDA would", + id="load-help", + markup=False, + ) def on_mount(self) -> None: self._apply("") @@ -4146,8 +4359,11 @@ class LoadOptionsScreen(OptionListNav, ModalScreen): def _apply(self, query: str) -> None: q = query.lower() - rows = [(name, desc) for name, desc in self._all - if not q or q in name.lower() or q in desc.lower()] + rows = [ + (name, desc) + for name, desc in self._all + if not q or q in name.lower() or q in desc.lower() + ] # An unlisted processor is still valid: IDA has 73 modules and this # offers 20, so a typed name that matches nothing is taken literally # rather than refused. @@ -4166,7 +4382,8 @@ class LoadOptionsScreen(OptionListNav, ModalScreen): if rows: ol.highlighted = 0 self.query_one("#pal-box").border_title = Text( - f"unrecognised file \u2014 processor? ({len(rows)})") + f"unrecognised file \u2014 processor? ({len(rows)})" + ) def action_choose(self) -> None: ol = self.query_one(OptionList) @@ -4181,14 +4398,16 @@ class LoadOptionsScreen(OptionListNav, ModalScreen): base = int(raw, 0) except ValueError: self.query_one("#load-help", Static).update( - f" {raw!r} is not an address \u2014 try 0x8000000") + f" {raw!r} is not an address \u2014 try 0x8000000" + ) self.query_one("#load-base", Input).focus() return if base % 16: # IDA's -b is in paragraphs, so an unaligned base can't be # expressed and would quietly load somewhere else. self.query_one("#load-help", Static).update( - f" {base:#x} must be 16-byte aligned") + f" {base:#x} must be 16-byte aligned" + ) self.query_one("#load-base", Input).focus() return self.dismiss({"processor": self._results[i][0], "base": base}) @@ -4214,8 +4433,11 @@ class ProjectPalette(OptionListNav, ModalScreen): def compose(self) -> ComposeResult: 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 Input( + placeholder="filter binaries\u2026 \u2191\u2193 select \u00b7 " + "Enter switch \u00b7 Esc close", + id="pal-input", + ) yield OptionList(id="pal-list") def on_mount(self) -> None: @@ -4232,16 +4454,20 @@ class ProjectPalette(OptionListNav, ModalScreen): def _apply(self, query: str) -> None: q = query.lower() - rows = [e for e in self._entries - if not q or q in e["label"].lower() or q in e["source"].lower()] + rows = [ + e + for e in self._entries + if not q or q in e["label"].lower() or q in e["source"].lower() + ] self._results = rows ol = self.query_one(OptionList) ol.clear_options() opts = [] for e in rows: label = Text() - label.append("\u25b8 " if e["active"] else " ", - _S_MNEM if e["active"] else _S_DIM) + label.append( + "\u25b8 " if e["active"] else " ", _S_MNEM if e["active"] else _S_DIM + ) label.append(f"{e['label']:<22}", _S_LABEL) if e["resident"]: mb = e.get("memory_mb") or 0 @@ -4261,7 +4487,8 @@ class ProjectPalette(OptionListNav, ModalScreen): active = next((i for i, e in enumerate(rows) if e["active"]), 0) ol.highlighted = active self.query_one("#pal-box").border_title = Text( - f"binaries: {len(rows)} of {len(self._entries)}") + f"binaries: {len(rows)} of {len(self._entries)}" + ) def action_choose(self) -> None: i = self.query_one(OptionList).highlighted @@ -4312,7 +4539,7 @@ _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 +_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 @@ -4342,8 +4569,13 @@ def logo_cells(max_rows: int | None = None) -> tuple[int, int]: 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))) + 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) @@ -4374,9 +4606,9 @@ 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 + 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.""" @@ -4398,11 +4630,16 @@ class LoadingScreen(ModalScreen): 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): + 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 @@ -4419,8 +4656,10 @@ class LoadingScreen(ModalScreen): yield Static(Align.center(logo), id="loading-logo") yield Static(f"\u23f3 loading {self._title}", id="loading-title") yield Static(self._note, id="loading-note") - yield Static("first open of a big binary can take a while \u00b7 " - "Esc to hide", id="loading-help") + yield Static( + "first open of a big binary can take a while \u00b7 Esc to hide", + id="loading-help", + ) def update_note(self, text: str) -> None: try: @@ -4466,7 +4705,7 @@ class LoadingScreen(ModalScreen): # 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 + 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: @@ -4557,7 +4796,7 @@ class StructEditor(ModalScreen): def __init__(self, program: Program) -> None: super().__init__() self._program = program - self._all: list[Struct] = [] # every struct the database has + 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 @@ -4588,9 +4827,11 @@ class StructEditor(ModalScreen): with Horizontal(id="se-panes"): with Vertical(id="se-left"): 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 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") @@ -4599,7 +4840,8 @@ class StructEditor(ModalScreen): yield Static( "Enter edit · / filter · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · " "d/Del delete", - id="se-status") + id="se-status", + ) def on_mount(self) -> None: self._refresh() @@ -4657,7 +4899,7 @@ class StructEditor(ModalScreen): 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" + name = s.name if len(s.name) <= width else s.name[: width - 1] + "\u2026" label = Text() nm = Text(f"{name:<{width}}", style=_S_LABEL) for p in pos: @@ -4670,8 +4912,9 @@ class StructEditor(ModalScreen): if rows: idx = 0 if select is not None: - idx = next((i for i, s in enumerate(self._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: @@ -4755,8 +4998,9 @@ class StructEditor(ModalScreen): 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)) + 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() @@ -4808,16 +5052,20 @@ class StructEditor(ModalScreen): formatted = None self.app.call_from_thread(self._after_save, name, err, text, formatted) - def _after_save(self, name: str | None, err: str | None, text: str, - formatted: str | None) -> None: + def _after_save( + self, name: str | None, err: str | None, text: str, formatted: str | None + ) -> None: if err: # IDA's parse error is usually empty/cryptic; name the likely cause. msg = err.strip() if not msg or "parse" in msg.lower() or "fail" in msg.lower(): bad = self._reserved_field(text) - msg = (f"'{bad}' is a reserved name in IDA's C parser — rename " - f"that field to save" if bad else - "IDA couldn't parse it (unknown type or reserved field name?)") + msg = ( + f"'{bad}' is a reserved name in IDA's C parser — rename " + f"that field to save" + if bad + else "IDA couldn't parse it (unknown type or reserved field name?)" + ) self._set_status(f"save failed — {msg}", error=True) return self._loaded = name @@ -4858,7 +5106,8 @@ class StructEditor(ModalScreen): kind = "union" if s.is_union else "struct" self.app.push_screen( ConfirmScreen(f"Delete {kind} '{s.name}' ?"), - lambda ok, name=s.name: self._delete(name) if ok else None) + lambda ok, name=s.name: self._delete(name) if ok else None, + ) @work(thread=True, exclusive=True, group="se-del") def _delete(self, name: str) -> None: @@ -4899,8 +5148,9 @@ class StructEditor(ModalScreen): if self._filter_focused() or self._filter: self._clear_filter() return - self._confirm_discard(lambda: self.dismiss(None), - "Discard unsaved changes and close?") + self._confirm_discard( + lambda: self.dismiss(None), "Discard unsaved changes and close?" + ) def _set_status(self, text, error: bool = False) -> None: # type: ignore[no-untyped-def] st = self.query_one("#se-status", Static) @@ -4918,16 +5168,16 @@ class StructEditor(ModalScreen): IDATUI_THEME = Theme( name="idatui", dark=True, - background="#12161c", # deep blue-black, softer than pure black - surface="#181d25", # views - panel="#212832", # dialogs, status bar, gutters + background="#12161c", # deep blue-black, softer than pure black + surface="#181d25", # views + panel="#212832", # dialogs, status bar, gutters foreground="#d6d9de", - primary="#5aa0d6", # focus / links: the one cool accent + primary="#5aa0d6", # focus / links: the one cool accent secondary="#2f5d82", - accent="#d0a215", # the same amber as a search match — one meaning - warning="#c9762f", # burnt orange — distinct from accent, reads as care - error="#ff5f5f", # already used for failure text - success="#6a9955", # already used for comments + accent="#d0a215", # the same amber as a search match — one meaning + warning="#c9762f", # burnt orange — distinct from accent, reads as care + error="#ff5f5f", # already used for failure text + success="#6a9955", # already used for comments ) @@ -4942,73 +5192,146 @@ class IdaCommands(Provider): app = self.app va = app._palette_action # dispatch to the focused code view return ( - ("Goto address / symbol…", "jump to an address or name (g)", - app.action_goto), + ( + "Goto address / symbol…", + "jump to an address or name (g)", + app.action_goto, + ), ("Find symbol…", "fuzzy function finder (Ctrl+N)", app.action_symbols), - ("Strings…", "browse every string in the binary (\")", - app.action_strings), - ("Switch binary…", "another binary in the project (Ctrl+O)", - app.action_switch_binary), - ("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)", - lambda: va("xrefs")), + ("Strings…", 'browse every string in the binary (")', app.action_strings), + ( + "Switch binary…", + "another binary in the project (Ctrl+O)", + app.action_switch_binary, + ), + ( + "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)", + lambda: va("xrefs"), + ), ("Back", "navigation history (Esc)", app.action_back), - ("Toggle disassembly / pseudocode", "decompile / listing (F5, Tab)", - app.action_toggle_view), - ("Continuous listing here", "flat segment listing (L)", - app.action_continuous_here), + ( + "Toggle disassembly / pseudocode", + "decompile / listing (F5, Tab)", + app.action_toggle_view, + ), + ( + "Continuous listing here", + "flat segment listing (L)", + app.action_continuous_here, + ), ("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)", - lambda: va("retype")), + ( + "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)", + lambda: va("retype"), + ), ("Add comment…", "comment at the cursor (;)", lambda: va("comment")), ("Define code", "make code at the cursor (c)", lambda: va("define_code")), - ("Create function", "define a function at the cursor (p)", - lambda: va("define_func")), - ("Make data", "define a data item at the cursor (d)", - lambda: va("make_data")), - ("Make string", "define a string at the cursor (a)", - lambda: va("make_string")), - ("Undefine", "undefine the item at the cursor (u)", - lambda: va("undefine")), - ("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), - ("Filter functions…", "glob-filter the function list (/)", - app.action_filter), - ("Toggle names pane", "function-list sidebar (Ctrl+B)", - app.action_toggle_functions), + ( + "Create function", + "define a function at the cursor (p)", + lambda: va("define_func"), + ), + ( + "Make data", + "define a data item at the cursor (d)", + lambda: va("make_data"), + ), + ( + "Make string", + "define a string at the cursor (a)", + lambda: va("make_string"), + ), + ("Undefine", "undefine the item at the cursor (u)", lambda: va("undefine")), + ( + "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, + ), + ( + "Filter functions…", + "glob-filter the function list (/)", + app.action_filter, + ), + ( + "Toggle names pane", + "function-list sidebar (Ctrl+B)", + app.action_toggle_functions, + ), ("Save database (.i64)", "persist changes (Ctrl+S)", app.action_save), ("Quit", "exit ida-tui (q)", app.action_quit), ) @@ -5211,45 +5534,52 @@ class IdaTui(App): Binding("escape", "back", "Back"), ] - def __init__(self, open_path: str | None = None, keepalive: bool = True, - rpc_path: str | None = None, ttl: int = 1800, - project=None, load_args: str = "", trace_path: str = "") -> None: + def __init__( + self, + open_path: str | None = None, + keepalive: bool = True, + rpc_path: str | None = None, + ttl: int = 1800, + 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. self._project = project self._pool = None - self._binary: str | None = None # active project binary (label) + self._binary: str | None = None # active project binary (label) self._states: dict[str, BinaryState] = {} - self._pending_restore = None # entry to reopen after a switch - self._goto_after_switch = None # cross-binary search hit to land on - self._hops: list[str] = [] # binaries a navigation crossed FROM - self._load_for_label = None # project binary the dialog is for - self._no_functions = False # analysis produced nothing at all - self._flash: str | None = None # message a pending reload must keep - 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 + self._pending_restore = None # entry to reopen after a switch + self._goto_after_switch = None # cross-binary search hit to land on + self._hops: list[str] = [] # binaries a navigation crossed FROM + self._load_for_label = None # project binary the dialog is for + self._no_functions = False # analysis produced nothing at all + self._flash: str | None = None # message a pending reload must keep + 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 + self._index = None # project-wide symbol/string index if project is not None: from .index import ProjectIndex from .pool import DatabasePool + self._pool = DatabasePool(project, ttl=ttl) - self._index = ProjectIndex( - os.path.join(project.index_dir, "project.db")) + 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 "" # first-open options for a headerless blob - self._new_database = False # Ctrl+L asks IDA Nexus for a fresh IDB - self._title = (os.path.basename(open_path) if open_path else "") + self._load_args = load_args or "" # first-open options for a headerless blob + self._new_database = False # Ctrl+L asks IDA Nexus 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. @@ -5267,13 +5597,15 @@ class IdaTui(App): self._filter_term = "" self._pending_filter = "" self._filter_timer = None - self._sort_col = 0 # 0=addr, 1=name, 2=size + self._sort_col = 0 # 0=addr, 1=name, 2=size self._sort_reverse = False # 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 = ViewMode.LISTING # currently shown view (in split: the focused pane) - self._split = False # side-by-side listing + pseudocode + 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 @@ -5289,8 +5621,9 @@ class IdaTui(App): self._search_ctx: tuple[object | None, int] = (None, 1) #: 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") + 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 @@ -5401,8 +5734,10 @@ class IdaTui(App): if self._load_args: return False from .formats import needs_load_options + if os.path.exists(self._open_path + ".i64") or os.path.exists( - os.path.splitext(self._open_path)[0] + ".i64"): + os.path.splitext(self._open_path)[0] + ".i64" + ): return False try: if registered_database(self._open_path): @@ -5424,18 +5759,23 @@ class IdaTui(App): if not self._can_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") + "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" - if n == 0 else - f"discards the database for this binary \u2014 {n} " - f"function{'s' if n != 1 else ''}, plus any names and comments " - f"you've added") - self.push_screen(ConfirmScreen("Reload with different options?", note), - self._on_reload_confirmed) + note = ( + "this image has no functions, so nothing is lost" + if n == 0 + else f"discards the database for this binary \u2014 {n} " + f"function{'s' if n != 1 else ''}, plus any names and comments " + f"you've added" + ) + self.push_screen( + ConfirmScreen("Reload with different options?", note), + self._on_reload_confirmed, + ) def _on_reload_confirmed(self, yes) -> None: # type: ignore[no-untyped-def] if not yes: @@ -5501,7 +5841,8 @@ class IdaTui(App): # binary as already described and never ask again. self._project.set_load(label, processor="", base=0) self._project._entries[self._project._refs.index(ref)].pop( - "processor", None) + "processor", None + ) self._project.save() self._load_args = "" if path: @@ -5521,14 +5862,16 @@ class IdaTui(App): if ref is None or ref.load_args: return None 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 + 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 def _ask_load_options(self, path: str, label: str | None = None) -> None: @@ -5541,6 +5884,7 @@ class IdaTui(App): def _on_load_options(self, choice) -> None: # type: ignore[no-untyped-def] from .formats import load_args + choice = choice or {} label, self._load_for_label = self._load_for_label, None proc, base = choice.get("processor", ""), int(choice.get("base", 0) or 0) @@ -5573,6 +5917,7 @@ class IdaTui(App): def _start_rpc(self) -> None: from .rpc import RpcServer + self._rpc = RpcServer(self, self._rpc_path) async def _serve() -> None: @@ -5594,6 +5939,7 @@ class IdaTui(App): when the user has read it and moved on. """ import time as _time + if priority: self._flash = text self._flash_until = _time.monotonic() + 8.0 @@ -5611,7 +5957,11 @@ class IdaTui(App): # It stops being true the moment a function exists, though: latching it # meant the warning survived defining one with `p` and kept telling you # the load was wrong when it no longer was. - if self._no_functions and self._func_index is not None and len(self._func_index): + if ( + self._no_functions + and self._func_index is not None + and len(self._func_index) + ): self._no_functions = False if self._no_functions: text += " \u2014 no functions: wrong processor/base? Ctrl+L to reload" @@ -5638,7 +5988,10 @@ class IdaTui(App): subprocess.run( ["tmux", "load-buffer", "-w", "-"], input=text.encode("utf-8", "replace"), - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0) + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2.0, + ) except Exception: # noqa: BLE001 pass return len(text) @@ -5662,8 +6015,7 @@ class IdaTui(App): except Exception: # noqa: BLE001 -- app teardown can win this race pass - self._idb_event_watch = watch( - changed, on_error=failed, debounce=0.2) + self._idb_event_watch = watch(changed, on_error=failed, debounce=0.2) def _stop_idb_event_watch(self) -> None: watcher, self._idb_event_watch = self._idb_event_watch, None @@ -5699,8 +6051,12 @@ class IdaTui(App): ) -> None: """Invalidate once per external edit burst and reload the active surface.""" program = self.program - if not events or client is not self.client or program is None \ - or program.client is not client: + if ( + not events + or client is not self.client + or program is None + or program.client is not client + ): return self._idb_refresh_seq += 1 seq = self._idb_refresh_seq @@ -5718,7 +6074,8 @@ class IdaTui(App): program.invalidate_external() self._status( f"{len(events)} external database " - f"change{'s' if len(events) != 1 else ''} — refreshing…") + f"change{'s' if len(events) != 1 else ''} — refreshing…" + ) self._reindex_functions() if self.is_hex: @@ -5751,15 +6108,35 @@ class IdaTui(App): fn = program.function_of(entry.ea) name = fn.name if fn is not None else program.region_label(entry.ea) self.app.call_from_thread( - self._apply_idb_listing, program, seq, entry, model, - cursor, top, anchor.cursor_x, name, fn is None) + self._apply_idb_listing, + program, + seq, + entry, + model, + cursor, + top, + anchor.cursor_x, + name, + fn is None, + ) def _apply_idb_listing( - self, program: Program, seq: int, entry: NavEntry, model, - cursor: int, top: int, cursor_x: int, name: str, is_region: bool, + self, + program: Program, + seq: int, + entry: NavEntry, + model, + cursor: int, + top: int, + cursor_x: int, + name: str, + is_region: bool, ) -> None: - if program is not self.program or seq != self._idb_refresh_seq \ - or entry is not self._cur: + if ( + program is not self.program + or seq != self._idb_refresh_seq + or entry is not self._cur + ): return if model is None: self._status(f"{entry.ea:#x} is no longer in a loaded segment") @@ -5771,8 +6148,12 @@ class IdaTui(App): if top >= 0: entry.scroll_y = top self.query_one(ListingView).load( - model, name, cursor=entry.cursor, cursor_x=entry.cursor_x, - scroll_y=top if top >= 0 else None) + model, + name, + cursor=entry.cursor, + cursor_x=entry.cursor_x, + scroll_y=top if top >= 0 else None, + ) if self.is_listing: self._show_active() @@ -5783,6 +6164,7 @@ class IdaTui(App): 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): self._on_connection_lost() @@ -5794,7 +6176,8 @@ class IdaTui(App): return self._reconnecting = True self._conn_screen = LoadingScreen( - "the analysis server", note="connection lost \u2014 reconnecting\u2026") + "the analysis server", note="connection lost \u2014 reconnecting\u2026" + ) self.push_screen(self._conn_screen) self._reconnect() @@ -5817,20 +6200,27 @@ class IdaTui(App): # must not silently reopen it by spawning a headless worker. try: if self._open_path is None: - self.app.call_from_thread(self._reconnect_failed, - "no binary to reopen") + self.app.call_from_thread(self._reconnect_failed, "no binary to reopen") return if self._project is not None and self._binary is not None: ref = self._project.by_label(self._binary) client = NexusClient( - ref.staged, ttl=self._ttl, load_args=ref.load_args, - output_database=ref.db, spawn=False) + ref.staged, + ttl=self._ttl, + load_args=ref.load_args, + output_database=ref.db, + spawn=False, + ) else: client = NexusClient( - self._open_path, ttl=self._ttl, - load_args=self._load_args, spawn=False) - client.connect(progress=lambda m: self.app.call_from_thread( - self._conn_note, m)) + self._open_path, + ttl=self._ttl, + load_args=self._load_args, + spawn=False, + ) + client.connect( + progress=lambda m: self.app.call_from_thread(self._conn_note, m) + ) except Exception as e: # noqa: BLE001 self.app.call_from_thread(self._reconnect_failed, str(e)) return @@ -5859,8 +6249,10 @@ class IdaTui(App): def _reconnect_failed(self, why: str) -> None: self._reconnecting = False - note = (f"database owner closed: {why} — reopen it in IDA, then " - "Esc and retry an action; or q to quit") + note = ( + f"database owner closed: {why} — reopen it in IDA, then " + "Esc and retry an action; or q to quit" + ) self._conn_note(note) self._status(note) @@ -5892,15 +6284,17 @@ class IdaTui(App): self._start_idb_event_watch(client) self._new_database = False self.app.call_from_thread( - self._status, f"{module} [{client.backend}] — loading functions…") + self._status, f"{module} [{client.backend}] — loading functions…" + ) self._load_functions() def _open_database_client(self): # type: ignore[no-untyped-def] """Attach through IDA Nexus, 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)) + 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 @@ -5908,17 +6302,21 @@ class IdaTui(App): return client if not self._open_path: self.app.call_from_thread( - self._status, "IDA Nexus needs a database or executable path") + self._status, "IDA Nexus 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"discovering IDA Nexus database for {base}…") - client = NexusClient(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)) + self._status, f"discovering IDA Nexus database for {base}…" + ) + client = NexusClient( + 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 @work(thread=True, exclusive=True, group="load-funcs") @@ -5926,7 +6324,9 @@ class IdaTui(App): assert self.program is not None idx = self.program.functions() self._func_index = idx - self.app.call_from_thread(lambda: self.query_one("#func-table", DataTable).clear()) + self.app.call_from_thread( + lambda: self.query_one("#func-table", DataTable).clear() + ) last = 0 while not idx.complete: idx.load_next_page() @@ -5934,21 +6334,20 @@ class IdaTui(App): last = len(idx) if rows: self.app.call_from_thread(self._append_rows, rows) - self.app.call_from_thread( - self._status, f"{last} functions…" - ) + self.app.call_from_thread(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"{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 + self._load_trace() # needs the index above: rebasing reads it @work(thread=True, exclusive=True, group="prewarm") def _prewarm_provider(self) -> None: @@ -5969,6 +6368,7 @@ class IdaTui(App): if not imps: return from collections import Counter + votes: Counter = Counter() for name in {i.name for i in imps}: for h in self._index.providers(name, exclude=self._binary): @@ -5981,7 +6381,8 @@ class IdaTui(App): try: if self._pool.prewarm(cand): self.app.call_from_thread( - self._status, f"pre-warmed {cand} (provides {n} imports)") + self._status, f"pre-warmed {cand} (provides {n} imports)" + ) except Exception: # noqa: BLE001 -- speculative work must never surface pass @@ -5995,11 +6396,13 @@ class IdaTui(App): if ref is None or not self._index.is_stale(self._binary, ref.source): return from .index import KIND_EXPORT, KIND_FUNC, KIND_IMPORT, KIND_STRING + idx = self._func_index - entries = [(KIND_FUNC, f.addr, f.name) for f in (idx.all_loaded() if idx else [])] + entries = [ + (KIND_FUNC, f.addr, f.name) for f in (idx.all_loaded() if idx else []) + ] try: - entries += [(KIND_STRING, s.addr, s.text) - for s in self.program.strings()] + entries += [(KIND_STRING, s.addr, s.text) for s in self.program.strings()] except Exception: # noqa: BLE001 -- symbols alone are still worth indexing pass try: @@ -6014,7 +6417,8 @@ class IdaTui(App): self.app.call_from_thread(self._status, f"indexing failed: {e}") return self.app.call_from_thread( - self._status, f"indexed {self._binary}: {n} symbols, strings + linkage") + self._status, f"indexed {self._binary}: {n} symbols, strings + linkage" + ) self._prewarm_provider() # -- initial landing --------------------------------------------------- # @@ -6057,8 +6461,9 @@ class IdaTui(App): 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)") + self._status( + f"no entry function — opened {first.name} (Ctrl+N: find symbol)" + ) else: self.action_symbols() else: @@ -6087,8 +6492,13 @@ class IdaTui(App): if start is None: self._status("no functions and no segments \u2014 nothing to show") return - self._open_at(start, self.program.section_of(start) or "image", - cursor=0, push=True, is_region=True) + self._open_at( + start, + self.program.section_of(start) or "image", + cursor=0, + push=True, + is_region=True, + ) def _can_reload(self) -> bool: """Whether IDA Nexus can replace this IDB with different options. @@ -6145,7 +6555,9 @@ class IdaTui(App): ci = term.islower() is_glob = ("*" in term) or ("?" in term) needle = term.lower() if ci else term - pat = needle if (is_glob and ("*" in needle or "?" in needle)) else f"*{needle}*" + pat = ( + needle if (is_glob and ("*" in needle or "?" in needle)) else f"*{needle}*" + ) matched: list[tuple[Func, tuple[int, int] | None]] = [] for f in funcs: if not term: @@ -6230,9 +6642,10 @@ class IdaTui(App): if not funcs: self._status("functions still loading…") return - self.push_screen(SymbolPalette(funcs, index=self._index, - binary=self._binary), - self._on_symbol_chosen) + self.push_screen( + SymbolPalette(funcs, index=self._index, binary=self._binary), + self._on_symbol_chosen, + ) def _on_symbol_chosen(self, choice) -> None: # type: ignore[no-untyped-def] if choice is None: @@ -6267,11 +6680,15 @@ class IdaTui(App): active one and other still-resident binaries can be dirty. """ if self._pool is None: - return [os.path.basename(self._open_path or "database")] if self._dirty else [] + return ( + [os.path.basename(self._open_path or "database")] if self._dirty else [] + ) out = [self._binary] if (self._dirty and self._binary) else [] - out += [label for label, st in self._states.items() - if st.dirty and label != self._binary - and self._pool.is_resident(label)] + out += [ + label + for label, st in self._states.items() + if st.dirty and label != self._binary and self._pool.is_resident(label) + ] return out async def action_quit(self) -> None: @@ -6290,13 +6707,16 @@ class IdaTui(App): # GUI/shared sessions retain state and inherit finalization. dirty = self._dirty_labels() self._loading_screen = LoadingScreen( - "discarding", note="finalizing database leases…") + "discarding", note="finalizing database leases…" + ) self.push_screen(self._loading_screen) self._discard_then_exit(dirty) elif choice == "save": # Save with the overlay up: writing a big .i64 takes seconds, and # doing it during teardown would look like a hang with no UI left. - self._loading_screen = LoadingScreen("saving", note="writing databases\u2026") + self._loading_screen = LoadingScreen( + "saving", note="writing databases\u2026" + ) self.push_screen(self._loading_screen) self._save_then_exit() # None: cancel, stay put @@ -6322,8 +6742,7 @@ class IdaTui(App): def _finish_discard(self, transferred: list[str]) -> None: if transferred and self._loading_screen is not None: labels = ", ".join(transferred) - self._loading_screen.update_note( - f"finalization transferred: {labels}") + self._loading_screen.update_note(f"finalization transferred: {labels}") self._finish_exit() @work(thread=True, exclusive=True, group="save-exit") @@ -6358,8 +6777,7 @@ class IdaTui(App): return if self._prompt_active(): return - self.push_screen(ProjectPalette(self._pool.status()), - self._on_binary_chosen) + self.push_screen(ProjectPalette(self._pool.status()), self._on_binary_chosen) def _on_binary_chosen(self, label: str | None) -> None: if label and label != self._binary: @@ -6377,10 +6795,16 @@ class IdaTui(App): # 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, - func_index=self._func_index, nav=list(self._nav), cur=self._cur, - active=self._active, split=self._split, - filter_term=self._filter_term, dirty=self._dirty) + label=self._binary, + program=self.program, + func_index=self._func_index, + nav=list(self._nav), + cur=self._cur, + active=self._active, + split=self._split, + filter_term=self._filter_term, + dirty=self._dirty, + ) self._loading_screen = LoadingScreen(label, note="switching\u2026") self.push_screen(self._loading_screen) self._do_switch(label) @@ -6389,8 +6813,9 @@ class IdaTui(App): def _do_switch(self, label: str) -> None: assert self._pool is not None try: - client = self._pool.get(label, progress=lambda m: - self.app.call_from_thread(self._status, m)) + client = self._pool.get( + label, progress=lambda m: self.app.call_from_thread(self._status, m) + ) except Exception as e: # noqa: BLE001 self.app.call_from_thread(self._switch_failed, label, str(e)) return @@ -6398,11 +6823,13 @@ class IdaTui(App): # 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) + reuse = ( + st is not None + and st.program is not None + and getattr(st.program, "client", None) is client + ) program = st.program if reuse else Program(client) - self.app.call_from_thread(self._after_switch, label, client, program, - st, reuse) + self.app.call_from_thread(self._after_switch, label, client, program, st, reuse) def _after_switch(self, label, client, program, st, reuse) -> None: # type: ignore[no-untyped-def] self.client = client @@ -6478,9 +6905,10 @@ class IdaTui(App): self._status("no strings found (needs the list_strings tool)") return self._status(f"strings: {len(items)}") - self.push_screen(StringsPalette(items, index=self._index, - binary=self._binary), - self._on_string_chosen) + self.push_screen( + StringsPalette(items, index=self._index, binary=self._binary), + self._on_string_chosen, + ) def action_find(self) -> None: """Ctrl+F: search the whole database — disassembly text, or bytes.""" @@ -6497,7 +6925,7 @@ class IdaTui(App): 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 + 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] @@ -6508,8 +6936,9 @@ class IdaTui(App): # 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) + 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: @@ -6549,8 +6978,7 @@ class IdaTui(App): # active, but the listing must still round-trip through addresses: # row indices do not survive an external structure change. lst = self.query_one(ListingView) - listing_anchor = ViewAnchor(view=ViewMode.LISTING, - cursor_x=lst.cursor_x) + listing_anchor = ViewAnchor(view=ViewMode.LISTING, cursor_x=lst.cursor_x) model = lst.model if model is not None: listing_anchor.ea = lst._cursor_ea() @@ -6570,20 +6998,27 @@ class IdaTui(App): cur.dec_scroll_x = round(dec.scroll_offset.x) dec.loading = True - want_ea = (self.query_one(GraphView)._cursor_ea() - if self.is_graph else None) + want_ea = self.query_one(GraphView)._cursor_ea() if self.is_graph else None if self.is_graph: self._graph_sticky = True self._status(f"{cur.name} — refreshing graph…") else: self._status(f"{cur.name} — refreshing…") - self._refresh_view(cur, mode, split, listing_anchor, - refresh_decomp, want_ea, self.program) + self._refresh_view( + cur, mode, split, listing_anchor, refresh_decomp, want_ea, self.program + ) @work(thread=True, exclusive=True, group="refresh-view") - def _refresh_view(self, cur: NavEntry, mode: ViewMode, split: bool, - anchor: ViewAnchor | None, refresh_decomp: bool, - want_ea: int | None, program) -> None: # type: ignore[no-untyped-def] + def _refresh_view( + self, + cur: NavEntry, + mode: ViewMode, + split: bool, + anchor: ViewAnchor | None, + refresh_decomp: bool, + want_ea: int | None, + program, + ) -> None: # type: ignore[no-untyped-def] """Invalidate and rebuild without blocking Textual's event loop.""" try: program.bump_items() @@ -6600,22 +7035,40 @@ class IdaTui(App): cursor, top = self._anchor_rows(anchor, model, target) except Exception as exc: # noqa: BLE001 -- a refresh is recoverable diag.note("refresh_view", exc) - self.app.call_from_thread( - self._view_refresh_failed, cur, program, str(exc)) + self.app.call_from_thread(self._view_refresh_failed, cur, program, str(exc)) return self.app.call_from_thread( - self._apply_view_refresh, cur, mode, split, anchor, - refresh_decomp, want_ea, program, model, cursor, top) + self._apply_view_refresh, + cur, + mode, + split, + anchor, + refresh_decomp, + want_ea, + program, + model, + cursor, + top, + ) def _view_refresh_failed(self, cur: NavEntry, program, error: str) -> None: # type: ignore[no-untyped-def] if self.program is program and self._cur is cur: self.query_one(DecompView).loading = False self._status(f"refresh failed: {error}", priority=True) - def _apply_view_refresh(self, cur: NavEntry, mode: ViewMode, split: bool, - anchor: ViewAnchor | None, refresh_decomp: bool, - want_ea: int | None, program, model, cursor: int, - top: int) -> None: # type: ignore[no-untyped-def] + def _apply_view_refresh( + self, + cur: NavEntry, + mode: ViewMode, + split: bool, + anchor: ViewAnchor | None, + refresh_decomp: bool, + want_ea: int | None, + program, + model, + cursor: int, + top: int, + ) -> None: # type: ignore[no-untyped-def] # A binary switch or navigation completed while the refresh was in # flight. Its newer view wins; never drag the user back. if self.program is not program or self._cur is not cur: @@ -6632,9 +7085,13 @@ class IdaTui(App): cur.cursor = max(cursor, 0) cur.cursor_x = anchor.cursor_x cur.scroll_y = top - lst.load(model, cur.name, cursor=cur.cursor, - cursor_x=cur.cursor_x, - scroll_y=top if top >= 0 else None) + lst.load( + model, + cur.name, + cursor=cur.cursor, + cursor_x=cur.cursor_x, + scroll_y=top if top >= 0 else None, + ) if refresh_decomp: self.query_one(DecompView).loaded_ea = None self._show_active() @@ -6659,8 +7116,11 @@ class IdaTui(App): if self._split: # In split mode Tab/F5 just moves focus between the two panes. 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.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 @@ -6752,7 +7212,8 @@ class IdaTui(App): if fn is None: self.app.call_from_thread( self._decomp_from_listing_failed, - "F5 — cursor is not inside a defined function ('p' to make one)") + "F5 — cursor is not inside a defined function ('p' to make one)", + ) return dec_idx = self._decomp_line_for(fn.addr, ea) self.app.call_from_thread(self._enter_decomp, fn.addr, fn.name, dec_idx) @@ -6774,8 +7235,9 @@ class IdaTui(App): ret.cursor_x = lst.cursor_x ret.scroll_y = round(lst.scroll_offset.y) self._decomp_return = ret - entry = NavEntry(ea=fn_addr, name=fn_name, is_region=False, - dec_cursor=max(dec_idx, 0)) + entry = NavEntry( + ea=fn_addr, name=fn_name, is_region=False, dec_cursor=max(dec_idx, 0) + ) self._cur = entry self._active = ViewMode.DECOMP self._show_active() @@ -6788,8 +7250,10 @@ class IdaTui(App): self._status("open a function first") return if not self._split and self.size.width < _SPLIT_MIN_WIDTH: - self._status(f"terminal too narrow for split — need ≈{_SPLIT_MIN_WIDTH} " - f"cols (have {self.size.width})") + self._status( + f"terminal too narrow for split — need ≈{_SPLIT_MIN_WIDTH} " + f"cols (have {self.size.width})" + ) return self._split = not self._split if self._active not in ("listing", "decomp"): @@ -6819,8 +7283,11 @@ class IdaTui(App): 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: + 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() @@ -6857,8 +7324,13 @@ class IdaTui(App): 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: + 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: @@ -6867,13 +7339,17 @@ class IdaTui(App): # 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?)") + 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") + f"(limit {self.GRAPH_MAX_BLOCKS}); staying in the listing" + ) return gv = self.query_one(GraphView) gv.set_graph(fc, want_ea) @@ -6887,12 +7363,15 @@ class IdaTui(App): 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 "" + 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") + 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) @@ -6918,7 +7397,7 @@ class IdaTui(App): lst.load(lm, name, cursor=idx, scroll_y=max(idx - _JUMP_CONTEXT, 0)) self._show_active() # split branch shows both + loads the decomp self._sync_split(self._active) # crude link now - self._load_split_map(ea) # region map (async) if decomp is loaded + self._load_split_map(ea) # region map (async) if decomp is loaded def action_hex(self) -> None: """Backslash: show the raw bytes of the loaded image, synced to the code @@ -6981,8 +7460,9 @@ class IdaTui(App): 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) + 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 @@ -6990,12 +7470,17 @@ class IdaTui(App): self.call_from_thread( self._status, f"exported {len(f.comments)} comments, {n_named} names, " - f"{len(f.types)} types → {out}", True) + 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.is_hex - else "goto: name or 0xADDR — Enter") + 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 inp.value = "" @@ -7026,7 +7511,7 @@ class IdaTui(App): # Local history is spent, but we got here from another binary. label = self._hops.pop() self._status(f"\u25c2 back to {label}\u2026") - self._switch_binary(label) # _states restores its nav and position + self._switch_binary(label) # _states restores its nav and position elif self.query_one("#left", FunctionsPanel).display: table.focus() else: @@ -7069,11 +7554,11 @@ class IdaTui(App): # 'code' xref, and land on a call/jump's real target instead. self._follow_disasm(ea, word, view._next_ea()) elif isinstance(view, DecompView) and view._texts: - self._follow_decomp(view._texts[view.cursor], word, - view._line_ea(view.cursor)) + 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: + 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: @@ -7152,14 +7637,16 @@ class IdaTui(App): return not all(c in "0123456789abcdefABCDEF" for c in word) @work(thread=True, group="nav") - def _follow_disasm(self, ea: int, word: str | None, - next_ea: int | None = None) -> None: + def _follow_disasm( + self, ea: int, word: str | None, next_ea: int | None = None + ) -> None: assert self.program is not None # Prefer the symbol under the cursor (handles multiple refs on a line). if self._looks_like_symbol(word): try: - self._do_navigate(self.program.resolve(word), push=True, - focus_name=word) + self._do_navigate( + self.program.resolve(word), push=True, focus_name=word + ) return except Exception: # noqa: BLE001 -- not a resolvable name; fall back pass @@ -7218,13 +7705,15 @@ class IdaTui(App): return False label, addr = found self.app.call_from_thread( - self._status, f"{name} \u2192 {label} (import resolved)") + self._status, f"{name} \u2192 {label} (import resolved)" + ) self.app.call_from_thread(self._switch_then_goto, label, addr) return True @work(thread=True, group="nav") - def _follow_decomp(self, line: str, word: str | None, - line_ea: int | None = None) -> None: + def _follow_decomp( + self, line: str, word: str | None, line_ea: int | None = None + ) -> None: if self._cur is None: return dec = self.program.decompile(self._cur.ea) @@ -7267,8 +7756,13 @@ class IdaTui(App): return None @work(thread=True, group="xrefs") - def _xrefs_disasm(self, ea: int, word: str | None, - here_ea: int | None = None, here_end: int | None = None) -> None: + def _xrefs_disasm( + self, + ea: int, + word: str | None, + here_ea: int | None = None, + here_end: int | None = None, + ) -> None: assert self.program is not None subj: int | None = None if self._looks_like_symbol(word): @@ -7285,8 +7779,13 @@ class IdaTui(App): self._xrefs_present(subj, word, here_ea, here_end) @work(thread=True, group="xrefs") - def _xrefs_decomp(self, line: str, word: str | None, - here_ea: int | None = None, here_end: int | None = None) -> None: + def _xrefs_decomp( + self, + line: str, + word: str | None, + here_ea: int | None = None, + here_end: int | None = None, + ) -> None: subj: int | None = None if self._cur is not None and word: dec = self.program.decompile(self._cur.ea) @@ -7317,9 +7816,13 @@ class IdaTui(App): span = i return span if span is not None else 0 - def _xrefs_present(self, subj: int, subj_name: str | None = None, - here_ea: int | None = None, - here_end: int | None = None) -> None: # worker + def _xrefs_present( + self, + subj: int, + subj_name: str | None = None, + here_ea: int | None = None, + here_end: int | None = None, + ) -> None: # worker assert self.program is not None try: return self._xrefs_present_inner(subj, subj_name, here_ea, here_end) @@ -7380,22 +7883,30 @@ class IdaTui(App): if not name: return [] from .domain import link_name + name = link_name(name) try: _, exports = self.program.linkage() except Exception: # noqa: BLE001 return [] if not any(e.name == name for e in exports): - return [] # we don't export it; nobody imports it FROM US + return [] # we don't export it; nobody imports it FROM US try: hits = self._index.importers(name, exclude=self._binary) except Exception: # noqa: BLE001 return [] - return [(h.binary, h.addr, f"{h.addr:08X} import [{h.binary}] {name}") - for h in hits] + return [ + (h.binary, h.addr, f"{h.addr:08X} import [{h.binary}] {name}") + for h in hits + ] - def _present_xrefs(self, label: str, items: list[tuple[object, str]], - focus_name: str | None = None, preselect: int = 0) -> None: + def _present_xrefs( + self, + label: str, + items: list[tuple[object, str]], + focus_name: str | None = None, + preselect: int = 0, + ) -> None: if not self._xref_active: return # cancelled (Esc) while we were still gathering self._xref_active = False @@ -7410,14 +7921,18 @@ class IdaTui(App): def _on_xref_chosen(self, addr) -> None: # type: ignore[no-untyped-def] if addr is None: return - if isinstance(addr, tuple): # a caller in another project binary + if isinstance(addr, tuple): # a caller in another project binary binary, ea = addr - self._switch_then_goto(binary, ea) # records a hop, so Esc returns + self._switch_then_goto(binary, ea) # records a hop, so Esc returns return # 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.is_decomp)) + self._goto_ea( + addr, + push=True, + focus_name=self._xref_focus_name, + prefer_decomp=(self.is_decomp), + ) # -- database edits ---------------------------------------------------- # # The bodies live in EditController (idatui/edit_ctl.py). What stays here is @@ -7466,18 +7981,21 @@ class IdaTui(App): self.edits.do_retype(kind, subject, word, new) @work(thread=True, exclusive=True, group="makedata") - def _do_make_data(self, ea: int, type_decl: str, - anchor: ViewAnchor | None = None) -> None: + 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="opformat") - def _do_op_format(self, mode: str, where: str, ea: int, col: int, - line: int = -1) -> None: + 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) @work(thread=True, exclusive=True, group="edititem") - def _do_edit_item(self, kind: str, ea: int, - anchor: ViewAnchor | None = None) -> None: + def _do_edit_item( + self, kind: str, ea: int, anchor: ViewAnchor | None = None + ) -> None: self.edits.do_edit_item(kind, ea, anchor) def _reload_active_code(self) -> None: @@ -7574,13 +8092,13 @@ class IdaTui(App): return if len(idx): self._no_functions = False - self._apply_filter(self._filter_term) # repopulate the names pane + self._apply_filter(self._filter_term) # repopulate the names pane @work(thread=True, exclusive=True, group="save") def _save(self) -> None: assert self.program is not None try: - self.journal.flush(self.program) # ride along into the .i64 + 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}") @@ -7596,12 +8114,22 @@ class IdaTui(App): # -- navigation to an arbitrary address ------------------------------- # @work(thread=True, group="nav") - def _goto_ea(self, ea: int, push: bool = True, - focus_name: str | None = None, prefer_decomp: bool = False) -> None: + def _goto_ea( + self, + ea: int, + push: bool = True, + focus_name: str | None = None, + prefer_decomp: bool = False, + ) -> None: self._do_navigate(ea, push, focus_name, prefer_decomp) - def _do_navigate(self, ea: int, push: bool, focus_name: str | None = None, - prefer_decomp: bool = False) -> None: # worker context + def _do_navigate( + self, + ea: int, + push: bool, + focus_name: str | None = None, + prefer_decomp: bool = False, + ) -> None: # worker context assert self.program is not None # Which navigation this is. Decompiling below can take a while, and if # you press Esc (or jump again) in the meantime this result is stale — @@ -7623,11 +8151,12 @@ class IdaTui(App): # anchor on the address but snap to the nearest line that # actually holds the referenced symbol (the marker line and # the symbol's line can differ), landing on the token. - dec_idx, col = self._decomp_locate(fn.addr, ea, - focus_name or fn.name) + dec_idx, col = self._decomp_locate( + fn.addr, ea, focus_name or fn.name + ) self.app.call_from_thread( - self._open_decomp_entry, fn.addr, fn.name, dec_idx, col, - push, seq) + self._open_decomp_entry, fn.addr, fn.name, dec_idx, col, push, seq + ) return # Otherwise: everything opens the one continuous listing at ``ea``. A # function name is used for the status label; a region gets a segment @@ -7636,12 +8165,18 @@ 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_if_current, seq, ea, name, idx, push, 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, - seq: int | None = None) -> None: + def _open_decomp_entry( + self, + fn_addr: int, + fn_name: str, + dec_idx: int, + dec_cursor_x: int, + push: bool, + seq: int | None = None, + ) -> None: """Open ``fn_addr`` in the decompiler as a real navigation (nav history aware), landing on pseudocode line ``dec_idx`` column ``dec_cursor_x``. @@ -7663,12 +8198,17 @@ class IdaTui(App): src.dec_cursor_x = dv.cursor_x src.dec_scroll_y = round(dv.scroll_offset.y) if not self._nav or self._nav[-1] is not src: - self._push_nav(src) # never stack a second copy of a spot + self._push_nav(src) # never stack a second copy of a spot else: self._save_current_pos() self._decomp_return = None # a real navigation abandons the F5 return - entry = NavEntry(ea=fn_addr, name=fn_name, view="decomp", - dec_cursor=dec_idx, dec_cursor_x=dec_cursor_x) + entry = NavEntry( + ea=fn_addr, + name=fn_name, + view="decomp", + dec_cursor=dec_idx, + dec_cursor_x=dec_cursor_x, + ) if push: self._push_nav(entry) self._open_entry(entry, push=False) @@ -7689,8 +8229,9 @@ class IdaTui(App): m = re.search(rf"\b{re.escape(name)}\b", clean) return m.start() if m else 0 - def _decomp_locate(self, fn_addr: int, ea: int, - token: str | None) -> tuple[int, int]: + def _decomp_locate( + self, fn_addr: int, ea: int, token: str | None + ) -> tuple[int, int]: """Best (line, column) for address ``ea`` in ``fn_addr``'s pseudocode. Anchors on the /*0xEA*/ marker line for ``ea``, but Hex-Rays can attribute @@ -7748,8 +8289,9 @@ class IdaTui(App): move, so it has no business being a step in the history.""" if a.ea != b.ea or a.view != b.view: return False - return (a.dec_cursor == b.dec_cursor if a.view == "decomp" - else a.cursor == b.cursor) + return ( + a.dec_cursor == b.dec_cursor if a.view == "decomp" else a.cursor == b.cursor + ) def _push_nav(self, entry: NavEntry) -> None: """Append to the nav stack unless that would duplicate where we already are. @@ -7810,9 +8352,16 @@ 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: + 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, @@ -7822,10 +8371,18 @@ class IdaTui(App): 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, - scroll_y: int = -1) -> None: + 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, + scroll_y: int = -1, + ) -> None: if push: self._save_current_pos() self._decomp_return = None # a real navigation abandons the F5 return @@ -7882,8 +8439,11 @@ class IdaTui(App): 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() + ( + self.query_one(HexView) + if self.is_hex + else (self._code_view() or self.query_one(ListingView)) + ).focus() else: prompt.close() return @@ -7913,10 +8473,12 @@ class IdaTui(App): # 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 "") + 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: @@ -7924,15 +8486,21 @@ class IdaTui(App): return if inp.id == "goto": self._end_goto() - (self.query_one(HexView) if self.is_hex - else (self._code_view() or self.query_one(ListingView))).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() + ( + 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 @@ -8097,13 +8665,13 @@ class IdaTui(App): e.scroll_y = round(lst.scroll_offset.y) @work(thread=True, group="nav") - def _open_function(self, ea: int, name: str | None = None, - push: bool = True) -> None: + def _open_function( + self, ea: int, name: str | None = None, push: bool = True + ) -> None: # Unified: opening a function is just navigating the one linear listing # to its entry address. self._do_navigate(ea, push) - def _code_mode(self) -> ViewMode: """The code view to return to from hex — always the unified listing.""" return ViewMode.LISTING @@ -8121,9 +8689,12 @@ class IdaTui(App): dec = self.query_one(DecompView) if dec.loaded_ea == entry.ea: # already decompiled: reposition without a recompile - dec.goto(entry.dec_cursor, entry.dec_cursor_x, - entry.dec_scroll_y if entry.dec_scroll_y >= 0 else -1, - entry.dec_scroll_x) + dec.goto( + entry.dec_cursor, + entry.dec_cursor_x, + entry.dec_scroll_y if entry.dec_scroll_y >= 0 else -1, + entry.dec_scroll_x, + ) self._show_active() # loads the pseudocode if loaded_ea != entry.ea return # Unified model: the code view is always the continuous listing, @@ -8139,13 +8710,21 @@ class IdaTui(App): # leave the viewport alone and just move the cursor; otherwise # scroll so the target sits a few lines below the top for context. top = round(lst.scroll_offset.y) - if lst.model is lm and top <= entry.cursor < top + lst._visible_height(): + if ( + lst.model is lm + and top <= entry.cursor < top + lst._visible_height() + ): sy = top else: sy = max(entry.cursor - _JUMP_CONTEXT, 0) lst.load( - lm, entry.name, cursor=entry.cursor, - cursor_x=entry.cursor_x, scroll_y=sy, focus=focus) + lm, + entry.name, + cursor=entry.cursor, + cursor_x=entry.cursor_x, + scroll_y=sy, + focus=focus, + ) self._active = ViewMode.LISTING self._show_active() # Graph mode is sticky: following a call from the graph should land in @@ -8157,8 +8736,15 @@ class IdaTui(App): # 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", "export", - "func-filter") + _PROMPT_IDS = ( + "search", + "rename", + "comment", + "retype", + "goto", + "export", + "func-filter", + ) def _prompt_active(self) -> bool: for iid in self._PROMPT_IDS: @@ -8265,8 +8851,10 @@ class IdaTui(App): sec = self.program.section_of(va) if self.program else None fo = self.program.file_offset(va) if self.program else None foff = f"file+{fo:#x}" if fo is not None else "file:--" - self._status(f"hex va={va:#x} {foff} [{sec or '?'}] " - "(g goto · Enter→code · Tab/Esc/\\→back)") + self._status( + f"hex va={va:#x} {foff} [{sec or '?'}] " + "(g goto · Enter→code · Tab/Esc/\\→back)" + ) def on_hex_view_moved(self, msg: HexView.Moved) -> None: self._hex_status(msg.va) @@ -8303,8 +8891,13 @@ class IdaTui(App): why = self.program.decomp_error(ea) self.app.call_from_thread(self._apply_decomp, ea, name, dec, why) - def _apply_decomp(self, ea: int, name: str, dec, # type: ignore[no-untyped-def] - why: str = "") -> None: + def _apply_decomp( + self, + ea: int, + name: str, + dec, # type: ignore[no-untyped-def] + why: str = "", + ) -> None: view = self.query_one(DecompView) view.loading = False if dec.failed: @@ -8348,11 +8941,12 @@ class IdaTui(App): 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 + self._load_split_map(ea) # then upgrade to the region map self._split_status() else: self._status( - f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}") + f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}" + ) def _sync_split(self, source: str, resync: bool = True) -> None: """Split view: highlight (+ scroll into view) the companion pane's @@ -8377,8 +8971,7 @@ class IdaTui(App): if source == "decomp": dec.set_link(None) # the driver shows its own cursor, no band line, screen = self._split_anchor(dec) - eas = (self._split_eamap[line] - if 0 <= line < len(self._split_eamap) else []) + eas = self._split_eamap[line] if 0 <= line < len(self._split_eamap) else [] if not eas: # fallback: the single /*ea*/ marker for the line one = dec._line_ea(line) eas = [one] if one is not None else [] @@ -8467,18 +9060,25 @@ class IdaTui(App): if self.is_decomp: dec = self.query_one(DecompView) ea = dec._line_ea(dec.cursor) - n = (len(self._split_eamap[dec.cursor]) - if 0 <= dec.cursor < len(self._split_eamap) else 0) + n = ( + len(self._split_eamap[dec.cursor]) + if 0 <= dec.cursor < len(self._split_eamap) + else 0 + ) at = f" @ {ea:#x}" if ea is not None else "" rel = f" \u2194 {n} insn" if n else "" - self._status(f"{self._cur.name}{at} " - f"[split \u00b7 pseudocode line {dec.cursor + 1}{rel}]" - f" (Tab/click: drive listing)") + self._status( + f"{self._cur.name}{at} " + f"[split \u00b7 pseudocode line {dec.cursor + 1}{rel}]" + f" (Tab/click: drive listing)" + ) else: ea = self.query_one(ListingView)._cursor_ea() at = f" @ {ea:#x}" if ea is not None else "" - self._status(f"{self._cur.name}{at} [split \u00b7 listing]" - f" (Tab/click: drive pseudocode)") + self._status( + f"{self._cur.name}{at} [split \u00b7 listing]" + f" (Tab/click: drive pseudocode)" + ) def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def] """In split, focusing a pane (Tab or a mouse click) makes it the leading/ @@ -8486,8 +9086,13 @@ class IdaTui(App): if not self._split: return w = event.control - new = ("decomp" if isinstance(w, DecompView) - else "listing" if isinstance(w, ListingView) else None) + new = ( + "decomp" + if isinstance(w, DecompView) + else "listing" + if isinstance(w, ListingView) + else None + ) if new is not None and new != self._active: self._active = new self._sync_split(new) @@ -8570,8 +9175,10 @@ class IdaTui(App): ea = msg.ea if ea is not None: sec = self.program.section_of(ea) if self.program else None - self._status(f"{sec or '?'} @ {ea:#x} [listing] " - "(c code · p func · u undefine · Enter follow)") + self._status( + f"{sec or '?'} @ {ea:#x} [listing] " + "(c code · p func · u undefine · Enter follow)" + ) # -- teardown ---------------------------------------------------------- # async def on_unmount(self) -> None: -- cgit v1.3.1-sl0p