tuile 0.13.0 → 0.15.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
data/examples/sampler.rb CHANGED
@@ -109,6 +109,37 @@ module SamplerExample
109
109
  end
110
110
  end
111
111
 
112
+ # A {Tuile::Component::TextArea} that steers a {Tuile::Component::ListDropdown}
113
+ # while it is open: movement keys move the highlight, ENTER accepts and ESC
114
+ # dismisses, and everything else — printables, editing, the ENTER that inserts
115
+ # a newline with no menu up — stays the TextArea's own.
116
+ #
117
+ # Subclassing *is* the seam for this. A component receives keys through
118
+ # `handle_key`, so one that wants different keys overrides it (here its
119
+ # `handle_text_input_key` hook) and calls `super` for the rest, which composes
120
+ # and stacks. None of this is baked into TextArea.
121
+ class SlashCommandTextArea < Tuile::Component::TextArea
122
+ # @param overlay [Tuile::Component::ListDropdown] the menu to steer.
123
+ def initialize(overlay)
124
+ super()
125
+ @overlay = overlay
126
+ end
127
+
128
+ protected
129
+
130
+ def handle_text_input_key(key)
131
+ return super unless @overlay.open?
132
+ return true if @overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
133
+
134
+ case key
135
+ when Tuile::Keys::ENTER then @overlay.choose
136
+ when Tuile::Keys::ESC then @overlay.close
137
+ else return super
138
+ end
139
+ true
140
+ end
141
+ end
142
+
112
143
  # Top-level sampler component: a shell row across the top — a
113
144
  # {Tuile::Component::MenuBar} of the demos, grouped, and a
114
145
  # {Tuile::Component::ComboBox} jump box at its right end — over one demo
@@ -134,7 +165,7 @@ module SamplerExample
134
165
  attr_reader :demo_window, :menu_bar, :jump_box
135
166
 
136
167
  # The bottom row. Tuile draws no status bar and reserves no row
137
- # (`D-status-bar`) — this one is the sampler's own, kept current by
168
+ # (`D_status_bar`) — this one is the sampler's own, kept current by
138
169
  # {Tuile::Screen#on_focus_changed=}. Naming the focused component makes Tab
139
170
  # traversal visible as you walk a pane, which no per-pane label shows.
140
171
  # @return [void]
@@ -199,7 +230,11 @@ module SamplerExample
199
230
  Menu.new("Typed", "y", [
200
231
  Entry.new("IntegerField", :build_integer_field, "i"),
201
232
  Entry.new("FloatField", :build_float_field, "f"),
202
- Entry.new("BigDecimalField", :build_big_decimal_field, "b")
233
+ Entry.new("BigDecimalField", :build_big_decimal_field, "b"),
234
+ Entry.new("DateField", :build_date_field, "d"),
235
+ Entry.new("TimeField", :build_time_field, "t"),
236
+ Entry.new("Bad input", :build_bad_input, "a"),
237
+ Entry.new("Validation", :build_validation, "v")
203
238
  ]),
204
239
  Menu.new("Choose", "c", [
205
240
  Entry.new("Checkbox", :build_checkboxes, "c"),
@@ -216,6 +251,7 @@ module SamplerExample
216
251
  Menu.new("Overlay", "o", [
217
252
  Entry.new("Popup", :build_popup_launcher, "p"),
218
253
  Entry.new("Notification", :build_notification_launcher, "n"),
254
+ Entry.new("ConfirmWindow", :build_confirm_launcher, "c"),
219
255
  Entry.new("InfoWindow", :build_info_launcher, "i"),
220
256
  Entry.new("PickerWindow", :build_picker_launcher, "k"),
221
257
  Entry.new("LogWindow", :build_log_window, "l")
@@ -226,6 +262,7 @@ module SamplerExample
226
262
  Entry.new("Narrow strips", :build_narrow_strips, "n"),
227
263
  Entry.new("Layout", :build_layout, "l"),
228
264
  Entry.new("Background", :build_background, "b"),
265
+ Entry.new("Visibility", :build_visibility, "v"),
229
266
  Entry.new("Focus & Tab", :build_focus_demo, "f")
230
267
  ])
231
268
  ].freeze
@@ -308,10 +345,13 @@ module SamplerExample
308
345
 
309
346
  def build_text_field
310
347
  prompt = Tuile::Component::Label.new
311
- prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
348
+ prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work.\n" \
349
+ "While it is empty it shows a placeholder — a hint in ink faint " \
350
+ "enough to miss, which is the point."
312
351
  field = Tuile::Component::TextField.new
352
+ field.placeholder = "dd.mm.yyyy"
313
353
  form do |f|
314
- f.add(prompt, Fixed[1])
354
+ f.add(prompt, Fixed[2])
315
355
  f.add(field, Fixed[1])
316
356
  end
317
357
  end
@@ -407,6 +447,8 @@ module SamplerExample
407
447
  prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
408
448
  "Up/Down step the value by one; an empty field counts as 0."
409
449
  field = Tuile::Component::IntegerField.new
450
+ # Set on the composed field, not on the TextField inside it.
451
+ field.placeholder = "1-65535"
410
452
  status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
411
453
  field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
412
454
  form do |f|
@@ -453,6 +495,185 @@ module SamplerExample
453
495
  end
454
496
  end
455
497
 
498
+ # DateField: several formats in, one format out. Type a date in any of the
499
+ # three spellings this pane accepts and Tab away — the field rewrites it in
500
+ # the *first* one, which is how the user sees that it understood them. Both
501
+ # halves of the settling rule are visible here too: nothing reddens while a
502
+ # date is being typed, and a buffer that never parsed reddens on the way
503
+ # out. The echo row is deliberately driven by `on_value_change` alone, so
504
+ # garbage leaves it stale: bad_input? is a *pull*, and the button is the "a
505
+ # form asks once, at the click" gesture that consults it (it also gives Tab
506
+ # somewhere to go, which is what makes both behaviours visible at all).
507
+ def build_date_field
508
+ prompt = Tuile::Component::Label.new
509
+ prompt.text = "Tab here, then type 4.9.2026 — or 09/04/2026, or 2026-09-04.\n" \
510
+ "Tab away or press Enter and a buffer that parses is rewritten as yyyy-mm-dd;\n" \
511
+ "one that doesn't is left as you typed it, and *then* the well goes red.\n" \
512
+ "Up/Down step a day, and an empty field steps to today."
513
+ field = Tuile::Component::DateField.new
514
+ field.formats = ["%Y-%m-%d", "%d.%m.%Y", "%m/%d/%Y"]
515
+ status = Tuile::Component::Label.new
516
+ # to_s, not inspect: Date#inspect spells out the Julian day and the
517
+ # calendar reform, which is noise next to the one fact this row is for.
518
+ report = -> { status.text = "value: #{field.value&.to_s || "nil"} bad_input?: #{field.bad_input?}" }
519
+ report.call
520
+ field.on_value_change = ->(_value) { report.call }
521
+ ask = Tuile::Component::Button.new("Ask again") { report.call }
522
+ form do |f|
523
+ f.add(prompt, Fixed[4])
524
+ f.add(field, Fixed[1], cross: Fixed[20])
525
+ f.add(status, Fixed[1])
526
+ f.add(ask, Fixed[1], cross: Fixed[button_width(ask)])
527
+ end
528
+ end
529
+
530
+ # TimeField: one knob for two things, and the reason it is one. Both fields
531
+ # below sit under a *Finnish* spelling that this pane installs on the
532
+ # screen, because the whole point is invisible under ISO: switching
533
+ # precision must not cost you the locale's separator. So the left field
534
+ # shows 13.45 and the right 13.45.00 — a dot either way, which a per-field
535
+ # format override could not have managed.
536
+ def build_time_field
537
+ # Assigned here rather than detected, so the pane demonstrates the same
538
+ # thing on an American box as on a Finnish one.
539
+ Tuile::Screen.instance.locale = Tuile::Locale::ISO.with(time_formats: ["%H.%M.%S", "%H:%M:%S"])
540
+ prompt = Tuile::Component::Label.new
541
+ prompt.text = "The session spells times the Finnish way (13.45). Tab into either field.\n" \
542
+ "Up/Down step a minute on the left, a second on the right (PageUp/PageDown an hour in both) —\n" \
543
+ "one knob, because precision is a property of the format the buffer holds.\n" \
544
+ "Note the dot survives the switch: that is what a per-field format would cost."
545
+ minutes = time_field_with(step: 60)
546
+ seconds = time_field_with(step: 1)
547
+ status = Tuile::Component::Label.new
548
+ report = lambda do
549
+ status.text = "minutes: #{minutes.value&.strftime("%H:%M:%S") || "nil"} (#{minutes.formats.first}) " \
550
+ "seconds: #{seconds.value&.strftime("%H:%M:%S") || "nil"} (#{seconds.formats.first})"
551
+ end
552
+ report.call
553
+ [minutes, seconds].each { _1.on_value_change = ->(_value) { report.call } }
554
+ form do |f|
555
+ f.add(prompt, Fixed[4])
556
+ f.add(labelled("Minute stride", minutes, field_width: 12), Fixed[1], cross: Fixed[30])
557
+ f.add(labelled("Second stride", seconds, field_width: 12), Fixed[1], cross: Fixed[30])
558
+ f.add(status, Fixed[1])
559
+ end
560
+ end
561
+
562
+ # @param step [Integer]
563
+ # @return [Tuile::Component::TimeField]
564
+ def time_field_with(step:)
565
+ Tuile::Component::TimeField.new.tap do |field|
566
+ field.step = step
567
+ field.set_to(13, 45)
568
+ end
569
+ end
570
+
571
+ # HasBadInput: the one fact on_value_change cannot carry. Type a lone "-"
572
+ # and watch the echo row stay silent — the value was nil before and is nil
573
+ # after, so there is no diff to report — while Save, which asks bad_input?
574
+ # instead of empty?, refuses and names the field.
575
+ def build_bad_input
576
+ prompt = Tuile::Component::Label.new
577
+ prompt.text = "Type a lone '-' into Amount (or '1e' into Rate), then press Save.\n" \
578
+ "Both read value: nil and empty?: true, exactly like an untouched field —\n" \
579
+ "which is why a form must ask bad_input? before it saves a nil over your work."
580
+ # The one pane that tags its widgets with `id`, so the sampler's own spec
581
+ # can drive them through Tuile::Testing.get by name.
582
+ amount = Tuile::Component::IntegerField.new.tap { _1.id = :amount }
583
+ rate = Tuile::Component::FloatField.new.tap { _1.id = :rate }
584
+ echo = Tuile::Component::Label.new.tap { _1.text = "on_value_change: (nothing yet)" }
585
+ amount.on_value_change = ->(v) { echo.text = "on_value_change: amount = #{v.inspect}" }
586
+ rate.on_value_change = ->(v) { echo.text = "on_value_change: rate = #{v.inspect}" }
587
+ save = Tuile::Component::Button.new("Save") { save_form("Amount" => amount, "Rate" => rate) }
588
+ save.id = :save
589
+ rows = group do |g|
590
+ g.add(labelled("Amount", amount), Fixed[1])
591
+ g.add(labelled("Rate", rate), Fixed[1])
592
+ end
593
+ form do |f|
594
+ f.add(prompt, Fixed[3])
595
+ f.add(rows, Fixed[2])
596
+ f.add(echo, Fixed[1])
597
+ f.add(save, Fixed[1], cross: Fixed[button_width(save)])
598
+ end
599
+ end
600
+
601
+ # The login form of `D_has_validation`: the click writes a verdict onto
602
+ # each field and the field paints itself red, while *this* pane owns the
603
+ # cells the messages go in — one Label per field, refilled from
604
+ # `on_error_message_change`. Note the handler sets *or clears* on every
605
+ # pass, which is the whole writer discipline; and that no field computes
606
+ # anything, so nothing here fights the fields' own `bad_input?`.
607
+ def build_validation
608
+ prompt = Tuile::Component::Label.new
609
+ prompt.text = "Press Log in empty, then with a 2-letter username: the message lands in\n" \
610
+ "this pane's cells (via a listener) and the field's well turns red.\n" \
611
+ "Tab between them — an invalid field still shows which one has focus."
612
+ username = Tuile::Component::TextField.new
613
+ password = Tuile::Component::PasswordField.new
614
+ fields = { "Username" => username, "Password" => password }
615
+ rows = group do |g|
616
+ fields.each { |caption, field| g.add(validated_row(caption, field), Fixed[1]) }
617
+ end
618
+ login = Tuile::Component::Button.new("Log in") { validate_login(fields) }
619
+ form do |f|
620
+ f.add(prompt, Fixed[3])
621
+ f.add(rows, Fixed[2])
622
+ f.add(login, Fixed[1], cross: Fixed[button_width(login)])
623
+ end
624
+ end
625
+
626
+ # @param caption [String]
627
+ # @param field [Tuile::Component] a field carrying {Tuile::Component::HasValidation}.
628
+ # @return [Tuile::Component] a row of caption, field, and the error Label
629
+ # the field's listener keeps current.
630
+ def validated_row(caption, field)
631
+ error = Tuile::Component::Label.new
632
+ field.on_error_message_change = ->(msg) { error.text = msg || Tuile::StyledString::EMPTY }
633
+ row do |r|
634
+ r.add(Tuile::Component::Label.new(caption), Fixed[14])
635
+ r.add(field, Fixed[22])
636
+ r.add(error, Expand[1])
637
+ end
638
+ end
639
+
640
+ # Two rules, so the pane shows both halves: "required" has nothing to tint,
641
+ # "too short" does.
642
+ # @param fields [Hash{String => Tuile::Component}] caption => field.
643
+ def validate_login(fields)
644
+ fields.each { |caption, field| field.error_message = login_problem(caption, field) }
645
+ return if fields.each_value.any?(&:error_message)
646
+
647
+ Tuile::Component::ConfirmWindow.alert("Logged in", "Welcome, #{fields["Username"].value}.")
648
+ end
649
+
650
+ # @param caption [String]
651
+ # @param field [Tuile::Component]
652
+ # @return [String, nil] the verdict, or `nil` — the *clear* half of
653
+ # set-or-clear-on-every-pass, without which a fixed field stays red.
654
+ def login_problem(caption, field)
655
+ return "#{caption} is required" if field.empty?
656
+ return "at least 3 characters" if field.value.length < 3
657
+
658
+ nil
659
+ end
660
+
661
+ # The Save gate of `D_bad_input`: asked once, at the click, so the
662
+ # continuously-true fact ("2" is a bad date on the way to "2026") is only
663
+ # ever read in a settled state.
664
+ # @param fields [Hash{String => Tuile::Component}] caption => field.
665
+ def save_form(fields)
666
+ bad = fields.filter_map do |caption, field|
667
+ "#{caption}: #{field.bad_input_message}" if field.respond_to?(:bad_input?) && field.bad_input?
668
+ end
669
+ if bad.empty?
670
+ values = fields.map { |caption, field| "#{caption}: #{field.value.inspect}" }
671
+ Tuile::Component::ConfirmWindow.alert("Saved", values.join("\n"))
672
+ else
673
+ Tuile::Component::ConfirmWindow.alert("Cannot save", "#{bad.size} problem(s):\n#{bad.join("\n")}")
674
+ end
675
+ end
676
+
456
677
  # @param value [BigDecimal, nil]
457
678
  # @return [String] the value tripled exactly, next to the same sum in Float.
458
679
  def triple_report(value)
@@ -491,52 +712,36 @@ module SamplerExample
491
712
  # Slash commands the demo offers; the menu filters these by what's typed.
492
713
  SLASH_COMMANDS = %w[/help /list /open /save /clear /quit].freeze
493
714
 
494
- # A non-modal Popup used as an autocomplete menu. Focus (and the caret)
495
- # stays in the TextArea the whole time: an `on_change` listener refills the
496
- # menu, an `on_key` interceptor forwards Up/Down/Enter/ESC to it while it's
497
- # open, and the menu floats above the field, anchored to the caret. None of
498
- # this is baked into TextArea — it's all assembled here from stock hooks.
715
+ # A ListDropdown driven from a TextArea — the same shape {ComboBox} and
716
+ # {Select} use, but wired by app code onto a field that knows nothing about
717
+ # it. Focus (and the caret) stays in the TextArea the whole time: an
718
+ # `on_change` listener refills the menu, and {SlashCommandTextArea} hands
719
+ # movement keys to `#move` and Enter to `#choose` while it is open.
499
720
  def build_slash_demo
500
721
  prompt = Tuile::Component::Label.new
501
- prompt.text = "Non-modal Popup as an autocomplete menu. Type a slash command\n" \
502
- "(try \"/\" or \"/s\"). The menu floats above the field without taking\n" \
722
+ prompt.text = "A ListDropdown driven from a TextArea. Type a slash command\n" \
723
+ "(try \"/\" or \"/s\"). The menu floats over the field without taking\n" \
503
724
  "focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
504
725
  "ordinary typing keeps editing the field and refilters the menu."
505
- area = Tuile::Component::TextArea.new
506
-
507
- list = Tuile::Component::List.new
508
- list.cursor = Tuile::Component::List::Cursor.new
509
- list.show_cursor_when_inactive = true # highlight the selection though focus stays in the field
510
- window = Tuile::Component::Window.new("Commands").tap { _1.content = list }
511
- overlay = Tuile::Component::Popup.new(content: window, modal: false)
726
+ overlay = Tuile::Component::ListDropdown.new
512
727
  @slash_overlay = overlay
728
+ area = SlashCommandTextArea.new(overlay)
513
729
 
514
730
  refill = lambda do
515
731
  matches = slash_matches(area)
516
732
  if matches.empty?
517
733
  overlay.close if overlay.open?
518
734
  else
735
+ overlay.items = matches
519
736
  overlay.open unless overlay.open?
520
- list.lines = matches
521
- anchor_overlay(overlay, area)
737
+ # Width is the driver's call, never the dropdown's: measure the
738
+ # commands rather than inherit the full-width TextArea's columns.
739
+ overlay.anchor_to(area.rect, rows: matches.size, width: slash_menu_width(matches))
522
740
  end
523
741
  end
524
742
 
525
743
  area.on_change = ->(_text) { refill.call }
526
- list.on_item_chosen = ->(_idx, line) { accept_slash_command(area, line.to_s) }
527
- area.on_key = lambda do |key|
528
- next false unless overlay.open?
529
-
530
- case key
531
- when Tuile::Keys::UP_ARROW, Tuile::Keys::DOWN_ARROW, Tuile::Keys::ENTER
532
- list.handle_key(key) # works though the list is unfocused — dispatch gates on focus, not the list
533
- when Tuile::Keys::ESC
534
- overlay.close
535
- true
536
- else
537
- false
538
- end
539
- end
744
+ overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
540
745
 
541
746
  form do |f|
542
747
  f.add(prompt, Fixed[4])
@@ -667,6 +872,42 @@ module SamplerExample
667
872
  end
668
873
  end
669
874
 
875
+ # `visible=` on a conditional form: the fields a checkbox above them
876
+ # governs. The rows close up completely when they go — a hidden child costs
877
+ # neither its slot nor the box's `spacing` gap, which is what separates it
878
+ # from a `Fixed[0]` collapse — and come back with their constraints and
879
+ # their typed text intact, since nothing was ever detached.
880
+ def build_visibility
881
+ prompt = Tuile::Component::Label.new
882
+ prompt.text = "Tick 'Business customer' to reveal two more fields.\n" \
883
+ "The rows close up with no double gap, Tab skips what is hidden,\n" \
884
+ "and text typed into a field survives being hidden and shown."
885
+ status = Tuile::Component::Label.new
886
+ company = labelled("Company", Tuile::Component::TextField.new)
887
+ vat = labelled("VAT id", Tuile::Component::TextField.new)
888
+ conditional = [company, vat]
889
+ business = Tuile::Component::Checkbox.new("Business customer")
890
+ apply = lambda do
891
+ conditional.each { _1.visible = business.checked? }
892
+ status.text = "visible fields: #{business.checked? ? 4 : 2}"
893
+ end
894
+ business.on_value_change = ->(_) { apply.call }
895
+ # The rows sit flush; the form keeps its blank row around the block.
896
+ rows = group do |g|
897
+ g.add(labelled("Name", Tuile::Component::TextField.new), Fixed[1])
898
+ g.add(labelled("Email", Tuile::Component::TextField.new), Fixed[1])
899
+ g.add(company, Fixed[1])
900
+ g.add(vat, Fixed[1])
901
+ end
902
+ apply.call
903
+ form do |f|
904
+ f.add(prompt, Fixed[3])
905
+ f.add(business, Fixed[1])
906
+ f.add(rows, Fixed[4])
907
+ f.add(status, Fixed[1])
908
+ end
909
+ end
910
+
670
911
  # One filterable log level: the item type a CheckboxGroup holds. Its `value`
671
912
  # is a Set of *these*, never of the labels shown on the rows.
672
913
  LogLevel = Data.define(:label, :tag, :color)
@@ -807,7 +1048,7 @@ module SamplerExample
807
1048
  short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
808
1049
 
809
1050
  update_status = lambda do
810
- under_cursor = SORT_ORDERS[group.content.cursor.position]
1051
+ under_cursor = SORT_ORDERS[group.list.cursor.position]
811
1052
  status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
812
1053
  end
813
1054
  resort = lambda do
@@ -818,9 +1059,9 @@ module SamplerExample
818
1059
  end
819
1060
  resort.call
820
1061
  group.on_value_change = ->(_order) { resort.call }
821
- # `content` is the composed List, which is where the cursor lives.
1062
+ # `list` is the composed List, which is where the cursor lives.
822
1063
  # Watching it is what makes the chrome/value split visible above.
823
- group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
1064
+ group.list.on_cursor_changed = ->(_idx, _line) { update_status.call }
824
1065
 
825
1066
  # Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
826
1067
  # demo — the sidebar width is a capped proportion, not a constraint.
@@ -928,6 +1169,21 @@ module SamplerExample
928
1169
  BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
929
1170
  ].freeze
930
1171
 
1172
+ # The one choice that can't be a constant: the terminal's *own* background
1173
+ # stepped +10 per channel — the borderless-pane tint, which only sits right
1174
+ # when it's derived from the real background. Nil on a terminal that
1175
+ # reported none, which is the branch most users will actually see.
1176
+ def terminal_tint_choice
1177
+ bg = Tuile::Screen.instance.background_color
1178
+ return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
1179
+
1180
+ BgChoice.new("Terminal background +10 (derived)",
1181
+ Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
1182
+ end
1183
+
1184
+ # @param derived [BgChoice] the live terminal-derived tint, offered second.
1185
+ def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
1186
+
931
1187
  def build_background
932
1188
  intro = Tuile::Component::Label.new
933
1189
  intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
@@ -951,7 +1207,8 @@ module SamplerExample
951
1207
  # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
952
1208
  # the hard-coded Colors are fixed by design, so no on_theme_changed here.
953
1209
  outer = nil
954
- combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
1210
+ derived = terminal_tint_choice
1211
+ combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
955
1212
  combo.item_label = :label.to_proc
956
1213
  combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
957
1214
 
@@ -960,6 +1217,17 @@ module SamplerExample
960
1217
  f.add(combo, Fixed[1], cross: Fixed[40])
961
1218
  f.add(box, Expand[1])
962
1219
  end
1220
+ # The derived tint is the one pick whose *color* moves under it: a flip
1221
+ # re-probes the terminal, so rebuild the choice and re-apply it if it is
1222
+ # the current one. Expect it to correct itself a frame late — the flip
1223
+ # report carries no RGB, so this hook runs once on the old background and
1224
+ # again when the re-probe answers.
1225
+ outer.on_theme_changed = lambda do
1226
+ was_derived = combo.value.equal?(derived)
1227
+ derived = terminal_tint_choice
1228
+ combo.items = bg_choices(derived)
1229
+ combo.value = derived if was_derived
1230
+ end
963
1231
  combo.value = BG_CHOICES.first # show "None" as the resting selection
964
1232
  outer
965
1233
  end
@@ -1166,6 +1434,61 @@ module SamplerExample
1166
1434
  end
1167
1435
  end
1168
1436
 
1437
+ # The three factories, the layer-1 builder (3-way), and a message long
1438
+ # enough to scroll. The status row makes the one-dismissal-channel contract
1439
+ # visible: every route out of a dialog lands in exactly one callback.
1440
+ def build_confirm_launcher
1441
+ label = Tuile::Component::Label.new
1442
+ label.text = "ConfirmWindow asks a question with a row of buttons, in a popup sized to\n" \
1443
+ "its content (capped at half the screen). Every button closes the dialog;\n" \
1444
+ "ESC, q or an outside click dismiss it instead. An underlined letter presses\n" \
1445
+ "its button from anywhere; Up/Down scroll a long message meanwhile."
1446
+ status = Tuile::Component::Label.new("Outcome: none yet")
1447
+ report = ->(outcome) { status.text = "Outcome: #{outcome}" }
1448
+ buttons = [
1449
+ Tuile::Component::Button.new("Confirm") do
1450
+ Tuile::Component::ConfirmWindow.confirm(
1451
+ "Delete Report Q4?", "This cannot be undone.",
1452
+ confirm: "Delete", on_dismiss: -> { report.call("kept the report") }
1453
+ ) { report.call("deleted the report") }
1454
+ end,
1455
+ Tuile::Component::Button.new("Yes/No") do
1456
+ Tuile::Component::ConfirmWindow.yes_no(
1457
+ "Overwrite draft.txt?", "The file already exists.",
1458
+ on_dismiss: -> { report.call("kept draft.txt") }
1459
+ ) { report.call("overwrote draft.txt") }
1460
+ end,
1461
+ Tuile::Component::Button.new("Alert") do
1462
+ Tuile::Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
1463
+ end,
1464
+ Tuile::Component::Button.new("3-way") do
1465
+ dialog = Tuile::Component::ConfirmWindow.new("Unsaved changes")
1466
+ dialog.message = "Save your changes before leaving?"
1467
+ dialog.button("Save") { report.call("saved") }
1468
+ dialog.button("Discard") { report.call("discarded") }
1469
+ dialog.button("Cancel")
1470
+ dialog.on_dismiss = -> { report.call("stayed put") }
1471
+ dialog.open
1472
+ end,
1473
+ Tuile::Component::Button.new("Long") do
1474
+ dialog = Tuile::Component::ConfirmWindow.new("Terms of Service")
1475
+ dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
1476
+ dialog.button("Accept") { report.call("accepted the terms") }
1477
+ dialog.button("Decline") { report.call("declined the terms") }
1478
+ dialog.on_dismiss = -> { report.call("left the terms unanswered") }
1479
+ dialog.open
1480
+ end
1481
+ ]
1482
+ strip = row do |r|
1483
+ buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
1484
+ end
1485
+ form do |f|
1486
+ f.add(label, Fixed[4])
1487
+ f.add(strip, Fixed[1])
1488
+ f.add(status, Fixed[1])
1489
+ end
1490
+ end
1491
+
1169
1492
  def build_popup_launcher
1170
1493
  launcher(
1171
1494
  "Popup is a modal overlay wrapping any Component.\n" \
@@ -1179,17 +1502,39 @@ module SamplerExample
1179
1502
  end
1180
1503
 
1181
1504
  def build_info_launcher
1182
- launcher(
1183
- "InfoWindow is a Window of read-only text lines, openable as a popup.",
1184
- "Open InfoWindow"
1185
- ) do
1186
- Tuile::Component::InfoWindow.open(
1187
- "Hello",
1188
- ["InfoWindow displays static text",
1189
- "inside a popup.",
1190
- "",
1191
- "Press ESC or q to close."]
1192
- )
1505
+ label = Tuile::Component::Label.new
1506
+ label.text = "InfoWindow is a Window with a read-only body: prose (message=) wraps in\n" \
1507
+ "a TextView, rows (lines=) stay one per row in a List, truncating. The\n" \
1508
+ "constructor picks the presentation by the body's type."
1509
+ buttons = [
1510
+ Tuile::Component::Button.new("Prose") do
1511
+ Tuile::Component::InfoWindow.open(
1512
+ "About",
1513
+ "InfoWindow renders a String as wrapping prose: this sentence is long " \
1514
+ "enough to wrap to the popup's width, and it scrolls when it outgrows " \
1515
+ "the box. Press ESC or q to close."
1516
+ )
1517
+ end,
1518
+ Tuile::Component::Button.new("Rows") do
1519
+ Tuile::Component::InfoWindow.open(
1520
+ "Files",
1521
+ ["drwxr-xr-x src/",
1522
+ "drwxr-xr-x spec/",
1523
+ "-rw-r--r-- README.md 4.1k",
1524
+ "-rw-r--r-- Rakefile 812",
1525
+ "",
1526
+ "Rows never wrap: a long row like this one is truncated at the popup's edge, keeping columns aligned.",
1527
+ "",
1528
+ "Press ESC or q to close."]
1529
+ )
1530
+ end
1531
+ ]
1532
+ strip = row do |r|
1533
+ buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
1534
+ end
1535
+ form do |f|
1536
+ f.add(label, Fixed[3])
1537
+ f.add(strip, Fixed[1])
1193
1538
  end
1194
1539
  end
1195
1540
 
@@ -1207,7 +1552,7 @@ module SamplerExample
1207
1552
 
1208
1553
  def build_log_window
1209
1554
  log = Tuile::Component::LogWindow.new("Log")
1210
- ["LogWindow is a Window wrapping an auto-scrolling TextView.",
1555
+ ["LogWindow is a Window framing an auto-scrolling LogTextView.",
1211
1556
  "Lines are appended via #log (safe from any thread).",
1212
1557
  "Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
1213
1558
  log
@@ -1315,18 +1660,13 @@ module SamplerExample
1315
1660
  area.caret = start + command.length + 1
1316
1661
  end
1317
1662
 
1318
- # Positions the overlay just below the caret, flipping above when there's
1319
- # no room beneath, and clamps it to the screen.
1320
- def anchor_overlay(overlay, area)
1321
- caret = area.cursor_position
1322
- return if caret.nil?
1323
-
1324
- screen_size = Tuile::Screen.instance.size
1325
- size = overlay.rect
1326
- top = caret.y + 1
1327
- top = [caret.y - size.height, 0].max if top + size.height > screen_size.height - 1
1328
- left = caret.x.clamp(0, [screen_size.width - size.width, 0].max)
1329
- overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
1663
+ # The slash menu's width: the widest command plus List's two row gutters,
1664
+ # clamped to the screen. ListDropdown places itself but never measures — the
1665
+ # width policy stays with the driver, exactly as it does for Select.
1666
+ # @return [Integer]
1667
+ def slash_menu_width(matches)
1668
+ widest = matches.map { Tuile::StyledString.plain(_1).display_width }.max || 0
1669
+ [widest + 2, Tuile::Screen.instance.size.width].min
1330
1670
  end
1331
1671
 
1332
1672
  # A button's natural width — enough to show "[ caption ]".
@@ -107,7 +107,7 @@ Recorded here so the open questions below stay narrow.
107
107
  - **{Tuile::Component::Tabs} already left the vertical axis free for this.**
108
108
  The strip claims Left/Right and *declines* Up/Down specifically so that this
109
109
  feature can move focus out of it vertically while Left/Right keep switching
110
- tabs inside it (`D-tabs`). It composes for nothing: the strip declines, the
110
+ tabs inside it (`D_tabs`). It composes for nothing: the strip declines, the
111
111
  key bubbles, the navigating ancestor moves. That is also the shape to copy
112
112
  for any future one-axis widget — claim one axis, leave the other.
113
113
 
@@ -215,7 +215,7 @@ Also: any public signature change means `rake sig` in the same commit.
215
215
 
216
216
  If built: the user-facing half goes to book ch5 (the key/Enter tables live
217
217
  there), the invariants half to AGENTS.md's key-dispatch section, and the
218
- choice-plus-rejected-roads half to `DECISIONS.md` as `D-arrow-navigation` —
218
+ choice-plus-rejected-roads half to `DECISIONS.md` as `D_arrow_navigation` —
219
219
  which must record the `Layout::Form` rejection and the Vaadin FormGroup
220
220
  precedent behind it, since that's the reasoning most likely to be
221
221
  re-litigated. Then retire this file.