aboutsummaryrefslogtreecommitdiffstats

Textual notes & app patterns

Hard-won Textual behaviour and the patterns this app relies on. Pairs with PAGING_FINDINGS.md (the ida-pro-mcp/idalib side).

Textual pitfalls

  • BINDINGS only merge from DOMNode subclasses. A plain mixin's BINDINGS are silently ignored — each view lists them explicitly (e.g. *SearchMixin.SEARCH_BINDINGS, *ColumnCursor.COL_BINDINGS).
  • ScrollView + render_line: y is the SCREEN line; we add scroll_offset.y ourselves. ScrollView.watch_scroll_y only repaints when the rounded scroll value changes.
  • scroll_to right after setting virtual_size clamps to 0max_scroll_y isn't recomputed until layout. Apply the scroll now AND again via call_after_refresh with refresh(layout=True); don't zero virtual_size on load (snaps scroll to 0 → visible flash). See ColumnCursor._apply_scroll, DisasmView._on_primed, DecompView.show.
  • Scrolling on already-laid-out content (no reload) leaves a stale frame. A plain scroll_to moves scroll_offset but Textual only repaints on a rounded scroll change, and with no virtual_size/content change there's no layout pass to force a paint — so the pane shows the old frame until the next interaction (moving the cursor auto-corrects it). Route these through _apply_scroll too (immediate scroll + deferred refresh(layout=True)). Bit us on same-function decompiler jumps (DecompView.goto); the reload path (DecompView.show) hides it because clearing the loading cover repaints.
  • Pilot lays out synchronously, so programmatic scroll always has a valid range in tests — it MASKS the real-terminal clamp/no-repaint bug. Assert the render (trace render_line's scroll at paint), not just scroll_offset.y. (test_tui.py: "pane is repainted at the restored scroll".) Note even a render trace can't catch the goto stale-frame case (no layout pass to observe) — it only shows in a real terminal.
  • widget.loading = True covers the widget via _cover_widget (NOT a normal child — query() won't find it; check widget._cover_widget) and blurs focus (focus → None), so Tab falls back to focus-navigation. Fix: make the app Tab/Shift+Tab binding priority=True; restore focus when loading ends.
  • Mouse: event.get_content_offset(widget) → offset past padding/border; add scroll_offset for the virtual (line, col). event.chain >= 2 = double-click. Subtract any left gutter (ColumnCursor._col_offset).
  • Cheap repaints: refresh(Region(0, row, w, 1)) for just the changed rows; reactive(x, repaint=False) to avoid an implicit full refresh on assignment.
  • Input defaults to a 3-row bordered widget. For a 1-row prompt use border: none, and do NOT dock: bottom it next to the Footer — they land on the same row and the Footer paints over it. Keep prompts in normal flow above the Footer (see #search/#rename/#status).
  • Textual ships no C/C++ tree-sitter grammarTextArea(language="cpp") is a silent no-op. We highlight pseudocode with Pygments (idatui/highlight.py).
  • A modal's Input messages bubble to the App. SymbolPalette (the Ctrl+N fuzzy finder) has its own Input; its Input.Changed/Input.Submitted bubble up to the app's handlers (which would run the #search/#func-filter logic and even hide the palette's input). Call event.stop() in the modal's handlers. A single-line Input doesn't bind up/down, so those bubble to the modal's BINDINGS (used to move the result list while the input keeps focus); enter is consumed by the Input → handle it via on_input_submitted.

App patterns

  • Name-generation invalidation (Program._name_gen / bump_names): a rename bumps the gen; disasm block caches are cleared (disasm names are live in the IDB), and decompile is gen-checked and force_recompiled lazily on mismatch. force_recompile needs items=[{addr}], not addr.
  • Cursor + scroll history: NavEntry stores cursor/cursor_x/scroll_y (disasm) and dec_cursor/dec_cursor_x/dec_scroll_y/dec_scroll_x (pseudocode). _save_current_pos() snapshots both views right before a push; restore on _open_entry / DecompView.show.
  • Follow: name-based (decompiler refs → resolve) THEN an address-based fallback (parse the line's /*0xEA*/ marker → xrefs_from → first code target). The address path is immune to a stale token right after a rename.
  • Views only ever render a viewport-sized slice; scale lives in the domain cache/paging layer, never in a widget.

Testing gotchas

  • goto to a non-existent function name is a no-op → "jump back" tests then pass trivially. Navigate to a REAL second function.
  • Scroll-restore tests with the cursor at the viewport top (rel = 0) are degenerate (scroll-into-view derives the same scroll). Use a mid-viewport cursor (rel > 0).
  • Cold session ⇒ slow first analysis ⇒ wait_until timeouts ⇒ flaky "want 0" failures. Re-run on a warm session before trusting a regression.
  • Edits mutate the .i64 — revert renames (via API) for idempotency; use a PID-unique temp name to dodge collisions from a prior crashed run.