tuile 0.16.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  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 +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  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 +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/examples/sampler.rb CHANGED
@@ -20,35 +20,33 @@ require "rainbow"
20
20
  require "tuile"
21
21
 
22
22
  module SamplerExample
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
+
23
27
  # `hint` is the app's token, not Tuile's: the framework carries accents for
24
28
  # the chrome *it* paints, and the status row below is the sampler's own
25
29
  # (`D_status_bar` — Tuile draws none). Paired in a ThemeDef so it survives an
26
30
  # OS appearance flip, where a bare `theme=` would be replaced. Both greys
27
31
  # quantize to :bright_black on a 16-color terminal, so the description stays
28
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.
29
39
  APP_THEME = Tuile::ThemeDef.new(
30
- dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
31
- light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
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
+ })
32
48
  )
33
49
 
34
- # Sampler-local container: a {Tuile::Component::Layout::Absolute} that runs a
35
- # caller-supplied block on `rect=` to position its children. Most demos are
36
- # plain stacks and use the box layouts instead; this is what's left for the
37
- # two that aren't — a sidebar whose width is `min(16, width / 3)`, which is a
38
- # cap on a proportion and so outside {Tuile::Component::Layout::Box}'s
39
- # Fixed/Percent/Expand vocabulary by design.
40
- class Panel < Tuile::Component::Layout::Absolute
41
- def initialize(&layout_block)
42
- super()
43
- @layout_block = layout_block
44
- end
45
-
46
- def rect=(new_rect)
47
- super
48
- @layout_block&.call(rect) unless rect.empty?
49
- end
50
- end
51
-
52
50
  # A {Tuile::Component::Layout::Vertical} that runs {#on_tick} on every frame
53
51
  # while it is on screen. The ticker is started on attach and cancelled on
54
52
  # detach, so selecting another demo — which detaches this pane — cannot leave
@@ -60,11 +58,16 @@ module SamplerExample
60
58
  @fps = fps
61
59
  end
62
60
 
63
- # @return [Proc, nil] called with no arguments on each frame.
64
- 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
65
68
 
66
69
  def handle_attached
67
- @ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
70
+ @ticker = screen.event_queue.tick_fps(@fps) { on_tick.fire(TickEvent.new(source: self)) }
68
71
  end
69
72
 
70
73
  def handle_detached
@@ -84,13 +87,18 @@ module SamplerExample
84
87
  @shortcut = shortcut
85
88
  end
86
89
 
87
- # @return [Proc, nil] called with no arguments when the shortcut arrives.
88
- attr_writer :on_shortcut
90
+ # What `on_shortcut` fires.
91
+ ShortcutEvent = Data.define(:source) { include Tuile::Event }
92
+
93
+ # @!method on_shortcut
94
+ # Fired when the claimed key arrives.
95
+ # @return [Tuile::Listeners]
96
+ listener :on_shortcut
89
97
 
90
98
  def handle_key?(key)
91
99
  return false unless key == @shortcut
92
100
 
93
- @on_shortcut&.call
101
+ on_shortcut.fire(ShortcutEvent.new(source: self))
94
102
  true
95
103
  end
96
104
  end
@@ -99,16 +107,28 @@ module SamplerExample
99
107
  # the chat-prompt shape, and the one that made a multi-line paste fire the
100
108
  # submit once per pasted line before Tuile drove bracketed paste. It handles
101
109
  # no paste of its own: pasted text never arrives as ENTER, so the inherited
102
- # insert-at-caret is already the wanted behavior, and `on_paste` here only
103
- # 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.
104
112
  class PromptTextArea < Tuile::Component::TextArea
105
- # @return [Proc, nil] called with the submitted text; the area then clears.
106
- attr_accessor :on_submit
107
- # @return [Proc, nil] called with the pasted text, before it is inserted.
108
- 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
109
129
 
110
130
  def handle_paste(text)
111
- @on_paste&.call(text)
131
+ on_paste_received.fire(PasteEvent.new(source: self, text: text))
112
132
  super
113
133
  end
114
134
 
@@ -117,8 +137,8 @@ module SamplerExample
117
137
  def handle_text_input_key?(key)
118
138
  return super unless key == Tuile::Keys::ENTER
119
139
 
120
- @on_submit&.call(text)
121
- self.text = ""
140
+ on_submit.fire(SubmitEvent.new(source: self, text: text))
141
+ self.value = ""
122
142
  true
123
143
  end
124
144
  end
@@ -170,10 +190,10 @@ module SamplerExample
170
190
  # unviewable with the pointer over it.
171
191
  #
172
192
  # canvas = Canvas.new
173
- # canvas.on_event = ->(line) { log.log(line) }
174
- # canvas.on_move = ->(event, count) { label.text = "#{event.x},#{event.y} (#{count})" }
193
+ # canvas.on_report { |e| log.log(e.line) }
194
+ # canvas.on_move { |e| label.text = "#{e.mouse.x},#{e.mouse.y} (#{e.moves})" }
175
195
  #
176
- # Two slots, because the traffic is two: {#on_event} carries the discrete
196
+ # Two slots, because the traffic is two: {#on_report} carries the discrete
177
197
  # events to a log, {#on_move} the ~84-a-second moves to one replaced row.
178
198
  # Feeding both to a log would drown enter/exit inside 12 ms.
179
199
  #
@@ -187,7 +207,7 @@ module SamplerExample
187
207
  # two-column trail cell would push every painted row past `rect.width`
188
208
  # (`D_ambiguous_width`).
189
209
  #
190
- # It paints every cell of its rect itself, one {Tuile::Component#draw_text}
210
+ # It paints every cell of its rect itself, one {Tuile::Canvas#set_text}
191
211
  # per run of like cells, and so skips `super` in {#repaint} — whose
192
212
  # auto-clear blanks the whole rect, which would re-emit every cell this widget
193
213
  # is about to paint over anyway. Repainting whole on every move still costs
@@ -206,12 +226,23 @@ module SamplerExample
206
226
  # @return [Hash{Symbol => String, nil}]
207
227
  DRAG_INK = { left: STROKE, right: nil }.freeze
208
228
 
209
- # @return [Proc, nil] called with one line (String) per discrete event.
210
- attr_accessor :on_event
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
211
239
 
212
- # @return [Proc, nil] called with the {Tuile::Mouse::Event} and the number
213
- # of moves so far (Integer), on every move and every drag.
214
- attr_accessor :on_move
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
215
246
 
216
247
  # @return [Tuile::Point] the keyboard caret, in rect-local coordinates.
217
248
  attr_reader :caret
@@ -228,40 +259,40 @@ module SamplerExample
228
259
  @moves = 0
229
260
  @drag_ink = nil
230
261
  @drag_outside = false
262
+ bg.default_color = Tuile::ComponentBackground::INPUT_WELL
231
263
  end
232
264
 
233
265
  def focusable? = true
234
266
 
235
267
  def tab_stop? = true
236
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.
237
271
  # @return [Tuile::Point, nil]
238
272
  def cursor_position
239
273
  return nil if rect.empty?
240
274
 
241
- Tuile::Point.new(rect.left + @caret.x, rect.top + @caret.y)
275
+ @caret
242
276
  end
243
277
 
244
- # @return [Tuile::Color]
245
- def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
246
-
247
278
  # Clamps the caret into the new rect, so a shrink cannot strand it — and
248
279
  # with it the hardware cursor — outside what this widget paints.
249
- # @param new_rect [Tuile::Rect]
250
- def rect=(new_rect)
280
+ # @param old_rect [Tuile::Rect]
281
+ def handle_rect_changed(old_rect)
251
282
  super
252
- @caret = Tuile::Point.new(@caret.x.clamp(0, [new_rect.width - 1, 0].max),
253
- @caret.y.clamp(0, [new_rect.height - 1, 0].max))
283
+ @caret = Tuile::Point.new(@caret.x.clamp(0, [rect.width - 1, 0].max),
284
+ @caret.y.clamp(0, [rect.height - 1, 0].max))
254
285
  end
255
286
 
256
287
  # @return [void]
257
- def repaint
288
+ def repaint(canvas)
258
289
  return if rect.empty?
259
290
 
260
291
  # The clear is what a self-painter opts out of; the cascade never is
261
292
  # (`D_repaint_cascade`), leaf or not.
262
293
  invalidate_children
263
294
  trail_color = screen.theme[:hint]
264
- rect.height.times { |row| draw_row(row, trail_color) }
295
+ rect.height.times { |row| draw_row(canvas, row, trail_color) }
265
296
  end
266
297
 
267
298
  # @param event [Tuile::Mouse::DownEvent]
@@ -349,11 +380,13 @@ module SamplerExample
349
380
  private
350
381
 
351
382
  # One row, as runs of like cells: every cell is painted exactly once, so
352
- # none is blanked and then painted over (`D_progress_bar`).
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.
353
386
  # @param row [Integer] rect-local row.
354
387
  # @param trail_color [Tuile::Color]
355
388
  # @return [void]
356
- def draw_row(row, trail_color)
389
+ def draw_row(canvas, row, trail_color)
357
390
  column = 0
358
391
  while column < rect.width
359
392
  glyph = @ink[Tuile::Point.new(column, row)]
@@ -361,21 +394,21 @@ module SamplerExample
361
394
  run += 1 while column + run < rect.width && @ink[Tuile::Point.new(column + run, row)] == glyph
362
395
  text = (glyph || " ") * run
363
396
  styled = glyph == TRAIL ? Tuile::StyledString.styled(text, fg: trail_color) : Tuile::StyledString.plain(text)
364
- draw_text(rect.left + column, rect.top + row, styled)
397
+ canvas.set_text(column, row, styled)
365
398
  column += run
366
399
  end
367
400
  end
368
401
 
369
- # The event's cell in rect-local coordinates, or nil when it lands outside
370
- # — which a grabbed {Tuile::Mouse::DragEvent} routinely does.
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.
371
406
  # @param event [Tuile::Mouse::Event]
372
407
  # @return [Tuile::Point, nil]
373
408
  def cell_at(event)
374
- column = event.x - rect.left
375
- row = event.y - rect.top
376
- return nil unless (0...rect.width).cover?(column) && (0...rect.height).cover?(row)
409
+ return nil unless (0...rect.width).cover?(event.x) && (0...rect.height).cover?(event.y)
377
410
 
378
- Tuile::Point.new(column, row)
411
+ Tuile::Point.new(event.x, event.y)
379
412
  end
380
413
 
381
414
  # Lays the dragged ink at `cell` and takes the caret with it, so a stroke
@@ -429,13 +462,13 @@ module SamplerExample
429
462
 
430
463
  # @param line [String]
431
464
  # @return [void]
432
- def report(line) = @on_event&.call(line)
465
+ def report(line) = on_report.fire(ReportEvent.new(source: self, line: line))
433
466
 
434
467
  # @param event [Tuile::Mouse::Event]
435
468
  # @return [void]
436
469
  def report_move(event)
437
470
  @moves += 1
438
- @on_move&.call(event, @moves)
471
+ on_move.fire(MoveEvent.new(source: self, mouse: event, moves: @moves))
439
472
  end
440
473
  end
441
474
 
@@ -465,7 +498,7 @@ module SamplerExample
465
498
 
466
499
  # The bottom row. Tuile draws no status bar and reserves no row
467
500
  # (`D_status_bar`) — this one is the sampler's own, kept current by
468
- # {Tuile::Screen#on_focus_changed=}. Naming the focused component makes Tab
501
+ # {Tuile::Screen#on_focus_changed}. Naming the focused component makes Tab
469
502
  # traversal visible as you walk a pane, which no per-pane label shows.
470
503
  # @return [void]
471
504
  def refresh_status
@@ -508,9 +541,10 @@ module SamplerExample
508
541
  # as a live index of the catalogue and any drift between the two is visible.
509
542
  #
510
543
  # Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
511
- # a duplicate among siblings, and six leaves therefore answer to a letter
544
+ # a duplicate among siblings, and seven leaves therefore answer to a letter
512
545
  # other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
513
- # Pic`k`erWindow, S`l`ash menu, DateTi`m`eField) — the underline shows which.
546
+ # Pic`k`erWindow, S`l`ash menu, DateTi`m`eField, F`o`rmLayout) — the
547
+ # underline shows which.
514
548
  # No item may use
515
549
  # `q`: quit is the unhandled-key fallback, so a `q` on the live level would
516
550
  # swallow it while the bar has focus.
@@ -559,6 +593,8 @@ module SamplerExample
559
593
  Entry.new("LogWindow", :build_log_window, "l")
560
594
  ]),
561
595
  Menu.new("Shell", "h", [
596
+ Entry.new("FormLayout", :build_form_layout, "o"),
597
+ Entry.new("Scroller", :build_scroller, "s"),
562
598
  Entry.new("TabSheet", :build_tab_sheet, "t"),
563
599
  Entry.new("MenuBar", :build_menu_bar, "m"),
564
600
  Entry.new("Narrow strips", :build_narrow_strips, "n"),
@@ -613,7 +649,7 @@ module SamplerExample
613
649
  def build_jump_box
614
650
  combo = Tuile::Component::ComboBox.new(items: ENTRIES)
615
651
  combo.item_label = :caption.to_proc
616
- combo.on_value_change = ->(entry) { load_entry(entry) if entry }
652
+ combo.on_value_change { |e| load_entry(e.value) if e.value }
617
653
  combo
618
654
  end
619
655
 
@@ -668,7 +704,7 @@ module SamplerExample
668
704
  "Home/End jump to row start/end; Up/Down at the first/last row jumps to text start/end.\n" \
669
705
  "Overflowing rows scroll vertically to keep the caret visible."
670
706
  area = Tuile::Component::TextArea.new
671
- area.text = "The quick brown fox jumps over the lazy dog. " \
707
+ area.value = "The quick brown fox jumps over the lazy dog. " \
672
708
  "Edit me — the text wraps to the area's width and scrolls vertically " \
673
709
  "once the cursor leaves the visible rows."
674
710
  form do |f|
@@ -687,7 +723,7 @@ module SamplerExample
687
723
  items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
688
724
  combo = Tuile::Component::ComboBox.new(items: items)
689
725
  status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
690
- combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
726
+ combo.on_value_change { |e| status.text = "Selected: #{e.value}" }
691
727
  form do |f|
692
728
  f.add(prompt, Fixed[3])
693
729
  # A cross constraint clamps to the pane, so this is 30 columns or fewer.
@@ -728,14 +764,14 @@ module SamplerExample
728
764
  status.text = "level: #{level.value.inspect} endings: #{endings.value&.label.inspect}"
729
765
  end
730
766
  update.call
731
- [level, endings].each { _1.on_value_change = ->(_v) { update.call } }
767
+ [level, endings].each { _1.on_value_change { update.call } }
732
768
 
733
769
  pane = ShortcutBox.new("r", spacing: 1, padding: FORM_PADDING)
734
770
  pane.add(prompt, Fixed[3])
735
771
  pane.add(labelled("Log level", level), Fixed[1])
736
772
  pane.add(labelled("Line endings", endings), Fixed[1])
737
773
  pane.add(status, Fixed[1])
738
- pane.on_shortcut = lambda do
774
+ pane.on_shortcut do
739
775
  level.value = "warn"
740
776
  endings.value = nil
741
777
  update.call
@@ -755,7 +791,7 @@ module SamplerExample
755
791
  # Set on the composed field, not on the TextField inside it.
756
792
  field.placeholder = "1-65535"
757
793
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
758
- field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
794
+ field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
759
795
  form do |f|
760
796
  f.add(prompt, Fixed[2])
761
797
  f.add(field, Fixed[1], cross: Fixed[20])
@@ -773,7 +809,7 @@ module SamplerExample
773
809
  "Up/Down step the value by one. Watch the value while you type '1.5'."
774
810
  field = Tuile::Component::FloatField.new
775
811
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
776
- field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
812
+ field.on_value_change { |e| status.text = "e.value: #{e.value.inspect}" }
777
813
  form do |f|
778
814
  f.add(prompt, Fixed[2])
779
815
  f.add(field, Fixed[1], cross: Fixed[20])
@@ -792,7 +828,7 @@ module SamplerExample
792
828
  "what you typed (19.90 keeps its zero)."
793
829
  field = Tuile::Component::BigDecimalField.new
794
830
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
795
- field.on_value_change = ->(value) { status.text = triple_report(value) }
831
+ field.on_value_change { |e| status.text = triple_report(e.value) }
796
832
  form do |f|
797
833
  f.add(prompt, Fixed[3])
798
834
  f.add(field, Fixed[1], cross: Fixed[20])
@@ -822,7 +858,7 @@ module SamplerExample
822
858
  # calendar reform, which is noise next to the one fact this row is for.
823
859
  report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
824
860
  report.call
825
- field.on_value_change = ->(_value) { report.call }
861
+ field.on_value_change { report.call }
826
862
  ask = Tuile::Component::Button.new("Ask again") { report.call }
827
863
  form do |f|
828
864
  f.add(prompt, Fixed[4])
@@ -855,7 +891,7 @@ module SamplerExample
855
891
  "seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
856
892
  end
857
893
  report.call
858
- [minutes, seconds].each { _1.on_value_change = ->(_value) { report.call } }
894
+ [minutes, seconds].each { _1.on_value_change { report.call } }
859
895
  form do |f|
860
896
  f.add(prompt, Fixed[4])
861
897
  f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
@@ -896,7 +932,7 @@ module SamplerExample
896
932
  # next to the one fact this row carries.
897
933
  report = -> { status.text = "value: #{field.value&.strftime("%Y-%m-%d %H:%M") || "nil"}" }
898
934
  report.call
899
- field.on_value_change = ->(_value) { report.call }
935
+ field.on_value_change { report.call }
900
936
  save = Tuile::Component::Button.new("Save") { save_form("Starts at" => field) }
901
937
  form do |f|
902
938
  f.add(prompt, Fixed[4])
@@ -920,8 +956,8 @@ module SamplerExample
920
956
  amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
921
957
  rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
922
958
  echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
923
- amount.on_value_change = ->(v) { echo.text = "on_value_change: amount = #{v.inspect}" }
924
- 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}" }
925
961
  save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
926
962
  save.id = :save
927
963
  rows = group do |g|
@@ -967,7 +1003,7 @@ module SamplerExample
967
1003
  # the field's listener keeps current.
968
1004
  def validated_row(caption, field)
969
1005
  error = Tuile::Component::Label.new
970
- 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 }
971
1007
  row do |r|
972
1008
  r.add(Tuile::Component::Label.new(caption), Fixed[14])
973
1009
  r.add(field, Fixed[22])
@@ -1034,11 +1070,11 @@ module SamplerExample
1034
1070
  user = Tuile::Component::TextField.new
1035
1071
  password = Tuile::Component::PasswordField.new
1036
1072
  reveal = Tuile::Component::Checkbox.new("Show password")
1037
- reveal.on_value_change = ->(on) { password.revealed = on }
1073
+ reveal.on_value_change { |e| password.revealed = e.value }
1038
1074
  status = Tuile::Component::Label.new
1039
1075
  refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
1040
1076
  refresh.call
1041
- [user, password].each { _1.on_change = ->(_) { refresh.call } }
1077
+ [user, password].each { _1.on_value_change { refresh.call } }
1042
1078
  form do |f|
1043
1079
  f.add(prompt, Fixed[4])
1044
1080
  f.add([user, password], Fixed[1], cross: Fixed[30]) # one constraint, both fields
@@ -1053,7 +1089,7 @@ module SamplerExample
1053
1089
  # A ListDropdown driven from a TextArea — the same shape {ComboBox} and
1054
1090
  # {Select} use, but wired by app code onto a field that knows nothing about
1055
1091
  # it. Focus (and the caret) stays in the TextArea the whole time: an
1056
- # `on_change` listener refills the menu, and {SlashCommandTextArea} hands
1092
+ # `on_value_change` listener refills the menu, and {SlashCommandTextArea} hands
1057
1093
  # movement keys to `#move` and Enter to `#choose` while it is open.
1058
1094
  def build_slash_demo
1059
1095
  prompt = Tuile::Component::Label.new
@@ -1071,15 +1107,14 @@ module SamplerExample
1071
1107
  overlay.close if overlay.open?
1072
1108
  else
1073
1109
  overlay.items = matches
1074
- overlay.open unless overlay.open?
1075
1110
  # Width is the driver's call, never the dropdown's: measure the
1076
1111
  # commands rather than inherit the full-width TextArea's columns.
1077
- overlay.anchor_to(area.rect, rows: matches.size, width: slash_menu_width(matches))
1112
+ overlay.anchor_to(area, width: slash_menu_width(matches))
1078
1113
  end
1079
1114
  end
1080
1115
 
1081
- area.on_change = ->(_text) { refill.call }
1082
- 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) }
1083
1118
 
1084
1119
  form do |f|
1085
1120
  f.add(prompt, Fixed[4])
@@ -1105,14 +1140,14 @@ module SamplerExample
1105
1140
  refresh = lambda do
1106
1141
  stats.text = "submits: #{submits} pastes: #{pastes} rows in draft: #{area.row_count}"
1107
1142
  end
1108
- area.on_change = ->(_text) { refresh.call }
1109
- area.on_paste = lambda do |text|
1143
+ area.on_value_change { refresh.call }
1144
+ area.on_paste_received do |e|
1110
1145
  pastes += 1
1111
- 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)
1112
1147
  end
1113
- area.on_submit = lambda do |text|
1148
+ area.on_submit do |e|
1114
1149
  submits += 1
1115
- log.add_line(Rainbow("submitted: #{text.inspect}").green)
1150
+ log.add_line(Rainbow("submitted: #{e.text.inspect}").green)
1116
1151
  end
1117
1152
  refresh.call
1118
1153
 
@@ -1198,7 +1233,7 @@ module SamplerExample
1198
1233
  status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
1199
1234
  end
1200
1235
  refresh.call
1201
- boxes.each { _1.on_value_change = ->(_) { refresh.call } }
1236
+ boxes.each { _1.on_value_change { refresh.call } }
1202
1237
  # The boxes sit flush against each other while the form keeps a blank row
1203
1238
  # around the block: a spacing-0 group nested in the spacing-1 form, rather
1204
1239
  # than a per-child gap the framework deliberately doesn't offer.
@@ -1229,7 +1264,7 @@ module SamplerExample
1229
1264
  conditional.each { _1.visible = business.checked? }
1230
1265
  status.text = "visible fields: #{business.checked? ? 4 : 2}"
1231
1266
  end
1232
- business.on_value_change = ->(_) { apply.call }
1267
+ business.on_value_change { apply.call }
1233
1268
  # The rows sit flush; the form keeps its blank row around the block.
1234
1269
  rows = group do |g|
1235
1270
  g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
@@ -1312,16 +1347,13 @@ module SamplerExample
1312
1347
  status.text = "value: {#{shown.join(", ")}} — #{log.items.size} of #{entries.size} lines"
1313
1348
  end
1314
1349
  refresh.call
1315
- group.on_value_change = ->(_set) { refresh.call }
1350
+ group.on_value_change { refresh.call }
1316
1351
 
1317
- # The body keeps a rect-callback {Panel}: its sidebar is `min(16, width/3)`
1318
- # — a cap on a proportion, which Fixed/Percent/Expand can't say. The stack
1319
- # around it is a box, so only the part that needs arithmetic has any.
1320
- body = panel(group, log) do |r|
1321
- group_width = [16, r.width / 3].min
1322
- group.rect = Tuile::Rect.new(r.left, r.top, group_width, [LOG_LEVELS.size, r.height].min)
1323
- log.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
1324
- [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])
1325
1357
  end
1326
1358
  form do |f|
1327
1359
  f.add(prompt, Fixed[4])
@@ -1396,20 +1428,17 @@ module SamplerExample
1396
1428
  update_status.call
1397
1429
  end
1398
1430
  resort.call
1399
- group.on_value_change = ->(_order) { resort.call }
1431
+ group.on_value_change { resort.call }
1400
1432
  # `list` is the composed List, which is where the cursor lives.
1401
1433
  # Watching it is what makes the chrome/value split visible above.
1402
- group.list.on_cursor_changed = ->(_idx, _line) { update_status.call }
1403
-
1404
- # Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
1405
- # demo — the sidebar width is a capped proportion, not a constraint.
1406
- body = panel(group, files) do |r|
1407
- # List pads a column either side of a row, so a label needs
1408
- # `width - 2`; the file rows lose one more to their scrollbar.
1409
- group_width = [14, r.width / 3].min
1410
- group.rect = Tuile::Rect.new(r.left, r.top, group_width, [SORT_ORDERS.size, r.height].min)
1411
- files.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
1412
- [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])
1413
1442
  end
1414
1443
  form do |f|
1415
1444
  f.add(prompt, Fixed[4])
@@ -1476,7 +1505,7 @@ module SamplerExample
1476
1505
  pane.add(prompt, Fixed[4])
1477
1506
  pane.add(determinate, Fixed[2])
1478
1507
  pane.add(indeterminate, Fixed[3])
1479
- pane.on_tick = lambda do
1508
+ pane.on_tick do
1480
1509
  done = done < PROGRESS_TOTAL ? done + 1 : 0
1481
1510
  bar.value = done
1482
1511
  refresh.call
@@ -1494,6 +1523,7 @@ module SamplerExample
1494
1523
  # of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
1495
1524
  BG_CHOICES = [
1496
1525
  BgChoice.new("None (terminal default)", nil),
1526
+ BgChoice.new("Terminal background, stepped (derived)", Tuile::Theme.ref(:terminal_tint)),
1497
1527
  BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
1498
1528
  BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
1499
1529
  BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
@@ -1507,21 +1537,6 @@ module SamplerExample
1507
1537
  BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
1508
1538
  ].freeze
1509
1539
 
1510
- # The one choice that can't be a constant: the terminal's *own* background
1511
- # stepped +10 per channel — the borderless-pane tint, which only sits right
1512
- # when it's derived from the real background. Nil on a terminal that
1513
- # reported none, which is the branch most users will actually see.
1514
- def terminal_tint_choice
1515
- bg = Tuile::Screen.instance.background_color
1516
- return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
1517
-
1518
- BgChoice.new("Terminal background +10 (derived)",
1519
- Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
1520
- end
1521
-
1522
- # @param derived [BgChoice] the live terminal-derived tint, offered second.
1523
- def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
1524
-
1525
1540
  def build_background
1526
1541
  intro = Tuile::Component::Label.new
1527
1542
  intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
@@ -1532,7 +1547,7 @@ module SamplerExample
1532
1547
  list.cursor = Tuile::Component::List::Cursor.new
1533
1548
  list.lines = (1..12).map { |i| "List row #{i}" }
1534
1549
  field = Tuile::Component::TextField.new
1535
- field.text = "TextField keeps its own background"
1550
+ field.value = "TextField keeps its own background"
1536
1551
 
1537
1552
  # A borderless sub-box holding the list + field; it inherits the tint too.
1538
1553
  box = Tuile::Component::Layout::Vertical.new(spacing: 1)
@@ -1542,34 +1557,81 @@ module SamplerExample
1542
1557
  # A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
1543
1558
  # the tint flows down to every descendant without its own background — the
1544
1559
  # label and the list — while the input widgets (the combo, the field) keep
1545
- # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
1546
- # the hard-coded Colors are fixed by design, so no handle_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.
1547
1563
  outer = nil
1548
- derived = terminal_tint_choice
1549
- combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
1564
+ combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
1550
1565
  combo.item_label = :label.to_proc
1551
- combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
1566
+ combo.on_value_change { |e| outer.bg_color = e.value.color }
1552
1567
 
1553
1568
  outer = form do |f|
1554
1569
  f.add(intro, Fixed[3])
1555
1570
  f.add(combo, Fixed[1], cross: Fixed[40])
1556
1571
  f.add(box, Expand[1])
1557
1572
  end
1558
- # The derived tint is the one pick whose *color* moves under it: a flip
1559
- # re-probes the terminal, so rebuild the choice and re-apply it if it is
1560
- # the current one. Expect it to correct itself a frame late — the flip
1561
- # report carries no RGB, so this hook runs once on the old background and
1562
- # again when the re-probe answers.
1563
- outer.on_theme_changed = lambda do
1564
- was_derived = combo.value.equal?(derived)
1565
- derived = terminal_tint_choice
1566
- combo.items = bg_choices(derived)
1567
- combo.value = derived if was_derived
1568
- end
1569
1573
  combo.value = BG_CHOICES.first # show "None" as the resting selection
1570
1574
  outer
1571
1575
  end
1572
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
+
1573
1635
  # Horizontal splitting a row between two equal Expand shares. Resize the
1574
1636
  # terminal to watch it recompute: on an odd width the spare column goes to
1575
1637
  # the left pane, since the remainder is handed to the earliest Expand first.
@@ -1617,7 +1679,7 @@ module SamplerExample
1617
1679
  "so they bubble past the strip."
1618
1680
 
1619
1681
  field = Tuile::Component::TextField.new
1620
- field.text = "type here"
1682
+ field.value = "type here"
1621
1683
  checkbox = Tuile::Component::Checkbox.new("Remember me", value: true)
1622
1684
  list = Tuile::Component::List.new
1623
1685
  list.cursor = Tuile::Component::List::Cursor.new
@@ -1639,7 +1701,7 @@ module SamplerExample
1639
1701
  "List row #{list.cursor.position} · Prose row #{view.scroll_top_row}"
1640
1702
  end
1641
1703
  report.call
1642
- sheet.on_tab_selected = ->(_index, _tab) { report.call }
1704
+ sheet.on_tab_selected { report.call }
1643
1705
 
1644
1706
  form do |f|
1645
1707
  f.add(prompt, Fixed[3])
@@ -1714,7 +1776,7 @@ module SamplerExample
1714
1776
  "(#{narrow.selected_index + 1} of #{narrow.tabs.size})"
1715
1777
  end
1716
1778
  report.call
1717
- narrow.on_tab_selected = ->(_index, _tab) { report.call }
1779
+ narrow.on_tab_selected { report.call }
1718
1780
 
1719
1781
  bar = Tuile::Component::MenuBar.new
1720
1782
  %w[File Edit View Window Help].each do |caption|
@@ -1805,7 +1867,7 @@ module SamplerExample
1805
1867
  dialog.button("Save") { report.call("saved") }
1806
1868
  dialog.button("Discard") { report.call("discarded") }
1807
1869
  dialog.button("Cancel")
1808
- dialog.on_dismiss = -> { report.call("stayed put") }
1870
+ dialog.on_dismiss { report.call("stayed put") }
1809
1871
  dialog.open
1810
1872
  end,
1811
1873
  Tuile::Component::Button.new("Long") do
@@ -1813,7 +1875,7 @@ module SamplerExample
1813
1875
  dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
1814
1876
  dialog.button("Accept") { report.call("accepted the terms") }
1815
1877
  dialog.button("Decline") { report.call("declined the terms") }
1816
- dialog.on_dismiss = -> { report.call("left the terms unanswered") }
1878
+ dialog.on_dismiss { report.call("left the terms unanswered") }
1817
1879
  dialog.open
1818
1880
  end
1819
1881
  ]
@@ -1930,10 +1992,10 @@ module SamplerExample
1930
1992
  pointer = Tuile::Component::Label.new
1931
1993
  pointer.text = "pointer: (move over the canvas)"
1932
1994
  log = Tuile::Component::LogWindow.new("Events")
1933
- canvas.on_event = ->(line) { log.log(line) }
1934
- canvas.on_move = lambda do |event, count|
1935
- kind = event.is_a?(Tuile::Mouse::DragEvent) ? "drag" : "move"
1936
- pointer.text = "pointer: #{event.x},#{event.y} (#{kind}, #{count} reported so far)"
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)"
1937
1999
  end
1938
2000
  surface = row do |r|
1939
2001
  r.add(Tuile::Component::Window.new("Canvas").tap { _1.content = canvas }, Percent[55])
@@ -1948,12 +2010,6 @@ module SamplerExample
1948
2010
 
1949
2011
  # --- Helpers -----------------------------------------------------------
1950
2012
 
1951
- def panel(*children, &layout_block)
1952
- p = Panel.new(&layout_block)
1953
- p.add(children)
1954
- p
1955
- end
1956
-
1957
2013
  # The standard demo shell: children stacked with a blank row between them,
1958
2014
  # inset from the window border. Every constraint below reads unqualified —
1959
2015
  # `Fixed`, `Expand`, `Insets` all live on {Tuile::Component::Layout}, which
@@ -2017,19 +2073,19 @@ module SamplerExample
2017
2073
  end
2018
2074
 
2019
2075
  # Replaces the slash token at the caret with `command` plus a trailing
2020
- # 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,
2021
2077
  # so the now-tokenless text closes the menu).
2022
2078
  def accept_slash_command(area, command)
2023
2079
  text = area.text
2024
2080
  caret = area.caret
2025
2081
  start = caret
2026
2082
  start -= 1 while start.positive? && !text[start - 1].match?(/\s/)
2027
- area.text = "#{text[0...start]}#{command} #{text[caret..]}"
2083
+ area.value = "#{text[0...start]}#{command} #{text[caret..]}"
2028
2084
  area.caret = start + command.length + 1
2029
2085
  end
2030
2086
 
2031
2087
  # The slash menu's width: the widest command plus List's two row gutters,
2032
- # 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
2033
2089
  # width policy stays with the driver, exactly as it does for Select.
2034
2090
  # @return [Integer]
2035
2091
  def slash_menu_width(matches)
@@ -2049,7 +2105,7 @@ if $PROGRAM_NAME == __FILE__
2049
2105
  screen.theme_def = SamplerExample::APP_THEME
2050
2106
  sampler = SamplerExample::Sampler.new
2051
2107
  screen.content = sampler
2052
- screen.on_focus_changed = -> { sampler.refresh_status }
2108
+ screen.on_focus_changed { sampler.refresh_status }
2053
2109
  sampler.refresh_status
2054
2110
  sampler.menu_bar.focus
2055
2111
  begin