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
data/examples/sampler.rb CHANGED
@@ -12,28 +12,40 @@
12
12
  # Keys: ←→ along the strip, Enter/↓ to open a menu, or a letter for the
13
13
  # underlined mnemonic. Tab / Shift+Tab move focus between the strip, the jump
14
14
  # box and the demo's widgets. q or ESC quits.
15
+ #
16
+ # Runs at `capture_mouse: :hover` — the Mouse demo needs it, and the level is
17
+ # one app-wide choice made at the event loop.
15
18
 
16
19
  require "rainbow"
17
20
  require "tuile"
18
21
 
19
22
  module SamplerExample
20
- # Sampler-local container: a {Tuile::Component::Layout::Absolute} that runs a
21
- # caller-supplied block on `rect=` to position its children. Most demos are
22
- # plain stacks and use the box layouts instead; this is what's left for the
23
- # two that aren't — a sidebar whose width is `min(16, width / 3)`, which is a
24
- # cap on a proportion and so outside {Tuile::Component::Layout::Box}'s
25
- # Fixed/Percent/Expand vocabulary by design.
26
- class Panel < Tuile::Component::Layout::Absolute
27
- def initialize(&layout_block)
28
- super()
29
- @layout_block = layout_block
30
- end
31
-
32
- def rect=(new_rect)
33
- super
34
- @layout_block&.call(rect) unless rect.empty?
35
- end
36
- end
23
+ # `color` with `by` added to each RGB channel, clamped. Tuile ships no color
24
+ # arithmetic; this is the app's.
25
+ STEP = ->(color, by) { Tuile::Color.rgb(*color.rgb.map { (_1 + by).clamp(0, 255) }) }
26
+
27
+ # `hint` is the app's token, not Tuile's: the framework carries accents for
28
+ # the chrome *it* paints, and the status row below is the sampler's own
29
+ # (`D_status_bar` — Tuile draws none). Paired in a ThemeDef so it survives an
30
+ # OS appearance flip, where a bare `theme=` would be replaced. Both greys
31
+ # quantize to :bright_black on a 16-color terminal, so the description stays
32
+ # dimmer than the key beside it even there.
33
+ #
34
+ # `terminal_tint` is derived: the terminal's *own* background stepped 10 per
35
+ # channel away from the ink — the borderless-pane tint, which only sits right
36
+ # when it comes from the real background. The screen re-derives it whenever
37
+ # the background changes; the fixed grey is for a terminal that reported none,
38
+ # which is the branch most users will actually see.
39
+ APP_THEME = Tuile::ThemeDef.new(
40
+ dark: Tuile::Theme::DARK.with(custom: {
41
+ hint: Tuile::Color::GREY54,
42
+ terminal_tint: ->(bg) { bg ? STEP.call(bg, 10) : Tuile::Color::GREY15 }
43
+ }),
44
+ light: Tuile::Theme::LIGHT.with(custom: {
45
+ hint: Tuile::Color::GREY62,
46
+ terminal_tint: ->(bg) { bg ? STEP.call(bg, -10) : Tuile::Color::GREY89 }
47
+ })
48
+ )
37
49
 
38
50
  # A {Tuile::Component::Layout::Vertical} that runs {#on_tick} on every frame
39
51
  # while it is on screen. The ticker is started on attach and cancelled on
@@ -46,21 +58,26 @@ module SamplerExample
46
58
  @fps = fps
47
59
  end
48
60
 
49
- # @return [Proc, nil] called with no arguments on each frame.
50
- attr_writer :on_tick
61
+ # What `on_tick` fires.
62
+ TickEvent = Data.define(:source) { include Tuile::Event }
63
+
64
+ # @!method on_tick
65
+ # Fired once per frame while this box is attached.
66
+ # @return [Tuile::Listeners]
67
+ listener :on_tick
51
68
 
52
- def on_attached
53
- @ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
69
+ def handle_attached
70
+ @ticker = screen.event_queue.tick_fps(@fps) { on_tick.fire(TickEvent.new(source: self)) }
54
71
  end
55
72
 
56
- def on_detached
73
+ def handle_detached
57
74
  @ticker&.cancel
58
75
  @ticker = nil
59
76
  end
60
77
  end
61
78
 
62
79
  # A {Tuile::Component::Layout::Vertical} that claims one key for itself. An
63
- # ancestor's `handle_key` is where a scope-wide binding belongs (key-dispatch
80
+ # ancestor's `handle_key?` is where a scope-wide binding belongs (key-dispatch
64
81
  # rung 3); the Select demo uses one to show the letter still arriving while a
65
82
  # Select has focus — the capability a ComboBox, which eats every printable
66
83
  # unconditionally, cannot offer.
@@ -70,13 +87,18 @@ module SamplerExample
70
87
  @shortcut = shortcut
71
88
  end
72
89
 
73
- # @return [Proc, nil] called with no arguments when the shortcut arrives.
74
- attr_writer :on_shortcut
90
+ # What `on_shortcut` fires.
91
+ ShortcutEvent = Data.define(:source) { include Tuile::Event }
75
92
 
76
- def handle_key(key)
93
+ # @!method on_shortcut
94
+ # Fired when the claimed key arrives.
95
+ # @return [Tuile::Listeners]
96
+ listener :on_shortcut
97
+
98
+ def handle_key?(key)
77
99
  return false unless key == @shortcut
78
100
 
79
- @on_shortcut&.call
101
+ on_shortcut.fire(ShortcutEvent.new(source: self))
80
102
  true
81
103
  end
82
104
  end
@@ -85,26 +107,38 @@ module SamplerExample
85
107
  # the chat-prompt shape, and the one that made a multi-line paste fire the
86
108
  # submit once per pasted line before Tuile drove bracketed paste. It handles
87
109
  # no paste of its own: pasted text never arrives as ENTER, so the inherited
88
- # insert-at-caret is already the wanted behavior, and `on_paste` here only
89
- # feeds the demo's counter.
110
+ # insert-at-caret is already the wanted behavior, and `on_paste_received` here
111
+ # only feeds the demo's counter.
90
112
  class PromptTextArea < Tuile::Component::TextArea
91
- # @return [Proc, nil] called with the submitted text; the area then clears.
92
- attr_accessor :on_submit
93
- # @return [Proc, nil] called with the pasted text, before it is inserted.
94
- attr_accessor :on_paste
113
+ # What `on_submit` fires.
114
+ SubmitEvent = Data.define(:source, :text) { include Tuile::Event }
115
+
116
+ # What `on_paste_received` fires.
117
+ PasteEvent = Data.define(:source, :text) { include Tuile::Event }
118
+
119
+ # @!method on_submit
120
+ # Fired with the submitted text; the area then clears.
121
+ # @return [Tuile::Listeners]
122
+ listener :on_submit
123
+
124
+ # @!method on_paste_received
125
+ # Fired with the pasted text, before it is inserted. Not `on_paste`:
126
+ # `handle_paste` is the override point, and a slot may not take its name.
127
+ # @return [Tuile::Listeners]
128
+ listener :on_paste_received
95
129
 
96
130
  def handle_paste(text)
97
- @on_paste&.call(text)
131
+ on_paste_received.fire(PasteEvent.new(source: self, text: text))
98
132
  super
99
133
  end
100
134
 
101
135
  protected
102
136
 
103
- def handle_text_input_key(key)
137
+ def handle_text_input_key?(key)
104
138
  return super unless key == Tuile::Keys::ENTER
105
139
 
106
- @on_submit&.call(text)
107
- self.text = ""
140
+ on_submit.fire(SubmitEvent.new(source: self, text: text))
141
+ self.value = ""
108
142
  true
109
143
  end
110
144
  end
@@ -115,8 +149,8 @@ module SamplerExample
115
149
  # a newline with no menu up — stays the TextArea's own.
116
150
  #
117
151
  # Subclassing *is* the seam for this. A component receives keys through
118
- # `handle_key`, so one that wants different keys overrides it (here its
119
- # `handle_text_input_key` hook) and calls `super` for the rest, which composes
152
+ # `handle_key?`, so one that wants different keys overrides it (here its
153
+ # `handle_text_input_key?` hook) and calls `super` for the rest, which composes
120
154
  # and stacks. None of this is baked into TextArea.
121
155
  class SlashCommandTextArea < Tuile::Component::TextArea
122
156
  # @param overlay [Tuile::Component::ListDropdown] the menu to steer.
@@ -127,7 +161,7 @@ module SamplerExample
127
161
 
128
162
  protected
129
163
 
130
- def handle_text_input_key(key)
164
+ def handle_text_input_key?(key)
131
165
  return super unless @overlay.open?
132
166
  return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
133
167
 
@@ -140,6 +174,304 @@ module SamplerExample
140
174
  end
141
175
  end
142
176
 
177
+ # A drawing surface answering every mouse handler, in two inks that both
178
+ # persist: a left-drag strokes `X`, a plain hover leaves a `.` trail behind
179
+ # the pointer, and a right-drag lifts marks again.
180
+ #
181
+ # X a diagonal drag, then one hover sweep across it
182
+ # X
183
+ # X
184
+ # X
185
+ # .....X........
186
+ # X
187
+ #
188
+ # A trail never overwrites a stroke, so the picture stays a readout of which
189
+ # channel drew which cell — an eraser-on-hover would instead make the drawing
190
+ # unviewable with the pointer over it.
191
+ #
192
+ # canvas = Canvas.new
193
+ # canvas.on_report { |e| log.log(e.line) }
194
+ # canvas.on_move { |e| label.text = "#{e.mouse.x},#{e.mouse.y} (#{e.moves})" }
195
+ #
196
+ # Two slots, because the traffic is two: {#on_report} carries the discrete
197
+ # events to a log, {#on_move} the ~84-a-second moves to one replaced row.
198
+ # Feeding both to a log would drown enter/exit inside 12 ms.
199
+ #
200
+ # Every gesture owes a key (`D_mouse`): the arrows move the caret, space or
201
+ # `x` strokes it, Delete lifts it, `c` clears. The trail, the enter/exit lines
202
+ # and {#on_move} need `capture_mouse: :hover` and arrive as nothing below it;
203
+ # the drag works from `:drag`, a single-cell stroke from `:clicks`.
204
+ #
205
+ # == Implementation details
206
+ # Both glyphs are ASCII on purpose: `·` is East-Asian Ambiguous, so a
207
+ # two-column trail cell would push every painted row past `rect.width`
208
+ # (`D_ambiguous_width`).
209
+ #
210
+ # It paints every cell of its rect itself, one {Tuile::Canvas#set_text}
211
+ # per run of like cells, and so skips `super` in {#repaint} — whose
212
+ # auto-clear blanks the whole rect, which would re-emit every cell this widget
213
+ # is about to paint over anyway. Repainting whole on every move still costs
214
+ # one cell on the wire, since a cell dirties only on a real change.
215
+ #
216
+ # Marks are keyed by rect-local {Tuile::Point}: ink beyond a narrowed rect
217
+ # stops painting and comes back when the rect grows again.
218
+ class Canvas < Tuile::Component
219
+ # @return [String] the drag/keyboard ink.
220
+ STROKE = "X"
221
+ # @return [String] the hover ink.
222
+ TRAIL = "."
223
+
224
+ # Which ink each button drags — `nil` erases. The middle button is absent:
225
+ # a press this component declines bubbles on to the window around it.
226
+ # @return [Hash{Symbol => String, nil}]
227
+ DRAG_INK = { left: STROKE, right: nil }.freeze
228
+
229
+ # What `on_report` fires.
230
+ ReportEvent = Data.define(:source, :line) { include Tuile::Event }
231
+
232
+ # What `on_move` fires.
233
+ MoveEvent = Data.define(:source, :mouse, :moves) { include Tuile::Event }
234
+
235
+ # @!method on_report
236
+ # Fired with one line of commentary per discrete event.
237
+ # @return [Tuile::Listeners]
238
+ listener :on_report
239
+
240
+ # @!method on_move
241
+ # Fired on every move and every drag, with the {Tuile::Mouse::Event} as
242
+ # `mouse` — not `event`, which would read as the event's event — and the
243
+ # number of moves so far.
244
+ # @return [Tuile::Listeners]
245
+ listener :on_move
246
+
247
+ # @return [Tuile::Point] the keyboard caret, in rect-local coordinates.
248
+ attr_reader :caret
249
+
250
+ # The marks, keyed by rect-local {Tuile::Point} — read-only in practice:
251
+ # writing one behind {#paint}'s back skips the `invalidate`.
252
+ # @return [Hash{Tuile::Point => String}]
253
+ attr_reader :ink
254
+
255
+ def initialize
256
+ super
257
+ @ink = {}
258
+ @caret = Tuile::Point.new(0, 0)
259
+ @moves = 0
260
+ @drag_ink = nil
261
+ @drag_outside = false
262
+ bg.default_color = Tuile::ComponentBackground::INPUT_WELL
263
+ end
264
+
265
+ def focusable? = true
266
+
267
+ def tab_stop? = true
268
+
269
+ # The caret is already rect-local, and so is what a cursor position means —
270
+ # {Tuile::Screen#cursor_position} puts it on screen.
271
+ # @return [Tuile::Point, nil]
272
+ def cursor_position
273
+ return nil if rect.empty?
274
+
275
+ @caret
276
+ end
277
+
278
+ # Clamps the caret into the new rect, so a shrink cannot strand it — and
279
+ # with it the hardware cursor — outside what this widget paints.
280
+ # @param old_rect [Tuile::Rect]
281
+ def handle_rect_changed(old_rect)
282
+ super
283
+ @caret = Tuile::Point.new(@caret.x.clamp(0, [rect.width - 1, 0].max),
284
+ @caret.y.clamp(0, [rect.height - 1, 0].max))
285
+ end
286
+
287
+ # @return [void]
288
+ def repaint(canvas)
289
+ return if rect.empty?
290
+
291
+ # The clear is what a self-painter opts out of; the cascade never is
292
+ # (`D_repaint_cascade`), leaf or not.
293
+ invalidate_children
294
+ trail_color = screen.theme[:hint]
295
+ rect.height.times { |row| draw_row(canvas, row, trail_color) }
296
+ end
297
+
298
+ # @param event [Tuile::Mouse::DownEvent]
299
+ # @return [Boolean]
300
+ def handle_mouse_down?(event)
301
+ unless DRAG_INK.key?(event.button)
302
+ report("down #{event.button} at #{event.x},#{event.y} — declined, bubbles to the window")
303
+ return false
304
+ end
305
+
306
+ report("down #{event.button} at #{event.x},#{event.y} — claimed, grab held")
307
+ @drag_ink = DRAG_INK.fetch(event.button)
308
+ @drag_outside = false
309
+ mark(cell_at(event))
310
+ true
311
+ end
312
+
313
+ # @param event [Tuile::Mouse::DragEvent]
314
+ # @return [void]
315
+ def handle_mouse_drag(event)
316
+ super
317
+ cell = cell_at(event)
318
+ if @drag_outside != cell.nil?
319
+ @drag_outside = cell.nil?
320
+ report(@drag_outside ? "drag left the canvas — the grab still delivers" : "drag back inside")
321
+ end
322
+ mark(cell)
323
+ report_move(event)
324
+ end
325
+
326
+ # @param event [Tuile::Mouse::UpEvent]
327
+ # @return [void]
328
+ def handle_mouse_up(event)
329
+ super
330
+ report("up at #{event.x},#{event.y} — grab released")
331
+ @drag_ink = nil
332
+ end
333
+
334
+ # @param event [Tuile::Mouse::MoveEvent]
335
+ # @return [Boolean]
336
+ def handle_mouse_move?(event)
337
+ cell = cell_at(event)
338
+ paint(cell, TRAIL) if cell && @ink[cell].nil?
339
+ report_move(event)
340
+ true
341
+ end
342
+
343
+ # @return [void]
344
+ def handle_mouse_enter
345
+ super
346
+ report("enter")
347
+ end
348
+
349
+ # @return [void]
350
+ def handle_mouse_exit
351
+ super
352
+ report("exit")
353
+ end
354
+
355
+ # Declines the notch — nothing here scrolls — which is what lets it bubble
356
+ # on to an ancestor that does.
357
+ # @param event [Tuile::Mouse::ScrollEvent]
358
+ # @return [Boolean] always false.
359
+ def handle_mouse_scroll?(event)
360
+ report("wheel #{event.direction} — declined, bubbles on")
361
+ false
362
+ end
363
+
364
+ # @param key [String]
365
+ # @return [Boolean]
366
+ def handle_key?(key)
367
+ case key
368
+ when Tuile::Keys::UP_ARROW then move_caret(0, -1)
369
+ when Tuile::Keys::DOWN_ARROW then move_caret(0, 1)
370
+ when Tuile::Keys::LEFT_ARROW then move_caret(-1, 0)
371
+ when Tuile::Keys::RIGHT_ARROW then move_caret(1, 0)
372
+ when " ", "x" then stroke_caret(STROKE)
373
+ when Tuile::Keys::DELETE, *Tuile::Keys::BACKSPACES then stroke_caret(nil)
374
+ when "c" then clear_marks
375
+ else return super
376
+ end
377
+ true
378
+ end
379
+
380
+ private
381
+
382
+ # One row, as runs of like cells: every cell is painted exactly once, so
383
+ # none is blanked and then painted over (`D_progress_bar`). The canvas
384
+ # paints in the same rect-local coordinates the marks are keyed by, so a
385
+ # run goes straight to `set_text` with no offset.
386
+ # @param row [Integer] rect-local row.
387
+ # @param trail_color [Tuile::Color]
388
+ # @return [void]
389
+ def draw_row(canvas, row, trail_color)
390
+ column = 0
391
+ while column < rect.width
392
+ glyph = @ink[Tuile::Point.new(column, row)]
393
+ run = 1
394
+ run += 1 while column + run < rect.width && @ink[Tuile::Point.new(column + run, row)] == glyph
395
+ text = (glyph || " ") * run
396
+ styled = glyph == TRAIL ? Tuile::StyledString.styled(text, fg: trail_color) : Tuile::StyledString.plain(text)
397
+ canvas.set_text(column, row, styled)
398
+ column += run
399
+ end
400
+ end
401
+
402
+ # The event's cell, or nil when it lands outside — which a grabbed
403
+ # {Tuile::Mouse::DragEvent} routinely does. A mouse event already arrives in
404
+ # this component's own coordinates, so there is nothing to subtract; the
405
+ # bounds test is the whole job.
406
+ # @param event [Tuile::Mouse::Event]
407
+ # @return [Tuile::Point, nil]
408
+ def cell_at(event)
409
+ return nil unless (0...rect.width).cover?(event.x) && (0...rect.height).cover?(event.y)
410
+
411
+ Tuile::Point.new(event.x, event.y)
412
+ end
413
+
414
+ # Lays the dragged ink at `cell` and takes the caret with it, so a stroke
415
+ # can be continued from the keyboard. Outside the rect (`nil`) it is a
416
+ # no-op, which is a grabbed drag's normal case.
417
+ # @param cell [Tuile::Point, nil] rect-local.
418
+ # @return [void]
419
+ def mark(cell)
420
+ return if cell.nil?
421
+
422
+ paint(cell, @drag_ink)
423
+ @caret = cell
424
+ end
425
+
426
+ # @param cell [Tuile::Point] rect-local.
427
+ # @param ink [String, nil] nil lifts the mark.
428
+ # @return [void]
429
+ def paint(cell, ink)
430
+ return if @ink[cell] == ink
431
+
432
+ ink.nil? ? @ink.delete(cell) : @ink.store(cell, ink)
433
+ invalidate
434
+ end
435
+
436
+ # @param ink [String, nil]
437
+ # @return [void]
438
+ def stroke_caret(ink)
439
+ paint(@caret, ink)
440
+ report("#{ink || "lift"} at #{@caret.x},#{@caret.y} — from the keyboard")
441
+ end
442
+
443
+ # @param columns [Integer]
444
+ # @param rows [Integer]
445
+ # @return [void]
446
+ def move_caret(columns, rows)
447
+ return if rect.empty?
448
+
449
+ @caret = Tuile::Point.new((@caret.x + columns).clamp(0, rect.width - 1),
450
+ (@caret.y + rows).clamp(0, rect.height - 1))
451
+ invalidate # the hardware cursor is placed from #cursor_position at flush
452
+ end
453
+
454
+ # @return [void]
455
+ def clear_marks
456
+ return if @ink.empty?
457
+
458
+ @ink.clear
459
+ report("cleared")
460
+ invalidate
461
+ end
462
+
463
+ # @param line [String]
464
+ # @return [void]
465
+ def report(line) = on_report.fire(ReportEvent.new(source: self, line: line))
466
+
467
+ # @param event [Tuile::Mouse::Event]
468
+ # @return [void]
469
+ def report_move(event)
470
+ @moves += 1
471
+ on_move.fire(MoveEvent.new(source: self, mouse: event, moves: @moves))
472
+ end
473
+ end
474
+
143
475
  # Top-level sampler component: a shell row across the top — a
144
476
  # {Tuile::Component::MenuBar} of the demos, grouped, and a
145
477
  # {Tuile::Component::ComboBox} jump box at its right end — over one demo
@@ -166,13 +498,14 @@ module SamplerExample
166
498
 
167
499
  # The bottom row. Tuile draws no status bar and reserves no row
168
500
  # (`D_status_bar`) — this one is the sampler's own, kept current by
169
- # {Tuile::Screen#on_focus_changed=}. Naming the focused component makes Tab
501
+ # {Tuile::Screen#on_focus_changed}. Naming the focused component makes Tab
170
502
  # traversal visible as you walk a pane, which no per-pane label shows.
171
503
  # @return [void]
172
504
  def refresh_status
173
505
  focused = screen.focused
174
506
  name = focused ? focused.class.name.sub("Tuile::Component::", "") : "(none)"
175
- @status.text = "q #{screen.theme.hint("quit")} ⇥ #{screen.theme.hint(name)}"
507
+ t = screen.theme
508
+ @status.text = "q #{t.fg(:hint, "quit")} ⇥ #{t.fg(:hint, name)}"
176
509
  end
177
510
 
178
511
  # Chrome for a demo pane: a blank row top and bottom, two columns either
@@ -208,9 +541,11 @@ module SamplerExample
208
541
  # as a live index of the catalogue and any drift between the two is visible.
209
542
  #
210
543
  # Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
211
- # a duplicate among siblings, and five leaves therefore answer to a letter
544
+ # a duplicate among siblings, and seven leaves therefore answer to a letter
212
545
  # other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
213
- # Pic`k`erWindow, S`l`ash menu) — the underline shows which. No item may use
546
+ # Pic`k`erWindow, S`l`ash menu, DateTi`m`eField, F`o`rmLayout) — the
547
+ # underline shows which.
548
+ # No item may use
214
549
  # `q`: quit is the unhandled-key fallback, so a `q` on the live level would
215
550
  # swallow it while the bar has focus.
216
551
  MENUS = [
@@ -233,6 +568,7 @@ module SamplerExample
233
568
  Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
234
569
  Entry.new("DateField", :build_date_field, "d"),
235
570
  Entry.new("TimeField", :build_time_field, "t"),
571
+ Entry.new("DateTimeField", :build_date_time_field, "m"),
236
572
  Entry.new("Bad input", :build_bad_input, "a"),
237
573
  Entry.new("Validation", :build_validation, "v")
238
574
  ]),
@@ -246,7 +582,7 @@ module SamplerExample
246
582
  ])
247
583
  ]),
248
584
  # One entry, so it is the item and not a menu — a top-level leaf on the
249
- # strip is a button, which nothing else here demos.
585
+ # strip is a button rather than a drop-down.
250
586
  Entry.new("Button", :build_buttons, "b"),
251
587
  Menu.new("Overlay", "o", [
252
588
  Entry.new("Popup", :build_popup_launcher, "p"),
@@ -257,6 +593,8 @@ module SamplerExample
257
593
  Entry.new("LogWindow", :build_log_window, "l")
258
594
  ]),
259
595
  Menu.new("Shell", "h", [
596
+ Entry.new("FormLayout", :build_form_layout, "o"),
597
+ Entry.new("Scroller", :build_scroller, "s"),
260
598
  Entry.new("TabSheet", :build_tab_sheet, "t"),
261
599
  Entry.new("MenuBar", :build_menu_bar, "m"),
262
600
  Entry.new("Narrow strips", :build_narrow_strips, "n"),
@@ -264,7 +602,10 @@ module SamplerExample
264
602
  Entry.new("Background", :build_background, "b"),
265
603
  Entry.new("Visibility", :build_visibility, "v"),
266
604
  Entry.new("Focus & Tab", :build_focus_demo, "f")
267
- ])
605
+ ]),
606
+ # The one demo needing a tracking level above the default, which is why
607
+ # the runner asks for `capture_mouse: :hover` for the whole app.
608
+ Entry.new("Mouse", :build_mouse_demo, "m")
268
609
  ].freeze
269
610
 
270
611
  # Every {Entry} in strip order — what the jump box offers.
@@ -308,7 +649,7 @@ module SamplerExample
308
649
  def build_jump_box
309
650
  combo = Tuile::Component::ComboBox.new(items: ENTRIES)
310
651
  combo.item_label = :caption.to_proc
311
- combo.on_value_change = ->(entry) { load_entry(entry) if entry }
652
+ combo.on_value_change { |e| load_entry(e.value) if e.value }
312
653
  combo
313
654
  end
314
655
 
@@ -363,7 +704,7 @@ module SamplerExample
363
704
  "Home/End jump to row start/end; Up/Down at the first/last row jumps to text start/end.\n" \
364
705
  "Overflowing rows scroll vertically to keep the caret visible."
365
706
  area = Tuile::Component::TextArea.new
366
- area.text = "The quick brown fox jumps over the lazy dog. " \
707
+ area.value = "The quick brown fox jumps over the lazy dog. " \
367
708
  "Edit me — the text wraps to the area's width and scrolls vertically " \
368
709
  "once the cursor leaves the visible rows."
369
710
  form do |f|
@@ -382,7 +723,7 @@ module SamplerExample
382
723
  items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
383
724
  combo = Tuile::Component::ComboBox.new(items: items)
384
725
  status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
385
- combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
726
+ combo.on_value_change { |e| status.text = "Selected: #{e.value}" }
386
727
  form do |f|
387
728
  f.add(prompt, Fixed[3])
388
729
  # A cross constraint clamps to the pane, so this is 30 columns or fewer.
@@ -423,14 +764,14 @@ module SamplerExample
423
764
  status.text = "level: #{level.value.inspect} endings: #{endings.value&.label.inspect}"
424
765
  end
425
766
  update.call
426
- [level, endings].each { _1.on_value_change = ->(_v) { update.call } }
767
+ [level, endings].each { _1.on_value_change { update.call } }
427
768
 
428
769
  pane = ShortcutBox.new("r", spacing: 1, padding: FORM_PADDING)
429
770
  pane.add(prompt, Fixed[3])
430
771
  pane.add(labelled("Log level", level), Fixed[1])
431
772
  pane.add(labelled("Line endings", endings), Fixed[1])
432
773
  pane.add(status, Fixed[1])
433
- pane.on_shortcut = lambda do
774
+ pane.on_shortcut do
434
775
  level.value = "warn"
435
776
  endings.value = nil
436
777
  update.call
@@ -450,7 +791,7 @@ module SamplerExample
450
791
  # Set on the composed field, not on the TextField inside it.
451
792
  field.placeholder = "1-65535"
452
793
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
453
- field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
794
+ field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
454
795
  form do |f|
455
796
  f.add(prompt, Fixed[2])
456
797
  f.add(field, Fixed[1], cross: Fixed[20])
@@ -468,7 +809,7 @@ module SamplerExample
468
809
  "Up/Down step the value by one. Watch the value while you type '1.5'."
469
810
  field = Tuile::Component::FloatField.new
470
811
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
471
- field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
812
+ field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
472
813
  form do |f|
473
814
  f.add(prompt, Fixed[2])
474
815
  f.add(field, Fixed[1], cross: Fixed[20])
@@ -487,7 +828,7 @@ module SamplerExample
487
828
  "what you typed (19.90 keeps its zero)."
488
829
  field = Tuile::Component::BigDecimalField.new
489
830
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
490
- field.on_value_change = ->(value) { status.text = triple_report(value) }
831
+ field.on_value_change { |e| status.text = triple_report(e.value) }
491
832
  form do |f|
492
833
  f.add(prompt, Fixed[3])
493
834
  f.add(field, Fixed[1], cross: Fixed[20])
@@ -517,7 +858,7 @@ module SamplerExample
517
858
  # calendar reform, which is noise next to the one fact this row is for.
518
859
  report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
519
860
  report.call
520
- field.on_value_change = ->(_value) { report.call }
861
+ field.on_value_change { report.call }
521
862
  ask = Tuile::Component::Button.new("Ask again") { report.call }
522
863
  form do |f|
523
864
  f.add(prompt, Fixed[4])
@@ -550,7 +891,7 @@ module SamplerExample
550
891
  "seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
551
892
  end
552
893
  report.call
553
- [minutes, seconds].each { _1.on_value_change = ->(_value) { report.call } }
894
+ [minutes, seconds].each { _1.on_value_change { report.call } }
554
895
  form do |f|
555
896
  f.add(prompt, Fixed[4])
556
897
  f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
@@ -568,6 +909,39 @@ module SamplerExample
568
909
  end
569
910
  end
570
911
 
912
+ # DateTimeField: the ink rule, which neither half can show alone. Leave the
913
+ # time empty and the *whole* widget reddens — half-filled is a fault no
914
+ # single half committed — where garbage in the date half reddens that half
915
+ # by itself. Both only on the way out, which is why pressing Enter first
916
+ # visibly changes nothing.
917
+ #
918
+ # The echo row carries the value and Save the verdict, the division
919
+ # `D_bad_input` draws: the notice is a push, fired when a half commits;
920
+ # `bad_input?` is a pull, read at the click. The Saved alert spells the
921
+ # value out with the `+00:00` the halves cannot fill in — `D_time_field`'s
922
+ # epoch cost, visible rather than hidden.
923
+ def build_date_time_field
924
+ prompt = Tuile::Component::Label.new
925
+ prompt.text = "Tab here, type 2026-09-14, leave the time empty — then press Enter: nothing reddens.\n" \
926
+ "Tab away and both halves redden at once: half-filled is the composite's own fault.\n" \
927
+ "Now type 2026-99-99 in the date half instead — it reddens alone, the time half stays clean.\n" \
928
+ "Save asks bad_input? at the click and names whichever fault it found."
929
+ field = Tuile::Component::DateTimeField.new
930
+ status = Tuile::Component::Label.new
931
+ # strftime, not inspect: DateTime#inspect spells out the Julian day, noise
932
+ # next to the one fact this row carries.
933
+ report = -> { status.text = "value: #{field.value&.strftime("%Y-%m-%d %H:%M") || "nil"}" }
934
+ report.call
935
+ field.on_value_change { report.call }
936
+ save = Tuile::Component::Button.new("Save") { save_form("Starts at" => field) }
937
+ form do |f|
938
+ f.add(prompt, Fixed[4])
939
+ f.add(labelled("Starts at", field, field_width: 20), Fixed[1], cross: Fixed[36])
940
+ f.add(status, Fixed[1])
941
+ f.add(save, Fixed[1], cross: Fixed[button_width(save)])
942
+ end
943
+ end
944
+
571
945
  # HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
572
946
  # and watch the echo row stay silent — the value was nil before and is nil
573
947
  # after, so there is no diff to report — while Save, which asks bad_input?
@@ -582,8 +956,8 @@ module SamplerExample
582
956
  amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
583
957
  rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
584
958
  echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
585
- amount.on_value_change = ->(v) { echo.text = "on_value_change: amount = #{v.inspect}" }
586
- rate.on_value_change = ->(v) { echo.text = "on_value_change: rate = #{v.inspect}" }
959
+ amount.on_value_change { |e| echo.text = "on_value_change: amount = #{e.value.inspect}" }
960
+ rate.on_value_change { |e| echo.text = "on_value_change: rate = #{e.value.inspect}" }
587
961
  save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
588
962
  save.id = :save
589
963
  rows = group do |g|
@@ -629,7 +1003,7 @@ module SamplerExample
629
1003
  # the field's listener keeps current.
630
1004
  def validated_row(caption, field)
631
1005
  error = Tuile::Component::Label.new
632
- field.on_error_message_change = ->(msg) { error.text = msg || Tuile::StyledString::EMPTY }
1006
+ field.on_error_message_change { |e| error.text = e.error_message || Tuile::StyledString::EMPTY }
633
1007
  row do |r|
634
1008
  r.add(Tuile::Component::Label.new(caption), Fixed[14])
635
1009
  r.add(field, Fixed[22])
@@ -696,11 +1070,11 @@ module SamplerExample
696
1070
  user = Tuile::Component::TextField.new
697
1071
  password = Tuile::Component::PasswordField.new
698
1072
  reveal = Tuile::Component::Checkbox.new("Show password")
699
- reveal.on_value_change = ->(on) { password.revealed = on }
1073
+ reveal.on_value_change { |e| password.revealed = e.value }
700
1074
  status = Tuile::Component::Label.new
701
1075
  refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
702
1076
  refresh.call
703
- [user, password].each { _1.on_change = ->(_) { refresh.call } }
1077
+ [user, password].each { _1.on_value_change { refresh.call } }
704
1078
  form do |f|
705
1079
  f.add(prompt, Fixed[4])
706
1080
  f.add([user, password], Fixed[1], cross: Fixed[30]) # one constraint, both fields
@@ -715,7 +1089,7 @@ module SamplerExample
715
1089
  # A ListDropdown driven from a TextArea — the same shape {ComboBox} and
716
1090
  # {Select} use, but wired by app code onto a field that knows nothing about
717
1091
  # it. Focus (and the caret) stays in the TextArea the whole time: an
718
- # `on_change` listener refills the menu, and {SlashCommandTextArea} hands
1092
+ # `on_value_change` listener refills the menu, and {SlashCommandTextArea} hands
719
1093
  # movement keys to `#move` and Enter to `#choose` while it is open.
720
1094
  def build_slash_demo
721
1095
  prompt = Tuile::Component::Label.new
@@ -733,15 +1107,14 @@ module SamplerExample
733
1107
  overlay.close if overlay.open?
734
1108
  else
735
1109
  overlay.items = matches
736
- overlay.open unless overlay.open?
737
1110
  # Width is the driver's call, never the dropdown's: measure the
738
1111
  # commands rather than inherit the full-width TextArea's columns.
739
- overlay.anchor_to(area.rect, rows: matches.size, width: slash_menu_width(matches))
1112
+ overlay.anchor_to(area, width: slash_menu_width(matches))
740
1113
  end
741
1114
  end
742
1115
 
743
- area.on_change = ->(_text) { refill.call }
744
- overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
1116
+ area.on_value_change { refill.call }
1117
+ overlay.list.on_item_chosen { |e| accept_slash_command(area, e.item.to_s) }
745
1118
 
746
1119
  form do |f|
747
1120
  f.add(prompt, Fixed[4])
@@ -767,14 +1140,14 @@ module SamplerExample
767
1140
  refresh = lambda do
768
1141
  stats.text = "submits: #{submits} pastes: #{pastes} rows in draft: #{area.row_count}"
769
1142
  end
770
- area.on_change = ->(_text) { refresh.call }
771
- area.on_paste = lambda do |text|
1143
+ area.on_value_change { refresh.call }
1144
+ area.on_paste_received do |e|
772
1145
  pastes += 1
773
- log.add_line(Rainbow("pasted #{text.lines.size} line(s), #{text.length} chars").cyan)
1146
+ log.add_line(Rainbow("pasted #{e.text.lines.size} line(s), #{e.text.length} chars").cyan)
774
1147
  end
775
- area.on_submit = lambda do |text|
1148
+ area.on_submit do |e|
776
1149
  submits += 1
777
- log.add_line(Rainbow("submitted: #{text.inspect}").green)
1150
+ log.add_line(Rainbow("submitted: #{e.text.inspect}").green)
778
1151
  end
779
1152
  refresh.call
780
1153
 
@@ -860,7 +1233,7 @@ module SamplerExample
860
1233
  status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
861
1234
  end
862
1235
  refresh.call
863
- boxes.each { _1.on_value_change = ->(_) { refresh.call } }
1236
+ boxes.each { _1.on_value_change { refresh.call } }
864
1237
  # The boxes sit flush against each other while the form keeps a blank row
865
1238
  # around the block: a spacing-0 group nested in the spacing-1 form, rather
866
1239
  # than a per-child gap the framework deliberately doesn't offer.
@@ -891,7 +1264,7 @@ module SamplerExample
891
1264
  conditional.each { _1.visible = business.checked? }
892
1265
  status.text = "visible fields: #{business.checked? ? 4 : 2}"
893
1266
  end
894
- business.on_value_change = ->(_) { apply.call }
1267
+ business.on_value_change { apply.call }
895
1268
  # The rows sit flush; the form keeps its blank row around the block.
896
1269
  rows = group do |g|
897
1270
  g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
@@ -945,7 +1318,7 @@ module SamplerExample
945
1318
  # *items* — LogLevel objects, not their labels — so the filter below is plain
946
1319
  # set membership, no lookup table. Rows come from `item_label`, which may
947
1320
  # return styled text (these colors are inherent to the data, not theme
948
- # accents, so they need no on_theme_changed hook).
1321
+ # accents, so they need no handle_theme_changed hook).
949
1322
  def build_checkbox_group
950
1323
  prompt = Tuile::Component::Label.new
951
1324
  # Kept under 48 columns a line, so an 80-column terminal shows it whole.
@@ -974,16 +1347,13 @@ module SamplerExample
974
1347
  status.text = "value: {#{shown.join(", ")}} — #{log.items.size} of #{entries.size} lines"
975
1348
  end
976
1349
  refresh.call
977
- group.on_value_change = ->(_set) { refresh.call }
1350
+ group.on_value_change { refresh.call }
978
1351
 
979
- # The body keeps a rect-callback {Panel}: its sidebar is `min(16, width/3)`
980
- # — a cap on a proportion, which Fixed/Percent/Expand can't say. The stack
981
- # around it is a box, so only the part that needs arithmetic has any.
982
- body = panel(group, log) do |r|
983
- group_width = [16, r.width / 3].min
984
- group.rect = Tuile::Rect.new(r.left, r.top, group_width, [LOG_LEVELS.size, r.height].min)
985
- log.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
986
- [r.width - group_width - 2, 4].max, r.height)
1352
+ # The sidebar is a third of the row but never over 16 columns — a capped
1353
+ # proportion, which is a Percent with a clamp.
1354
+ body = row do |r|
1355
+ r.add(group, Percent[33].clamp(..16), cross: Fixed[LOG_LEVELS.size])
1356
+ r.add(log, Expand[1])
987
1357
  end
988
1358
  form do |f|
989
1359
  f.add(prompt, Fixed[4])
@@ -1014,7 +1384,7 @@ module SamplerExample
1014
1384
  SAMPLE_FILES = [
1015
1385
  SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
1016
1386
  SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
1017
- SampleFile.new("DECISIONS.md", 48_990, "2026-07-31"),
1387
+ SampleFile.new("design/decisions.md", 48_990, "2026-07-31"),
1018
1388
  SampleFile.new("Gemfile", 312, "2026-06-18"),
1019
1389
  SampleFile.new("README.md", 9674, "2026-07-12"),
1020
1390
  SampleFile.new("Rakefile", 2118, "2026-06-18"),
@@ -1058,20 +1428,17 @@ module SamplerExample
1058
1428
  update_status.call
1059
1429
  end
1060
1430
  resort.call
1061
- group.on_value_change = ->(_order) { resort.call }
1431
+ group.on_value_change { resort.call }
1062
1432
  # `list` is the composed List, which is where the cursor lives.
1063
1433
  # Watching it is what makes the chrome/value split visible above.
1064
- group.list.on_cursor_changed = ->(_idx, _line) { update_status.call }
1065
-
1066
- # Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
1067
- # demo — the sidebar width is a capped proportion, not a constraint.
1068
- body = panel(group, files) do |r|
1069
- # List pads a column either side of a row, so a label needs
1070
- # `width - 2`; the file rows lose one more to their scrollbar.
1071
- group_width = [14, r.width / 3].min
1072
- group.rect = Tuile::Rect.new(r.left, r.top, group_width, [SORT_ORDERS.size, r.height].min)
1073
- files.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
1074
- [r.width - group_width - 2, 4].max, r.height)
1434
+ group.list.on_cursor_changed { update_status.call }
1435
+
1436
+ # Side-by-side body with a capped sidebar, as in the CheckboxGroup demo.
1437
+ # List pads a column either side of a row, so a label needs `width - 2`;
1438
+ # the file rows lose one more to their scrollbar.
1439
+ body = row do |r|
1440
+ r.add(group, Percent[33].clamp(..14), cross: Fixed[SORT_ORDERS.size])
1441
+ r.add(files, Expand[1])
1075
1442
  end
1076
1443
  form do |f|
1077
1444
  f.add(prompt, Fixed[4])
@@ -1138,7 +1505,7 @@ module SamplerExample
1138
1505
  pane.add(prompt, Fixed[4])
1139
1506
  pane.add(determinate, Fixed[2])
1140
1507
  pane.add(indeterminate, Fixed[3])
1141
- pane.on_tick = lambda do
1508
+ pane.on_tick do
1142
1509
  done = done < PROGRESS_TOTAL ? done + 1 : 0
1143
1510
  bar.value = done
1144
1511
  refresh.call
@@ -1156,6 +1523,7 @@ module SamplerExample
1156
1523
  # of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
1157
1524
  BG_CHOICES = [
1158
1525
  BgChoice.new("None (terminal default)", nil),
1526
+ BgChoice.new("Terminal background, stepped (derived)", Tuile::Theme.ref(:terminal_tint)),
1159
1527
  BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
1160
1528
  BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
1161
1529
  BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
@@ -1169,21 +1537,6 @@ module SamplerExample
1169
1537
  BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
1170
1538
  ].freeze
1171
1539
 
1172
- # The one choice that can't be a constant: the terminal's *own* background
1173
- # stepped +10 per channel — the borderless-pane tint, which only sits right
1174
- # when it's derived from the real background. Nil on a terminal that
1175
- # reported none, which is the branch most users will actually see.
1176
- def terminal_tint_choice
1177
- bg = Tuile::Screen.instance.background_color
1178
- return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
1179
-
1180
- BgChoice.new("Terminal background +10 (derived)",
1181
- Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
1182
- end
1183
-
1184
- # @param derived [BgChoice] the live terminal-derived tint, offered second.
1185
- def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
1186
-
1187
1540
  def build_background
1188
1541
  intro = Tuile::Component::Label.new
1189
1542
  intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
@@ -1194,7 +1547,7 @@ module SamplerExample
1194
1547
  list.cursor = Tuile::Component::List::Cursor.new
1195
1548
  list.lines = (1..12).map { |i| "List row #{i}" }
1196
1549
  field = Tuile::Component::TextField.new
1197
- field.text = "TextField keeps its own background"
1550
+ field.value = "TextField keeps its own background"
1198
1551
 
1199
1552
  # A borderless sub-box holding the list + field; it inherits the tint too.
1200
1553
  box = Tuile::Component::Layout::Vertical.new(spacing: 1)
@@ -1204,34 +1557,81 @@ module SamplerExample
1204
1557
  # A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
1205
1558
  # the tint flows down to every descendant without its own background — the
1206
1559
  # label and the list — while the input widgets (the combo, the field) keep
1207
- # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
1208
- # the hard-coded Colors are fixed by design, so no on_theme_changed here.
1560
+ # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook —
1561
+ # the derived terminal tint too, re-derived when the background changes —
1562
+ # and the hard-coded Colors are fixed by design, so no handle_theme_changed.
1209
1563
  outer = nil
1210
- derived = terminal_tint_choice
1211
- combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
1564
+ combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
1212
1565
  combo.item_label = :label.to_proc
1213
- combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
1566
+ combo.on_value_change { |e| outer.bg_color = e.value.color }
1214
1567
 
1215
1568
  outer = form do |f|
1216
1569
  f.add(intro, Fixed[3])
1217
1570
  f.add(combo, Fixed[1], cross: Fixed[40])
1218
1571
  f.add(box, Expand[1])
1219
1572
  end
1220
- # The derived tint is the one pick whose *color* moves under it: a flip
1221
- # re-probes the terminal, so rebuild the choice and re-apply it if it is
1222
- # the current one. Expect it to correct itself a frame late — the flip
1223
- # report carries no RGB, so this hook runs once on the old background and
1224
- # again when the re-probe answers.
1225
- outer.on_theme_changed = lambda do
1226
- was_derived = combo.value.equal?(derived)
1227
- derived = terminal_tint_choice
1228
- combo.items = bg_choices(derived)
1229
- combo.value = derived if was_derived
1230
- end
1231
1573
  combo.value = BG_CHOICES.first # show "None" as the resting selection
1232
1574
  outer
1233
1575
  end
1234
1576
 
1577
+ # FormLayout: hand it fields and captions, and it stacks the FormItems it
1578
+ # builds. No binder here — the two name fields validate themselves from
1579
+ # their own `on_value_change`, and `error_message=` is what puts the text
1580
+ # in the item's message row. That row is also the gap, so a field going
1581
+ # invalid while you type into the one below it moves nothing.
1582
+ def build_form_layout
1583
+ prompt = Tuile::Component::Label.new
1584
+ prompt.text = "Tab into a name field, type a letter, then erase it: the message row under it\n" \
1585
+ "fills and the well goes red, and nothing below moves. The ∙ beside a caption\n" \
1586
+ "is the required marker — chrome, not a rule; the listener is the rule."
1587
+ fields = Tuile::Component::FormLayout.new
1588
+ first = Tuile::Component::TextField.new
1589
+ surname = Tuile::Component::TextField.new
1590
+ [first, surname].each do |field|
1591
+ field.on_value_change { |e| e.source.error_message = e.value.empty? ? "Must not be blank" : nil }
1592
+ end
1593
+ fields.add(first, caption: "First name", required: true)
1594
+ fields.add(surname, caption: "Surname", required: true)
1595
+ fields.add(Tuile::Component::DateField.new, caption: "Date of birth")
1596
+ form do |f|
1597
+ f.add(prompt, Fixed[3])
1598
+ f.add(fields, Expand[1], cross: Fixed[FORM_WIDTH])
1599
+ end
1600
+ end
1601
+
1602
+ # Wide enough for the longest message row below a field.
1603
+ FORM_WIDTH = 30
1604
+
1605
+ # A form taller than its viewport. Every field starts empty, hence
1606
+ # invalid, so each message row is filled and visibly scrolls in with it.
1607
+ def build_scroller
1608
+ prompt = Tuile::Component::Label.new
1609
+ prompt.text = "Tab down the form: the view follows focus, caption and message included, and\n" \
1610
+ "the wheel scrolls it too. The Scroller claims no keys — ↑↓ stay the fields'."
1611
+ fields = Tuile::Component::FormLayout.new
1612
+ ["First name", "Surname", "Email", "Phone", "Street", "City", "Postcode"].each do |caption|
1613
+ fields.add(non_empty(Tuile::Component::TextField.new), caption:, required: true)
1614
+ end
1615
+ fields.add(non_empty(Tuile::Component::DateField.new), caption: "Date of birth", required: true)
1616
+ # Nothing measures (`D_scroller`): eight items, three rows each.
1617
+ scroller = Tuile::Component::Scroller.new(fields, content_rows: 8 * 3)
1618
+ form do |f|
1619
+ f.add(prompt, Fixed[2])
1620
+ f.add(scroller, Fixed[10], cross: Fixed[FORM_WIDTH + 2])
1621
+ end
1622
+ end
1623
+
1624
+ # Validates `field` as non-empty now and on every change, setting *or
1625
+ # clearing* the verdict each time, as {Tuile::Component::HasValidation} asks.
1626
+ # @param field [Tuile::Component]
1627
+ # @return [Tuile::Component] `field`.
1628
+ def non_empty(field)
1629
+ validate = -> { field.error_message = field.empty? ? "Can not be empty" : nil }
1630
+ field.on_value_change(&validate)
1631
+ validate.call
1632
+ field
1633
+ end
1634
+
1235
1635
  # Horizontal splitting a row between two equal Expand shares. Resize the
1236
1636
  # terminal to watch it recompute: on an odd width the spare column goes to
1237
1637
  # the left pane, since the remainder is handed to the earliest Expand first.
@@ -1279,7 +1679,7 @@ module SamplerExample
1279
1679
  "so they bubble past the strip."
1280
1680
 
1281
1681
  field = Tuile::Component::TextField.new
1282
- field.text = "type here"
1682
+ field.value = "type here"
1283
1683
  checkbox = Tuile::Component::Checkbox.new("Remember me", value: true)
1284
1684
  list = Tuile::Component::List.new
1285
1685
  list.cursor = Tuile::Component::List::Cursor.new
@@ -1301,7 +1701,7 @@ module SamplerExample
1301
1701
  "List row #{list.cursor.position} · Prose row #{view.scroll_top_row}"
1302
1702
  end
1303
1703
  report.call
1304
- sheet.on_tab_selected = ->(_index, _tab) { report.call }
1704
+ sheet.on_tab_selected { report.call }
1305
1705
 
1306
1706
  form do |f|
1307
1707
  f.add(prompt, Fixed[3])
@@ -1376,7 +1776,7 @@ module SamplerExample
1376
1776
  "(#{narrow.selected_index + 1} of #{narrow.tabs.size})"
1377
1777
  end
1378
1778
  report.call
1379
- narrow.on_tab_selected = ->(_index, _tab) { report.call }
1779
+ narrow.on_tab_selected { report.call }
1380
1780
 
1381
1781
  bar = Tuile::Component::MenuBar.new
1382
1782
  %w[File Edit View Window Help].each do |caption|
@@ -1467,7 +1867,7 @@ module SamplerExample
1467
1867
  dialog.button("Save") { report.call("saved") }
1468
1868
  dialog.button("Discard") { report.call("discarded") }
1469
1869
  dialog.button("Cancel")
1470
- dialog.on_dismiss = -> { report.call("stayed put") }
1870
+ dialog.on_dismiss { report.call("stayed put") }
1471
1871
  dialog.open
1472
1872
  end,
1473
1873
  Tuile::Component::Button.new("Long") do
@@ -1475,7 +1875,7 @@ module SamplerExample
1475
1875
  dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
1476
1876
  dialog.button("Accept") { report.call("accepted the terms") }
1477
1877
  dialog.button("Decline") { report.call("declined the terms") }
1478
- dialog.on_dismiss = -> { report.call("left the terms unanswered") }
1878
+ dialog.on_dismiss { report.call("left the terms unanswered") }
1479
1879
  dialog.open
1480
1880
  end
1481
1881
  ]
@@ -1543,9 +1943,14 @@ module SamplerExample
1543
1943
  "PickerWindow asks the user to pick one option by a single keystroke.",
1544
1944
  "Open PickerWindow"
1545
1945
  ) do
1946
+ # Captions paint in the terminal's own foreground — the picker
1947
+ # recommends no color of its own. Styling one is the app's call, and
1948
+ # per option: a caption may be a String, an ANSI-coded String (what
1949
+ # `theme.fg` hands back) or a StyledString.
1546
1950
  Tuile::Component::PickerWindow.open(
1547
1951
  "Pick a fruit",
1548
- [%w[a Apple], %w[b Banana], %w[c Cherry]]
1952
+ [%w[a Apple], %w[b Banana],
1953
+ ["c", "Cherry #{screen.theme.fg(:hint, "(in season)")}"]]
1549
1954
  ) { |key| Tuile.logger.info("Picked: #{key}") }
1550
1955
  end
1551
1956
  end
@@ -1578,14 +1983,33 @@ module SamplerExample
1578
1983
  end
1579
1984
  end
1580
1985
 
1581
- # --- Helpers -----------------------------------------------------------
1582
-
1583
- def panel(*children, &layout_block)
1584
- p = Panel.new(&layout_block)
1585
- p.add(children)
1586
- p
1986
+ def build_mouse_demo
1987
+ intro = Tuile::Component::Label.new
1988
+ intro.text = "Drag on the canvas to draw X; right-drag erases; just hovering leaves a dim\n" \
1989
+ "trail. Arrows move the caret, space or x strokes it, Delete lifts it, c clears.\n" \
1990
+ "Discrete events go to the log; the row below is the live pointer."
1991
+ canvas = Canvas.new
1992
+ pointer = Tuile::Component::Label.new
1993
+ pointer.text = "pointer: (move over the canvas)"
1994
+ log = Tuile::Component::LogWindow.new("Events")
1995
+ canvas.on_report { |e| log.log(e.line) }
1996
+ canvas.on_move do |e|
1997
+ kind = e.mouse.is_a?(Tuile::Mouse::DragEvent) ? "drag" : "move"
1998
+ pointer.text = "pointer: #{e.mouse.x},#{e.mouse.y} (#{kind}, #{e.moves} reported so far)"
1999
+ end
2000
+ surface = row do |r|
2001
+ r.add(Tuile::Component::Window.new("Canvas").tap { _1.content = canvas }, Percent[55])
2002
+ r.add(log, Expand[1])
2003
+ end
2004
+ form do |f|
2005
+ f.add(intro, Fixed[3])
2006
+ f.add(surface, Expand[1])
2007
+ f.add(pointer, Fixed[1])
2008
+ end
1587
2009
  end
1588
2010
 
2011
+ # --- Helpers -----------------------------------------------------------
2012
+
1589
2013
  # The standard demo shell: children stacked with a blank row between them,
1590
2014
  # inset from the window border. Every constraint below reads unqualified —
1591
2015
  # `Fixed`, `Expand`, `Insets` all live on {Tuile::Component::Layout}, which
@@ -1649,19 +2073,19 @@ module SamplerExample
1649
2073
  end
1650
2074
 
1651
2075
  # Replaces the slash token at the caret with `command` plus a trailing
1652
- # space, then drops the caret after it (which re-fires on_change → refill,
2076
+ # space, then drops the caret after it (which re-fires on_value_change → refill,
1653
2077
  # so the now-tokenless text closes the menu).
1654
2078
  def accept_slash_command(area, command)
1655
2079
  text = area.text
1656
2080
  caret = area.caret
1657
2081
  start = caret
1658
2082
  start -= 1 while start.positive? && !text[start - 1].match?(/\s/)
1659
- area.text = "#{text[0...start]}#{command} #{text[caret..]}"
2083
+ area.value = "#{text[0...start]}#{command} #{text[caret..]}"
1660
2084
  area.caret = start + command.length + 1
1661
2085
  end
1662
2086
 
1663
2087
  # The slash menu's width: the widest command plus List's two row gutters,
1664
- # clamped to the screen. ListDropdown places itself but never measures — the
2088
+ # clamped to the screen. ListDropdown hangs off its anchor but never measures — the
1665
2089
  # width policy stays with the driver, exactly as it does for Select.
1666
2090
  # @return [Integer]
1667
2091
  def slash_menu_width(matches)
@@ -1678,13 +2102,19 @@ end
1678
2102
  # component tree without spinning up the real event loop.
1679
2103
  if $PROGRAM_NAME == __FILE__
1680
2104
  screen = Tuile::Screen.new
2105
+ screen.theme_def = SamplerExample::APP_THEME
1681
2106
  sampler = SamplerExample::Sampler.new
1682
2107
  screen.content = sampler
1683
- screen.on_focus_changed = -> { sampler.refresh_status }
2108
+ screen.on_focus_changed { sampler.refresh_status }
1684
2109
  sampler.refresh_status
1685
2110
  sampler.menu_bar.focus
1686
2111
  begin
1687
- screen.run_event_loop
2112
+ # `:hover` for the whole app, because the level is set once here and is
2113
+ # all-or-nothing: the Mouse demo's trail and enter/exit lines arrive at no
2114
+ # lower one. The upgrade from the default `:clicks` costs the ~84 motion
2115
+ # reports a second (`R_mouse_reporting`) — not select-to-copy, which mode
2116
+ # 1000 had already taken.
2117
+ screen.run_event_loop(capture_mouse: :hover)
1688
2118
  ensure
1689
2119
  screen.close
1690
2120
  end