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
BINDINGSonly merge fromDOMNodesubclasses. A plain mixin'sBINDINGSare silently ignored — each view lists them explicitly (e.g.*SearchMixin.SEARCH_BINDINGS,*ColumnCursor.COL_BINDINGS).ScrollView+render_line:yis the SCREEN line; we addscroll_offset.yourselves.ScrollView.watch_scroll_yonly repaints when the rounded scroll value changes.scroll_toright after settingvirtual_sizeclamps to 0 —max_scroll_yisn't recomputed until layout. Apply the scroll now AND again viacall_after_refreshwithrefresh(layout=True); don't zerovirtual_sizeon load (snaps scroll to 0 → visible flash). SeeColumnCursor._apply_scroll,DisasmView._on_primed,DecompView.show.- Scrolling on already-laid-out content (no reload) leaves a stale frame. A
plain
scroll_tomovesscroll_offsetbut 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_scrolltoo (immediate scroll + deferredrefresh(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 justscroll_offset.y. (test_tui.py: "pane is repainted at the restored scroll".) Note even a render trace can't catch thegotostale-frame case (no layout pass to observe) — it only shows in a real terminal. widget.loading = Truecovers the widget via_cover_widget(NOT a normal child —query()won't find it; checkwidget._cover_widget) and blurs focus (focus → None), soTabfalls back to focus-navigation. Fix: make the appTab/Shift+Tabbindingpriority=True; restore focus when loading ends.- Mouse:
event.get_content_offset(widget)→ offset past padding/border; addscroll_offsetfor 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. Inputdefaults to a 3-row bordered widget. For a 1-row prompt useborder: none, and do NOTdock: bottomit next to theFooter— 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 grammar —
TextArea(language="cpp")is a silent no-op. We highlight pseudocode with Pygments (idatui/highlight.py). - A modal's
Inputmessages bubble to the App.SymbolPalette(the Ctrl+N fuzzy finder) has its ownInput; itsInput.Changed/Input.Submittedbubble up to the app's handlers (which would run the#search/#func-filterlogic and even hide the palette's input). Callevent.stop()in the modal's handlers. A single-lineInputdoesn't bindup/down, so those bubble to the modal's BINDINGS (used to move the result list while the input keeps focus);enteris consumed by the Input → handle it viaon_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), anddecompileis gen-checked andforce_recompiled lazily on mismatch.force_recompileneedsitems=[{addr}], notaddr. - Cursor + scroll history:
NavEntrystorescursor/cursor_x/scroll_y(disasm) anddec_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
gototo 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_untiltimeouts ⇒ 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.
