aboutsummaryrefslogtreecommitdiffstats
path: root/docs/TEXTUAL_NOTES.md
blob: 6ce903727cb5b1105db2dd19e7435b0781834ee3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# 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 0**`max_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 grammar**`TextArea(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_recompile`d 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.