aboutsummaryrefslogtreecommitdiffstats
path: root/docs/TEXTUAL_NOTES.md
blob: aba84aede153bc7a05541df256b1380018067336 (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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# Textual notes & app patterns

Hard-won Textual behaviour and the patterns this app relies on. Pairs with
`PAGING_FINDINGS.md` (the 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`,
  `ListingView._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`).
  For the **editable** C in the struct editor, `highlight.CTextArea` overrides
  `TextArea._build_highlight_map()` and fills the widget's own `_highlights`
  map from the same lexer — the hook the tree-sitter path would fill, so the
  line cache, selection and cursor stay stock. Two traps: those spans are
  **byte** offsets into the line (the renderer decodes them with
  `build_byte_to_codepoint_dict`, so character offsets smear on non-ASCII), and
  a `TextAreaTheme` that sets `base_style` overrides the widget's CSS colours —
  ours sets only `syntax_styles` so the editor keeps the app's background.
- **Fixed-size splash art disappears instead of shrinking.** The kitty image is
  scaled by the terminal into whatever cell box you place it in (`c=`/`r=`), so
  sizing it to the artwork's natural height and then asking "is there room?" is
  all-or-nothing — a 31-row zellij pane was ONE row short of the 41 the splash
  wanted, and the logo silently vanished. Size the art to the room instead
  (`logo_cells(max_rows)`), and keep the chrome constant honest:
  `LOGO_CHROME_ROWS = 10` is border 2 + padding 2 + the art's margin 1 + title 1
  + note 1+1 + help 1+1, which the old `rows + 9` under-counted by one, so at
  exactly the threshold the help line was clipped off the bottom.
- **Centre modals with a rule, not a list.** `ModalScreen { align: center middle; }`
  matches subclasses, so every dialog inherits it and the next one is centred
  for free. Naming the screens instead (`SymbolPalette, StringsPalette, …`) is
  how two palettes shipped pinned to the top of the screen. Textual's own
  `CommandPalette` is a ModalScreen too and wants its stock top alignment, so it
  opts out explicitly. The `modal_centering` scenario asserts both the rule and
  that it reaches a real dialog's `region`.
- **Modal chrome lives in ONE grouped CSS rule** (`#quit-box, #help-box, …` in
  `IdaTui.CSS`): `border: round`, title colour, subtitle colour. Per-box rules
  carry only size. Dialog titles are `border_title`/`border_subtitle` on the box,
  not a docked `Static` — but **`border_title` parses Textual markup** when you
  give it a `str` (`render_str`), so a title like `symbols [this binary]: 128`
  loses or breaks on the `[…]`. Pass a `rich.text.Text` and it's taken literally.
  Also note it keeps only the FIRST line of a multi-line title.
- **Widgets inside a dialog bring their own frames.** `OptionList` defaults to
  `border: tall $border-blurred` + `background: $surface`, which inside a bordered
  dialog reads as a second panel; the modal rule clears both so the dialog has one
  edge. Focus still reads from the highlight bar (bright focused / dim blurred).
- **Look at chrome with `experiments/modal_shot.py`** (no IDA, no worker, any
  python3 with textual): it renders each dialog over a fake background at a size
  you choose. A tiled pane is the worst possible place to judge a dialog.
- **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.


## A priority app binding fires even under a modal

`Binding("tab,shift+tab", "toggle_view", priority=True)` on the App runs before
the focus chain, so Tab never reached ANY dialog — nothing in a modal could be
tabbed to, in this app, ever. It looked like a bug in one dialog (the load
options address field was unreachable) and was actually app-wide.

The fix is in the action, not the binding: if a modal is up, hand the key back.

```python
def action_toggle_view(self):
    if self.screen is not self.screen_stack[0]:
        self.screen.focus_next()
        return
```

Related: DOM order is rarely the tab order you want. In a dialog with a filter
box, an option list and a second field, `focus_next()` stops at the list — which
is arrow-driven and has nothing to type — so text meant for the second field
lands in the filter. Override `focus_next`/`focus_previous` on the screen to
cycle the fields people actually type into.

## A modal can outgrow the terminal and clip its own controls

`#pal-list { max-height: 24 }` plus a 21-row list pushed the address field and
help line off the bottom of the screen. Nothing errors; the field is simply not
there, which reads to the user as "Tab does nothing". Cap the scrollable part
per-dialog so the controls below it are always visible.