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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +4266 -226
- data/README.md +44 -24
- data/TERMINOLOGY.md +22 -7
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +67 -3
- data/book/06-theming.md +153 -7
- data/book/07-components.md +643 -67
- data/book/08-testing.md +94 -0
- data/book/09-styled-text.md +3 -3
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +402 -62
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +49 -29
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +68 -33
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +43 -11
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -13
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +22 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +209 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +26 -10
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +48 -59
- data/lib/tuile/component.rb +580 -54
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +37 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +251 -55
- data/lib/tuile/screen_pane.rb +50 -44
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4545 -770
- 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).
|