tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/ideas/focus-accent.md
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# The focus accent — should `Button` / `Checkbox` / `Tabs` / `MenuBar` / `List` move onto `default_bg_color`?
|
|
2
|
-
|
|
3
|
-
**Status:** measured and parked, 2026-09-01. Spun off from `D_bg_surface`,
|
|
4
|
-
which introduced `Component#default_bg_color` and migrated the *well* widgets
|
|
5
|
-
(`AbstractStringField`, `Select`, `ComboBox`) onto it while explicitly leaving
|
|
6
|
-
this family alone. That note records the decision; this one records the
|
|
7
|
-
*measurement*, so whoever revisits it doesn't re-run the experiment.
|
|
8
|
-
|
|
9
|
-
## The question
|
|
10
|
-
|
|
11
|
-
Five widgets highlight themselves on focus by stomping a background onto their
|
|
12
|
-
painted content at repaint time:
|
|
13
|
-
|
|
14
|
-
```ruby
|
|
15
|
-
label = label.with_bg(screen.theme.active_bg_color) if active? # Button, Checkbox
|
|
16
|
-
segment.with_bg(screen.theme.active_bg_color) # Tabs, MenuBar
|
|
17
|
-
is_cursor ? base.with_bg(screen.theme.active_bg_color) : base # List
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
`default_bg_color` can express the per-component half of that —
|
|
21
|
-
`{ active: screen.theme.active_bg_color }`, or the allocation-free
|
|
22
|
-
`active? ? … : nil`, with no `:normal` key so an unfocused widget falls through
|
|
23
|
-
to the ambient. So: should it?
|
|
24
|
-
|
|
25
|
-
## Measured, not argued (Checkbox, 2026-09-01)
|
|
26
|
-
|
|
27
|
-
The migration was written and run. It is:
|
|
28
|
-
|
|
29
|
-
```diff
|
|
30
|
-
label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
|
|
31
|
-
- label = label.with_bg(screen.theme.active_bg_color) if active?
|
|
32
|
-
draw_text(rect.left, rect.top, label)
|
|
33
|
-
end
|
|
34
|
-
+
|
|
35
|
-
+ # @return [Color, nil]
|
|
36
|
-
+ def default_bg_color = active? ? screen.theme.active_bg_color : nil
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
**Net +2 lines.** Nothing is deleted, because these widgets have no *well* —
|
|
40
|
-
there is no reach-around-the-chain to remove, which is exactly what the
|
|
41
|
-
`AbstractStringField` migration did delete (`#background`). So it is not a
|
|
42
|
-
simplification.
|
|
43
|
-
|
|
44
|
-
**The whole suite passed: 2787 examples, 0 failures.** Both behavior changes
|
|
45
|
-
below are unpinned by any spec — see *The missing guard*.
|
|
46
|
-
|
|
47
|
-
Probed directly, migrated vs. original, on a focused `Checkbox` at 20×1:
|
|
48
|
-
|
|
49
|
-
| case | original | migrated |
|
|
50
|
-
|---|---|---|
|
|
51
|
-
| caption span carries `bg: BLUE` | `59, 59` — accent covers it | `59, :blue` — **the span survives** |
|
|
52
|
-
| `bg_color = 52` | `59, 59` — accent stomps the tint | `52, 52` — tint wins, no focus shade |
|
|
53
|
-
|
|
54
|
-
## What the measurement says
|
|
55
|
-
|
|
56
|
-
**A surface and an accent are different things, and the hook is only right for
|
|
57
|
-
one.** A *surface* is what your cells sit on; it is legitimately the app's to
|
|
58
|
-
override, which is the whole point of `bg_color` winning over
|
|
59
|
-
`default_bg_color`. An *accent* is a signal painted **over** whatever is there,
|
|
60
|
-
and it must be unconditional — the moment it can be selectively suppressed
|
|
61
|
-
(by a caption span, by an app tint) it stops being a reliable indicator.
|
|
62
|
-
|
|
63
|
-
Row 1 is therefore a regression: `with_bg` is override-all, `under_bg` (what
|
|
64
|
-
`draw_text` applies) is fill-unset, so a caption carrying its own background
|
|
65
|
-
punches a hole in the highlight.
|
|
66
|
-
|
|
67
|
-
Row 2 is worse than it looks: `Checkbox` paints no caret, so a flat `bg_color`
|
|
68
|
-
removes its only focus affordance. Recoverable with `{ normal:, active: }`, but
|
|
69
|
-
the *default* got worse. This is the same objection that made `Select` the
|
|
70
|
-
awkward case in `D_bg_surface` — there it was accepted because a `Select` really
|
|
71
|
-
does paint a well, and the flat surface is a real thing to want.
|
|
72
|
-
|
|
73
|
-
**And for three of the five it isn't even expressible.** `Tabs`, `MenuBar` and
|
|
74
|
-
`List` accent a *segment or row*, not the component. A per-component hook has
|
|
75
|
-
nothing to say about "the third tab" or "the cursor row". Only `Button` and
|
|
76
|
-
`Checkbox` are candidates at all, which makes a partial migration a
|
|
77
|
-
consistency *loss*, not a gain.
|
|
78
|
-
|
|
79
|
-
## Options, when this is picked up
|
|
80
|
-
|
|
81
|
-
- **(A) Leave it, pin it.** The status quo plus the specs below. Cheapest, and
|
|
82
|
-
the measurement above says the status quo is right.
|
|
83
|
-
- **(B) Migrate `Button` + `Checkbox` only.** Costs +4 lines, splits the family
|
|
84
|
-
three ways, and takes both regressions. Hard to justify on this evidence.
|
|
85
|
-
- **(C) Name the accent as its own concept.** The honest generalisation: a
|
|
86
|
-
paint-time `over_bg` / accent layer that is *not* part of the `bg_color`
|
|
87
|
-
chain and is applied after it, with an explicit `with_bg` (override-all)
|
|
88
|
-
contract. That would cover all five *and* the segment/row cases, because the
|
|
89
|
-
layer is applied to a `StyledString` rather than declared per component. It
|
|
90
|
-
is a second colour channel, though — weigh against `D_theme_ref`'s "not a
|
|
91
|
-
third colour channel" reasoning before adding one.
|
|
92
|
-
- **(D) Make focus indication a framework concern.** `Screen` knows the focus
|
|
93
|
-
chain; it could accent the focused component's extent centrally. Almost
|
|
94
|
-
certainly wrong — it would need per-widget opt-out (a focused `TextArea` must
|
|
95
|
-
not have its whole rect stomped) and reintroduces a framework-owned paint
|
|
96
|
-
pass the top-down layout rule keeps out.
|
|
97
|
-
|
|
98
|
-
## The missing guard (do this regardless of the outcome)
|
|
99
|
-
|
|
100
|
-
Neither behavior in the table is asserted anywhere, which is how a future
|
|
101
|
-
migration would land silently. `checkbox_spec` and `button_spec` want:
|
|
102
|
-
|
|
103
|
-
- the focus accent covers a caption span that carries its own background
|
|
104
|
-
(pins `with_bg`, not `under_bg`);
|
|
105
|
-
- `bg_color` set on the widget does **not** suppress the focus accent
|
|
106
|
-
(pins the accent as unconditional).
|
|
107
|
-
|
|
108
|
-
Eight lines each, and they convert `D_bg_surface`'s "deliberately not migrated"
|
|
109
|
-
from recorded to enforced.
|
|
110
|
-
|
|
111
|
-
## Related
|
|
112
|
-
|
|
113
|
-
`D_bg_surface` (the chain, the state map, the surface/accent line),
|
|
114
|
-
`D_bg_inherit` (`under_bg` vs `with_bg`), `D_boolean_fields` (why the extent
|
|
115
|
-
arithmetic must not vary with `bg_color`), `D_theme_ref` (why there is not a
|
|
116
|
-
third colour channel).
|
data/ideas/form-layout.md
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
# `FormLayout`: where the caption, the message and the required marker go
|
|
2
|
-
|
|
3
|
-
**Status:** filed 2026-09-03 as `ideas/caption-and-error-ownership.md`, which
|
|
4
|
-
asked whether a field or the container around it owns the caption and the error
|
|
5
|
-
state. **That question is settled, and the field half has shipped** — read
|
|
6
|
-
`D_caption_ownership` (a field carries no caption; the container does) and
|
|
7
|
-
`D_has_validation` (the field stores the verdict in
|
|
8
|
-
`HasValidation#error_message` and paints it as a red *well*; whoever owns the
|
|
9
|
-
cells paints the *message*, subscribing to `on_error_message_change`) before
|
|
10
|
-
this note. Nothing here re-argues either. Note in particular that an invalid
|
|
11
|
-
field shows **no ink at all**: it gets a slight red *well*, never the red
|
|
12
|
-
foreground on the glyphs that the first draft recommended.
|
|
13
|
-
|
|
14
|
-
What is left — and all this note now holds — is the *container* half: the
|
|
15
|
-
`FormLayout` that has the cells. Unbuilt, and still what infra item 2 of
|
|
16
|
-
`ideas/new-components.md` is blocked on. Graduates into a `D_form_layout`, the
|
|
17
|
-
component's rdoc, a book ch7 section, a README Components row and a CHANGELOG
|
|
18
|
-
line.
|
|
19
|
-
|
|
20
|
-
## What the layout has to place
|
|
21
|
-
|
|
22
|
-
Three pieces of chrome, none of which the field carries or can carry:
|
|
23
|
-
|
|
24
|
-
- **the caption** — `FormLayout#add(field, caption: "Username")`; the string
|
|
25
|
-
lives in the layout's per-child map, never on the field.
|
|
26
|
-
- **the message** — read off `child.error_message`, painted in
|
|
27
|
-
`Theme#error_color`.
|
|
28
|
-
- **the required marker** — a red dot beside the caption; chrome like the
|
|
29
|
-
caption, so it rides the container too (below).
|
|
30
|
-
|
|
31
|
-
## Recommended shape: one row per field, inline on both sides
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
Username ∙ [________________] Required
|
|
35
|
-
Password ∙ [________________] Required
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
(the `∙` is the required marker, red; whether it sits before or after the
|
|
39
|
-
caption text, and whether it widens the caption column, goes with the caption
|
|
40
|
-
geometry below.)
|
|
41
|
-
|
|
42
|
-
- **Inline right for the message.** One row, no growth, nothing bottom-up —
|
|
43
|
-
which is the whole reason to prefer it. The message is bounded by the row, so
|
|
44
|
-
the layout truncates with an ellipsis (`InfoWindow#lines=` is the model).
|
|
45
|
-
- **A form-level status row showing the first error is the *app's*** to build
|
|
46
|
-
from the same data, not the layout's — `D_status_bar` from the other side: the
|
|
47
|
-
framework reserves no row it was not asked for.
|
|
48
|
-
|
|
49
|
-
## Wiring the message
|
|
50
|
-
|
|
51
|
-
The layout paints the message in cells the *field* does not invalidate, so it
|
|
52
|
-
has to be told: subscribe to `on_error_message_change` at `add`, unsubscribe at
|
|
53
|
-
`remove`. That notice exists for exactly this consumer, and `D_has_validation`
|
|
54
|
-
records why it is plain listener inversion rather than the push notice
|
|
55
|
-
`D_bad_input` withheld (this fact is discrete, that one is continuous).
|
|
56
|
-
|
|
57
|
-
**Open, and a real conflict:** `on_error_message_change` is a single
|
|
58
|
-
`attr_accessor` slot, and its rdoc says the container painting the message
|
|
59
|
-
claims it — *"an app painting its own takes it instead."* A `FormLayout` that
|
|
60
|
-
claims it silently at `add` therefore disables an app that already set it,
|
|
61
|
-
which is the one-callback-slot failure `D_no_key_interceptor` names (all four
|
|
62
|
-
composed fields hit it). Decide with the component: either the layout refuses
|
|
63
|
-
to overwrite a non-nil slot, or it chains the previous callable, or the notice
|
|
64
|
-
grows a subscriber list. Do not just assign it.
|
|
65
|
-
|
|
66
|
-
## Facts it rests on, so they don't get re-derived
|
|
67
|
-
|
|
68
|
-
- **A per-child attribute map is a solved shape.** `Box` keeps constraints in an
|
|
69
|
-
identity-keyed per-child map that is explicitly *not* a second copy of
|
|
70
|
-
ordering (`D_box_layouts`); a `FormLayout` holding `{field => caption}` copies
|
|
71
|
-
it. `Component::Slot` is the tree-native answer for a swappable region
|
|
72
|
-
(`D_slots`).
|
|
73
|
-
- **It owes an `on_child_visibility_changed` override**, like `Box` — it is the
|
|
74
|
-
rule for any container with layout arithmetic (`D_visibility`). A conditional
|
|
75
|
-
form field is *the* consumer that brought `visible=` in, so a `FormLayout`
|
|
76
|
-
that skipped this would leave the hole in exactly the place it was built for:
|
|
77
|
-
the caption row and the message cells of a hidden field must go with it.
|
|
78
|
-
- **Measuring captions does not reopen bottom-up sizing.** Aligning a caption
|
|
79
|
-
column needs the *container's own* strings measured — caller-side arithmetic,
|
|
80
|
-
the same move `Select` makes when it measures its labels and assigns the rect
|
|
81
|
-
(`D_select`; `D_box_layouts`' "`align:` is legal only because the cross extent
|
|
82
|
-
is caller-supplied"). It looks like the banned channel and isn't.
|
|
83
|
-
- **The layout is the authority for caption↔field lookup**, since it holds the
|
|
84
|
-
only copy of the association — so it owes a locator
|
|
85
|
-
(`form.field_for(caption: "Name")`), which is what Karibu-Testing does against
|
|
86
|
-
Vaadin 25 form items. `Component#id` + `Tuile::Testing.get`
|
|
87
|
-
(`D_component_lookup`) is a *different* handle, not a replacement:
|
|
88
|
-
an id is a tag the app assigns.
|
|
89
|
-
- **Top-down layout is the heaviest prior**, and it is what makes both halves
|
|
90
|
-
inline: a field is handed one row and cannot grow a second for a caption or a
|
|
91
|
-
message.
|
|
92
|
-
|
|
93
|
-
## Still open
|
|
94
|
-
|
|
95
|
-
- **Caption geometry** — caption column left (aligned, measured caller-side)
|
|
96
|
-
versus a row above. Inline-left is the natural pair to the inline-right
|
|
97
|
-
message; the row-above shape wants the message on a third row and reopens the
|
|
98
|
-
growth question.
|
|
99
|
-
- **Required indicator — decided in shape, open in glyph.**
|
|
100
|
-
`FormLayout#add(field, caption:, required: true)` paints a **red dot beside
|
|
101
|
-
the caption**, as Vaadin does. `D_has_value` parked the indicator; this is
|
|
102
|
-
where it lands, and the field still does not know it is required.
|
|
103
|
-
|
|
104
|
-
Three things checked against Vaadin 25.2 rather than remembered, since they
|
|
105
|
-
shape the TUI version:
|
|
106
|
-
|
|
107
|
-
- Vaadin marks a required field *"with an indicator next to the label"* — so
|
|
108
|
-
the marker rides the **caption**, which in Tuile means it rides the
|
|
109
|
-
container, exactly as `D_caption_ownership` puts the caption there. The
|
|
110
|
-
precedent lines up; nothing to re-argue.
|
|
111
|
-
- Both the glyph and its color are style properties
|
|
112
|
-
(`--vaadin-input-field-required-indicator` /
|
|
113
|
-
`-color`, `::part(required-indicator)`), i.e. Vaadin treats *which* mark as
|
|
114
|
-
a theme choice, not a semantic. Tuile's equivalent question is whether the
|
|
115
|
-
dot's red is a reused `Theme#error_color` or a token of its own — a required
|
|
116
|
-
field is not (yet) *invalid*, and every `Theme` member is validated
|
|
117
|
-
`is_a?(Color)` with a hand-rolled `Theme.new` having to pass all of them, so
|
|
118
|
-
a new token is a breaking change to weigh, not a freebie.
|
|
119
|
-
- The docs also recommend *"an instruction text at the top of the form
|
|
120
|
-
explaining the required indicator"* — worth knowing that even Vaadin does
|
|
121
|
-
not consider the marker self-explanatory. In Tuile that legend is the app's
|
|
122
|
-
row, not the layout's (`D_status_bar`).
|
|
123
|
-
|
|
124
|
-
**The glyph is the one real Tuile problem, and it is the ambiguous-width bet
|
|
125
|
-
(`D_ambiguous_width`).** Measured with the gem: `•` U+2022 and `●` U+25CF and
|
|
126
|
-
`·` U+00B7 are all East-Asian **Ambiguous** — 1 column under Tuile's policy, 2
|
|
127
|
-
under ambiguous-as-wide, so each would enlarge the inventory that keeps the
|
|
128
|
-
bet cheap to reverse. `∙` U+2219 BULLET OPERATOR and `◦` U+25E6 measure **1
|
|
129
|
-
under both policies** and are the dots that cost nothing. So: default to `∙`
|
|
130
|
-
(or ASCII `*` with the dot as an opt-in knob, per the rule that a new
|
|
131
|
-
component defaults to ASCII when the pretty glyph is Ambiguous), and if the
|
|
132
|
-
marker becomes a knob it validates at assignment that it took one cluster one
|
|
133
|
-
column wide (`D_scrollbar_ink`).
|
|
134
|
-
- **Helper text**, the other half of the seam `ideas/new-components.md` infra
|
|
135
|
-
item 2 names, is undesigned — and the inline-right cells are already spoken
|
|
136
|
-
for by the message.
|
|
137
|
-
|
|
138
|
-
## Related
|
|
139
|
-
|
|
140
|
-
`D_caption_ownership` and `D_has_validation` (**the two entries this note's
|
|
141
|
-
first half graduated into** — read them first), `D_bad_input` (the field's own
|
|
142
|
-
report, which reddens the same well), `ideas/binder.md` (the writer of
|
|
143
|
-
`error_message`; the four-layer vocabulary), `D_on_blur` (the commit point a
|
|
144
|
-
field can canonicalize from; the bad-input push notice that is still unbuilt),
|
|
145
|
-
`D_date_field` (a field whose
|
|
146
|
-
input outruns its value), `ideas/new-components.md` (infra item 2; Tier 2 Form
|
|
147
|
-
Layout, Custom Field), `D_box_layouts` (the per-child attribute map;
|
|
148
|
-
caller-supplied cross extent), `D_slots`, `D_select` (caller-side measurement),
|
|
149
|
-
`D_status_bar` (no framework-reserved row), `D_no_key_interceptor` (one callback
|
|
150
|
-
slot cannot be shared), `D_has_value` (the parked required indicator),
|
|
151
|
-
AGENTS.md "Input values" (caption is chrome, text is value).
|
data/ideas/hover/probe.rb
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Terminal mouse-mode probe — fills one row of `ideas/hover.md`'s matrix.
|
|
5
|
-
# Research tooling for that idea; it dies with the note at graduation.
|
|
6
|
-
#
|
|
7
|
-
# Deliberately depends on nothing but stdlib: this measures the *terminal*, so
|
|
8
|
-
# Tuile's own parsing (and its fixed 5-byte escape gulp) must stay out of the
|
|
9
|
-
# way. Bytes are read greedily and parsed incrementally, so the log shows what
|
|
10
|
-
# actually arrived rather than what a fixed-width reader would have made of it.
|
|
11
|
-
#
|
|
12
|
-
# Run it in its OWN terminal / tmux window — never in one where another TUI
|
|
13
|
-
# owns stdin, and never in the terminal a coding agent is drawing on: mode 1003
|
|
14
|
-
# would pour motion reports into *its* stdin.
|
|
15
|
-
#
|
|
16
|
-
# ruby ideas/hover/probe.rb [logfile]
|
|
17
|
-
#
|
|
18
|
-
# Rows recorded so far live in `ideas/hover.md` under "Measured". Still wanted:
|
|
19
|
-
# Alacritty bare, Alacritty over ssh, tmux local. The logs themselves are not
|
|
20
|
-
# committed — a re-run regenerates them, and the findings are what graduate.
|
|
21
|
-
#
|
|
22
|
-
# The parser is covered by ideas/hover/probe_spec.rb — run that before trusting
|
|
23
|
-
# an interactive session, since a decode slip wastes the whole run (it already
|
|
24
|
-
# caught an X10 coordinate off-by-one: the button code is byte-32, but the
|
|
25
|
-
# coordinates are 1-based *and* offset, so they decode as byte-33).
|
|
26
|
-
|
|
27
|
-
require "io/console"
|
|
28
|
-
|
|
29
|
-
LOG = if __FILE__ == $PROGRAM_NAME
|
|
30
|
-
File.open(ARGV[0] || "mouse_probe.log", "w").tap { _1.sync = true }
|
|
31
|
-
else
|
|
32
|
-
$stderr # loaded for testing: never open a log, never touch the terminal
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def say(msg) = ($stdout.print("#{msg}\r\n"); $stdout.flush)
|
|
36
|
-
def log(msg) = LOG.puts(msg)
|
|
37
|
-
def esc(seq) = ($stdout.print(seq); $stdout.flush)
|
|
38
|
-
|
|
39
|
-
def both(msg)
|
|
40
|
-
say msg
|
|
41
|
-
log msg
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# --- incremental parser -----------------------------------------------------
|
|
45
|
-
# Returns [events, leftover]. Each event is [kind, detail].
|
|
46
|
-
def parse(buf)
|
|
47
|
-
events = []
|
|
48
|
-
until buf.empty?
|
|
49
|
-
if buf.start_with?("\e[M")
|
|
50
|
-
break if buf.bytesize < 6
|
|
51
|
-
|
|
52
|
-
# Button code is offset by 32; coordinates are 1-based *and* offset by 32,
|
|
53
|
-
# so they come back with -33. Mixing these up is the classic X10 slip.
|
|
54
|
-
cb, cx, cy = buf[3].ord - 32, buf[4].ord - 33, buf[5].ord - 33
|
|
55
|
-
events << [:x10, decode_cb(cb) + " at #{cx},#{cy}"]
|
|
56
|
-
buf = buf[6..]
|
|
57
|
-
elsif (m = buf.match(/\A\e\[<(\d+);(\d+);(\d+)([Mm])/))
|
|
58
|
-
cb = m[1].to_i
|
|
59
|
-
kind = m[4] == "M" ? "press" : "release"
|
|
60
|
-
events << [:sgr, "#{decode_cb(cb)} [#{kind}] at #{m[2].to_i - 1},#{m[3].to_i - 1}"]
|
|
61
|
-
buf = buf[m[0].bytesize..]
|
|
62
|
-
elsif buf.start_with?("\e[I")
|
|
63
|
-
events << [:focus, "FocusIn"]
|
|
64
|
-
buf = buf[3..]
|
|
65
|
-
elsif buf.start_with?("\e[O")
|
|
66
|
-
events << [:focus, "FocusOut"]
|
|
67
|
-
buf = buf[3..]
|
|
68
|
-
elsif (m = buf.match(/\A\e\[\?(\d+);(\d+)\$y/))
|
|
69
|
-
events << [:decrpm, "mode #{m[1]} => #{decrpm_meaning(m[2].to_i)}"]
|
|
70
|
-
buf = buf[m[0].bytesize..]
|
|
71
|
-
elsif buf.start_with?("\e[<") || buf.start_with?("\e[?")
|
|
72
|
-
break # incomplete, wait for more bytes
|
|
73
|
-
else
|
|
74
|
-
events << [:other, buf[0].inspect]
|
|
75
|
-
buf = buf[1..]
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
[events, buf]
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def decode_cb(code)
|
|
82
|
-
base = code & 0b11
|
|
83
|
-
mods = []
|
|
84
|
-
mods << "shift" if code & 4 != 0
|
|
85
|
-
mods << "meta" if code & 8 != 0
|
|
86
|
-
mods << "ctrl" if code & 16 != 0
|
|
87
|
-
motion = code & 32 != 0
|
|
88
|
-
wheel = code & 64 != 0
|
|
89
|
-
name =
|
|
90
|
-
if wheel
|
|
91
|
-
%w[wheel_up wheel_down wheel_left wheel_right][base] || "wheel?#{base}"
|
|
92
|
-
else
|
|
93
|
-
%w[left middle right release][base]
|
|
94
|
-
end
|
|
95
|
-
"code=#{code} #{motion ? 'MOTION ' : ''}#{name}#{mods.empty? ? '' : " +#{mods.join('+')}"}"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def decrpm_meaning(ps)
|
|
99
|
-
{ 0 => "NOT RECOGNIZED", 1 => "set", 2 => "reset (supported)",
|
|
100
|
-
3 => "permanently set", 4 => "permanently reset" }[ps] || "unknown(#{ps})"
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# --- drain ------------------------------------------------------------------
|
|
104
|
-
# Reads for `seconds`, returning every parsed event. `stop_on` ends it early.
|
|
105
|
-
def drain(seconds, stop_on: nil)
|
|
106
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
107
|
-
buf = +""
|
|
108
|
-
out = []
|
|
109
|
-
loop do
|
|
110
|
-
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
111
|
-
break if left <= 0
|
|
112
|
-
next unless IO.select([$stdin], nil, nil, [left, 0.2].min)
|
|
113
|
-
|
|
114
|
-
chunk = $stdin.read_nonblock(4096)
|
|
115
|
-
# Timestamp the *read*, and record the chunk size: inter-arrival gaps and
|
|
116
|
-
# multi-event chunks are how we tell whether ssh/tmux coalesced anything.
|
|
117
|
-
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
118
|
-
buf << chunk
|
|
119
|
-
events, buf = parse(buf)
|
|
120
|
-
log format(" [read %+7.3fs %3dB -> %d ev]", now - deadline + seconds, chunk.bytesize, events.size) if events.any?
|
|
121
|
-
events.each do |kind, detail|
|
|
122
|
-
out << [kind, detail]
|
|
123
|
-
log " #{kind}: #{detail}"
|
|
124
|
-
end
|
|
125
|
-
break if stop_on && buf.empty? && out.any? { |(_, d)| d.include?(stop_on) }
|
|
126
|
-
end
|
|
127
|
-
out
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def wait_for_enter
|
|
131
|
-
say " [press ENTER to start]"
|
|
132
|
-
loop do
|
|
133
|
-
IO.select([$stdin], nil, nil, 60) or break
|
|
134
|
-
break if $stdin.read_nonblock(4096).include?("\r") || false
|
|
135
|
-
rescue IO::WaitReadable, EOFError
|
|
136
|
-
break
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
ALL_OFF = "\e[?1000l\e[?1002l\e[?1003l\e[?1004l\e[?1006l"
|
|
141
|
-
|
|
142
|
-
return unless __FILE__ == $PROGRAM_NAME
|
|
143
|
-
|
|
144
|
-
begin
|
|
145
|
-
$stdin.raw do
|
|
146
|
-
log "=== mouse probe #{Time.now.iso8601 rescue Time.now} ==="
|
|
147
|
-
log "TERM=#{ENV['TERM']} COLORTERM=#{ENV['COLORTERM']} TMUX=#{ENV['TMUX']}"
|
|
148
|
-
log "size=#{IO.console.winsize.reverse.join('x')}"
|
|
149
|
-
log ""
|
|
150
|
-
|
|
151
|
-
# -- Phase 0: DECRQM support ---------------------------------------------
|
|
152
|
-
both "PHASE 0 DECRQM mode-support probe (no mouse needed)"
|
|
153
|
-
[9, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1016].each do |mode|
|
|
154
|
-
esc "\e[?#{mode}$p"
|
|
155
|
-
got = drain(0.3)
|
|
156
|
-
both " #{mode}: #{got.empty? ? 'NO REPLY' : got.map(&:last).join(', ')}"
|
|
157
|
-
end
|
|
158
|
-
log ""
|
|
159
|
-
|
|
160
|
-
# -- Phase 1: 1000 + X10 -------------------------------------------------
|
|
161
|
-
both ""
|
|
162
|
-
both "PHASE 1 mode 1000, X10 encoding — click a few times (5s)"
|
|
163
|
-
wait_for_enter
|
|
164
|
-
esc "\e[?1000h"
|
|
165
|
-
got = drain(5)
|
|
166
|
-
esc "\e[?1000l"
|
|
167
|
-
both " => #{got.size} events; releases seen: #{got.count { |(_, d)| d.include?('release') }}"
|
|
168
|
-
log ""
|
|
169
|
-
|
|
170
|
-
# -- Phase 2: 1000 + SGR 1006 -------------------------------------------
|
|
171
|
-
both ""
|
|
172
|
-
both "PHASE 2 mode 1000 + SGR 1006 — click a few times, incl. right-click (5s)"
|
|
173
|
-
wait_for_enter
|
|
174
|
-
esc "\e[?1006h\e[?1000h"
|
|
175
|
-
got = drain(5)
|
|
176
|
-
esc "\e[?1000l"
|
|
177
|
-
sgr = got.count { |(k, _)| k == :sgr }
|
|
178
|
-
both " => #{got.size} events, #{sgr} in SGR form (0 means 1006 was ignored)"
|
|
179
|
-
log ""
|
|
180
|
-
|
|
181
|
-
# -- Phase 3: 1002 -------------------------------------------------------
|
|
182
|
-
both ""
|
|
183
|
-
both "PHASE 3 mode 1002 — FIRST move with no button (expect nothing),"
|
|
184
|
-
both " THEN drag with the left button held (expect motion) (8s)"
|
|
185
|
-
wait_for_enter
|
|
186
|
-
esc "\e[?1002h"
|
|
187
|
-
got = drain(8)
|
|
188
|
-
esc "\e[?1002l"
|
|
189
|
-
motion = got.count { |(_, d)| d.include?("MOTION") }
|
|
190
|
-
both " => #{got.size} events, #{motion} motion"
|
|
191
|
-
log ""
|
|
192
|
-
|
|
193
|
-
# -- Phase 4: 1003 rate --------------------------------------------------
|
|
194
|
-
both ""
|
|
195
|
-
both "PHASE 4 mode 1003 — move the mouse around normally, NON-STOP (10s)"
|
|
196
|
-
wait_for_enter
|
|
197
|
-
esc "\e[?1003h"
|
|
198
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
199
|
-
got = drain(10)
|
|
200
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
201
|
-
esc "\e[?1003l"
|
|
202
|
-
motion = got.count { |(_, d)| d.include?("MOTION") }
|
|
203
|
-
both format(" => %d events (%d motion) in %.1fs = %.1f/s <-- THE NUMBER",
|
|
204
|
-
got.size, motion, elapsed, got.size / elapsed)
|
|
205
|
-
log ""
|
|
206
|
-
|
|
207
|
-
# -- Phase 5: pointer leaves the window ---------------------------------
|
|
208
|
-
both ""
|
|
209
|
-
both "PHASE 5 mode 1003 + 1004 — move the pointer OUT of this window,"
|
|
210
|
-
both " click another app, then come back (10s)"
|
|
211
|
-
wait_for_enter
|
|
212
|
-
esc "\e[?1003h\e[?1004h"
|
|
213
|
-
got = drain(10)
|
|
214
|
-
esc "\e[?1003l\e[?1004l"
|
|
215
|
-
focus = got.select { |(k, _)| k == :focus }
|
|
216
|
-
both " => #{got.size} events; focus events: #{focus.empty? ? 'NONE' : focus.map(&:last).join(', ')}"
|
|
217
|
-
both " (NONE means nothing tells us the pointer left — hover would strand)"
|
|
218
|
-
log ""
|
|
219
|
-
|
|
220
|
-
# -- Phase 6: wide coordinates ------------------------------------------
|
|
221
|
-
cols = IO.console.winsize[1]
|
|
222
|
-
both ""
|
|
223
|
-
if cols > 223
|
|
224
|
-
both "PHASE 6 mode 1003 + 1006 — click PAST column 223 (width=#{cols}) (8s)"
|
|
225
|
-
wait_for_enter
|
|
226
|
-
esc "\e[?1006h\e[?1003h"
|
|
227
|
-
got = drain(8)
|
|
228
|
-
esc "\e[?1003l\e[?1006l"
|
|
229
|
-
both " => #{got.size} events; max x seen: #{got.map { |(_, d)| d[/at (\d+),/, 1].to_i }.max}"
|
|
230
|
-
else
|
|
231
|
-
both "PHASE 6 SKIPPED — terminal is #{cols} cols, need >223 to test the X10 cap"
|
|
232
|
-
log "PHASE 6 skipped: width #{cols}"
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
both ""
|
|
236
|
-
both "done — log written to #{LOG.path}"
|
|
237
|
-
end
|
|
238
|
-
ensure
|
|
239
|
-
esc ALL_OFF
|
|
240
|
-
LOG.close
|
|
241
|
-
end
|
data/ideas/hover/probe_spec.rb
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Covers probe.rb's byte decoder. Deliberately NOT under spec/ — this is
|
|
5
|
-
# research tooling for ideas/hover.md, not gem code, so it must stay out of
|
|
6
|
-
# `rake spec` and out of CI. Plain assertions, no rspec:
|
|
7
|
-
#
|
|
8
|
-
# ruby ideas/hover/probe_spec.rb
|
|
9
|
-
#
|
|
10
|
-
# Run it before an interactive probe session: a decode slip wastes the whole
|
|
11
|
-
# run, and one already happened (X10 coordinates are byte-33, not byte-32 —
|
|
12
|
-
# the offset differs from the button code's because coordinates are 1-based).
|
|
13
|
-
|
|
14
|
-
require_relative "probe"
|
|
15
|
-
|
|
16
|
-
FAILURES = []
|
|
17
|
-
|
|
18
|
-
def check(input, want)
|
|
19
|
-
events, leftover = parse(input.dup)
|
|
20
|
-
got = events.map { |kind, detail| "#{kind}: #{detail}" }
|
|
21
|
-
if got == want && leftover.empty?
|
|
22
|
-
puts " ok #{input.inspect}"
|
|
23
|
-
else
|
|
24
|
-
FAILURES << input
|
|
25
|
-
puts " FAIL #{input.inspect}\n got #{got.inspect} leftover=#{leftover.inspect}\n want #{want.inspect}"
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# A fragment must be held for the next read, never half-consumed: real reads
|
|
30
|
-
# do not align to event boundaries (measured: 4 reads in one 10s sample came
|
|
31
|
-
# back at 11.5 bytes/event, i.e. one event split across two reads).
|
|
32
|
-
def check_held(fragment)
|
|
33
|
-
events, leftover = parse(fragment.dup)
|
|
34
|
-
if events.empty? && leftover == fragment
|
|
35
|
-
puts " ok held #{fragment.inspect}"
|
|
36
|
-
else
|
|
37
|
-
FAILURES << fragment
|
|
38
|
-
puts " FAIL #{fragment.inspect} -> #{events.inspect} leftover=#{leftover.inspect}"
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
puts "X10 encoding (button code is byte-32; coordinates are byte-33):"
|
|
43
|
-
check "\e[M !\"", ["x10: code=0 left at 0,1"]
|
|
44
|
-
check "\e[M#!!", ["x10: code=3 release at 0,0"]
|
|
45
|
-
check "\e[M\"!!", ["x10: code=2 right at 0,0"]
|
|
46
|
-
check "\e[M!!!", ["x10: code=1 middle at 0,0"]
|
|
47
|
-
check "\e[M`!!", ["x10: code=64 wheel_up at 0,0"]
|
|
48
|
-
check "\e[Ma!!", ["x10: code=65 wheel_down at 0,0"]
|
|
49
|
-
check "\e[M0!!", ["x10: code=16 left +ctrl at 0,0"]
|
|
50
|
-
puts "X10 motion codes — the set that arrives under 1002/1003:"
|
|
51
|
-
check "\e[M@!!", ["x10: code=32 MOTION left at 0,0"] # drag; all of 1002's traffic
|
|
52
|
-
check "\e[MC!!", ["x10: code=35 MOTION release at 0,0"] # hover; 1003 only
|
|
53
|
-
puts "X10 burst — two reports back to back must split cleanly (a fixed 6-byte"
|
|
54
|
-
puts "read is safe here, which is why a PTY spec may burst X10 but not SGR):"
|
|
55
|
-
check "\e[M !\"\e[M#!\"", ["x10: code=0 left at 0,1", "x10: code=3 release at 0,1"]
|
|
56
|
-
|
|
57
|
-
puts "SGR 1006 — press/release told apart by the final byte, and the release"
|
|
58
|
-
puts "carries its button (X10's release code 3 is anonymous):"
|
|
59
|
-
check "\e[<0;1;1M", ["sgr: code=0 left [press] at 0,0"]
|
|
60
|
-
check "\e[<0;1;1m", ["sgr: code=0 left [release] at 0,0"]
|
|
61
|
-
check "\e[<2;300;40m", ["sgr: code=2 right [release] at 299,39"]
|
|
62
|
-
check "\e[<35;11;6M", ["sgr: code=35 MOTION release [press] at 10,5"]
|
|
63
|
-
check "\e[<64;5;5M", ["sgr: code=64 wheel_up [press] at 4,4"]
|
|
64
|
-
puts "SGR past the X10 cap — the whole reason to request 1006:"
|
|
65
|
-
check "\e[<0;500;9M", ["sgr: code=0 left [press] at 499,8"]
|
|
66
|
-
|
|
67
|
-
puts "focus tracking (mode 1004) and DECRQM replies:"
|
|
68
|
-
check "\e[I", ["focus: FocusIn"]
|
|
69
|
-
check "\e[O", ["focus: FocusOut"]
|
|
70
|
-
check "\e[?1003;2$y", ["decrpm: mode 1003 => reset (supported)"]
|
|
71
|
-
check "\e[?1005;0$y", ["decrpm: mode 1005 => NOT RECOGNIZED"]
|
|
72
|
-
|
|
73
|
-
puts "partial input is held, not consumed:"
|
|
74
|
-
["\e[M", "\e[M!", "\e[<0;1", "\e[?100"].each { check_held(_1) }
|
|
75
|
-
|
|
76
|
-
puts
|
|
77
|
-
if FAILURES.empty?
|
|
78
|
-
puts "ALL PARSER TESTS PASS"
|
|
79
|
-
else
|
|
80
|
-
puts "#{FAILURES.size} FAILED"
|
|
81
|
-
end
|
|
82
|
-
exit(FAILURES.empty? ? 0 : 1)
|