tuile 0.8.0 → 0.10.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -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/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
data/examples/sampler.rb CHANGED
@@ -32,6 +32,30 @@ module SamplerExample
32
32
  end
33
33
  end
34
34
 
35
+ # A {Panel} that runs {#on_tick} on every frame while it is on screen. The
36
+ # ticker is started on attach and cancelled on detach, so selecting another
37
+ # demo — which detaches this pane — cannot leave one firing at the old pane
38
+ # forever. Owning a mounted-lifetime resource this way is the whole point of
39
+ # the attach hooks.
40
+ class TickingPanel < Panel
41
+ def initialize(fps, &layout_block)
42
+ super(&layout_block)
43
+ @fps = fps
44
+ end
45
+
46
+ # @return [Proc, nil] called with no arguments on each frame.
47
+ attr_writer :on_tick
48
+
49
+ def on_attached
50
+ @ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
51
+ end
52
+
53
+ def on_detached
54
+ @ticker&.cancel
55
+ @ticker = nil
56
+ end
57
+ end
58
+
35
59
  # Top-level sampler component. Splits the screen into a left entry list
36
60
  # and a right demo pane; each `load_entry` rebuilds the demo from
37
61
  # scratch so it always starts in a clean state.
@@ -67,10 +91,18 @@ module SamplerExample
67
91
  ["Label", :build_label],
68
92
  ["TextField", :build_text_field],
69
93
  ["TextArea", :build_text_area],
94
+ ["ComboBox", :build_combo_box],
95
+ ["IntegerField", :build_integer_field],
96
+ ["PasswordField", :build_password_field],
70
97
  ["Slash menu", :build_slash_demo],
71
98
  ["TextView", :build_text_view],
72
99
  ["Button", :build_buttons],
100
+ ["Checkbox", :build_checkboxes],
101
+ ["CheckboxGroup", :build_checkbox_group],
102
+ ["RadioGroup", :build_radio_group],
73
103
  ["List", :build_list],
104
+ ["ProgressBar", :build_progress_bar],
105
+ ["Background", :build_background],
74
106
  ["Layout", :build_layout],
75
107
  ["Popup", :build_popup_launcher],
76
108
  ["InfoWindow", :build_info_launcher],
@@ -141,6 +173,73 @@ module SamplerExample
141
173
  end
142
174
  end
143
175
 
176
+ # ComboBox: its value is the selected item, not the typed text — the status
177
+ # line echoes it as it commits. The dropdown closes itself on blur, so no
178
+ # overlay bookkeeping is needed here (unlike the slash demo below).
179
+ def build_combo_box
180
+ prompt = Tuile::Component::Label.new
181
+ prompt.text = "Tab here, then type to filter. ↑↓ move the highlight, Enter accepts, ESC dismisses.\n" \
182
+ "The dropdown floats above or below the field and tints itself apart from the content."
183
+ items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
184
+ combo = Tuile::Component::ComboBox.new(items: items)
185
+ status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
186
+ combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
187
+ panel(prompt, combo, status) do |r|
188
+ inner = inner_rect(r)
189
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
190
+ combo.rect = Tuile::Rect.new(inner.left, inner.top + 5, [inner.width, 30].min, 1)
191
+ status.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, 1)
192
+ end
193
+ end
194
+
195
+ # IntegerField: its value is a typed Integer (or nil), parsed from the
196
+ # digits you type — the status line echoes it. Up/Down step it like a
197
+ # spinner. Only the value seam shows on its face; the digit filtering and
198
+ # parsing are internal.
199
+ def build_integer_field
200
+ prompt = Tuile::Component::Label.new
201
+ prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
202
+ "Up/Down step the value by one; an empty field counts as 0."
203
+ field = Tuile::Component::IntegerField.new
204
+ status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
205
+ field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
206
+ panel(prompt, field, status) do |r|
207
+ inner = inner_rect(r)
208
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
209
+ field.rect = Tuile::Rect.new(inner.left, inner.top + 4, [inner.width, 20].min, 1)
210
+ status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
211
+ end
212
+ end
213
+
214
+ # PasswordField: a TextField that paints a mask instead of its text. The
215
+ # plaintext stays in `value` throughout — the status line proves it by
216
+ # reporting the length — and the "Show password" box flips `revealed`,
217
+ # since a TTY field has no room for an in-field reveal button.
218
+ def build_password_field
219
+ prompt = Tuile::Component::Label.new
220
+ prompt.text = "Tab through the two fields and type. The password paints one * per character,\n" \
221
+ "whatever you type — try a CJK passphrase: the caret still tracks the mask.\n" \
222
+ "Ctrl+Left/Right jump to the ends while masked, so the caret can't give away\n" \
223
+ "where the spaces are; they resume word jumping once revealed."
224
+ user = Tuile::Component::TextField.new
225
+ password = Tuile::Component::PasswordField.new
226
+ reveal = Tuile::Component::Checkbox.new("Show password")
227
+ reveal.on_value_change = ->(on) { password.revealed = on }
228
+ status = Tuile::Component::Label.new
229
+ refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
230
+ refresh.call
231
+ [user, password].each { _1.on_change = ->(_) { refresh.call } }
232
+ panel(prompt, user, password, reveal, status) do |r|
233
+ inner = inner_rect(r)
234
+ width = [inner.width, 30].min
235
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
236
+ user.rect = Tuile::Rect.new(inner.left, inner.top + 6, width, 1)
237
+ password.rect = Tuile::Rect.new(inner.left, inner.top + 8, width, 1)
238
+ reveal.rect = Tuile::Rect.new(inner.left, inner.top + 10, inner.width, 1)
239
+ status.rect = Tuile::Rect.new(inner.left, inner.top + 12, inner.width, 1)
240
+ end
241
+ end
242
+
144
243
  # Slash commands the demo offers; the menu filters these by what's typed.
145
244
  SLASH_COMMANDS = %w[/help /list /open /save /clear /quit].freeze
146
245
 
@@ -249,13 +348,211 @@ module SamplerExample
249
348
  panel(label, ok, cancel, result) do |r|
250
349
  inner = inner_rect(r)
251
350
  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)
351
+ ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, button_width(ok), 1)
352
+ cancel.rect = Tuile::Rect.new(inner.left + button_width(ok) + 2, inner.top + 4,
353
+ button_width(cancel), 1)
255
354
  result.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
256
355
  end
257
356
  end
258
357
 
358
+ CHECKBOX_OPTIONS = ["Enable syslog forwarding", "Rotate logs daily", "Compress archives",
359
+ "Email on failure", "Verbose output"].freeze
360
+
361
+ # Checkbox: Space (or a click on the label) toggles. Each box is handed the
362
+ # full column width, which is what makes the extent visible — the highlight
363
+ # and the click target stop at the end of the caption, not at the rect's.
364
+ def build_checkboxes
365
+ prompt = Tuile::Component::Label.new
366
+ prompt.text = "Tab here, then Space to toggle; a left-click on a label toggles too.\n" \
367
+ "Each box spans the whole column, but only the caption highlights —\n" \
368
+ "clicking the empty space to its right just moves focus."
369
+ status = Tuile::Component::Label.new
370
+ boxes = CHECKBOX_OPTIONS.map { Tuile::Component::Checkbox.new(_1) }
371
+ refresh = lambda do
372
+ on = boxes.select(&:checked?).map { _1.caption.to_s }
373
+ status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
374
+ end
375
+ refresh.call
376
+ boxes.each { _1.on_value_change = ->(_) { refresh.call } }
377
+ panel(prompt, *boxes, status) do |r|
378
+ inner = inner_rect(r)
379
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
380
+ boxes.each_with_index do |box, i|
381
+ box.rect = Tuile::Rect.new(inner.left, inner.top + 5 + i, inner.width, 1)
382
+ end
383
+ status.rect = Tuile::Rect.new(inner.left, inner.top + 6 + boxes.size, inner.width, 1)
384
+ end
385
+ end
386
+
387
+ # One filterable log level: the item type a CheckboxGroup holds. Its `value`
388
+ # is a Set of *these*, never of the labels shown on the rows.
389
+ LogLevel = Data.define(:label, :tag, :color)
390
+
391
+ LOG_LEVELS = [
392
+ LogLevel.new("Debug", "DEBUG", :cyan),
393
+ LogLevel.new("Info", "INFO", :green),
394
+ LogLevel.new("Warnings", "WARN", :yellow),
395
+ LogLevel.new("Errors", "ERROR", :red)
396
+ ].freeze
397
+
398
+ # `[tag, message]` pairs; the tag names the LogLevel that owns the line.
399
+ SAMPLE_LOG = [
400
+ ["DEBUG", "config loaded from /etc/tuile.conf"],
401
+ ["INFO", "listening on 0.0.0.0:8080"],
402
+ ["DEBUG", "cache warm: 128 entries"],
403
+ ["WARN", "TLS certificate expires in 6 days"],
404
+ ["INFO", "GET /health 200 (1.2ms)"],
405
+ ["DEBUG", "pool checkout: 3/16 busy"],
406
+ ["ERROR", "upstream timeout after 5000ms"],
407
+ ["INFO", "GET /index 200 (18ms)"],
408
+ ["WARN", "slow query: 1.8s SELECT * FROM tiles"],
409
+ ["DEBUG", "gc pause 4ms"],
410
+ ["ERROR", "connection reset by peer (retrying)"],
411
+ ["INFO", "POST /tiles 201 (32ms)"],
412
+ ["DEBUG", "pool checkout: 11/16 busy"],
413
+ ["WARN", "queue depth 240, above the 200 mark"],
414
+ ["INFO", "GET /tiles/42 200 (7ms)"],
415
+ ["ERROR", "failed to write /var/log/tuile.log: no space left"],
416
+ ["DEBUG", "flush wrote 96 cells"],
417
+ ["INFO", "shutdown signal received"]
418
+ ].freeze
419
+
420
+ # CheckboxGroup filtering an adjacent log. Its value is a Set of the selected
421
+ # *items* — LogLevel objects, not their labels — so the filter below is plain
422
+ # set membership, no lookup table. Rows come from `item_label`, which may
423
+ # return styled text (these colors are inherent to the data, not theme
424
+ # accents, so they need no on_theme_changed hook).
425
+ def build_checkbox_group
426
+ prompt = Tuile::Component::Label.new
427
+ # Kept under 48 columns a line, so an 80-column terminal shows it whole.
428
+ prompt.text = "Tab here. ↑↓ moves the cursor, Space toggles.\n" \
429
+ "Enter or a click anywhere on a row toggles too —\n" \
430
+ "in a list, the whole row is the target.\n" \
431
+ "The log redraws from the value on every toggle."
432
+ levels_by_tag = LOG_LEVELS.to_h { [_1.tag, _1] }
433
+ entries = SAMPLE_LOG.map { |tag, message| [levels_by_tag.fetch(tag), message] }
434
+
435
+ group = Tuile::Component::CheckboxGroup.new(items: LOG_LEVELS, value: LOG_LEVELS.last(2))
436
+ group.item_label = ->(level) { Rainbow(level.label).color(level.color) }
437
+
438
+ log = Tuile::Component::List.new
439
+ log.cursor = Tuile::Component::List::Cursor.new
440
+ log.scrollbar_visibility = :visible
441
+ status = Tuile::Component::Label.new
442
+
443
+ refresh = lambda do
444
+ selected = group.value
445
+ log.lines = entries.select { |level, _| selected.include?(level) }
446
+ .map { |level, message| "#{Rainbow(level.tag.ljust(5)).color(level.color)} #{message}" }
447
+ # The Set iterates in *toggle* order, so intersect with items to report
448
+ # it in the order the rows are shown — the documented idiom.
449
+ shown = (LOG_LEVELS & selected.to_a).map(&:label)
450
+ status.text = "value: {#{shown.join(", ")}} — #{log.lines.size} of #{entries.size} lines"
451
+ end
452
+ refresh.call
453
+ group.on_value_change = ->(_set) { refresh.call }
454
+
455
+ panel(prompt, group, log, status) do |r|
456
+ inner = inner_rect(r)
457
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
458
+ # Status above the body, so it stays next to the group however tall the
459
+ # pane gets; the log takes whatever height is left.
460
+ status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
461
+ top = inner.top + 8
462
+ body_height = [inner.height - 9, LOG_LEVELS.size].max
463
+ group_width = [16, inner.width / 3].min
464
+ group.rect = Tuile::Rect.new(inner.left, top, group_width, LOG_LEVELS.size)
465
+ log.rect = Tuile::Rect.new(inner.left + group_width + 2, top,
466
+ [inner.width - group_width - 2, 4].max, body_height)
467
+ end
468
+ end
469
+
470
+ # One sort order: the item type a RadioGroup holds. Its `value` is one of
471
+ # *these*, and the chosen object carries the behavior — so re-sorting is
472
+ # `value.sorter.call(files)`, never a lookup from a label back to a
473
+ # comparator.
474
+ SortOrder = Data.define(:label, :sorter)
475
+
476
+ SORT_ORDERS = [
477
+ SortOrder.new("Name A-Z", ->(files) { files.sort_by(&:name) }),
478
+ SortOrder.new("Name Z-A", ->(files) { files.sort_by(&:name).reverse }),
479
+ SortOrder.new("Biggest", ->(files) { files.sort_by { -_1.size } }),
480
+ SortOrder.new("Newest", ->(files) { files.sort_by(&:date).reverse })
481
+ ].freeze
482
+
483
+ SampleFile = Data.define(:name, :size, :date)
484
+
485
+ # Names stay under 14 columns and sizes round to distinct k values, so the
486
+ # rows fit the pane at 80 columns and every sort order reorders visibly.
487
+ SAMPLE_FILES = [
488
+ SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
489
+ SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
490
+ SampleFile.new("DECISIONS.md", 48_990, "2026-07-31"),
491
+ SampleFile.new("Gemfile", 312, "2026-06-18"),
492
+ SampleFile.new("README.md", 9674, "2026-07-12"),
493
+ SampleFile.new("Rakefile", 2118, "2026-06-18"),
494
+ SampleFile.new("list.rb", 21_006, "2026-07-24"),
495
+ SampleFile.new("sampler.rb", 22_180, "2026-07-31"),
496
+ SampleFile.new("screen.rb", 18_442, "2026-07-28"),
497
+ SampleFile.new("text_view.rb", 14_338, "2026-07-19"),
498
+ SampleFile.new("theme.rb", 6512, "2026-07-23"),
499
+ SampleFile.new("tuile.gemspec", 1284, "2026-06-20")
500
+ ].freeze
501
+
502
+ # RadioGroup driving an adjacent file list. Two things worth watching: the
503
+ # value is the selected *item* (a SortOrder carrying its own comparator),
504
+ # and the cursor is chrome — arrows move it without touching the value, so
505
+ # the status line's two halves drift apart until you press Space.
506
+ def build_radio_group
507
+ prompt = Tuile::Component::Label.new
508
+ # Kept under 48 columns a line, so an 80-column terminal shows it whole.
509
+ prompt.text = "Tab here. ↑↓ move the cursor only.\n" \
510
+ "Space, Enter or a click selects — and only\n" \
511
+ "then does the list re-sort. Picking another\n" \
512
+ "clears the previous; there is no deselect."
513
+
514
+ group = Tuile::Component::RadioGroup.new(items: SORT_ORDERS, value: SORT_ORDERS.first)
515
+ group.item_label = :label.to_proc
516
+
517
+ files = Tuile::Component::List.new
518
+ files.cursor = Tuile::Component::List::Cursor.new
519
+ files.scrollbar_visibility = :visible
520
+ status = Tuile::Component::Label.new
521
+ short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
522
+
523
+ update_status = lambda do
524
+ under_cursor = SORT_ORDERS[group.content.cursor.position]
525
+ status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
526
+ end
527
+ resort = lambda do
528
+ files.lines = group.value.sorter.call(SAMPLE_FILES).map do |file|
529
+ "#{file.name.ljust(13)} #{short_size.call(file.size).rjust(4)} #{file.date}"
530
+ end
531
+ update_status.call
532
+ end
533
+ resort.call
534
+ group.on_value_change = ->(_order) { resort.call }
535
+ # `content` is the composed List, which is where the cursor lives.
536
+ # Watching it is what makes the chrome/value split visible above.
537
+ group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
538
+
539
+ panel(prompt, group, files, status) do |r|
540
+ inner = inner_rect(r)
541
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
542
+ # Status above the body, so it stays next to the group however tall the
543
+ # pane gets; the file list takes whatever height is left.
544
+ status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
545
+ top = inner.top + 8
546
+ body_height = [inner.height - 9, SORT_ORDERS.size].max
547
+ # List pads a column either side of a row, so a label needs
548
+ # `width - 2`; the file rows lose one more to their scrollbar.
549
+ group_width = [14, inner.width / 3].min
550
+ group.rect = Tuile::Rect.new(inner.left, top, group_width, SORT_ORDERS.size)
551
+ files.rect = Tuile::Rect.new(inner.left + group_width + 2, top,
552
+ [inner.width - group_width - 2, 4].max, body_height)
553
+ end
554
+ end
555
+
259
556
  def build_list
260
557
  list = Tuile::Component::List.new
261
558
  list.cursor = Tuile::Component::List::Cursor.new
@@ -264,6 +561,119 @@ module SamplerExample
264
561
  list
265
562
  end
266
563
 
564
+ # Files the fake job in the ProgressBar demo pretends to process.
565
+ PROGRESS_TOTAL = 50
566
+
567
+ # Frames per second of the demo's fake job — its own pace, unrelated to
568
+ # {Tuile::Component::ProgressBar::INDETERMINATE_FPS}, which paces only the
569
+ # animation the bar runs for itself.
570
+ PROGRESS_FPS = 8
571
+
572
+ # Two bars: a determinate one whose value a pane-owned ticker walks up and
573
+ # wraps around, and an indeterminate one that animates itself. Neither
574
+ # paints text — the lines beneath them are sibling Labels fed from
575
+ # {Tuile::Component::ProgressBar#percent}, which is what lets the app word
576
+ # the progress ("42% — 21/50 files") instead of taking whatever the widget
577
+ # would have formatted.
578
+ def build_progress_bar
579
+ prompt = Tuile::Component::Label.new
580
+ prompt.text = "A ProgressBar paints no text of its own —\n" \
581
+ "the line below it is a sibling Label fed\n" \
582
+ "from bar.percent. A ticker owned by this\n" \
583
+ "pane advances the value while it's on screen."
584
+
585
+ bar = Tuile::Component::ProgressBar.new(range: 0..PROGRESS_TOTAL)
586
+ bar.bar_color = Tuile::Color::GREEN
587
+ status = Tuile::Component::Label.new
588
+
589
+ spinner = Tuile::Component::ProgressBar.new(indeterminate: true)
590
+ spinner_caption = Tuile::Component::Label.new
591
+ spinner_caption.text = "Indeterminate: no total yet, so the bar owns\n" \
592
+ "its own animation — no ticker in the app."
593
+
594
+ done = 0
595
+ refresh = -> { status.text = "#{bar.percent}% — #{done}/#{PROGRESS_TOTAL} files" }
596
+ refresh.call
597
+
598
+ pane = TickingPanel.new(PROGRESS_FPS) do |r|
599
+ inner = inner_rect(r)
600
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
601
+ bar.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
602
+ status.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, 1)
603
+ spinner.rect = Tuile::Rect.new(inner.left, inner.top + 9, inner.width, 1)
604
+ spinner_caption.rect = Tuile::Rect.new(inner.left, inner.top + 10, inner.width, 2)
605
+ end
606
+ pane.add([prompt, bar, status, spinner, spinner_caption])
607
+ pane.on_tick = lambda do
608
+ done = done < PROGRESS_TOTAL ? done + 1 : 0
609
+ bar.value = done
610
+ refresh.call
611
+ end
612
+ pane
613
+ end
614
+
615
+ # One background the Background demo offers: a display label and the value
616
+ # handed to {Component#bg_color=} — a live {Tuile::Theme::Ref}, a hard-coded
617
+ # {Tuile::Color}, or nil (terminal default).
618
+ BgChoice = Data.define(:label, :color)
619
+
620
+ # The palette the Background combo filters over: theme refs first (they
621
+ # re-resolve on a light/dark flip, so they track the scheme), then a spread
622
+ # of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
623
+ BG_CHOICES = [
624
+ BgChoice.new("None (terminal default)", nil),
625
+ BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
626
+ BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
627
+ BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
628
+ BgChoice.new("ANSI blue", Tuile::Color::BLUE),
629
+ BgChoice.new("ANSI magenta", Tuile::Color::MAGENTA),
630
+ BgChoice.new("ANSI bright black", Tuile::Color::BRIGHT_BLACK),
631
+ BgChoice.new("Palette 236 (charcoal)", Tuile::Color.palette(236)),
632
+ BgChoice.new("Palette 22 (deep green)", Tuile::Color.palette(22)),
633
+ BgChoice.new("Deep purple (RGB)", Tuile::Color.rgb(48, 25, 82)),
634
+ BgChoice.new("Midnight teal (RGB)", Tuile::Color.rgb(10, 40, 45)),
635
+ BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
636
+ ].freeze
637
+
638
+ def build_background
639
+ intro = Tuile::Component::Label.new
640
+ intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
641
+ "Pick one below — this label and the list inherit it; input widgets keep their own well.\n" \
642
+ "Theme refs track light/dark flips; hard-coded colors stay put."
643
+
644
+ list = Tuile::Component::List.new
645
+ list.cursor = Tuile::Component::List::Cursor.new
646
+ list.lines = (1..12).map { |i| "List row #{i}" }
647
+ field = Tuile::Component::TextField.new
648
+ field.text = "TextField keeps its own background"
649
+
650
+ # A borderless sub-box holding the list + field; it inherits the tint too.
651
+ box = panel(list, field) do |r|
652
+ list_h = [r.height - 2, 1].max
653
+ list.rect = Tuile::Rect.new(r.left, r.top, r.width, list_h)
654
+ field.rect = Tuile::Rect.new(r.left, r.top + list_h + 1, r.width, 1)
655
+ end
656
+
657
+ # A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
658
+ # the tint flows down to every descendant without its own background — the
659
+ # label and the list — while the input widgets (the combo, the field) keep
660
+ # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
661
+ # the hard-coded Colors are fixed by design, so no on_theme_changed here.
662
+ outer = nil
663
+ combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
664
+ combo.item_label = :label.to_proc
665
+ combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
666
+
667
+ outer = panel(intro, combo, box) do |r|
668
+ inner = inner_rect(r)
669
+ intro.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
670
+ combo.rect = Tuile::Rect.new(inner.left, inner.top + 5, [inner.width, 40].min, 1)
671
+ box.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, [inner.height - 8, 2].max)
672
+ end
673
+ combo.value = BG_CHOICES.first # show "None" as the resting selection
674
+ outer
675
+ end
676
+
267
677
  def build_layout
268
678
  left = Tuile::Component::Window.new("Left")
269
679
  left.content = Tuile::Component::Label.new.tap { _1.text = "Nested left window." }
@@ -319,11 +729,9 @@ module SamplerExample
319
729
 
320
730
  def build_log_window
321
731
  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
- ])
732
+ ["LogWindow is a Window wrapping an auto-scrolling TextView.",
733
+ "Lines are appended via #log (safe from any thread).",
734
+ "Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
327
735
  log
328
736
  end
329
737
 
@@ -339,9 +747,9 @@ module SamplerExample
339
747
  panel(label, a, b, field) do |r|
340
748
  inner = inner_rect(r)
341
749
  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)
750
+ a.rect = Tuile::Rect.new(inner.left, inner.top + 4, button_width(a), 1)
751
+ b.rect = Tuile::Rect.new(inner.left + button_width(a) + 2, inner.top + 4,
752
+ button_width(b), 1)
345
753
  field.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
346
754
  end
347
755
  end
@@ -361,7 +769,7 @@ module SamplerExample
361
769
  panel(label, button) do |r|
362
770
  inner = inner_rect(r)
363
771
  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)
772
+ button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button_width(button), 1)
365
773
  end
366
774
  end
367
775
 
@@ -410,6 +818,9 @@ module SamplerExample
410
818
  overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
411
819
  end
412
820
 
821
+ # A button's natural width — enough to show "[ caption ]".
822
+ def button_width(button) = button.caption.display_width + 4
823
+
413
824
  # Carves a 2-column padding out of the panel rect so the demo content
414
825
  # doesn't run flush to the window border.
415
826
  def inner_rect(rect)
@@ -419,12 +830,16 @@ module SamplerExample
419
830
  end
420
831
  end
421
832
 
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
833
+ # Guard the runner so specs can `require` this file to unit-test the Sampler
834
+ # component tree without spinning up the real event loop.
835
+ if $PROGRAM_NAME == __FILE__
836
+ screen = Tuile::Screen.new
837
+ sampler = SamplerExample::Sampler.new
838
+ screen.content = sampler
839
+ sampler.entry_list.focus
840
+ begin
841
+ screen.run_event_loop
842
+ ensure
843
+ screen.close
844
+ end
430
845
  end
@@ -0,0 +1,109 @@
1
+ # Components Vaadin has and Tuile doesn't — the survey
2
+
3
+ **Status:** survey done 2026-07-25 against Vaadin **25.2** (54 free/OSS
4
+ components, via the Vaadin docs MCP). This file is the roadmap; each
5
+ component we actually decide to build gets its own `ideas/<name>.md`.
6
+ Retire this file once the interesting part of the list is either built or
7
+ explicitly rejected — the tiering below is the only nugget worth keeping,
8
+ and it belongs here, not in a durable doc, because it goes stale as we
9
+ build.
10
+
11
+ Batch 1 ("field components only") is **done** — every idea filed under it has
12
+ graduated: `checkbox` (`DECISIONS.md` `D-boolean-fields`) and `checkbox-group`
13
+ (`D-checkbox-group`), both built 2026-07-30; `radio-group` (`D-radio-group`),
14
+ built 2026-07-31; `progress-bar` (`D-color-slots`, book ch7 "Reporting
15
+ progress") and `password-field` (`D-integer-field`'s taxonomy, book ch7
16
+ "Editing text"), both built 2026-08-02.
17
+
18
+ ## What Tuile already has
19
+
20
+ Seven of the 54 have a counterpart: Button, Text Field, Text Area,
21
+ Integer Field, Combo Box (1:1), Dialog ({Tuile::Component::Popup} plus
22
+ `Window`/`InfoWindow`/`PickerWindow`), Themable Mixin
23
+ ({Tuile::Theme}/{Tuile::ThemeDef}). **List Box** is half-there:
24
+ {Tuile::Component::List} is line-based, with no typed items and no
25
+ multi-select.
26
+
27
+ Tuile also has pieces Vaadin doesn't name as components — `ListDropdown`,
28
+ `TextView`, `LogWindow`, `VerticalScrollBar` — so the gap is not
29
+ symmetric.
30
+
31
+ That leaves ~46 gaps.
32
+
33
+ ## Tier 1 — reachable from what exists (S–M each)
34
+
35
+ | Component | Builds on | Note |
36
+ |---|---|---|
37
+ | Box layouts (H/V) | `Layout` | Tuile has only `Layout::Absolute`. Biggest structural win; unblocks half of this table |
38
+ | ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`D-boolean-fields`); tri-state still deferred |
39
+ | ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`D-radio-group`); composes a `List`, cursor roams and Space selects |
40
+ | ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`D-checkbox-group`); composes a `List`, frozen `Set` value |
41
+ | Select | `ComboBox` − filter | ComboBox with a read-only field; near-free. Deferred once already in `D-combobox` (wants the parked read-only axis) |
42
+ | Password Field | `TextField` | masked repaint only |
43
+ | Number Field | `IntegerField` twin | same composed-field shape, `Float` |
44
+ | Progress Bar | `draw_line` + `EventQueue#tick_fps` | ticker for the indeterminate mode |
45
+ | Notification | `Popup` + `Ticker` | needs corner-anchored (non-centered) popup placement |
46
+ | Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
47
+ | Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
48
+ | Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
49
+ | Popover | extract `ComboBox#anchor` + `ListDropdown` geometry | generalize the anchored non-modal overlay; gates the next two |
50
+ | Menu Bar | `ListDropdown::Menu` + Popover | |
51
+ | Context Menu | same | `:right` button already parses |
52
+ | Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
53
+ | Breadcrumbs | `Label`/`StyledString` | clickable path segments |
54
+ | Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
55
+
56
+ ## Tier 2 — worth doing, each blocked on a new seam
57
+
58
+ | Component | Blocked on |
59
+ |---|---|
60
+ | **Grid** (the flagship gap) | column model + renderer strategies + typed items + horizontal scroll (L) |
61
+ | Form Layout | a field label/helper seam (Vaadin's `HasLabel`) — Tuile fields carry no caption |
62
+ | Email Field | a validation seam (`HasValidation`: invalid state + error line) |
63
+ | Date / Time / DateTime Picker | calendar-grid popup over Popover (L) |
64
+ | Multi Select Combo Box | Checkbox Group + ComboBox |
65
+ | Split Layout → Master Detail Layout | mouse **motion/drag**: Tuile runs X10 mode 1000 (press only, no release, no motion) |
66
+ | Virtual List | a lazy data-provider strategy on `List` |
67
+ | Side Nav | hierarchical collapsible list (the sampler's nav is the prototype) |
68
+ | App Layout | shell: title bar + drawer + content slot |
69
+ | Custom Field | formalize the composed typed-field pattern — or *reject* it as a shared base (COP: inherit to *be*, not to share); `D-integer-field` already sketches the taxonomy |
70
+ | Message Input / Message List / Login | nothing — pure assemblies, good example fodder |
71
+ | Upload | reinterpret as a file-chooser dialog (`file_commander` has the ingredients) |
72
+ | Icon | a glyph / Nerd-Font constants module |
73
+
74
+ ## Tier 3 — design tension or marginal
75
+
76
+ - **Scroller** — scrolling *arbitrary* content needs clipping/viewport
77
+ machinery and pushes against the top-down layout invariant (it wants to
78
+ measure content). Best kept as a documented road-not-taken.
79
+ - **Tooltip** — competes with Tuile's status-bar `keyboard_hint` idiom.
80
+ - **Card** — overlaps `Window` almost entirely.
81
+ - **Avatar / Avatar Group** — initials in a box; little value on a TTY.
82
+ - **Not applicable:** Field Highlighter (collaboration), Themable Mixin
83
+ (covered by `Theme`).
84
+
85
+ ## Infrastructure that gates clusters
86
+
87
+ These are prerequisites, not components, and each deserves its own idea
88
+ file when its cluster comes up:
89
+
90
+ 1. **Box layouts** (H/V) — everything form-shaped wants them.
91
+ 2. **Field label + helper text seam** → Form Layout.
92
+ 3. **Validation seam** → Email Field, forms generally.
93
+ 4. **Anchored Popover extraction** → Menu Bar, Context Menu, pickers,
94
+ Tooltip.
95
+ 5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
96
+ divider, Slider drag, scrollbar drag.
97
+ 6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
98
+ List.
99
+
100
+ Vaadin's `Binder` is the natural companion for the forms cluster but is
101
+ not a component; `D-has-value` already parks the forms-layer questions
102
+ (converters, read-only, required indicator).
103
+
104
+ ## ~~Cross-cutting open question: component color slots vs. theme tokens~~
105
+
106
+ **Settled 2026-08-01 as `DECISIONS.md` `D-color-slots`** — the slot, defaulting
107
+ to `nil`. Slider and Badge are bound by it when they land; Badge's promotion
108
+ trigger (a *second* built-in needing the same semantic color) is written up
109
+ there, so neither has to re-argue it.