tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
data/examples/sampler.rb CHANGED
@@ -15,11 +15,12 @@ require "rainbow"
15
15
  require "tuile"
16
16
 
17
17
  module SamplerExample
18
- # Sampler-local container: a {Tuile::Component::Layout::Absolute} that
19
- # runs a caller-supplied block on `rect=` to position its children.
20
- # Sampler demos sometimes have a 1-row Label sitting in a tall pane,
21
- # but the stock layout's auto-clear already handles those gaps for us
22
- # — Panel just needs the rect-callback to drive child positioning.
18
+ # Sampler-local container: a {Tuile::Component::Layout::Absolute} that runs a
19
+ # caller-supplied block on `rect=` to position its children. Most demos are
20
+ # plain stacks and use the box layouts instead; this is what's left for the
21
+ # two that aren't — a sidebar whose width is `min(16, width / 3)`, which is a
22
+ # cap on a proportion and so outside {Tuile::Component::Layout::Box}'s
23
+ # Fixed/Percent/Expand vocabulary by design.
23
24
  class Panel < Tuile::Component::Layout::Absolute
24
25
  def initialize(&layout_block)
25
26
  super()
@@ -32,6 +33,52 @@ module SamplerExample
32
33
  end
33
34
  end
34
35
 
36
+ # A {Tuile::Component::Layout::Vertical} that runs {#on_tick} on every frame
37
+ # while it is on screen. The ticker is started on attach and cancelled on
38
+ # detach, so selecting another demo — which detaches this pane — cannot leave
39
+ # one firing at the old pane forever. Owning a mounted-lifetime resource this
40
+ # way is the whole point of the attach hooks.
41
+ class TickingBox < Tuile::Component::Layout::Vertical
42
+ def initialize(fps, **)
43
+ super(**)
44
+ @fps = fps
45
+ end
46
+
47
+ # @return [Proc, nil] called with no arguments on each frame.
48
+ attr_writer :on_tick
49
+
50
+ def on_attached
51
+ @ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
52
+ end
53
+
54
+ def on_detached
55
+ @ticker&.cancel
56
+ @ticker = nil
57
+ end
58
+ end
59
+
60
+ # A {Tuile::Component::Layout::Vertical} that claims one key for itself. An
61
+ # ancestor's `handle_key` is where a scope-wide binding belongs (key-dispatch
62
+ # rung 3); the Select demo uses one to show the letter still arriving while a
63
+ # Select has focus — the capability a ComboBox, which eats every printable
64
+ # unconditionally, cannot offer.
65
+ class ShortcutBox < Tuile::Component::Layout::Vertical
66
+ def initialize(shortcut, **)
67
+ super(**)
68
+ @shortcut = shortcut
69
+ end
70
+
71
+ # @return [Proc, nil] called with no arguments when the shortcut arrives.
72
+ attr_writer :on_shortcut
73
+
74
+ def handle_key(key)
75
+ return false unless key == @shortcut
76
+
77
+ @on_shortcut&.call
78
+ true
79
+ end
80
+ end
81
+
35
82
  # Top-level sampler component. Splits the screen into a left entry list
36
83
  # and a right demo pane; each `load_entry` rebuilds the demo from
37
84
  # scratch so it always starts in a clean state.
@@ -48,6 +95,10 @@ module SamplerExample
48
95
 
49
96
  attr_reader :left_window, :right_window, :entry_list
50
97
 
98
+ # Chrome for a demo pane: a blank row top and bottom, two columns either
99
+ # side, so content doesn't run flush to the window border.
100
+ FORM_PADDING = Insets[top: 1, bottom: 1, left: 2, right: 2]
101
+
51
102
  def rect=(new_rect)
52
103
  super
53
104
  return if rect.empty?
@@ -67,10 +118,21 @@ module SamplerExample
67
118
  ["Label", :build_label],
68
119
  ["TextField", :build_text_field],
69
120
  ["TextArea", :build_text_area],
121
+ ["ComboBox", :build_combo_box],
122
+ ["Select", :build_select],
123
+ ["IntegerField", :build_integer_field],
124
+ ["FloatField", :build_float_field],
125
+ ["BigDecimalField", :build_big_decimal_field],
126
+ ["PasswordField", :build_password_field],
70
127
  ["Slash menu", :build_slash_demo],
71
128
  ["TextView", :build_text_view],
72
129
  ["Button", :build_buttons],
130
+ ["Checkbox", :build_checkboxes],
131
+ ["CheckboxGroup", :build_checkbox_group],
132
+ ["RadioGroup", :build_radio_group],
73
133
  ["List", :build_list],
134
+ ["ProgressBar", :build_progress_bar],
135
+ ["Background", :build_background],
74
136
  ["Layout", :build_layout],
75
137
  ["Popup", :build_popup_launcher],
76
138
  ["InfoWindow", :build_info_launcher],
@@ -116,10 +178,9 @@ module SamplerExample
116
178
  prompt = Tuile::Component::Label.new
117
179
  prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
118
180
  field = Tuile::Component::TextField.new
119
- panel(prompt, field) do |r|
120
- inner = inner_rect(r)
121
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 1)
122
- field.rect = Tuile::Rect.new(inner.left, inner.top + 3, inner.width, 1)
181
+ form do |f|
182
+ f.add(prompt, Fixed[1])
183
+ f.add(field, Fixed[1])
123
184
  end
124
185
  end
125
186
 
@@ -133,11 +194,165 @@ module SamplerExample
133
194
  area.text = "The quick brown fox jumps over the lazy dog. " \
134
195
  "Edit me — the text wraps to the area's width and scrolls vertically " \
135
196
  "once the cursor leaves the visible rows."
136
- panel(prompt, area) do |r|
137
- inner = inner_rect(r)
138
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
139
- area_height = [inner.height - 6, 4].max
140
- area.rect = Tuile::Rect.new(inner.left, inner.top + 5, inner.width, area_height)
197
+ form do |f|
198
+ f.add(prompt, Fixed[3])
199
+ f.add(area, Expand[1])
200
+ end
201
+ end
202
+
203
+ # ComboBox: its value is the selected item, not the typed text — the status
204
+ # line echoes it as it commits. The dropdown closes itself on blur, so no
205
+ # overlay bookkeeping is needed here (unlike the slash demo below).
206
+ def build_combo_box
207
+ prompt = Tuile::Component::Label.new
208
+ prompt.text = "Tab here, then type to filter. ↑↓ move the highlight, Enter accepts, ESC dismisses.\n" \
209
+ "The dropdown floats above or below the field and tints itself apart from the content."
210
+ items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
211
+ combo = Tuile::Component::ComboBox.new(items: items)
212
+ status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
213
+ combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
214
+ form do |f|
215
+ f.add(prompt, Fixed[3])
216
+ # A cross constraint clamps to the pane, so this is 30 columns or fewer.
217
+ f.add(combo, Fixed[1], cross: Fixed[30])
218
+ f.add(status, Fixed[1])
219
+ end
220
+ end
221
+
222
+ # One line-ending choice: the item type the second Select below holds, so its
223
+ # `value` is a LineEnding carrying the bytes to write — never a label to look
224
+ # a separator back up from.
225
+ LineEnding = Data.define(:label, :bytes)
226
+
227
+ LINE_ENDINGS = [
228
+ LineEnding.new("LF (Unix)", "\n"),
229
+ LineEnding.new("CRLF (Windows)", "\r\n"),
230
+ LineEnding.new("CR (classic Mac)", "\r")
231
+ ].freeze
232
+
233
+ # Two Selects, an enum each — developer-authored labels, which is what a
234
+ # Select is for and a ComboBox isn't. Three things worth watching: the
235
+ # dropdown is measured to its widest label rather than to the field (so the
236
+ # line-endings menu is wider than its face), a nil value is a legal blank
237
+ # face rather than a placeholder, and `r` reaches the *pane* while a Select
238
+ # has focus — no printable but Space belongs to the widget.
239
+ def build_select
240
+ prompt = Tuile::Component::Label.new
241
+ prompt.text = "Tab between the two Selects. Enter, Space or ↓ opens;\n" \
242
+ "↑↓ move the highlight, Enter or Space commits, ESC cancels.\n" \
243
+ "Press r to reset — the pane gets the letter, not the Select."
244
+
245
+ level = Tuile::Component::Select.new(items: %w[debug info warn error fatal], value: "warn")
246
+ endings = Tuile::Component::Select.new(items: LINE_ENDINGS)
247
+ endings.item_label = :label.to_proc
248
+
249
+ status = Tuile::Component::Label.new
250
+ update = lambda do
251
+ status.text = "level: #{level.value.inspect} endings: #{endings.value&.label.inspect}"
252
+ end
253
+ update.call
254
+ [level, endings].each { _1.on_value_change = ->(_v) { update.call } }
255
+
256
+ pane = ShortcutBox.new("r", spacing: 1, padding: FORM_PADDING)
257
+ pane.add(prompt, Fixed[3])
258
+ pane.add(labelled("Log level", level), Fixed[1])
259
+ pane.add(labelled("Line endings", endings), Fixed[1])
260
+ pane.add(status, Fixed[1])
261
+ pane.on_shortcut = lambda do
262
+ level.value = "warn"
263
+ endings.value = nil
264
+ update.call
265
+ end
266
+ pane
267
+ end
268
+
269
+ # IntegerField: its value is a typed Integer (or nil), parsed from the
270
+ # digits you type — the status line echoes it. Up/Down step it like a
271
+ # spinner. Only the value seam shows on its face; the digit filtering and
272
+ # parsing are internal.
273
+ def build_integer_field
274
+ prompt = Tuile::Component::Label.new
275
+ prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
276
+ "Up/Down step the value by one; an empty field counts as 0."
277
+ field = Tuile::Component::IntegerField.new
278
+ status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
279
+ field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
280
+ form do |f|
281
+ f.add(prompt, Fixed[2])
282
+ f.add(field, Fixed[1], cross: Fixed[20])
283
+ f.add(status, Fixed[1])
284
+ end
285
+ end
286
+
287
+ # FloatField: the IntegerField one Ruby type over — a single decimal point
288
+ # is allowed too, and the value is a Float. The status line echoes the
289
+ # value, which is where the generous parse shows: a buffer of "1." already
290
+ # reads as 1.0 rather than blinking to nil while you reach for a digit.
291
+ def build_float_field
292
+ prompt = Tuile::Component::Label.new
293
+ prompt.text = "Tab here, then type digits, one '.' and a leading -. Anything else is ignored.\n" \
294
+ "Up/Down step the value by one. Watch the value while you type '1.5'."
295
+ field = Tuile::Component::FloatField.new
296
+ status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
297
+ field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
298
+ form do |f|
299
+ f.add(prompt, Fixed[2])
300
+ f.add(field, Fixed[1], cross: Fixed[20])
301
+ f.add(status, Fixed[1])
302
+ end
303
+ end
304
+
305
+ # BigDecimalField: the same shape again, holding an exact decimal. The
306
+ # status line multiplies by three both ways, so typing "0.1" shows the
307
+ # difference the field exists for. Needs the bigdecimal gem — Tuile's one
308
+ # optional dependency, required by this component and nothing else.
309
+ def build_big_decimal_field
310
+ prompt = Tuile::Component::Label.new
311
+ prompt.text = "Tab here and type 0.1 — then compare the two products below.\n" \
312
+ "This is the field for money: no binary rounding, and nothing pads or trims\n" \
313
+ "what you typed (19.90 keeps its zero)."
314
+ field = Tuile::Component::BigDecimalField.new
315
+ status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
316
+ field.on_value_change = ->(value) { status.text = triple_report(value) }
317
+ form do |f|
318
+ f.add(prompt, Fixed[3])
319
+ f.add(field, Fixed[1], cross: Fixed[20])
320
+ f.add(status, Fixed[2])
321
+ end
322
+ end
323
+
324
+ # @param value [BigDecimal, nil]
325
+ # @return [String] the value tripled exactly, next to the same sum in Float.
326
+ def triple_report(value)
327
+ return "value: nil" if value.nil?
328
+
329
+ "value: #{value.to_s("F")}\n" \
330
+ "×3 exact: #{(value * 3).to_s("F")} ×3 as Float: #{value.to_f * 3}"
331
+ end
332
+
333
+ # PasswordField: a TextField that paints a mask instead of its text. The
334
+ # plaintext stays in `value` throughout — the status line proves it by
335
+ # reporting the length — and the "Show password" box flips `revealed`,
336
+ # since a TTY field has no room for an in-field reveal button.
337
+ def build_password_field
338
+ prompt = Tuile::Component::Label.new
339
+ prompt.text = "Tab through the two fields and type. The password paints one * per character,\n" \
340
+ "whatever you type — try a CJK passphrase: the caret still tracks the mask.\n" \
341
+ "Ctrl+Left/Right jump to the ends while masked, so the caret can't give away\n" \
342
+ "where the spaces are; they resume word jumping once revealed."
343
+ user = Tuile::Component::TextField.new
344
+ password = Tuile::Component::PasswordField.new
345
+ reveal = Tuile::Component::Checkbox.new("Show password")
346
+ reveal.on_value_change = ->(on) { password.revealed = on }
347
+ status = Tuile::Component::Label.new
348
+ refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
349
+ refresh.call
350
+ [user, password].each { _1.on_change = ->(_) { refresh.call } }
351
+ form do |f|
352
+ f.add(prompt, Fixed[4])
353
+ f.add([user, password], Fixed[1], cross: Fixed[30]) # one constraint, both fields
354
+ f.add(reveal, Fixed[1])
355
+ f.add(status, Fixed[1])
141
356
  end
142
357
  end
143
358
 
@@ -191,11 +406,9 @@ module SamplerExample
191
406
  end
192
407
  end
193
408
 
194
- panel(prompt, area) do |r|
195
- inner = inner_rect(r)
196
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
197
- area_height = [inner.height - 7, 4].max
198
- area.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, area_height)
409
+ form do |f|
410
+ f.add(prompt, Fixed[4])
411
+ f.add(area, Expand[1])
199
412
  end
200
413
  end
201
414
 
@@ -223,11 +436,9 @@ module SamplerExample
223
436
  "freely."
224
437
  window.content = view
225
438
  window.scrollbar = true
226
- panel(prompt, window) do |r|
227
- inner = inner_rect(r)
228
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
229
- view_height = [inner.height - 5, 4].max
230
- window.rect = Tuile::Rect.new(inner.left, inner.top + 4, inner.width, view_height)
439
+ form do |f|
440
+ f.add(prompt, Fixed[2])
441
+ f.add(window, Expand[1])
231
442
  end
232
443
  end
233
444
 
@@ -246,13 +457,218 @@ module SamplerExample
246
457
  counters[:cancel] += 1
247
458
  refresh.call
248
459
  end
249
- panel(label, ok, cancel, result) do |r|
250
- inner = inner_rect(r)
251
- label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
252
- ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, ok.content_size.width, 1)
253
- cancel.rect = Tuile::Rect.new(inner.left + ok.content_size.width + 2, inner.top + 4,
254
- cancel.content_size.width, 1)
255
- result.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
460
+ buttons = row do |r|
461
+ r.add(ok, Fixed[button_width(ok)])
462
+ r.add(cancel, Fixed[button_width(cancel)])
463
+ end
464
+ form do |f|
465
+ f.add(label, Fixed[2])
466
+ f.add(buttons, Fixed[1])
467
+ f.add(result, Fixed[1])
468
+ end
469
+ end
470
+
471
+ CHECKBOX_OPTIONS = ["Enable syslog forwarding", "Rotate logs daily", "Compress archives",
472
+ "Email on failure", "Verbose output"].freeze
473
+
474
+ # Checkbox: Space, Enter (or a click on the label) toggles. Each box is handed
475
+ # the full column width, which is what makes the extent visible — the
476
+ # highlight and the click target stop at the end of the caption, not the rect's.
477
+ def build_checkboxes
478
+ prompt = Tuile::Component::Label.new
479
+ prompt.text = "Tab here, then Space or Enter to toggle; a left-click on a label toggles too.\n" \
480
+ "Each box spans the whole column, but only the caption highlights —\n" \
481
+ "clicking the empty space to its right just moves focus."
482
+ status = Tuile::Component::Label.new
483
+ boxes = CHECKBOX_OPTIONS.map { Tuile::Component::Checkbox.new(_1) }
484
+ refresh = lambda do
485
+ on = boxes.select(&:checked?).map { _1.caption.to_s }
486
+ status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
487
+ end
488
+ refresh.call
489
+ boxes.each { _1.on_value_change = ->(_) { refresh.call } }
490
+ # The boxes sit flush against each other while the form keeps a blank row
491
+ # around the block: a spacing-0 group nested in the spacing-1 form, rather
492
+ # than a per-child gap the framework deliberately doesn't offer.
493
+ rows = group { |g| g.add(boxes, Fixed[1]) }
494
+ form do |f|
495
+ f.add(prompt, Fixed[3])
496
+ f.add(rows, Fixed[boxes.size])
497
+ f.add(status, Fixed[1])
498
+ end
499
+ end
500
+
501
+ # One filterable log level: the item type a CheckboxGroup holds. Its `value`
502
+ # is a Set of *these*, never of the labels shown on the rows.
503
+ LogLevel = Data.define(:label, :tag, :color)
504
+
505
+ LOG_LEVELS = [
506
+ LogLevel.new("Debug", "DEBUG", :cyan),
507
+ LogLevel.new("Info", "INFO", :green),
508
+ LogLevel.new("Warnings", "WARN", :yellow),
509
+ LogLevel.new("Errors", "ERROR", :red)
510
+ ].freeze
511
+
512
+ # `[tag, message]` pairs; the tag names the LogLevel that owns the line.
513
+ SAMPLE_LOG = [
514
+ ["DEBUG", "config loaded from /etc/tuile.conf"],
515
+ ["INFO", "listening on 0.0.0.0:8080"],
516
+ ["DEBUG", "cache warm: 128 entries"],
517
+ ["WARN", "TLS certificate expires in 6 days"],
518
+ ["INFO", "GET /health 200 (1.2ms)"],
519
+ ["DEBUG", "pool checkout: 3/16 busy"],
520
+ ["ERROR", "upstream timeout after 5000ms"],
521
+ ["INFO", "GET /index 200 (18ms)"],
522
+ ["WARN", "slow query: 1.8s SELECT * FROM tiles"],
523
+ ["DEBUG", "gc pause 4ms"],
524
+ ["ERROR", "connection reset by peer (retrying)"],
525
+ ["INFO", "POST /tiles 201 (32ms)"],
526
+ ["DEBUG", "pool checkout: 11/16 busy"],
527
+ ["WARN", "queue depth 240, above the 200 mark"],
528
+ ["INFO", "GET /tiles/42 200 (7ms)"],
529
+ ["ERROR", "failed to write /var/log/tuile.log: no space left"],
530
+ ["DEBUG", "flush wrote 96 cells"],
531
+ ["INFO", "shutdown signal received"]
532
+ ].freeze
533
+
534
+ # CheckboxGroup filtering an adjacent log. Its value is a Set of the selected
535
+ # *items* — LogLevel objects, not their labels — so the filter below is plain
536
+ # set membership, no lookup table. Rows come from `item_label`, which may
537
+ # return styled text (these colors are inherent to the data, not theme
538
+ # accents, so they need no on_theme_changed hook).
539
+ def build_checkbox_group
540
+ prompt = Tuile::Component::Label.new
541
+ # Kept under 48 columns a line, so an 80-column terminal shows it whole.
542
+ prompt.text = "Tab here. ↑↓ moves the cursor, Space toggles.\n" \
543
+ "Enter or a click anywhere on a row toggles too —\n" \
544
+ "in a list, the whole row is the target.\n" \
545
+ "The log redraws from the value on every toggle."
546
+ levels_by_tag = LOG_LEVELS.to_h { [_1.tag, _1] }
547
+ entries = SAMPLE_LOG.map { |tag, message| [levels_by_tag.fetch(tag), message] }
548
+
549
+ group = Tuile::Component::CheckboxGroup.new(items: LOG_LEVELS, value: LOG_LEVELS.last(2))
550
+ group.item_label = ->(level) { Rainbow(level.label).color(level.color) }
551
+
552
+ log = Tuile::Component::List.new
553
+ log.cursor = Tuile::Component::List::Cursor.new
554
+ log.scrollbar_visibility = :visible
555
+ status = Tuile::Component::Label.new
556
+
557
+ refresh = lambda do
558
+ selected = group.value
559
+ log.lines = entries.select { |level, _| selected.include?(level) }
560
+ .map { |level, message| "#{Rainbow(level.tag.ljust(5)).color(level.color)} #{message}" }
561
+ # The Set iterates in *toggle* order, so intersect with items to report
562
+ # it in the order the rows are shown — the documented idiom.
563
+ shown = (LOG_LEVELS & selected.to_a).map(&:label)
564
+ status.text = "value: {#{shown.join(", ")}} — #{log.lines.size} of #{entries.size} lines"
565
+ end
566
+ refresh.call
567
+ group.on_value_change = ->(_set) { refresh.call }
568
+
569
+ # The body keeps a rect-callback {Panel}: its sidebar is `min(16, width/3)`
570
+ # — a cap on a proportion, which Fixed/Percent/Expand can't say. The stack
571
+ # around it is a box, so only the part that needs arithmetic has any.
572
+ body = panel(group, log) do |r|
573
+ group_width = [16, r.width / 3].min
574
+ group.rect = Tuile::Rect.new(r.left, r.top, group_width, [LOG_LEVELS.size, r.height].min)
575
+ log.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
576
+ [r.width - group_width - 2, 4].max, r.height)
577
+ end
578
+ form do |f|
579
+ f.add(prompt, Fixed[4])
580
+ # Status above the body, so it stays next to the group however tall the
581
+ # pane gets; the log takes whatever height is left.
582
+ f.add(status, Fixed[1])
583
+ f.add(body, Expand[1])
584
+ end
585
+ end
586
+
587
+ # One sort order: the item type a RadioGroup holds. Its `value` is one of
588
+ # *these*, and the chosen object carries the behavior — so re-sorting is
589
+ # `value.sorter.call(files)`, never a lookup from a label back to a
590
+ # comparator.
591
+ SortOrder = Data.define(:label, :sorter)
592
+
593
+ SORT_ORDERS = [
594
+ SortOrder.new("Name A-Z", ->(files) { files.sort_by(&:name) }),
595
+ SortOrder.new("Name Z-A", ->(files) { files.sort_by(&:name).reverse }),
596
+ SortOrder.new("Biggest", ->(files) { files.sort_by { -_1.size } }),
597
+ SortOrder.new("Newest", ->(files) { files.sort_by(&:date).reverse })
598
+ ].freeze
599
+
600
+ SampleFile = Data.define(:name, :size, :date)
601
+
602
+ # Names stay under 14 columns and sizes round to distinct k values, so the
603
+ # rows fit the pane at 80 columns and every sort order reorders visibly.
604
+ SAMPLE_FILES = [
605
+ SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
606
+ SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
607
+ SampleFile.new("DECISIONS.md", 48_990, "2026-07-31"),
608
+ SampleFile.new("Gemfile", 312, "2026-06-18"),
609
+ SampleFile.new("README.md", 9674, "2026-07-12"),
610
+ SampleFile.new("Rakefile", 2118, "2026-06-18"),
611
+ SampleFile.new("list.rb", 21_006, "2026-07-24"),
612
+ SampleFile.new("sampler.rb", 22_180, "2026-07-31"),
613
+ SampleFile.new("screen.rb", 18_442, "2026-07-28"),
614
+ SampleFile.new("text_view.rb", 14_338, "2026-07-19"),
615
+ SampleFile.new("theme.rb", 6512, "2026-07-23"),
616
+ SampleFile.new("tuile.gemspec", 1284, "2026-06-20")
617
+ ].freeze
618
+
619
+ # RadioGroup driving an adjacent file list. Two things worth watching: the
620
+ # value is the selected *item* (a SortOrder carrying its own comparator),
621
+ # and the cursor is chrome — arrows move it without touching the value, so
622
+ # the status line's two halves drift apart until you press Space.
623
+ def build_radio_group
624
+ prompt = Tuile::Component::Label.new
625
+ # Kept under 48 columns a line, so an 80-column terminal shows it whole.
626
+ prompt.text = "Tab here. ↑↓ move the cursor only.\n" \
627
+ "Space, Enter or a click selects — and only\n" \
628
+ "then does the list re-sort. Picking another\n" \
629
+ "clears the previous; there is no deselect."
630
+
631
+ group = Tuile::Component::RadioGroup.new(items: SORT_ORDERS, value: SORT_ORDERS.first)
632
+ group.item_label = :label.to_proc
633
+
634
+ files = Tuile::Component::List.new
635
+ files.cursor = Tuile::Component::List::Cursor.new
636
+ files.scrollbar_visibility = :visible
637
+ status = Tuile::Component::Label.new
638
+ short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
639
+
640
+ update_status = lambda do
641
+ under_cursor = SORT_ORDERS[group.content.cursor.position]
642
+ status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
643
+ end
644
+ resort = lambda do
645
+ files.lines = group.value.sorter.call(SAMPLE_FILES).map do |file|
646
+ "#{file.name.ljust(13)} #{short_size.call(file.size).rjust(4)} #{file.date}"
647
+ end
648
+ update_status.call
649
+ end
650
+ resort.call
651
+ group.on_value_change = ->(_order) { resort.call }
652
+ # `content` is the composed List, which is where the cursor lives.
653
+ # Watching it is what makes the chrome/value split visible above.
654
+ group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
655
+
656
+ # Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
657
+ # demo — the sidebar width is a capped proportion, not a constraint.
658
+ body = panel(group, files) do |r|
659
+ # List pads a column either side of a row, so a label needs
660
+ # `width - 2`; the file rows lose one more to their scrollbar.
661
+ group_width = [14, r.width / 3].min
662
+ group.rect = Tuile::Rect.new(r.left, r.top, group_width, [SORT_ORDERS.size, r.height].min)
663
+ files.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
664
+ [r.width - group_width - 2, 4].max, r.height)
665
+ end
666
+ form do |f|
667
+ f.add(prompt, Fixed[4])
668
+ # Status above the body, so it stays next to the group however tall the
669
+ # pane gets; the file list takes whatever height is left.
670
+ f.add(status, Fixed[1])
671
+ f.add(body, Expand[1])
256
672
  end
257
673
  end
258
674
 
@@ -264,16 +680,134 @@ module SamplerExample
264
680
  list
265
681
  end
266
682
 
683
+ # Files the fake job in the ProgressBar demo pretends to process.
684
+ PROGRESS_TOTAL = 50
685
+
686
+ # Frames per second of the demo's fake job — its own pace, unrelated to
687
+ # {Tuile::Component::ProgressBar::INDETERMINATE_FPS}, which paces only the
688
+ # animation the bar runs for itself.
689
+ PROGRESS_FPS = 8
690
+
691
+ # Two bars: a determinate one whose value a pane-owned ticker walks up and
692
+ # wraps around, and an indeterminate one that animates itself. Neither
693
+ # paints text — the lines beneath them are sibling Labels fed from
694
+ # {Tuile::Component::ProgressBar#percent}, which is what lets the app word
695
+ # the progress ("42% — 21/50 files") instead of taking whatever the widget
696
+ # would have formatted.
697
+ def build_progress_bar
698
+ prompt = Tuile::Component::Label.new
699
+ prompt.text = "A ProgressBar paints no text of its own —\n" \
700
+ "the line below it is a sibling Label fed\n" \
701
+ "from bar.percent. A ticker owned by this\n" \
702
+ "pane advances the value while it's on screen."
703
+
704
+ bar = Tuile::Component::ProgressBar.new(range: 0..PROGRESS_TOTAL)
705
+ bar.bar_color = Tuile::Color::GREEN
706
+ status = Tuile::Component::Label.new
707
+
708
+ spinner = Tuile::Component::ProgressBar.new(indeterminate: true)
709
+ spinner_caption = Tuile::Component::Label.new
710
+ spinner_caption.text = "Indeterminate: no total yet, so the bar owns\n" \
711
+ "its own animation — no ticker in the app."
712
+
713
+ done = 0
714
+ refresh = -> { status.text = "#{bar.percent}% — #{done}/#{PROGRESS_TOTAL} files" }
715
+ refresh.call
716
+
717
+ # Each bar sits flush against its caption, with a blank row between the two
718
+ # pairs — two spacing-0 groups inside the spacing-1 stack.
719
+ determinate = group do |g|
720
+ g.add(bar, Fixed[1])
721
+ g.add(status, Fixed[1])
722
+ end
723
+ indeterminate = group do |g|
724
+ g.add(spinner, Fixed[1])
725
+ g.add(spinner_caption, Fixed[2])
726
+ end
727
+ pane = TickingBox.new(PROGRESS_FPS, spacing: 1, padding: FORM_PADDING)
728
+ pane.add(prompt, Fixed[4])
729
+ pane.add(determinate, Fixed[2])
730
+ pane.add(indeterminate, Fixed[3])
731
+ pane.on_tick = lambda do
732
+ done = done < PROGRESS_TOTAL ? done + 1 : 0
733
+ bar.value = done
734
+ refresh.call
735
+ end
736
+ pane
737
+ end
738
+
739
+ # One background the Background demo offers: a display label and the value
740
+ # handed to {Component#bg_color=} — a live {Tuile::Theme::Ref}, a hard-coded
741
+ # {Tuile::Color}, or nil (terminal default).
742
+ BgChoice = Data.define(:label, :color)
743
+
744
+ # The palette the Background combo filters over: theme refs first (they
745
+ # re-resolve on a light/dark flip, so they track the scheme), then a spread
746
+ # of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
747
+ BG_CHOICES = [
748
+ BgChoice.new("None (terminal default)", nil),
749
+ BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
750
+ BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
751
+ BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
752
+ BgChoice.new("ANSI blue", Tuile::Color::BLUE),
753
+ BgChoice.new("ANSI magenta", Tuile::Color::MAGENTA),
754
+ BgChoice.new("ANSI bright black", Tuile::Color::BRIGHT_BLACK),
755
+ BgChoice.new("Palette 236 (charcoal)", Tuile::Color.palette(236)),
756
+ BgChoice.new("Palette 22 (deep green)", Tuile::Color.palette(22)),
757
+ BgChoice.new("Deep purple (RGB)", Tuile::Color.rgb(48, 25, 82)),
758
+ BgChoice.new("Midnight teal (RGB)", Tuile::Color.rgb(10, 40, 45)),
759
+ BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
760
+ ].freeze
761
+
762
+ def build_background
763
+ intro = Tuile::Component::Label.new
764
+ intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
765
+ "Pick one below — this label and the list inherit it; input widgets keep their own well.\n" \
766
+ "Theme refs track light/dark flips; hard-coded colors stay put."
767
+
768
+ list = Tuile::Component::List.new
769
+ list.cursor = Tuile::Component::List::Cursor.new
770
+ list.lines = (1..12).map { |i| "List row #{i}" }
771
+ field = Tuile::Component::TextField.new
772
+ field.text = "TextField keeps its own background"
773
+
774
+ # A borderless sub-box holding the list + field; it inherits the tint too.
775
+ box = Tuile::Component::Layout::Vertical.new(spacing: 1)
776
+ box.add(list, Expand[1])
777
+ box.add(field, Fixed[1])
778
+
779
+ # A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
780
+ # the tint flows down to every descendant without its own background — the
781
+ # label and the list — while the input widgets (the combo, the field) keep
782
+ # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
783
+ # the hard-coded Colors are fixed by design, so no on_theme_changed here.
784
+ outer = nil
785
+ combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
786
+ combo.item_label = :label.to_proc
787
+ combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
788
+
789
+ outer = form do |f|
790
+ f.add(intro, Fixed[3])
791
+ f.add(combo, Fixed[1], cross: Fixed[40])
792
+ f.add(box, Expand[1])
793
+ end
794
+ combo.value = BG_CHOICES.first # show "None" as the resting selection
795
+ outer
796
+ end
797
+
798
+ # Horizontal splitting a row between two equal Expand shares. Resize the
799
+ # terminal to watch it recompute: on an odd width the spare column goes to
800
+ # the left pane, since the remainder is handed to the earliest Expand first.
267
801
  def build_layout
268
802
  left = Tuile::Component::Window.new("Left")
269
- left.content = Tuile::Component::Label.new.tap { _1.text = "Nested left window." }
803
+ left.content = Tuile::Component::Label.new.tap do
804
+ _1.text = "Horizontal splits the row\nbetween two Expand[1] panes."
805
+ end
270
806
  right = Tuile::Component::Window.new("Right")
271
- right.content = Tuile::Component::Label.new.tap { _1.text = "Nested right window." }
272
- panel(left, right) do |r|
273
- half = r.width / 2
274
- left.rect = Tuile::Rect.new(r.left, r.top, half, r.height)
275
- right.rect = Tuile::Rect.new(r.left + half, r.top, r.width - half, r.height)
807
+ right.content = Tuile::Component::Label.new.tap do
808
+ _1.text = "No arithmetic here — the\nlayout does it."
276
809
  end
810
+ Tuile::Component::Layout::Horizontal.new.tap { _1.add([left, right], Expand[1]) }
277
811
  end
278
812
 
279
813
  # --- Modal launchers ---------------------------------------------------
@@ -319,11 +853,9 @@ module SamplerExample
319
853
 
320
854
  def build_log_window
321
855
  log = Tuile::Component::LogWindow.new("Log")
322
- log.content.add_lines([
323
- "LogWindow is a Window wrapping an auto-scrolling List.",
324
- "Lines are appended via #add_line / #add_lines.",
325
- "Used with Logger::IO it captures arbitrary log output."
326
- ])
856
+ ["LogWindow is a Window wrapping an auto-scrolling TextView.",
857
+ "Lines are appended via #log (safe from any thread).",
858
+ "Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
327
859
  log
328
860
  end
329
861
 
@@ -336,13 +868,14 @@ module SamplerExample
336
868
  a = Tuile::Component::Button.new("Button A")
337
869
  b = Tuile::Component::Button.new("Button B")
338
870
  field = Tuile::Component::TextField.new
339
- panel(label, a, b, field) do |r|
340
- inner = inner_rect(r)
341
- label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
342
- a.rect = Tuile::Rect.new(inner.left, inner.top + 4, a.content_size.width, 1)
343
- b.rect = Tuile::Rect.new(inner.left + a.content_size.width + 2, inner.top + 4,
344
- b.content_size.width, 1)
345
- field.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
871
+ buttons = row do |r|
872
+ r.add(a, Fixed[button_width(a)])
873
+ r.add(b, Fixed[button_width(b)])
874
+ end
875
+ form do |f|
876
+ f.add(label, Fixed[2])
877
+ f.add(buttons, Fixed[1])
878
+ f.add(field, Fixed[1])
346
879
  end
347
880
  end
348
881
 
@@ -354,14 +887,46 @@ module SamplerExample
354
887
  p
355
888
  end
356
889
 
890
+ # The standard demo shell: children stacked with a blank row between them,
891
+ # inset from the window border. Every constraint below reads unqualified —
892
+ # `Fixed`, `Expand`, `Insets` all live on {Tuile::Component::Layout}, which
893
+ # is an ancestor of this class.
894
+ #
895
+ # form do |f|
896
+ # f.add(prompt, Fixed[3])
897
+ # f.add(field, Fixed[1], cross: Fixed[20])
898
+ # f.add(log, Expand[1]) # takes whatever height is left
899
+ # end
900
+ #
901
+ # @return [Tuile::Component::Layout::Vertical]
902
+ def form(&) = Tuile::Component::Layout::Vertical.new(spacing: 1, padding: FORM_PADDING).tap(&)
903
+
904
+ # A tight sub-stack for rows that belong together, nested inside a {#form} to
905
+ # suppress its blank row between them — the grouped-gap idiom, and the reason
906
+ # spacing is a property of the box rather than of each child.
907
+ # @return [Tuile::Component::Layout::Vertical]
908
+ def group(&) = Tuile::Component::Layout::Vertical.new.tap(&)
909
+
910
+ # Widgets side by side, two columns apart.
911
+ # @return [Tuile::Component::Layout::Horizontal]
912
+ def row(&) = Tuile::Component::Layout::Horizontal.new(spacing: 2).tap(&)
913
+
914
+ # One form row: a caption in a fixed left column, then the field.
915
+ # @return [Tuile::Component::Layout::Horizontal]
916
+ def labelled(caption, field, caption_width: 14, field_width: 22)
917
+ row do |r|
918
+ r.add(Tuile::Component::Label.new(caption), Fixed[caption_width])
919
+ r.add(field, Fixed[field_width])
920
+ end
921
+ end
922
+
357
923
  def launcher(description, button_caption, &on_click)
358
924
  label = Tuile::Component::Label.new
359
925
  label.text = description
360
926
  button = Tuile::Component::Button.new(button_caption, &on_click)
361
- panel(label, button) do |r|
362
- inner = inner_rect(r)
363
- label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
364
- button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button.content_size.width, 1)
927
+ form do |f|
928
+ f.add(label, Fixed[3])
929
+ f.add(button, Fixed[1], cross: Fixed[button_width(button)])
365
930
  end
366
931
  end
367
932
 
@@ -410,21 +975,21 @@ module SamplerExample
410
975
  overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
411
976
  end
412
977
 
413
- # Carves a 2-column padding out of the panel rect so the demo content
414
- # doesn't run flush to the window border.
415
- def inner_rect(rect)
416
- pad = 2
417
- Tuile::Rect.new(rect.left + pad, rect.top, [rect.width - (pad * 2), 0].max, rect.height)
418
- end
978
+ # A button's natural width — enough to show "[ caption ]".
979
+ def button_width(button) = button.caption.display_width + 4
419
980
  end
420
981
  end
421
982
 
422
- screen = Tuile::Screen.new
423
- sampler = SamplerExample::Sampler.new
424
- screen.content = sampler
425
- sampler.entry_list.focus
426
- begin
427
- screen.run_event_loop
428
- ensure
429
- screen.close
983
+ # Guard the runner so specs can `require` this file to unit-test the Sampler
984
+ # component tree without spinning up the real event loop.
985
+ if $PROGRAM_NAME == __FILE__
986
+ screen = Tuile::Screen.new
987
+ sampler = SamplerExample::Sampler.new
988
+ screen.content = sampler
989
+ sampler.entry_list.focus
990
+ begin
991
+ screen.run_event_loop
992
+ ensure
993
+ screen.close
994
+ end
430
995
  end