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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -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
@@ -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)