tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
data/examples/sampler.rb CHANGED
@@ -12,11 +12,25 @@
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
23
+ # `hint` is the app's token, not Tuile's: the framework carries accents for
24
+ # the chrome *it* paints, and the status row below is the sampler's own
25
+ # (`D_status_bar` — Tuile draws none). Paired in a ThemeDef so it survives an
26
+ # OS appearance flip, where a bare `theme=` would be replaced. Both greys
27
+ # quantize to :bright_black on a 16-color terminal, so the description stays
28
+ # dimmer than the key beside it even there.
29
+ 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 })
32
+ )
33
+
20
34
  # Sampler-local container: a {Tuile::Component::Layout::Absolute} that runs a
21
35
  # caller-supplied block on `rect=` to position its children. Most demos are
22
36
  # plain stacks and use the box layouts instead; this is what's left for the
@@ -49,18 +63,18 @@ module SamplerExample
49
63
  # @return [Proc, nil] called with no arguments on each frame.
50
64
  attr_writer :on_tick
51
65
 
52
- def on_attached
66
+ def handle_attached
53
67
  @ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
54
68
  end
55
69
 
56
- def on_detached
70
+ def handle_detached
57
71
  @ticker&.cancel
58
72
  @ticker = nil
59
73
  end
60
74
  end
61
75
 
62
76
  # 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
77
+ # ancestor's `handle_key?` is where a scope-wide binding belongs (key-dispatch
64
78
  # rung 3); the Select demo uses one to show the letter still arriving while a
65
79
  # Select has focus — the capability a ComboBox, which eats every printable
66
80
  # unconditionally, cannot offer.
@@ -73,7 +87,7 @@ module SamplerExample
73
87
  # @return [Proc, nil] called with no arguments when the shortcut arrives.
74
88
  attr_writer :on_shortcut
75
89
 
76
- def handle_key(key)
90
+ def handle_key?(key)
77
91
  return false unless key == @shortcut
78
92
 
79
93
  @on_shortcut&.call
@@ -100,7 +114,7 @@ module SamplerExample
100
114
 
101
115
  protected
102
116
 
103
- def handle_text_input_key(key)
117
+ def handle_text_input_key?(key)
104
118
  return super unless key == Tuile::Keys::ENTER
105
119
 
106
120
  @on_submit&.call(text)
@@ -109,6 +123,322 @@ module SamplerExample
109
123
  end
110
124
  end
111
125
 
126
+ # A {Tuile::Component::TextArea} that steers a {Tuile::Component::ListDropdown}
127
+ # while it is open: movement keys move the highlight, ENTER accepts and ESC
128
+ # dismisses, and everything else — printables, editing, the ENTER that inserts
129
+ # a newline with no menu up — stays the TextArea's own.
130
+ #
131
+ # Subclassing *is* the seam for this. A component receives keys through
132
+ # `handle_key?`, so one that wants different keys overrides it (here its
133
+ # `handle_text_input_key?` hook) and calls `super` for the rest, which composes
134
+ # and stacks. None of this is baked into TextArea.
135
+ class SlashCommandTextArea < Tuile::Component::TextArea
136
+ # @param overlay [Tuile::Component::ListDropdown] the menu to steer.
137
+ def initialize(overlay)
138
+ super()
139
+ @overlay = overlay
140
+ end
141
+
142
+ protected
143
+
144
+ def handle_text_input_key?(key)
145
+ return super unless @overlay.open?
146
+ return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
147
+
148
+ case key
149
+ when Tuile::Keys::ENTER then @overlay.choose
150
+ when Tuile::Keys::ESC then @overlay.close
151
+ else return super
152
+ end
153
+ true
154
+ end
155
+ end
156
+
157
+ # A drawing surface answering every mouse handler, in two inks that both
158
+ # persist: a left-drag strokes `X`, a plain hover leaves a `.` trail behind
159
+ # the pointer, and a right-drag lifts marks again.
160
+ #
161
+ # X a diagonal drag, then one hover sweep across it
162
+ # X
163
+ # X
164
+ # X
165
+ # .....X........
166
+ # X
167
+ #
168
+ # A trail never overwrites a stroke, so the picture stays a readout of which
169
+ # channel drew which cell — an eraser-on-hover would instead make the drawing
170
+ # unviewable with the pointer over it.
171
+ #
172
+ # canvas = Canvas.new
173
+ # canvas.on_event = ->(line) { log.log(line) }
174
+ # canvas.on_move = ->(event, count) { label.text = "#{event.x},#{event.y} (#{count})" }
175
+ #
176
+ # Two slots, because the traffic is two: {#on_event} carries the discrete
177
+ # events to a log, {#on_move} the ~84-a-second moves to one replaced row.
178
+ # Feeding both to a log would drown enter/exit inside 12 ms.
179
+ #
180
+ # Every gesture owes a key (`D_mouse`): the arrows move the caret, space or
181
+ # `x` strokes it, Delete lifts it, `c` clears. The trail, the enter/exit lines
182
+ # and {#on_move} need `capture_mouse: :hover` and arrive as nothing below it;
183
+ # the drag works from `:drag`, a single-cell stroke from `:clicks`.
184
+ #
185
+ # == Implementation details
186
+ # Both glyphs are ASCII on purpose: `·` is East-Asian Ambiguous, so a
187
+ # two-column trail cell would push every painted row past `rect.width`
188
+ # (`D_ambiguous_width`).
189
+ #
190
+ # It paints every cell of its rect itself, one {Tuile::Component#draw_text}
191
+ # per run of like cells, and so skips `super` in {#repaint} — whose
192
+ # auto-clear blanks the whole rect, which would re-emit every cell this widget
193
+ # is about to paint over anyway. Repainting whole on every move still costs
194
+ # one cell on the wire, since a cell dirties only on a real change.
195
+ #
196
+ # Marks are keyed by rect-local {Tuile::Point}: ink beyond a narrowed rect
197
+ # stops painting and comes back when the rect grows again.
198
+ class Canvas < Tuile::Component
199
+ # @return [String] the drag/keyboard ink.
200
+ STROKE = "X"
201
+ # @return [String] the hover ink.
202
+ TRAIL = "."
203
+
204
+ # Which ink each button drags — `nil` erases. The middle button is absent:
205
+ # a press this component declines bubbles on to the window around it.
206
+ # @return [Hash{Symbol => String, nil}]
207
+ DRAG_INK = { left: STROKE, right: nil }.freeze
208
+
209
+ # @return [Proc, nil] called with one line (String) per discrete event.
210
+ attr_accessor :on_event
211
+
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
215
+
216
+ # @return [Tuile::Point] the keyboard caret, in rect-local coordinates.
217
+ attr_reader :caret
218
+
219
+ # The marks, keyed by rect-local {Tuile::Point} — read-only in practice:
220
+ # writing one behind {#paint}'s back skips the `invalidate`.
221
+ # @return [Hash{Tuile::Point => String}]
222
+ attr_reader :ink
223
+
224
+ def initialize
225
+ super
226
+ @ink = {}
227
+ @caret = Tuile::Point.new(0, 0)
228
+ @moves = 0
229
+ @drag_ink = nil
230
+ @drag_outside = false
231
+ end
232
+
233
+ def focusable? = true
234
+
235
+ def tab_stop? = true
236
+
237
+ # @return [Tuile::Point, nil]
238
+ def cursor_position
239
+ return nil if rect.empty?
240
+
241
+ Tuile::Point.new(rect.left + @caret.x, rect.top + @caret.y)
242
+ end
243
+
244
+ # @return [Tuile::Color]
245
+ def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
246
+
247
+ # Clamps the caret into the new rect, so a shrink cannot strand it — and
248
+ # with it the hardware cursor — outside what this widget paints.
249
+ # @param new_rect [Tuile::Rect]
250
+ def rect=(new_rect)
251
+ 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))
254
+ end
255
+
256
+ # @return [void]
257
+ def repaint
258
+ return if rect.empty?
259
+
260
+ # The clear is what a self-painter opts out of; the cascade never is
261
+ # (`D_repaint_cascade`), leaf or not.
262
+ invalidate_children
263
+ trail_color = screen.theme[:hint]
264
+ rect.height.times { |row| draw_row(row, trail_color) }
265
+ end
266
+
267
+ # @param event [Tuile::Mouse::DownEvent]
268
+ # @return [Boolean]
269
+ def handle_mouse_down?(event)
270
+ unless DRAG_INK.key?(event.button)
271
+ report("down #{event.button} at #{event.x},#{event.y} — declined, bubbles to the window")
272
+ return false
273
+ end
274
+
275
+ report("down #{event.button} at #{event.x},#{event.y} — claimed, grab held")
276
+ @drag_ink = DRAG_INK.fetch(event.button)
277
+ @drag_outside = false
278
+ mark(cell_at(event))
279
+ true
280
+ end
281
+
282
+ # @param event [Tuile::Mouse::DragEvent]
283
+ # @return [void]
284
+ def handle_mouse_drag(event)
285
+ super
286
+ cell = cell_at(event)
287
+ if @drag_outside != cell.nil?
288
+ @drag_outside = cell.nil?
289
+ report(@drag_outside ? "drag left the canvas — the grab still delivers" : "drag back inside")
290
+ end
291
+ mark(cell)
292
+ report_move(event)
293
+ end
294
+
295
+ # @param event [Tuile::Mouse::UpEvent]
296
+ # @return [void]
297
+ def handle_mouse_up(event)
298
+ super
299
+ report("up at #{event.x},#{event.y} — grab released")
300
+ @drag_ink = nil
301
+ end
302
+
303
+ # @param event [Tuile::Mouse::MoveEvent]
304
+ # @return [Boolean]
305
+ def handle_mouse_move?(event)
306
+ cell = cell_at(event)
307
+ paint(cell, TRAIL) if cell && @ink[cell].nil?
308
+ report_move(event)
309
+ true
310
+ end
311
+
312
+ # @return [void]
313
+ def handle_mouse_enter
314
+ super
315
+ report("enter")
316
+ end
317
+
318
+ # @return [void]
319
+ def handle_mouse_exit
320
+ super
321
+ report("exit")
322
+ end
323
+
324
+ # Declines the notch — nothing here scrolls — which is what lets it bubble
325
+ # on to an ancestor that does.
326
+ # @param event [Tuile::Mouse::ScrollEvent]
327
+ # @return [Boolean] always false.
328
+ def handle_mouse_scroll?(event)
329
+ report("wheel #{event.direction} — declined, bubbles on")
330
+ false
331
+ end
332
+
333
+ # @param key [String]
334
+ # @return [Boolean]
335
+ def handle_key?(key)
336
+ case key
337
+ when Tuile::Keys::UP_ARROW then move_caret(0, -1)
338
+ when Tuile::Keys::DOWN_ARROW then move_caret(0, 1)
339
+ when Tuile::Keys::LEFT_ARROW then move_caret(-1, 0)
340
+ when Tuile::Keys::RIGHT_ARROW then move_caret(1, 0)
341
+ when " ", "x" then stroke_caret(STROKE)
342
+ when Tuile::Keys::DELETE, *Tuile::Keys::BACKSPACES then stroke_caret(nil)
343
+ when "c" then clear_marks
344
+ else return super
345
+ end
346
+ true
347
+ end
348
+
349
+ private
350
+
351
+ # 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`).
353
+ # @param row [Integer] rect-local row.
354
+ # @param trail_color [Tuile::Color]
355
+ # @return [void]
356
+ def draw_row(row, trail_color)
357
+ column = 0
358
+ while column < rect.width
359
+ glyph = @ink[Tuile::Point.new(column, row)]
360
+ run = 1
361
+ run += 1 while column + run < rect.width && @ink[Tuile::Point.new(column + run, row)] == glyph
362
+ text = (glyph || " ") * run
363
+ styled = glyph == TRAIL ? Tuile::StyledString.styled(text, fg: trail_color) : Tuile::StyledString.plain(text)
364
+ draw_text(rect.left + column, rect.top + row, styled)
365
+ column += run
366
+ end
367
+ end
368
+
369
+ # The event's cell in rect-local coordinates, or nil when it lands outside
370
+ # — which a grabbed {Tuile::Mouse::DragEvent} routinely does.
371
+ # @param event [Tuile::Mouse::Event]
372
+ # @return [Tuile::Point, nil]
373
+ 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)
377
+
378
+ Tuile::Point.new(column, row)
379
+ end
380
+
381
+ # Lays the dragged ink at `cell` and takes the caret with it, so a stroke
382
+ # can be continued from the keyboard. Outside the rect (`nil`) it is a
383
+ # no-op, which is a grabbed drag's normal case.
384
+ # @param cell [Tuile::Point, nil] rect-local.
385
+ # @return [void]
386
+ def mark(cell)
387
+ return if cell.nil?
388
+
389
+ paint(cell, @drag_ink)
390
+ @caret = cell
391
+ end
392
+
393
+ # @param cell [Tuile::Point] rect-local.
394
+ # @param ink [String, nil] nil lifts the mark.
395
+ # @return [void]
396
+ def paint(cell, ink)
397
+ return if @ink[cell] == ink
398
+
399
+ ink.nil? ? @ink.delete(cell) : @ink.store(cell, ink)
400
+ invalidate
401
+ end
402
+
403
+ # @param ink [String, nil]
404
+ # @return [void]
405
+ def stroke_caret(ink)
406
+ paint(@caret, ink)
407
+ report("#{ink || "lift"} at #{@caret.x},#{@caret.y} — from the keyboard")
408
+ end
409
+
410
+ # @param columns [Integer]
411
+ # @param rows [Integer]
412
+ # @return [void]
413
+ def move_caret(columns, rows)
414
+ return if rect.empty?
415
+
416
+ @caret = Tuile::Point.new((@caret.x + columns).clamp(0, rect.width - 1),
417
+ (@caret.y + rows).clamp(0, rect.height - 1))
418
+ invalidate # the hardware cursor is placed from #cursor_position at flush
419
+ end
420
+
421
+ # @return [void]
422
+ def clear_marks
423
+ return if @ink.empty?
424
+
425
+ @ink.clear
426
+ report("cleared")
427
+ invalidate
428
+ end
429
+
430
+ # @param line [String]
431
+ # @return [void]
432
+ def report(line) = @on_event&.call(line)
433
+
434
+ # @param event [Tuile::Mouse::Event]
435
+ # @return [void]
436
+ def report_move(event)
437
+ @moves += 1
438
+ @on_move&.call(event, @moves)
439
+ end
440
+ end
441
+
112
442
  # Top-level sampler component: a shell row across the top — a
113
443
  # {Tuile::Component::MenuBar} of the demos, grouped, and a
114
444
  # {Tuile::Component::ComboBox} jump box at its right end — over one demo
@@ -141,7 +471,8 @@ module SamplerExample
141
471
  def refresh_status
142
472
  focused = screen.focused
143
473
  name = focused ? focused.class.name.sub("Tuile::Component::", "") : "(none)"
144
- @status.text = "q #{screen.theme.hint("quit")} ⇥ #{screen.theme.hint(name)}"
474
+ t = screen.theme
475
+ @status.text = "q #{t.fg(:hint, "quit")} ⇥ #{t.fg(:hint, name)}"
145
476
  end
146
477
 
147
478
  # Chrome for a demo pane: a blank row top and bottom, two columns either
@@ -177,9 +508,10 @@ module SamplerExample
177
508
  # as a live index of the catalogue and any drift between the two is visible.
178
509
  #
179
510
  # Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
180
- # a duplicate among siblings, and five leaves therefore answer to a letter
511
+ # a duplicate among siblings, and six leaves therefore answer to a letter
181
512
  # other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
182
- # Pic`k`erWindow, S`l`ash menu) — the underline shows which. No item may use
513
+ # Pic`k`erWindow, S`l`ash menu, DateTi`m`eField) — the underline shows which.
514
+ # No item may use
183
515
  # `q`: quit is the unhandled-key fallback, so a `q` on the live level would
184
516
  # swallow it while the bar has focus.
185
517
  MENUS = [
@@ -199,7 +531,12 @@ module SamplerExample
199
531
  Menu.new("Typed", "y", [
200
532
  Entry.new("IntegerField", :build_integer_field, "i"),
201
533
  Entry.new("FloatField", :build_float_field, "f"),
202
- Entry.new("BigDecimalField", :build_big_decimal_field, "b")
534
+ Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
535
+ Entry.new("DateField", :build_date_field, "d"),
536
+ Entry.new("TimeField", :build_time_field, "t"),
537
+ Entry.new("DateTimeField", :build_date_time_field, "m"),
538
+ Entry.new("Bad input", :build_bad_input, "a"),
539
+ Entry.new("Validation", :build_validation, "v")
203
540
  ]),
204
541
  Menu.new("Choose", "c", [
205
542
  Entry.new("Checkbox", :build_checkboxes, "c"),
@@ -211,7 +548,7 @@ module SamplerExample
211
548
  ])
212
549
  ]),
213
550
  # One entry, so it is the item and not a menu — a top-level leaf on the
214
- # strip is a button, which nothing else here demos.
551
+ # strip is a button rather than a drop-down.
215
552
  Entry.new("Button", :build_buttons, "b"),
216
553
  Menu.new("Overlay", "o", [
217
554
  Entry.new("Popup", :build_popup_launcher, "p"),
@@ -227,8 +564,12 @@ module SamplerExample
227
564
  Entry.new("Narrow strips", :build_narrow_strips, "n"),
228
565
  Entry.new("Layout", :build_layout, "l"),
229
566
  Entry.new("Background", :build_background, "b"),
567
+ Entry.new("Visibility", :build_visibility, "v"),
230
568
  Entry.new("Focus & Tab", :build_focus_demo, "f")
231
- ])
569
+ ]),
570
+ # The one demo needing a tracking level above the default, which is why
571
+ # the runner asks for `capture_mouse: :hover` for the whole app.
572
+ Entry.new("Mouse", :build_mouse_demo, "m")
232
573
  ].freeze
233
574
 
234
575
  # Every {Entry} in strip order — what the jump box offers.
@@ -309,10 +650,13 @@ module SamplerExample
309
650
 
310
651
  def build_text_field
311
652
  prompt = Tuile::Component::Label.new
312
- prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
653
+ prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work.\n" \
654
+ "While it is empty it shows a placeholder — a hint in ink faint " \
655
+ "enough to miss, which is the point."
313
656
  field = Tuile::Component::TextField.new
657
+ field.placeholder = "dd.mm.yyyy"
314
658
  form do |f|
315
- f.add(prompt, Fixed[1])
659
+ f.add(prompt, Fixed[2])
316
660
  f.add(field, Fixed[1])
317
661
  end
318
662
  end
@@ -408,6 +752,8 @@ module SamplerExample
408
752
  prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
409
753
  "Up/Down step the value by one; an empty field counts as 0."
410
754
  field = Tuile::Component::IntegerField.new
755
+ # Set on the composed field, not on the TextField inside it.
756
+ field.placeholder = "1-65535"
411
757
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
412
758
  field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
413
759
  form do |f|
@@ -454,6 +800,218 @@ module SamplerExample
454
800
  end
455
801
  end
456
802
 
803
+ # DateField: several formats in, one format out. Type a date in any of the
804
+ # three spellings this pane accepts and Tab away — the field rewrites it in
805
+ # the *first* one, which is how the user sees that it understood them. Both
806
+ # halves of the settling rule are visible here too: nothing reddens while a
807
+ # date is being typed, and a buffer that never parsed reddens on the way
808
+ # out. The echo row is deliberately driven by `on_value_change` alone, so
809
+ # garbage leaves it stale: bad_input? is a *pull*, and the button is the "a
810
+ # form asks once, at the click" gesture that consults it (it also gives Tab
811
+ # somewhere to go, which is what makes both behaviours visible at all).
812
+ def build_date_field
813
+ prompt = Tuile::Component::Label.new
814
+ prompt.text = "Tab here, then type 4.9.2026 — or 09/04/2026, or 2026-09-04.\n" \
815
+ "Tab away or press Enter and a buffer that parses is rewritten as yyyy-mm-dd;\n" \
816
+ "one that doesn't is left as you typed it, and *then* the well goes red.\n" \
817
+ "Up/Down step a day, and an empty field steps to today."
818
+ field = Tuile::Component::DateField.new
819
+ field.formats = ["%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y"]
820
+ status = Tuile::Component::Label.new
821
+ # to_s, not inspect: Date#inspect spells out the Julian day and the
822
+ # calendar reform, which is noise next to the one fact this row is for.
823
+ report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
824
+ report.call
825
+ field.on_value_change = ->(_value) { report.call }
826
+ ask = Tuile::Component::Button.new("Ask again") { report.call }
827
+ form do |f|
828
+ f.add(prompt, Fixed[4])
829
+ f.add(field, Fixed[1], cross: Fixed[20])
830
+ f.add(status, Fixed[1])
831
+ f.add(ask, Fixed[1], cross: Fixed[button_width(ask)])
832
+ end
833
+ end
834
+
835
+ # TimeField: one knob for two things, and the reason it is one. Both fields
836
+ # below sit under a *Finnish* spelling that this pane installs on the
837
+ # screen, because the whole point is invisible under ISO: switching
838
+ # precision must not cost you the locale's separator. So the left field
839
+ # shows 13.45 and the right 13.45.00 — a dot either way, which a per-field
840
+ # format override could not have managed.
841
+ def build_time_field
842
+ # Assigned here rather than detected, so the pane demonstrates the same
843
+ # thing on an American box as on a Finnish one.
844
+ Tuile::Screen.instance.locale = Tuile::Locale::ISO.with(time_formats: ["%H.%M.%S", "%H:%M:%S"])
845
+ prompt = Tuile::Component::Label.new
846
+ prompt.text = "The session spells times the Finnish way (13.45). Tab into either field.\n" \
847
+ "Up/Down step a minute on the left, a second on the right (PageUp/PageDown an hour in both) —\n" \
848
+ "one knob, because precision is a property of the format the buffer holds.\n" \
849
+ "Note the dot survives the switch: that is what a per-field format would cost."
850
+ minutes = time_field_with(step: 60)
851
+ seconds = time_field_with(step: 1)
852
+ status = Tuile::Component::Label.new
853
+ report = lambda do
854
+ status.text = "minutes: #{minutes.value&.strftime("%H:%M:%S") || "nil"} (#{minutes.formats.first}) " \
855
+ "seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
856
+ end
857
+ report.call
858
+ [minutes, seconds].each { _1.on_value_change = ->(_value) { report.call } }
859
+ form do |f|
860
+ f.add(prompt, Fixed[4])
861
+ f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
862
+ f.add(labelled("Second stride", seconds, field_width: 12), Fixed[1], cross: Fixed[30])
863
+ f.add(status, Fixed[1])
864
+ end
865
+ end
866
+
867
+ # @param step [Integer]
868
+ # @return [Tuile::Component::TimeField]
869
+ def time_field_with(step:)
870
+ Tuile::Component::TimeField.new.tap do |field|
871
+ field.step = step
872
+ field.set_to(13, 45)
873
+ end
874
+ end
875
+
876
+ # DateTimeField: the ink rule, which neither half can show alone. Leave the
877
+ # time empty and the *whole* widget reddens — half-filled is a fault no
878
+ # single half committed — where garbage in the date half reddens that half
879
+ # by itself. Both only on the way out, which is why pressing Enter first
880
+ # visibly changes nothing.
881
+ #
882
+ # The echo row carries the value and Save the verdict, the division
883
+ # `D_bad_input` draws: the notice is a push, fired when a half commits;
884
+ # `bad_input?` is a pull, read at the click. The Saved alert spells the
885
+ # value out with the `+00:00` the halves cannot fill in — `D_time_field`'s
886
+ # epoch cost, visible rather than hidden.
887
+ def build_date_time_field
888
+ prompt = Tuile::Component::Label.new
889
+ prompt.text = "Tab here, type 2026-09-14, leave the time empty — then press Enter: nothing reddens.\n" \
890
+ "Tab away and both halves redden at once: half-filled is the composite's own fault.\n" \
891
+ "Now type 2026-99-99 in the date half instead — it reddens alone, the time half stays clean.\n" \
892
+ "Save asks bad_input? at the click and names whichever fault it found."
893
+ field = Tuile::Component::DateTimeField.new
894
+ status = Tuile::Component::Label.new
895
+ # strftime, not inspect: DateTime#inspect spells out the Julian day, noise
896
+ # next to the one fact this row carries.
897
+ report = -> { status.text = "value: #{field.value&.strftime("%Y-%m-%d %H:%M") || "nil"}" }
898
+ report.call
899
+ field.on_value_change = ->(_value) { report.call }
900
+ save = Tuile::Component::Button.new("Save") { save_form("Starts at" => field) }
901
+ form do |f|
902
+ f.add(prompt, Fixed[4])
903
+ f.add(labelled("Starts at", field, field_width: 20), Fixed[1], cross: Fixed[36])
904
+ f.add(status, Fixed[1])
905
+ f.add(save, Fixed[1], cross: Fixed[button_width(save)])
906
+ end
907
+ end
908
+
909
+ # HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
910
+ # and watch the echo row stay silent — the value was nil before and is nil
911
+ # after, so there is no diff to report — while Save, which asks bad_input?
912
+ # instead of empty?, refuses and names the field.
913
+ def build_bad_input
914
+ prompt = Tuile::Component::Label.new
915
+ prompt.text = "Type a lone '-' into Amount (or '1e' into Rate), then press Save.\n" \
916
+ "Both read value: nil and empty?: true, exactly like an untouched field —\n" \
917
+ "which is why a form must ask bad_input? before it saves a nil over your work."
918
+ # The one pane that tags its widgets with `id`, so the sampler's own spec
919
+ # can drive them through Tuile::Testing.get by name.
920
+ amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
921
+ rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
922
+ 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}" }
925
+ save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
926
+ save.id = :save
927
+ rows = group do |g|
928
+ g.add(labelled("Amount", amount), Fixed[1])
929
+ g.add(labelled("Rate", rate), Fixed[1])
930
+ end
931
+ form do |f|
932
+ f.add(prompt, Fixed[3])
933
+ f.add(rows, Fixed[2])
934
+ f.add(echo, Fixed[1])
935
+ f.add(save, Fixed[1], cross: Fixed[button_width(save)])
936
+ end
937
+ end
938
+
939
+ # The login form of `D_has_validation`: the click writes a verdict onto
940
+ # each field and the field paints itself red, while *this* pane owns the
941
+ # cells the messages go in — one Label per field, refilled from
942
+ # `on_error_message_change`. Note the handler sets *or clears* on every
943
+ # pass, which is the whole writer discipline; and that no field computes
944
+ # anything, so nothing here fights the fields' own `bad_input?`.
945
+ def build_validation
946
+ prompt = Tuile::Component::Label.new
947
+ prompt.text = "Press Log in empty, then with a 2-letter username: the message lands in\n" \
948
+ "this pane's cells (via a listener) and the field's well turns red.\n" \
949
+ "Tab between them — an invalid field still shows which one has focus."
950
+ username = Tuile::Component::TextField.new
951
+ password = Tuile::Component::PasswordField.new
952
+ fields = { "Username" => username, "Password" => password }
953
+ rows = group do |g|
954
+ fields.each { |caption, field| g.add(validated_row(caption, field), Fixed[1]) }
955
+ end
956
+ login = Tuile::Component::Button.new("Log in") { validate_login(fields) }
957
+ form do |f|
958
+ f.add(prompt, Fixed[3])
959
+ f.add(rows, Fixed[2])
960
+ f.add(login, Fixed[1], cross: Fixed[button_width(login)])
961
+ end
962
+ end
963
+
964
+ # @param caption [String]
965
+ # @param field [Tuile::Component] a field carrying {Tuile::Component::HasValidation}.
966
+ # @return [Tuile::Component] a row of caption, field, and the error Label
967
+ # the field's listener keeps current.
968
+ def validated_row(caption, field)
969
+ error = Tuile::Component::Label.new
970
+ field.on_error_message_change = ->(msg) { error.text = msg || Tuile::StyledString::EMPTY }
971
+ row do |r|
972
+ r.add(Tuile::Component::Label.new(caption), Fixed[14])
973
+ r.add(field, Fixed[22])
974
+ r.add(error, Expand[1])
975
+ end
976
+ end
977
+
978
+ # Two rules, so the pane shows both halves: "required" has nothing to tint,
979
+ # "too short" does.
980
+ # @param fields [Hash{String => Tuile::Component}] caption => field.
981
+ def validate_login(fields)
982
+ fields.each { |caption, field| field.error_message = login_problem(caption, field) }
983
+ return if fields.each_value.any?(&:error_message)
984
+
985
+ Tuile::Component::ConfirmWindow.alert("Logged in", "Welcome, #{fields["Username"].value}.")
986
+ end
987
+
988
+ # @param caption [String]
989
+ # @param field [Tuile::Component]
990
+ # @return [String, nil] the verdict, or `nil` — the *clear* half of
991
+ # set-or-clear-on-every-pass, without which a fixed field stays red.
992
+ def login_problem(caption, field)
993
+ return "#{caption} is required" if field.empty?
994
+ return "at least 3 characters" if field.value.length < 3
995
+
996
+ nil
997
+ end
998
+
999
+ # The Save gate of `D_bad_input`: asked once, at the click, so the
1000
+ # continuously-true fact ("2" is a bad date on the way to "2026") is only
1001
+ # ever read in a settled state.
1002
+ # @param fields [Hash{String => Tuile::Component}] caption => field.
1003
+ def save_form(fields)
1004
+ bad = fields.filter_map do |caption, field|
1005
+ "#{caption}: #{field.bad_input_message}" if field.respond_to?(:bad_input?) && field.bad_input?
1006
+ end
1007
+ if bad.empty?
1008
+ values = fields.map { |caption, field| "#{caption}: #{field.value.inspect}" }
1009
+ Tuile::Component::ConfirmWindow.alert("Saved", values.join("\n"))
1010
+ else
1011
+ Tuile::Component::ConfirmWindow.alert("Cannot save", "#{bad.size} problem(s):\n#{bad.join("\n")}")
1012
+ end
1013
+ end
1014
+
457
1015
  # @param value [BigDecimal, nil]
458
1016
  # @return [String] the value tripled exactly, next to the same sum in Float.
459
1017
  def triple_report(value)
@@ -495,19 +1053,17 @@ module SamplerExample
495
1053
  # A ListDropdown driven from a TextArea — the same shape {ComboBox} and
496
1054
  # {Select} use, but wired by app code onto a field that knows nothing about
497
1055
  # it. Focus (and the caret) stays in the TextArea the whole time: an
498
- # `on_change` listener refills the menu, and an `on_key` interceptor hands
499
- # movement keys to `#move` and Enter to `#choose` while it is open. None of
500
- # this is baked into TextArea.
1056
+ # `on_change` listener refills the menu, and {SlashCommandTextArea} hands
1057
+ # movement keys to `#move` and Enter to `#choose` while it is open.
501
1058
  def build_slash_demo
502
1059
  prompt = Tuile::Component::Label.new
503
1060
  prompt.text = "A ListDropdown driven from a TextArea. Type a slash command\n" \
504
1061
  "(try \"/\" or \"/s\"). The menu floats over the field without taking\n" \
505
1062
  "focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
506
1063
  "ordinary typing keeps editing the field and refilters the menu."
507
- area = Tuile::Component::TextArea.new
508
-
509
1064
  overlay = Tuile::Component::ListDropdown.new
510
1065
  @slash_overlay = overlay
1066
+ area = SlashCommandTextArea.new(overlay)
511
1067
 
512
1068
  refill = lambda do
513
1069
  matches = slash_matches(area)
@@ -524,20 +1080,6 @@ module SamplerExample
524
1080
 
525
1081
  area.on_change = ->(_text) { refill.call }
526
1082
  overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
527
- area.on_key = lambda do |key|
528
- next false unless overlay.open?
529
- next true if overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
530
-
531
- case key
532
- when Tuile::Keys::ENTER
533
- overlay.choose
534
- when Tuile::Keys::ESC
535
- overlay.close
536
- true
537
- else
538
- false
539
- end
540
- end
541
1083
 
542
1084
  form do |f|
543
1085
  f.add(prompt, Fixed[4])
@@ -668,6 +1210,42 @@ module SamplerExample
668
1210
  end
669
1211
  end
670
1212
 
1213
+ # `visible=` on a conditional form: the fields a checkbox above them
1214
+ # governs. The rows close up completely when they go — a hidden child costs
1215
+ # neither its slot nor the box's `spacing` gap, which is what separates it
1216
+ # from a `Fixed[0]` collapse — and come back with their constraints and
1217
+ # their typed text intact, since nothing was ever detached.
1218
+ def build_visibility
1219
+ prompt = Tuile::Component::Label.new
1220
+ prompt.text = "Tick 'Business customer' to reveal two more fields.\n" \
1221
+ "The rows close up with no double gap, Tab skips what is hidden,\n" \
1222
+ "and text typed into a field survives being hidden and shown."
1223
+ status = Tuile::Component::Label.new
1224
+ company = labelled("Company", Tuile::Component::TextField.new)
1225
+ vat = labelled("VAT id", Tuile::Component::TextField.new)
1226
+ conditional = [company, vat]
1227
+ business = Tuile::Component::Checkbox.new("Business customer")
1228
+ apply = lambda do
1229
+ conditional.each { _1.visible = business.checked? }
1230
+ status.text = "visible fields: #{business.checked? ? 4 : 2}"
1231
+ end
1232
+ business.on_value_change = ->(_) { apply.call }
1233
+ # The rows sit flush; the form keeps its blank row around the block.
1234
+ rows = group do |g|
1235
+ g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
1236
+ g.add(labelled("Email", Tuile::Component::TextField.new), Fixed[1])
1237
+ g.add(company, Fixed[1])
1238
+ g.add(vat, Fixed[1])
1239
+ end
1240
+ apply.call
1241
+ form do |f|
1242
+ f.add(prompt, Fixed[3])
1243
+ f.add(business, Fixed[1])
1244
+ f.add(rows, Fixed[4])
1245
+ f.add(status, Fixed[1])
1246
+ end
1247
+ end
1248
+
671
1249
  # One filterable log level: the item type a CheckboxGroup holds. Its `value`
672
1250
  # is a Set of *these*, never of the labels shown on the rows.
673
1251
  LogLevel = Data.define(:label, :tag, :color)
@@ -705,7 +1283,7 @@ module SamplerExample
705
1283
  # *items* — LogLevel objects, not their labels — so the filter below is plain
706
1284
  # set membership, no lookup table. Rows come from `item_label`, which may
707
1285
  # return styled text (these colors are inherent to the data, not theme
708
- # accents, so they need no on_theme_changed hook).
1286
+ # accents, so they need no handle_theme_changed hook).
709
1287
  def build_checkbox_group
710
1288
  prompt = Tuile::Component::Label.new
711
1289
  # Kept under 48 columns a line, so an 80-column terminal shows it whole.
@@ -774,7 +1352,7 @@ module SamplerExample
774
1352
  SAMPLE_FILES = [
775
1353
  SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
776
1354
  SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
777
- SampleFile.new("DECISIONS.md", 48_990, "2026-07-31"),
1355
+ SampleFile.new("design/decisions.md", 48_990, "2026-07-31"),
778
1356
  SampleFile.new("Gemfile", 312, "2026-06-18"),
779
1357
  SampleFile.new("README.md", 9674, "2026-07-12"),
780
1358
  SampleFile.new("Rakefile", 2118, "2026-06-18"),
@@ -808,7 +1386,7 @@ module SamplerExample
808
1386
  short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
809
1387
 
810
1388
  update_status = lambda do
811
- under_cursor = SORT_ORDERS[group.content.cursor.position]
1389
+ under_cursor = SORT_ORDERS[group.list.cursor.position]
812
1390
  status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
813
1391
  end
814
1392
  resort = lambda do
@@ -819,9 +1397,9 @@ module SamplerExample
819
1397
  end
820
1398
  resort.call
821
1399
  group.on_value_change = ->(_order) { resort.call }
822
- # `content` is the composed List, which is where the cursor lives.
1400
+ # `list` is the composed List, which is where the cursor lives.
823
1401
  # Watching it is what makes the chrome/value split visible above.
824
- group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
1402
+ group.list.on_cursor_changed = ->(_idx, _line) { update_status.call }
825
1403
 
826
1404
  # Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
827
1405
  # demo — the sidebar width is a capped proportion, not a constraint.
@@ -965,7 +1543,7 @@ module SamplerExample
965
1543
  # the tint flows down to every descendant without its own background — the
966
1544
  # label and the list — while the input widgets (the combo, the field) keep
967
1545
  # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
968
- # the hard-coded Colors are fixed by design, so no on_theme_changed here.
1546
+ # the hard-coded Colors are fixed by design, so no handle_theme_changed here.
969
1547
  outer = nil
970
1548
  derived = terminal_tint_choice
971
1549
  combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
@@ -1303,9 +1881,14 @@ module SamplerExample
1303
1881
  "PickerWindow asks the user to pick one option by a single keystroke.",
1304
1882
  "Open PickerWindow"
1305
1883
  ) do
1884
+ # Captions paint in the terminal's own foreground — the picker
1885
+ # recommends no color of its own. Styling one is the app's call, and
1886
+ # per option: a caption may be a String, an ANSI-coded String (what
1887
+ # `theme.fg` hands back) or a StyledString.
1306
1888
  Tuile::Component::PickerWindow.open(
1307
1889
  "Pick a fruit",
1308
- [%w[a Apple], %w[b Banana], %w[c Cherry]]
1890
+ [%w[a Apple], %w[b Banana],
1891
+ ["c", "Cherry #{screen.theme.fg(:hint, "(in season)")}"]]
1309
1892
  ) { |key| Tuile.logger.info("Picked: #{key}") }
1310
1893
  end
1311
1894
  end
@@ -1338,6 +1921,31 @@ module SamplerExample
1338
1921
  end
1339
1922
  end
1340
1923
 
1924
+ def build_mouse_demo
1925
+ intro = Tuile::Component::Label.new
1926
+ intro.text = "Drag on the canvas to draw X; right-drag erases; just hovering leaves a dim\n" \
1927
+ "trail. Arrows move the caret, space or x strokes it, Delete lifts it, c clears.\n" \
1928
+ "Discrete events go to the log; the row below is the live pointer."
1929
+ canvas = Canvas.new
1930
+ pointer = Tuile::Component::Label.new
1931
+ pointer.text = "pointer: (move over the canvas)"
1932
+ 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)"
1937
+ end
1938
+ surface = row do |r|
1939
+ r.add(Tuile::Component::Window.new("Canvas").tap { _1.content = canvas }, Percent[55])
1940
+ r.add(log, Expand[1])
1941
+ end
1942
+ form do |f|
1943
+ f.add(intro, Fixed[3])
1944
+ f.add(surface, Expand[1])
1945
+ f.add(pointer, Fixed[1])
1946
+ end
1947
+ end
1948
+
1341
1949
  # --- Helpers -----------------------------------------------------------
1342
1950
 
1343
1951
  def panel(*children, &layout_block)
@@ -1438,13 +2046,19 @@ end
1438
2046
  # component tree without spinning up the real event loop.
1439
2047
  if $PROGRAM_NAME == __FILE__
1440
2048
  screen = Tuile::Screen.new
2049
+ screen.theme_def = SamplerExample::APP_THEME
1441
2050
  sampler = SamplerExample::Sampler.new
1442
2051
  screen.content = sampler
1443
2052
  screen.on_focus_changed = -> { sampler.refresh_status }
1444
2053
  sampler.refresh_status
1445
2054
  sampler.menu_bar.focus
1446
2055
  begin
1447
- screen.run_event_loop
2056
+ # `:hover` for the whole app, because the level is set once here and is
2057
+ # all-or-nothing: the Mouse demo's trail and enter/exit lines arrive at no
2058
+ # lower one. The upgrade from the default `:clicks` costs the ~84 motion
2059
+ # reports a second (`R_mouse_reporting`) — not select-to-copy, which mode
2060
+ # 1000 had already taken.
2061
+ screen.run_event_loop(capture_mouse: :hover)
1448
2062
  ensure
1449
2063
  screen.close
1450
2064
  end