tuile 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
data/ideas/hover.md ADDED
@@ -0,0 +1,909 @@
1
+ # Hover — motion events, `on_mouse_enter` / `on_mouse_exit`, and who paints the accent
2
+
3
+ **Status:** designed and measured 2026-09-03; **nothing implemented.** Paused
4
+ here — see *Where this stands* for the resume point. The note was reframed on
5
+ the same day it was filed (*The opt-in reframe*), which retired its first
6
+ conclusion; several other rulings were revised in place and are marked where
7
+ they changed.
8
+
9
+ Three questions, and the plan is to answer them in that order rather than
10
+ together:
11
+
12
+ 1. **Plumbing.** Settle the event vocabulary, parse the motion codes and SGR
13
+ encoding correctly, put motion behind the mode ladder — and *test it on real
14
+ terminals*. **Design settled; measurement done** (*Measured*, one row, the
15
+ rest skipped with reasons).
16
+ 2. **Notices.** Derive enter/exit per component from the move stream. Mostly
17
+ settled; two questions open.
18
+ 3. **Ink.** *Then* decide, with the first two in hand: abandon the accent and
19
+ let each app paint its own, do it for `MenuBar` only, or do it flatly for
20
+ every component. **Untouched by design.**
21
+
22
+ Step 3 is deliberately last and deliberately reversible: steps 1–2 are useful
23
+ on their own, and a framework accent can be added later but not removed.
24
+
25
+ ## Where this stands (resume point, 2026-09-03)
26
+
27
+ **Settled** — the event vocabulary and its three rulings, the two-level opt-in
28
+ and the `capture_mouse:` ladder, the encoding (request 1006, parse both), the
29
+ scroll split, exit-before-enter, the hover lifecycle from 1004, the tiled
30
+ resolution rule, and the demo. Each is marked *settled* at its own section; do
31
+ not re-litigate one without reading the paragraph that closed it.
32
+
33
+ **Open** — nine things, and they split by what unblocks them:
34
+
35
+ - *Needs a hand on a mouse* (both scoped in *Measured*): tmux pane offset in a
36
+ split, and text selection under 1003.
37
+ - *Decidable by reasoning now*: open questions 1, 3, 7, 9, 10, 11 — the hover
38
+ target's shape, chain-vs-leaf, whether `on_mouse_exit` survives outcome (A),
39
+ the silent no-op, the `Ticker` delay, and whether the scroll split also
40
+ changes scroll routing.
41
+
42
+ **The next substantive move is deciding whether to *build* step 1.** It is now
43
+ fully specified: `kind:` on `MouseEvent`, extract `MouseScrollEvent` and
44
+ `MouseMoveEvent`, the `capture_mouse:` ladder, and a buffered SGR parser
45
+ replacing `Keys.getkey`'s 5-byte gulp. Note what that costs beyond the code —
46
+ it is a **breaking change**, so it owes a CHANGELOG entry and at least one `D_`,
47
+ and three housekeeping items fall out of it:
48
+
49
+ 1. Correct `D_menu_bar` and `D_no_context_menu`, which both say "press-only, no
50
+ release" — releases *do* arrive under mode 1000, they are merely
51
+ button-anonymous.
52
+ 2. Split `ideas/new-components.md` item 5 into 1002-drag and 1003-hover; it
53
+ currently lumps them, which is the conflation this note exists partly to
54
+ unpick.
55
+ 3. The parse fix (`kind:`, distinguishing press from release) is **unconditional
56
+ and separable** — it corrects today's default profile and needs neither the
57
+ mode ladder nor the matrix, so it can land first and alone.
58
+
59
+ **If this note is picked up cold**, read in this order: *The opt-in reframe* →
60
+ *The opt-in model* → *The event vocabulary* → *Measured*. The rest is detail
61
+ hanging off those four.
62
+
63
+ ## The opt-in reframe (supersedes the first draft's conclusion)
64
+
65
+ The first draft's headline objection was that a hover accent competes with the
66
+ focus accent — two highlights, same token, and the keyboard user cannot tell
67
+ which one Enter goes to. **That objection was scoped wrong.** It holds only for
68
+ a widget the framework accents *automatically*, and the intended shape is that
69
+ **the app names which components hover**: the OK button in a dialog, a
70
+ "Scroll to bottom" affordance the way the Claude CLI harness has one, a menu
71
+ item. Nothing else lights up, so nothing competes.
72
+
73
+ And the example that makes it clearly right: **a "Scroll to bottom" `Label` is
74
+ not focusable at all.** It is a click target outside the Tab cycle, so there is
75
+ no focus accent for a hover accent to be confused with — the ambiguity is
76
+ *structurally absent*. Which inverts the finding:
77
+
78
+ > **Hover is most valuable exactly where focus cannot go.** The competing-signal
79
+ > problem is real for `Button` (focusable, already accented) and vanishes for a
80
+ > non-focusable click target, which is the case with no other affordance at all.
81
+
82
+ That is worth noticing as a gap in its own right: Tuile has no "clickable but
83
+ not focusable" idiom today. `Component#handle_mouse` focuses if `focusable?`
84
+ and otherwise just descends, so such a thing is a `Label` subclass with a
85
+ `handle_mouse` override — reachable, unnamed, and undocumented. **Open
86
+ question:** is the hover target a *flag on `Component`*, or is it a component
87
+ kind (a `Link`, a non-focusable `Button`) that happens to hover? The COP answer
88
+ leans to the latter, and it would keep `Component` from growing a knob.
89
+
90
+ ## The opt-in model — two levels, and hover is never load-bearing
91
+
92
+ **Settled 2026-09-03.** Motion is opt-in the way the whole mouse already is:
93
+ one switch at `run_event_loop`, off by default, so **an app that does not ask
94
+ pays nothing** — not a byte on the wire, not an event in the queue, not a
95
+ branch in the hot path. That is what makes the rest of this note safe to build,
96
+ and it is worth stating before any design: the default profile does not change.
97
+
98
+ Two levels, and they answer different questions:
99
+
100
+ | level | question | shape |
101
+ |---|---|---|
102
+ | **the mode** | does this app want to pay for motion at all? | one kwarg at `run_event_loop` |
103
+ | **the component** | which widgets do something when hovered? | having a handler / a hover color |
104
+
105
+ The mode switch is a statement about the app's *character* — a rich TUI says
106
+ yes, a log tailer says no — not a per-feature toggle. The component level is
107
+ where "flatly for everything" vs. "only the menus" is expressed, and it needs no
108
+ framework knob at all (see *the opt-in mechanism* below).
109
+
110
+ ### The invariant that keeps it honest
111
+
112
+ > **A hover feature is a second route to an affordance that already exists.
113
+ > Never the only route.**
114
+
115
+ `MenuBar` must work exactly as well with motion off, and it does — check both
116
+ sub-cases against the rule:
117
+
118
+ - *Open-on-hover of a sibling menu while a cascade is open* → with motion off
119
+ you **click** the sibling. Same outcome, one more click.
120
+ - *Item highlight under the pointer* → with motion off the **arrows** move the
121
+ cursor. Hover just becomes a third way to set a position that already has
122
+ two.
123
+
124
+ This is the acceptance test for every future hover consumer, and it is what
125
+ stops the opt-in from quietly becoming mandatory. A proposal that fails it — a
126
+ disclosure reachable *only* by hovering — is rejected on that ground alone,
127
+ because it would make a mode-off app strictly less capable rather than merely
128
+ less smooth. It also keeps the existing suite valid: no PTY spec needs motion
129
+ to prove `MenuBar` works, because nothing about `MenuBar` depends on it.
130
+
131
+ ### The kwarg
132
+
133
+ `run_event_loop(capture_mouse: true)` is a Boolean today (`screen.rb:411`).
134
+ Widen it into a **ladder named for what the app gets**, not for the mechanism —
135
+ each rung a strict superset of the one before, ordered by cost:
136
+
137
+ ```ruby
138
+ capture_mouse: false # nothing
139
+ capture_mouse: true # == :clicks → 1000 (the default; today's behavior)
140
+ capture_mouse: :drag # → 1002
141
+ capture_mouse: :hover # → 1003
142
+ ```
143
+
144
+ `true` stays the default and becomes an alias for `:clicks`, so nothing breaks.
145
+ One knob makes the illegal state — motion without mouse capture —
146
+ *unrepresentable*, where a second `track_motion:` kwarg would need a guard and a
147
+ raise to say the same thing. Symbol enums are house style already
148
+ (`scrollbar_visibility=`, which also shows the discipline of refusing an
149
+ `:auto`).
150
+
151
+ Naming them `:drag` / `:hover` rather than a single `:motion` matters: `:motion`
152
+ conflates 1002 and 1003, which is exactly the conflation
153
+ `ideas/new-components.md` item 5 already makes and which this note exists partly
154
+ to unpick.
155
+
156
+ ### The one real cost of two levels: a silent no-op
157
+
158
+ An app that overrides `on_mouse_enter` and forgets the mode switch gets
159
+ **nothing, silently** — no error, no warning, no hint. That is the footgun, and
160
+ it has no cheap detection (the framework cannot know a hook was overridden
161
+ without probing every component, and components are added after the loop
162
+ starts). Mitigations, none free: document it on both members so either rdoc
163
+ mentions the other; put it in the book's mouse section; and let a dedicated
164
+ `examples/` script be the copy-paste source rather than the sampler. **Open
165
+ question** whether anything stronger is warranted — a one-time `Tuile.logger`
166
+ warning the first time a hover hook fires with the mode off is possible but
167
+ inverts the dependency (the framework would have to notice a hook it never
168
+ called).
169
+
170
+ ### What the settled mode kills
171
+
172
+ Recording this because it removes work the earlier draft was carrying:
173
+
174
+ - **No refcounting.** The "enable 1003 while a cascade is open, disable it
175
+ after" design needed an enable/disable count as soon as two consumers
176
+ overlapped. Gone.
177
+ - **No mid-loop mode toggling**, and so no inheriting `D_background_rgb`'s rule
178
+ about which thread may write to the terminal between frames. The escape is
179
+ written once in `run_event_loop`'s existing `begin`/`ensure` pair, beside the
180
+ three modes already there.
181
+ - **Scoped tracking stays available as a later optimization** if the measured
182
+ event rate turns out to be a problem, but it is no longer part of the design.
183
+
184
+ ## Step 1 — plumbing
185
+
186
+ ### Tuile receives no motion today
187
+
188
+ {Screen#run_event_loop} enables **mode 1000** only (`screen.rb:419` →
189
+ `MouseEvent.start_tracking`, `"\e[?1000h"`). The reporting modes are separate
190
+ DECSETs, not a level — you set exactly one, and each is a strict superset of
191
+ the one above:
192
+
193
+ | mode | press | release | motion | who wants it |
194
+ |---|---|---|---|---|
195
+ | `1000` | ✓ | ✓ | — | today's clicks and wheel |
196
+ | `1002` | ✓ | ✓ | while a button is held | Split divider, Slider, scrollbar drag |
197
+ | `1003` | ✓ | ✓ | **always** | **hover**, open-on-hover, Tooltip |
198
+
199
+ **1002 buys nothing over 1000 for release events** — both report press and
200
+ release identically; 1002 *only* adds drag-motion. So "1002 with the motion
201
+ ignored" is exactly 1000 plus wasted bytes, and is never the right answer.
202
+ Releases arrive today and are simply discarded.
203
+
204
+ Two neighbours to keep straight, because both invite mistakes:
205
+
206
+ - **Mode 9** is the true press-only mode (X10 compatibility, no release). Tuile
207
+ does not use it; "X10" in this note and in `mouse_event.rb` refers to the
208
+ *encoding*, not to mode 9.
209
+ - **Mode 1001 is "highlight tracking" and must never be enabled** — the
210
+ terminal waits for the *application* to reply, and a non-participating app can
211
+ hang it. When talking about 1002/1003, say **motion** or **hover** tracking;
212
+ "highlight tracking" is 1001's actual name and using it loosely will
213
+ eventually get 1001 typed by accident.
214
+ - **Mode 1004** is not a mouse mode at all: it reports terminal focus in/out
215
+ (`\e[I` / `\e[O`), is independently settable, and is wanted here only to clear
216
+ a stranded hover (see *no reliable exit event*).
217
+
218
+ **Hover needs 1003, drag needs only 1002, and they are not one prerequisite.**
219
+ `ideas/new-components.md` item 5 lumps them ("mouse motion/drag, modes
220
+ 1002/1006"); split it when either lands. A drag flood is bounded — it lasts as
221
+ long as a button is down and the user is doing one deliberate thing. A 1003
222
+ flood is a report per cell crossed, unconditionally, including while the app is
223
+ idle and while the user is merely moving the mouse to a different window.
224
+
225
+ ### The wire format (verified by reading)
226
+
227
+ {MouseEvent.parse} decodes `Cb = code + 32` and cases on the code
228
+ (`mouse_event.rb:51-59`). The X10 code layout is `button | 4 shift | 8 meta |
229
+ 16 ctrl | 32 motion | 64 wheel`, so:
230
+
231
+ | event | code | `parse` gives today |
232
+ |---|---|---|
233
+ | left press | 0 | `:left` |
234
+ | wheel up / down | 64 / 65 | `:scroll_up` / `:scroll_down` |
235
+ | **release (any button)** | 3 | **`button: nil`** |
236
+ | motion, left held | 32 | `button: nil` |
237
+ | motion, no button | 35 | `button: nil` |
238
+
239
+ Two consequences:
240
+
241
+ - **No collision, so flipping to 1003 is safe today.** Motion codes 32–35 sit
242
+ clear of the wheel's 64–67 (the `- 32` offset is applied first), so motion
243
+ would *not* manufacture phantom scroll events, and every `handle_mouse` gates
244
+ on `event.button == :left`, so motion would arrive and be ignored. That makes
245
+ step 1 genuinely low-risk to spike.
246
+ - **`button: nil` is already an overload**, and this is a pre-existing wart the
247
+ work would fix rather than create. It means "release" today — releases *do*
248
+ arrive under mode 1000, they are just button-anonymous, which is why nothing
249
+ has ever used them — against an rdoc that says `nil` means "not known"
250
+ (`mouse_event.rb:8`). Both `D_menu_bar` and `D_no_context_menu` say
251
+ "press-only, no release"; that is imprecise and should be corrected when this
252
+ lands.
253
+
254
+ ### The event vocabulary — settled 2026-09-03
255
+
256
+ Derived from consumers rather than from the wire, which is what makes it come
257
+ out small:
258
+
259
+ | event | who actually needs it |
260
+ |---|---|
261
+ | press | everything today — `Button`, `Checkbox`, `List` row, `Select`, `MenuBar` |
262
+ | scroll notch | `TextView`, `List`, `TextArea`, `ListDropdown` |
263
+ | release | nothing today; drag, and press-visual-feedback |
264
+ | move | **nobody directly** — it is substrate |
265
+ | enter / exit | hover consumers (derived from move) |
266
+ | drag | Split divider, Slider, scrollbar thumb (derived from press→moves→release) |
267
+
268
+ **Almost nothing wants a raw move** — enter/exit and drag are both *derived*, so
269
+ moves are consumed inside `Screen` rather than broadcast. But "never delivered"
270
+ was too strong, and the demo in open question 12 is what found it: a component
271
+ that paints something **at the pointer's position inside itself** — a crosshair,
272
+ a following tooltip, a canvas — needs the position on every move, and enter/exit
273
+ cannot supply it.
274
+
275
+ So the rule is narrower than "nowhere public": **a move goes to the hovered
276
+ chain, and only to a component that overrides `on_mouse_move`.** One that does
277
+ not override it pays nothing and never sees the flood. That is the same
278
+ opt-in-by-handler mechanism the rest of the design uses, and it is what makes
279
+ the volume safe — which is also the *real* argument for keeping moves out of
280
+ `handle_mouse`, see below.
281
+
282
+ **Wire / queue layer — three classes:**
283
+
284
+ ```ruby
285
+ MouseEvent(kind: :press | :release, button:, x:, y:) # buttons only
286
+ MouseScrollEvent(direction:, x:, y:) # the wheel
287
+ MouseMoveEvent(button:, x:, y:) # button = held, or nil
288
+ ```
289
+
290
+ **Component layer — barely changes:**
291
+
292
+ ```ruby
293
+ handle_mouse(event) # press/release; existing name, existing routing
294
+ handle_scroll(event) # the wheel
295
+ on_mouse_enter / on_mouse_exit # hooks, :hover only
296
+ on_mouse_move(point) # hook, :hover only, opt-in by override
297
+ ```
298
+
299
+ **This reverses the first draft's lean**, which argued one class with a `kind`
300
+ field and rejected a separate move class as "modelling the wire wrong". Two
301
+ things overturned it.
302
+
303
+ First, once scroll and move leave, `MouseEvent` + `button` means exactly what it
304
+ says, so **no rename is needed** — the naming complaint that started this was
305
+ really a symptom of three event kinds sharing one class.
306
+
307
+ Second — and this is the load-bearing one — **the delivery rule differs, and
308
+ volume makes that decisive.** A press goes to every component whose rect
309
+ contains the point, unconditionally; a move goes to the hovered chain and only
310
+ to opted-in overriders. Route moves through `handle_mouse` as `kind: :move` and
311
+ **every existing `handle_mouse` starts receiving ~84 events/s** and needs a
312
+ guard against them — the exact trap the "`:release` is parsed but not delivered"
313
+ ruling avoids, at 84× the rate. A separate class makes the flood
314
+ unreachable-by-default instead of guarded-by-convention.
315
+
316
+ *Corrected:* the first version of this argument said press and move need
317
+ different routing because press "goes to *every* child whose rect contains the
318
+ point, while move must resolve a single topmost target". That overstated it —
319
+ **overlapping tiled siblings are already forbidden** (`component.rb:572`: "as
320
+ long as siblings don't overlap each other — which Tuile already requires"), so
321
+ at most one child contains any point and the tree walk is effectively the same
322
+ for both. The delivery-and-volume argument above is the one that actually holds.
323
+
324
+ `MouseMoveEvent` keeps a `button` because under 1003 motion genuinely carries
325
+ held-button state (codes 32/33/34) — which is exactly what a future drag needs,
326
+ so the field is honest there rather than vestigial.
327
+
328
+ `MouseEvent` is a `Data.define(:button, :x, :y)` constructed **positionally** at
329
+ `mouse_event.rb:60` and in ~56 spec call sites, so adding `kind:` needs an
330
+ `initialize` override with a default (the `PasteEvent` / `TTYSizeEvent`
331
+ precedent) to keep three-arg construction working.
332
+
333
+ ### Three rulings that come with the vocabulary
334
+
335
+ - **Activate on press, not on click — there is deliberately no click
336
+ synthesis.** Real click semantics (press *and* release on the same widget,
337
+ drag-off-to-cancel) are achievable under mode 1000 today, since releases
338
+ already arrive. Declined: activation would then depend on two events instead
339
+ of one, over ssh and tmux where either can be lost, and the failure mode is
340
+ "buttons stop working". Press-activation is also snappier on a laggy link and
341
+ survives a terminal that reports no release at all. This is why the class is
342
+ **not** renamed `MouseClickEvent` — that would name a synthesis Tuile
343
+ deliberately does not perform.
344
+ - **`:release` is parsed but not delivered, until a consumer exists.** Not
345
+ merely YAGNI — delivering it *breaks* existing widgets. Twelve sites filter on
346
+ `event.button == :left` and would see a second event per click, so anything
347
+ toggling on a left event would double-toggle. Parse it, keep it out of
348
+ delivery.
349
+ - **Enter/exit fire only under `:hover`.** Mode 1002 *can* produce motion (while
350
+ a button is held), so a partial enter/exit during a drag is technically
351
+ available. Declined: a component that receives enter/exit *sometimes* is worse
352
+ than one that never does. Consistency over capability.
353
+
354
+ ### The scroll split — what it actually buys
355
+
356
+ Blast radius is small and measured: **exactly two scroll consumers**
357
+ (`list.rb:323-325`, `text_view.rb:376-378`) move to `handle_scroll`. The twelve
358
+ `event.button == :left` filters **stay as they are** — they filter button
359
+ *identity*, not event kind, so the split does not delete them. The wins are
360
+ structural rather than a line count:
361
+
362
+ - **A wheel notch is not a button.** `direction:` replaces
363
+ `button: :scroll_up`, which was always a category error.
364
+ - **Scroll can no longer leak into click logic.** `ScreenPane#handle_mouse`'s
365
+ outside-click dismissal (`screen_pane.rb:238`) and `Component#handle_mouse`'s
366
+ click-to-focus (`component.rb:259`) currently exclude scroll *by filter*; with
367
+ a separate class they exclude it *by type*. `D_notification`'s stray-spin
368
+ lesson becomes unrepresentable rather than remembered.
369
+ - **Scroll routing becomes independently specifiable** — the innermost
370
+ *scrollable* under the pointer, bubbling when it cannot scroll further, the
371
+ way a browser does. That is unavailable today because scroll rides the click
372
+ routing, and it is the one new capability here.
373
+
374
+ Bundle it with the rest: the vocabulary change is the breaking change, so
375
+ everything needing one should land together rather than breaking `handle_mouse`
376
+ implementors twice.
377
+
378
+ **And the parse fix is unconditional** — it ships whether or not anyone ever
379
+ enables motion. Releases already arrive under mode 1000 and already land as
380
+ `button: nil`, so distinguishing `:press` from `:release` is a correctness fix
381
+ to today's default profile that happens to leave a `:move` slot ready. Worth
382
+ separating in the commit history for that reason: the wire-format cleanup is
383
+ not gated on the mode, and does not need the terminal matrix to justify it.
384
+
385
+ ### Two mechanical hazards, both concrete
386
+
387
+ - **The 5-byte gulp is exactly right for X10 and wrong for SGR.**
388
+ `Keys.getkey` reads `\e` then `read_nonblock(5)`, and the comment at
389
+ `keys.rb:161-167` is explicit that 6 would over-read "on tight mouse-event
390
+ bursts". 5 works *because* an X10 report is exactly 6 bytes. **Mode 1006 (SGR)
391
+ reports are variable-length** (`\e[<35;12;34M`), so adopting it needs a drain
392
+ rule of its own, like the `\e[?` and `\e]` loops beside it.
393
+ - **The queue coalesces repaints but not events** — and the arithmetic says
394
+ that is fine. `event_loop` yields `EmptyQueueEvent` only when the queue is
395
+ empty (`event_queue.rb:339`), so a flood defers the repaint to the drain,
396
+ which is the right behavior for free. The theoretical failure is that if
397
+ handling were slower than arrival the queue would never empty and the UI
398
+ would stop repainting altogether — a freeze, not a trailing accent. **But
399
+ default handling is a hit-test walk that ends in every component declining**:
400
+ order ~100 `rect.contains?` comparisons, tens of microseconds, against a
401
+ **measured 83.7 reports/s** (see *Measured* below — the estimate here was
402
+ ~160/s, so the real margin is twice as wide). Three to four orders of
403
+ magnitude of headroom. The first draft called a
404
+ mitigation "probably mandatory"; that was overstated. **Measure it to retire
405
+ the question — and treat throttling, event collapsing and forced repaints as
406
+ out of scope for this note.** If the number ever surprises us, that is a
407
+ separate idea with the measurement to justify it.
408
+
409
+ ### Encoding: request 1006 always, parse both
410
+
411
+ Reporting modes (1000/1002/1003) say *what* is reported; encoding modes say
412
+ *how* the coordinates are packed. They are orthogonal, set independently, and
413
+ both persist — which is the fact that makes this easy.
414
+
415
+ **Request `\e[?1006h` alongside whichever reporting mode, unconditionally, and
416
+ keep the X10 parser.** A terminal that does not understand 1006 silently ignores
417
+ it and keeps sending X10, so parsing both degrades gracefully with no risk of
418
+ total mouse loss — and where it *is* supported (xterm, Alacritty, kitty, foot,
419
+ tmux, iTerm2, VTE: effectively everywhere in 2026, but confirm in the matrix)
420
+ two things arrive that X10 cannot express:
421
+
422
+ - **Coordinates past 223.** X10 packs a coordinate into one byte, so it caps
423
+ there. A dead click past column 223 is invisible; a hover accent that stops
424
+ working on the right half of a wide terminal is a reported bug.
425
+ - **A release that says which button came up.** SGR distinguishes press from
426
+ release by the final byte (`M` vs. lowercase `m`) *and* carries the button
427
+ number; X10 has one anonymous release code (3). Anything beyond
428
+ single-button drag needs this.
429
+
430
+ Keeping the X10 path costs nothing — it exists and is tested. What the addition
431
+ does cost is the drain rule above, and X10's convenient PTY burst-safety: a
432
+ fixed 6-byte report splits cleanly on a boundary, a variable-length one does
433
+ not. `1005` (UTF-8) and `1015` (urxvt) are both ambiguous to parse and neither
434
+ is wanted; `1016` (SGR-Pixels) reports pixels, meaningless on a cell grid.
435
+
436
+ ### Measured — tmux-over-ssh, 2026-09-03
437
+
438
+ **One row of four is done.** tmux 3.6 (`mouse on` *and* `mouse off`), outer
439
+ terminal Alacritty, over ssh, `TERM=tmux-256color`, 141×34. Run with
440
+ `ideas/hover/probe.rb`; its parser is covered by `ideas/hover/probe_spec.rb`,
441
+ which is worth running first — a decode slip wastes the whole interactive
442
+ session, and one already did (X10 coordinates are `byte - 33`, not `- 32`; the
443
+ offset differs from the button code's because coordinates are 1-based). The raw
444
+ logs are deliberately not committed: a re-run regenerates them, and what
445
+ graduates is below.
446
+
447
+ | # | question | answer |
448
+ |---|---|---|
449
+ | 1 | does 1003 motion arrive? | **yes** — 837 events, all `code=35` |
450
+ | 2 | event rate | **83.7/s** (`mouse on`) / **82.6/s** (off) |
451
+ | 3 | pointer leaves window | **no event**; motion just stops. 1004 works (4 FocusOut/In pairs per run) |
452
+ | 4 | tmux `mouse on` vs `off` | **no material difference** — see below |
453
+ | 5 | tmux pane offset | **untested** (single pane) |
454
+ | 6 | does anything coalesce? | **no** — ~2.1 events/read, batches of 1–3, up to 8 |
455
+ | 7 | X10 vs SGR 1006 | 1006 **works**: 68/68 reports in SGR form |
456
+ | 8 | latency / bandwidth | ~12 B/event × 83.7/s ≈ **1 KB/s**, ~40 reads/s |
457
+ | 9 | text selection under 1003 | **untested** (needs an eye, not a log) |
458
+ | — | column > 223 | **untested** — 141 cols here |
459
+
460
+ **The rate is half the working estimate.** This note assumed ~160/s; measured
461
+ is 83.7/s, so the headroom argument for doing nothing about throttling is
462
+ twice as strong as written, and the packet rate is ~40/s rather than 160/s.
463
+ The earlier bandwidth worry is settled as a non-issue, not merely suspected to
464
+ be one.
465
+
466
+ **tmux `mouse on` does not steal motion from the app.** This was the least
467
+ predictable row and it came out clean: identical rates, drag-motion working in
468
+ both configurations, presses and releases arriving in both. An application that
469
+ requests tracking wins over tmux's own pane-select and copy-mode handling.
470
+
471
+ **1002 is confirmed drag-only, empirically.** Moving with no button held
472
+ produced *nothing*; the phase's 275 motion events were all `code=32` (left
473
+ held), with **zero** `code=35`. The mode table above is now measured rather
474
+ than read off a spec.
475
+
476
+ **Reads do not align to event boundaries**, which decides the parser shape.
477
+ Mostly 12.0 bytes/event per read, but four reads came back at **11.5** — one
478
+ event split across two reads. Combined with ~2 events per read as the norm,
479
+ `Keys.getkey`'s fixed 5-byte gulp cannot survive SGR, and the replacement must
480
+ be a **buffered incremental parser**, not a wider gulp. (X10's fixed 6 bytes
481
+ stays burst-safe, which is what keeps the PTY-burst exception true for it.)
482
+
483
+ ### Why the other three rows are not worth running
484
+
485
+ **tmux is the adversarial hop, and hop count is monotonic for most of the
486
+ matrix.** tmux is the only layer that *interprets* mouse reports rather than
487
+ forwarding bytes — it has its own pane-select and copy-mode handling to
488
+ reconcile — while ssh is a transparent pipe and Alacritty is the source. So for
489
+ items 1, 2, 3, 6 and 8 (does motion arrive, at what rate, does 1004 work, does
490
+ anything coalesce, latency), removing layers strictly improves fidelity and
491
+ tmux-over-ssh is the pessimistic case. Three more runs would confirm what is
492
+ already implied.
493
+
494
+ **But tmux does not *subsume* Alacritty — it masks it.** tmux parses the
495
+ incoming report and re-emits in whatever encoding the *application* requested,
496
+ so the 68/68 SGR result proves **tmux** can emit SGR and says nothing about what
497
+ Alacritty emits: tmux would have handed us SGR either way. tmux-over-ssh is
498
+ therefore a *different* case, not a superset. Skipping the bare run is still
499
+ right (Alacritty's 1006 support is not in doubt, and the app sees SGR through
500
+ tmux regardless), but do not record this as "the worst case covers everything"
501
+ — someone will later lean on it for something it does not cover.
502
+
503
+ **Column >223 is closed by design, not by testing.** The cap is a property of
504
+ the X10 *encoding*, and the settled decision requests 1006 and parses SGR, so
505
+ it is unreachable on the supported path. The only residual is a terminal that
506
+ ignores 1006 *and* is wider than 223 — where clicks past column 223 are
507
+ **already broken today**. A pre-existing limitation, not a hover regression.
508
+
509
+ **What does still want measuring**, and neither is a "remove a layer" run:
510
+
511
+ - **tmux pane offset in a split** (item 5) — tmux-specific, so no amount of
512
+ layer-removal touches it, and it is the item most likely to be wrong in a way
513
+ that *silently mis-places* hover: a click offset can pass unnoticed when
514
+ targets are large, a continuously-tracked pointer cannot. Thirty seconds:
515
+ split the window, run the probe in one pane, click a known cell, confirm the
516
+ coordinates come back pane-relative.
517
+ - **Text selection under 1003** (item 9) — needs an eye, not a log, and it
518
+ genuinely differs between bare and tmux (tmux layers its own copy-mode on
519
+ top). It feeds the `capture_mouse: :hover` rdoc: opting in trades away more
520
+ select-to-copy than `true` already does, and whether Shift+drag still
521
+ overrides is the mitigation to document.
522
+
523
+ **DECRQM cannot feature-detect the reporting modes.** Probing `\e[?<n>$p` got
524
+ **no reply at all** for 9, 1000, 1002, 1003, 1005, 1015 and 1016; only 1004 and
525
+ 1006 answered (`reset (supported)`). So there is no runtime capability check for
526
+ the mode ladder — which retroactively makes *request-and-parse-both* the only
527
+ viable strategy rather than merely the convenient one, and means the ladder can
528
+ never validate itself.
529
+
530
+ ### The terminal matrix — the actual deliverable of step 1
531
+
532
+ The checklist the probe implements, kept for reference and for re-running after
533
+ any parser change. **tmux-over-ssh is done and the other three rows are
534
+ deliberately skipped** — see *Measured* above for the results and *Why the other
535
+ three rows are not worth running* for the reasoning. Only items 5 and 9 are
536
+ still open. Per environment:
537
+
538
+ 1. **Does 1003 motion arrive at all**, and with what `Cb` codes?
539
+ 2. **Event rate**: count reports for ten seconds of ordinary mouse movement,
540
+ and time the handling of one. Expected verdict is "no mitigation needed";
541
+ the point of measuring is to retire the question, not to justify a fix.
542
+ 3. **Pointer leaves the window** — anything at all? (Expected: nothing. See
543
+ *no reliable exit event*.) And does mode 1004 focus-out (`\e[O`) arrive?
544
+ 4. **tmux with `mouse on` vs `mouse off`** — these are different paths: with
545
+ mouse off tmux passes the bytes through, with mouse on tmux interprets them
546
+ and re-emits to an app that requested tracking. Both need a row.
547
+ 5. **tmux pane offset** — are coordinates pane-relative or window-relative in a
548
+ split? tmux should translate; verify rather than assume.
549
+ 6. **Does anything upstream coalesce?** If neither ssh nor tmux drops
550
+ intermediate reports, the app is the only place it can happen.
551
+ 7. **X10 vs SGR 1006** per environment: does requesting 1006 take effect, and
552
+ does a terminal that ignores it fall back to X10 cleanly (the
553
+ parse-both premise)? Plus behavior past column 223, reachable in a
554
+ full-screen tmux on a wide monitor, and whether an SGR release really
555
+ carries its button number.
556
+ 8. **Latency, not bandwidth.** The first draft called ssh bandwidth a cost;
557
+ that is probably wrong and should be measured rather than repeated — 6 bytes
558
+ × ~160 reports/s is ~1 KB/s of payload, nothing. The plausible costs are
559
+ **packet rate** (a 40-byte TCP header per report) and **round-trip latency**,
560
+ which is what would make the accent visibly trail. tmux-over-ssh doubles the
561
+ hops and adds tmux's own loop.
562
+ 9. **Text selection.** Under 1003 the terminal's native drag-select is captured
563
+ far more aggressively than under 1000 — which matters, because
564
+ `capture_mouse:`'s rdoc already frames select-to-copy as the thing you trade
565
+ away. Check whether Shift+drag still overrides it per terminal; that is the
566
+ mitigation. If it does not hold everywhere, that is a fact for the mode
567
+ switch's rdoc — an app opting in is trading more select-to-copy away than
568
+ `capture_mouse: true` already trades — not an argument for scoping, which the
569
+ opt-in model has settled.
570
+
571
+ ### Testing it in specs
572
+
573
+ Two pieces of good news:
574
+
575
+ - **A burst of X10 mouse reports is safe to write in a PTY spec**, unlike
576
+ ESC-then-key. `getkey` reads one byte then gulps 5, and a report is exactly
577
+ 6, so back-to-back reports split cleanly on the boundary. That is a genuine
578
+ second exception to AGENTS.md's pacing rule (bracketed paste is the first),
579
+ and it is exactly what a flood test needs. It holds for X10 only — SGR's
580
+ variable length would break bursting, which is one more reason to fix the
581
+ drain rule before switching encodings.
582
+ - **Unit specs need no terminal**: `FakeEventQueue` + `FakeScreen` can post
583
+ synthetic move events and assert the enter/exit sequence directly.
584
+
585
+ ## Step 2 — the notices
586
+
587
+ ### Enter/exit are *derived*, so they are hooks, not queue events
588
+
589
+ Worth separating from the framing above: `MouseMoveEvent` is a wire event, but
590
+ enter and exit are **computed by diffing** the previous hovered target against
591
+ the new one. Nothing is parsed. So they should not be `EventQueue` events —
592
+ routing them through the queue would re-resolve a target that was already
593
+ resolved at diff time, and the queue has no other synthesized *targeted* event.
594
+ They are the `on_attached` / `on_detached` shape from `D_attach_hooks`: **one
595
+ firing site, a fixed order, at most one call per component per transition.**
596
+
597
+ **Order: exit before enter** (settled 2026-09-03, the DOM order). No component
598
+ is ever hovered twice at once, which is what a driver switching a menu panel
599
+ wants, and a handler firing on enter can rely on the previous target having
600
+ already torn down.
601
+
602
+ ### Where the state lives
603
+
604
+ Hover is one global position resolved to a component — the `Screen#focused`
605
+ shape exactly. `Screen` is the service and `ScreenPane` is the UI
606
+ (`D_tree_first`), and `focused=` lives on `Screen`, so: **`Screen#hovered`**,
607
+ plus a per-component `hovered?` mirroring `active?` (a component's own `repaint`
608
+ needs to know, if it paints anything).
609
+
610
+ ### Which component is under the pointer — a rule the click path never needed
611
+
612
+ Simpler than the first draft feared, because **overlapping tiled siblings are
613
+ already forbidden** — `component.rb:572` states it outright ("as long as
614
+ siblings don't overlap each other — which Tuile already requires"), and the ban
615
+ is load-bearing: `children_tile_rect?` sums child *areas* to decide whether to
616
+ wipe gaps, so an overlap silently mis-computes it. In a legal tree at most one
617
+ child contains any point, so the tiled resolution is unique by construction and
618
+ needs no tie-break. Two rules remain:
619
+
620
+ - **Popups first.** `ScreenPane#handle_mouse` already resolves topmost
621
+ (`@popups.reverse_each.find`, `screen_pane.rb:237`), and hover *must* go
622
+ through it: popups overdraw content with no clipping, so a component beneath a
623
+ popup contains the point and is not visible. This is the only *layer* rule
624
+ needed, precisely because the tiled tree has no overlap of its own.
625
+ - **Hit-test `extent_rect`, not `rect`.** A `Button` in a wide form column must
626
+ not light up when the pointer is on the dead tail it does not paint. This is
627
+ `D_extent`'s hit-testing consumer, and it is *cleaner* than the click case —
628
+ clicks deliberately let the tail focus the widget while refusing to activate
629
+ it, whereas hover has no focus half, so `extent_rect` applies without a
630
+ carve-out.
631
+
632
+ ### Leaf or chain — the reframe flips this
633
+
634
+ The first draft leaned leaf-only, on the grounds that chain-hover would make a
635
+ `Window` tint whenever anything inside it is hovered. **That was an argument
636
+ against an automatic accent, not against chain notification** — and once opt-in
637
+ is the design, it evaporates: a `Window` that did not ask for hover paints
638
+ nothing, so notifying it costs nothing and enables the cases that want it (a
639
+ container reacting when the pointer is anywhere inside it).
640
+
641
+ So: **fire along the ancestor chain**, with enter/exit computed on the
642
+ *symmetric difference* of the two chains — which is what browsers do, and what
643
+ `focused=`'s `active=` walk already does for focus (`screen.rb:337-343`). The
644
+ diff is a set difference rather than a pointer compare; that is the whole added
645
+ cost.
646
+
647
+ ### The opt-in mechanism — probably no flag at all
648
+
649
+ - **A `hoverable?` predicate** mirroring `focusable?` is the obvious move, but
650
+ `focusable?` is a *method* apps override in a subclass, and marking one
651
+ `Button` hoverable without subclassing needs a writer — a new pattern on
652
+ `Component`.
653
+ - **Better: opt-in *is* having a handler or a hover color.** The framework
654
+ fires enter/exit on the chain unconditionally (it is a diff, and it is cheap);
655
+ a component that neither overrides the hook nor was given a hover color does
656
+ nothing. Nothing to consult, nothing to keep in sync, no knob. This is the
657
+ COP-shaped answer: the component decides by what it *does*, not by a flag the
658
+ framework reads off it.
659
+
660
+ ### Hook, listener, or one `Screen` channel
661
+
662
+ Three precedents, ascending in cost — and they are not exclusive:
663
+
664
+ - **`Screen#on_hover_changed=`** — one app-level channel mirroring
665
+ `Screen#on_focus_changed=` (`screen.rb:346`), the shape the deleted status bar
666
+ was replaced with (`D_status_bar`). Cheapest, and enough for an app that wants
667
+ to drive its own painting.
668
+ - **A protected hook pair** — `on_mouse_enter` / `on_mouse_exit`, overridden by
669
+ a subclass, invoked via `__send__` per `D_hook_visibility`. This is what
670
+ `MenuBar` open-on-hover actually needs.
671
+ - **A listener registry** — `on_mouse_enter { }` in the `on_value_change` style.
672
+ No consumer yet asks for multiple subscribers.
673
+
674
+ Naming: `enter`/`exit` is the Swing pair, `enter`/`leave` the DOM one. Focus
675
+ grew its own "leave" hook on 2026-09-04 (`Component#on_blur`, `D_on_blur`), and
676
+ that entry settled two things this note can copy rather than re-argue: exit
677
+ fires before enter, and an app-level `on_focus_changed` did **not** make the
678
+ per-component hook unnecessary — a component that must react to *itself* cannot
679
+ do it from a screen-wide notice. Whether hover's exit clears that bar is still
680
+ open (see Q7), since hover paints nothing by default.
681
+
682
+ ### There is no reliable exit event
683
+
684
+ Mode 1003 reports motion *inside* the terminal. A pointer that leaves the window
685
+ sends nothing — **measured: motion simply stops**, and the last report sits at
686
+ whatever cell it was last sampled in. So the last-hovered component stays
687
+ hovered and anything it painted strands.
688
+
689
+ **Mode 1004 answers most of it, and it works** (measured through tmux-over-ssh:
690
+ four FocusOut/FocusIn pairs per run, in both tmux configs). It fires on a
691
+ genuine pointer exit *and* on an alt-tab with the pointer still inside the
692
+ window — and **both should clear hover**, because with the app unfocused,
693
+ painting an accent for a pointer the user is not driving is simply wrong. That
694
+ gives a complete lifecycle out of 1004 plus motion alone:
695
+
696
+ > **Clear hover on FocusOut. On FocusIn, keep it cleared until the next
697
+ > `MouseMoveEvent`** — the pointer may be anywhere, and we do not know where
698
+ > until it moves.
699
+
700
+ Plus two cheap belts: **any keystroke clears hover**, and a component's
701
+ `on_detached` must clear it or `Screen#hovered` strands a reference to a
702
+ detached component (the `@popup_prior_focus` failure mode).
703
+
704
+ **Rejected: infer the exit from an edge cell.** Tempting, because a real
705
+ pointer-exit's last report *was* at an edge (`0,7`, `0,6`, `0,5` in the sample —
706
+ the mid-screen ones turned out to be alt-tabs, not exits). Don't build it, for
707
+ two reasons. **Edge cells are legitimate, common hover targets** — column 0 is
708
+ where a scrollbar, a sidebar's first column and a `MenuBar`'s first item live,
709
+ so "last motion at an edge, then quiet" is indistinguishable from a pointer
710
+ resting on exactly the widget you most want hovered; the heuristic would flicker
711
+ the accent off under a *stationary* pointer, which is worse than a strand. And
712
+ **a fast exit may emit no edge report at all**: sampling is ~84 Hz, so a quick
713
+ flick out of the window can have its last report several cells short of the
714
+ boundary, making it an unreliable signal as well as an unsafe one.
715
+
716
+ The residual gap after all that is narrow: the pointer wanders off the window
717
+ while the terminal keeps *keyboard* focus (1004 is about keyboard focus, so
718
+ nothing fires). Nothing reports it, it is cosmetic, and the never-load-bearing
719
+ rule already absorbs it.
720
+
721
+ ## Step 3 — the ink, once 1 and 2 are in hand
722
+
723
+ The three outcomes, as framed:
724
+
725
+ - **(A) Abandon the framework accent; the app paints.** With steps 1–2 done
726
+ this is not really abandonment — it is the whole Claude-CLI-`Label` case
727
+ working, with the app's own `repaint` reading `hovered?`. Zero new ink, no
728
+ `BG_STATES` change, no focused-vs-hovered precedence rule, and it stays
729
+ reversible. **The recommended landing point.**
730
+ - **(B) `MenuBar` only** — and this is the one with real behavior behind it, not
731
+ just ink. See below; it is more interesting than it looks.
732
+ - **(C) Flatly, for every component.** Needs `BG_STATES` to grow `:hover`
733
+ (admissible in principle — AGENTS.md says a key is added "when Tuile grows the
734
+ *state*", and this would be Tuile growing one), *plus* a
735
+ focused-and-hovered precedence ruling that a two-state map never had to answer,
736
+ *plus* an answer to `focus-accent.md`'s finding that three of the five
737
+ accenting widgets highlight a **segment or row**, not the component, which a
738
+ per-component hook cannot express. That last one is fatal on its own: the
739
+ interesting hover targets *are* the segment/row cases.
740
+
741
+ ### What `focus-accent.md` already settles for step 3
742
+
743
+ That note measured migrating the five accenting widgets onto
744
+ `default_bg_color`: **+2 lines each, and inexpressible for `Tabs`, `MenuBar` and
745
+ `List`.** Hover lands on the same rock, so if a framework accent is ever built
746
+ it is via that note's **option (C)** — a paint-time `over_bg` accent layer
747
+ applied to a `StyledString` rather than declared per component, which covers
748
+ segment and row granularity. Hover is the second consumer that makes (C) worth
749
+ pricing rather than parking. Weigh against `D_theme_ref`'s "not a third colour
750
+ channel" first.
751
+
752
+ One thing that is *not* in the way: `List` applies its cursor highlight at paint
753
+ (`is_cursor ? base.with_bg(…) : base`), not into the memoized row, and the
754
+ cache-dropping rule is about *geometry* inputs — so a hover accent is the same
755
+ shape and needs no `drop_row_cache`.
756
+
757
+ ### `MenuBar` is the strong case, and it needs no new ink
758
+
759
+ Two sub-cases, and both dodge the accent question entirely:
760
+
761
+ - **Hover highlight inside an open dropdown = move the `List` cursor.** The
762
+ cursor already highlights, arrows already move it, Enter already activates
763
+ it. Hover just becomes a third way to set the position — so there is no second
764
+ accent, no new token, and no ambiguity, and it is what every desktop menu
765
+ does. This is the cleanest hover feature in the whole note.
766
+ - **Open-on-hover of the strip, *only while a cascade is already open*** — the
767
+ desktop convention (hovering a closed menu bar does nothing; once one menu is
768
+ open, hovering a sibling switches to it). `D_menu_bar` defers this explicitly
769
+ on "needs mouse motion".
770
+
771
+ Both satisfy the never-load-bearing rule for free, which is why this is the
772
+ case to build first if anything is built.
773
+
774
+ ### The accessibility argument, taken seriously
775
+
776
+ Hover-open submenus and a highlighted item under the pointer are worth
777
+ something for **low-vision and magnifier users** specifically: when you can see
778
+ a fraction of the screen at a time, a highlight that tracks the pointer answers
779
+ "where am I" continuously, and auto-opening a submenu removes a precise click
780
+ from the sequence. That is a real benefit and it is the strongest *motivation*
781
+ in this note. Two consequences follow from taking it seriously rather than
782
+ citing it:
783
+
784
+ - **Open-on-hover needs a delay, or it is worse than nothing.** Dragging the
785
+ pointer across a strip with no delay flash-opens every menu in turn — for a
786
+ magnifier user that is actively disorienting, and for a motor-impaired user it
787
+ is a stream of accidental opens. Desktop menus use ~200–400 ms. Tuile has the
788
+ machinery (`Ticker`, which {Component::ProgressBar} owns), and
789
+ `D_progress_bar`'s `sync_ticker` is the pattern to copy: the timer is *synced
790
+ from an invariant* (cascade open && motion enabled && pointer on a sibling
791
+ segment), never toggled by the enter and exit hooks, because a third mutation
792
+ site turns those two into a 2×2 the naive pair gets half wrong.
793
+ - **It argues against a second highlight, which is what the cursor-reuse design
794
+ already does.** For a low-vision user, two similar-but-distinct highlights on
795
+ screen is worse than one — the discrimination task is the expensive part. So
796
+ "hover moves the `List` cursor" is not just the cheap implementation, it is
797
+ the *accessible* one, and a separate hover ink would be a regression for the
798
+ population that motivates the feature.
799
+
800
+ Worth stating plainly, though, because it changes the priority: **the bigger
801
+ accessibility lever here is not hover at all.** `D_menu_bar` records that with
802
+ no Alt and no function keys the only way to *reach* the bar is Tab — "the
803
+ deferral that costs something". A user who cannot use a mouse gains far more
804
+ from `Alt+F` than any pointer user gains from hover. If accessibility is the
805
+ reason to spend a session, `Keys` growing function keys and a bar mnemonic
806
+ outranks all of this.
807
+
808
+ ## The demo — `examples/hover.rb`
809
+
810
+ Settled 2026-09-03, and it earned its keep before being written: designing it is
811
+ what found the `on_mouse_move` hole above. A `Layout::Horizontal` of two
812
+ `Percent[50]` panes:
813
+
814
+ - **Left — one custom component**, exercising all three channels: it changes its
815
+ background on `on_mouse_enter` / `on_mouse_exit`, changes it *again* on a
816
+ press (so hover and click are visibly distinct signals), and paints an `X` at
817
+ the pointer's cell from `on_mouse_move`. That last one is the whole reason the
818
+ move hook exists, and the pane is its only demo.
819
+ - **Right — a live log** of every event the left pane received, auto-scrolling.
820
+
821
+ Two house-rules corrections to the obvious implementation:
822
+
823
+ - **Use {Component::LogTextView}, not a `List`.** `List` has **no appenders**
824
+ (removed in 0.12.0, `D_list_items`) — an app that grows one keeps its own
825
+ array and re-assigns, and a re-assignment drops the whole row cache, so at 84
826
+ events/s the viewport would re-render every row 84 times a second.
827
+ `LogTextView` is the auto-scrolling, incrementally-appending one and is what
828
+ `LogWindow` is built from. It also demos a second component for free.
829
+ - **Don't log raw moves verbatim.** At ~84/s the log becomes unreadable and
830
+ proves nothing. Log the *discrete* events (enter, exit, press) in full, and
831
+ show the live pointer position in the left pane — the `X` already is that
832
+ readout, optionally with a coordinate label. If a move trace is wanted, it
833
+ belongs as a counter or a single replaced line, not one row per event.
834
+
835
+ The demo also needs `capture_mouse: :hover`, which makes it the copy-paste
836
+ source for the two-level opt-in and the natural place to document the silent
837
+ no-op (open question 9).
838
+
839
+ ## Open questions, collected
840
+
841
+ Struck-through entries are settled and kept so a re-reader can see the question
842
+ was asked and answered rather than missed. **Two further open items are
843
+ measurements, not decisions, and live in *Measured*:** tmux pane offset in a
844
+ split, and text selection under 1003.
845
+
846
+ 1. Is the hover target a flag on `Component`, or a component *kind* (a `Link` /
847
+ non-focusable `Button`)? Related: should Tuile name the
848
+ clickable-but-not-focusable idiom at all?
849
+ 2. ~~`kind:` field on `MouseEvent` vs. a separate `MouseMoveEvent`.~~
850
+ **Settled 2026-09-03:** both — three wire classes (`MouseEvent` with
851
+ `kind: :press|:release`, `MouseScrollEvent`, `MouseMoveEvent`), no rename,
852
+ moves never delivered to components. Press-not-click, release-parsed-but-
853
+ undelivered, and enter/exit-only-under-`:hover` settled with it.
854
+ 3. Chain or leaf for enter/exit. (Lean: chain, given opt-in.)
855
+ 4. ~~Scoped 1003 vs. all-or-nothing at `run_event_loop`.~~ **Settled
856
+ 2026-09-03:** all-or-nothing, opt-in, off by default. Scoped tracking stays a
857
+ later optimization if the measured rate demands it.
858
+ 5. ~~Does mode 1004 focus-out actually arrive?~~ **Measured 2026-09-03:** yes
859
+ under tmux-over-ssh, on both real exits and alt-tabs. Lifecycle settled
860
+ (clear on FocusOut; stay cleared through FocusIn until the next move); the
861
+ edge heuristic is rejected. Still unmeasured in the other three
862
+ environments.
863
+ 6. ~~Exit-before-enter, or the reverse?~~ **Settled 2026-09-03:** exit first,
864
+ the DOM order.
865
+ 7. If step 3 lands as (A), does `on_mouse_exit` still earn its place — or does
866
+ `Screen#on_hover_changed=` plus `hovered?` cover every consumer? (The focus
867
+ half of this question was answered *against* the app-level-only design on
868
+ 2026-09-04 — `D_on_blur`. Hover differs in that no widget commits anything
869
+ on exit, so it may still land the other way.)
870
+ 8. ~~Overlapping tiled rects: last-in-paint-order wins, or refuse?~~ **Settled
871
+ 2026-09-03: the question does not arise** — overlapping tiled siblings are
872
+ already forbidden (`component.rb:572`, and `children_tile_rect?` depends on
873
+ it), so at most one child contains any point. Only the popup layer needs a
874
+ topmost rule, and it already has one.
875
+ 9. Anything stronger than docs for the silent no-op (hover hook overridden,
876
+ mode off)?
877
+ 10. Does open-on-hover need a `Ticker` delay, and is ~250 ms the number? See
878
+ *the accessibility argument*.
879
+ 11. Does the scroll split also change scroll *routing* — innermost scrollable
880
+ under the pointer, bubbling when it cannot scroll further — or does it keep
881
+ today's click routing and bank only the type separation? (The capability is
882
+ the split's main prize, but it is a second behavior change and could land
883
+ after.)
884
+ 12. ~~Demo shape?~~ **Settled 2026-09-03:** a dedicated `examples/hover.rb`
885
+ (which also keeps the sampler's PTY spec on the default profile), two panes
886
+ side by side — see *The demo* below.
887
+
888
+ ## Related
889
+
890
+ `ideas/hover/probe.rb` + `probe_spec.rb` (the terminal probe that fills the
891
+ matrix; research tooling, dies with this note),
892
+ `ideas/focus-accent.md` (the surface/accent line, the segment-vs-component
893
+ problem, and option (C) which a framework hover accent would share),
894
+ `ideas/new-components.md` (item 5, the motion prerequisite that needs splitting
895
+ into 1002-drag and 1003-hover; Tier 2 Split Layout; Tier 3 Tooltip),
896
+ `D_menu_bar` (open-on-hover, deferred on motion; and the "press-only, no
897
+ release" imprecision), `D_no_context_menu` (same, and the left-button-only
898
+ ruling), `D_extent` (hit-test the extent, not the rect),
899
+ `D_bg_surface` (`BG_STATES` is closed and framework-defined),
900
+ `D_theme_ref` (not a third colour channel), `D_inverse` (model the SGR rather
901
+ than faking it, if a non-background hover ink is ever wanted),
902
+ `D_attach_hooks` (the edge-trigger shape enter/exit must copy),
903
+ `D_hook_visibility` (a framework-invoked hook is protected, reached with
904
+ `__send__`), `D_tree_first` (why `hovered` belongs on `Screen`),
905
+ `D_progress_bar` (`sync_ticker` — how a hover-delay timer must be owned),
906
+ `D_background_rgb` (which thread may write to the terminal mid-loop — no longer
907
+ binding here, since the settled mode writes its escape once at loop start),
908
+ `D_status_bar` (`Screen#on_focus_changed=` as the app-channel precedent),
909
+ `D_bracketed_paste` (the other sanctioned PTY burst).