tuile 0.12.0 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
data/examples/sampler.rb CHANGED
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Tuile sampler. Two-pane demo app showcasing the components shipped with
5
- # the framework. The left pane is a navigation list; moving the cursor
6
- # loads the highlighted demo into the right pane. Tab / Shift+Tab move
7
- # focus between the list and the demo's widgets.
4
+ # Tuile sampler. Demo app showcasing the components shipped with the framework.
5
+ # A menu bar across the top groups the demos the way the README's Components
6
+ # table does; the combo box at its right end jumps to one by name. Either way
7
+ # the demo loads into the window below, and focus returns to the menu bar.
8
8
  #
9
9
  # Run from the gem root:
10
10
  # bundle exec ruby -Ilib examples/sampler.rb
11
11
  #
12
- # Keys (global): q or ESC to quit.
12
+ # Keys: ←→ along the strip, Enter/↓ to open a menu, or a letter for the
13
+ # underlined mnemonic. Tab / Shift+Tab move focus between the strip, the jump
14
+ # box and the demo's widgets. q or ESC quits.
13
15
 
14
16
  require "rainbow"
15
17
  require "tuile"
@@ -79,86 +81,216 @@ module SamplerExample
79
81
  end
80
82
  end
81
83
 
82
- # Top-level sampler component. Splits the screen into a left entry list
83
- # and a right demo pane; each `load_entry` rebuilds the demo from
84
- # scratch so it always starts in a clean state.
85
- class Sampler < Tuile::Component::Layout::Absolute
84
+ # A {Tuile::Component::TextArea} that rebinds ENTER to "submit and clear" —
85
+ # the chat-prompt shape, and the one that made a multi-line paste fire the
86
+ # submit once per pasted line before Tuile drove bracketed paste. It handles
87
+ # no paste of its own: pasted text never arrives as ENTER, so the inherited
88
+ # insert-at-caret is already the wanted behavior, and `on_paste` here only
89
+ # feeds the demo's counter.
90
+ class PromptTextArea < Tuile::Component::TextArea
91
+ # @return [Proc, nil] called with the submitted text; the area then clears.
92
+ attr_accessor :on_submit
93
+ # @return [Proc, nil] called with the pasted text, before it is inserted.
94
+ attr_accessor :on_paste
95
+
96
+ def handle_paste(text)
97
+ @on_paste&.call(text)
98
+ super
99
+ end
100
+
101
+ protected
102
+
103
+ def handle_text_input_key(key)
104
+ return super unless key == Tuile::Keys::ENTER
105
+
106
+ @on_submit&.call(text)
107
+ self.text = ""
108
+ true
109
+ end
110
+ end
111
+
112
+ # Top-level sampler component: a shell row across the top — a
113
+ # {Tuile::Component::MenuBar} of the demos, grouped, and a
114
+ # {Tuile::Component::ComboBox} jump box at its right end — over one demo
115
+ # window filling the rest. Each load rebuilds the demo from scratch, so it
116
+ # always starts in a clean state.
117
+ #
118
+ # The two navigators are two *inputs to one selection*, not two selections:
119
+ # the menu answers "what is there?", the jump box answers "take me to X", and
120
+ # both write to the same place. See {#select_entry}.
121
+ class Sampler < Tuile::Component::Layout::Vertical
86
122
  def initialize
87
123
  super()
88
- @entry_list = build_entry_list
89
- @left_window = Tuile::Component::Window.new("Components").tap { _1.content = @entry_list }
90
- @right_window = Tuile::Component::Window.new
91
- add(@left_window)
92
- add(@right_window)
93
- load_entry(0)
124
+ @menu_bar = build_shell_bar
125
+ @jump_box = build_jump_box
126
+ @demo_window = Tuile::Component::Window.new
127
+ @status = Tuile::Component::Label.new
128
+ add(shell_row, Fixed[1])
129
+ add(@demo_window, Expand[1])
130
+ add(@status, Fixed[1])
131
+ select_entry(ENTRIES.first)
94
132
  end
95
133
 
96
- attr_reader :left_window, :right_window, :entry_list
134
+ attr_reader :demo_window, :menu_bar, :jump_box
135
+
136
+ # 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
138
+ # {Tuile::Screen#on_focus_changed=}. Naming the focused component makes Tab
139
+ # traversal visible as you walk a pane, which no per-pane label shows.
140
+ # @return [void]
141
+ def refresh_status
142
+ focused = screen.focused
143
+ name = focused ? focused.class.name.sub("Tuile::Component::", "") : "(none)"
144
+ @status.text = "q #{screen.theme.hint("quit")} ⇥ #{screen.theme.hint(name)}"
145
+ end
97
146
 
98
147
  # Chrome for a demo pane: a blank row top and bottom, two columns either
99
148
  # side, so content doesn't run flush to the window border.
100
149
  FORM_PADDING = Insets[top: 1, bottom: 1, left: 2, right: 2]
101
150
 
102
- def rect=(new_rect)
103
- super
104
- return if rect.empty?
105
-
106
- list_width = (rect.width / 3).clamp(20, 40)
107
- @left_window.rect = Tuile::Rect.new(rect.left, rect.top, list_width, rect.height)
108
- @right_window.rect = Tuile::Rect.new(rect.left + list_width, rect.top,
109
- rect.width - list_width, rect.height)
110
- end
151
+ # Shows `entry`'s demo. **The jump box is the selection model** — every
152
+ # navigator writes to it and its `on_value_change` is the only caller of
153
+ # `load_entry` — so the round trip needs no re-entrancy guard:
154
+ # {Tuile::Component::HasValue#value=} returns early on an equal value, and
155
+ # re-picking the entry already shown is a silent no-op.
156
+ # @param entry [Entry]
157
+ # @return [void]
158
+ def select_entry(entry) = (@jump_box.value = entry)
111
159
 
112
160
  private
113
161
 
114
- # Ordered list of demo entries: `[caption, builder_method]`. The
115
- # builder runs at selection time, so every load gets a fresh component
116
- # tree (an empty TextField, an un-clicked Button, etc.).
117
- ENTRIES = [
118
- ["Label", :build_label],
119
- ["TextField", :build_text_field],
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],
127
- ["Slash menu", :build_slash_demo],
128
- ["TextView", :build_text_view],
129
- ["Button", :build_buttons],
130
- ["Checkbox", :build_checkboxes],
131
- ["CheckboxGroup", :build_checkbox_group],
132
- ["RadioGroup", :build_radio_group],
133
- ["List", :build_list],
134
- ["ProgressBar", :build_progress_bar],
135
- ["Background", :build_background],
136
- ["Layout", :build_layout],
137
- ["Notification", :build_notification_launcher],
138
- ["Popup", :build_popup_launcher],
139
- ["InfoWindow", :build_info_launcher],
140
- ["PickerWindow", :build_picker_launcher],
141
- ["LogWindow", :build_log_window],
142
- ["Focus & Tab", :build_focus_demo]
162
+ # One demo: the caption shown everywhere, the builder that mints its pane,
163
+ # and the letter that reaches it from its own menu level. The builder runs
164
+ # at selection time, so every load gets a fresh component tree (an empty
165
+ # TextField, an un-clicked Button, etc.).
166
+ #
167
+ # A value type, because the jump box holds entries as its items and
168
+ # {Tuile::Component::HasValue#value=} compares them — see {#select_entry}.
169
+ Entry = Data.define(:caption, :builder, :mnemonic)
170
+
171
+ # A menu on the strip, or a submenu inside one: a caption, its letter, and
172
+ # its children — {Entry}s, or further `Menu`s.
173
+ Menu = Data.define(:caption, :mnemonic, :items)
174
+
175
+ # The strip, left to right. The grouping mirrors the README's **Components**
176
+ # sections (= book ch7's tour, organized by the job), so the sampler doubles
177
+ # as a live index of the catalogue and any drift between the two is visible.
178
+ #
179
+ # Mnemonics are *hand-picked*: {Tuile::Component::MenuBar#add_item} raises on
180
+ # a duplicate among siblings, and five leaves therefore answer to a letter
181
+ # other than their initial (Past`e`, Checkbox`G`roup, C`o`mboBox,
182
+ # Pic`k`erWindow, S`l`ash menu) — the underline shows which. No item may use
183
+ # `q`: quit is the unhandled-key fallback, so a `q` on the live level would
184
+ # swallow it while the bar has focus.
185
+ MENUS = [
186
+ Menu.new("Show", "s", [
187
+ Entry.new("Label", :build_label, "l"),
188
+ Entry.new("TextView", :build_text_view, "t"),
189
+ Entry.new("ProgressBar", :build_progress_bar, "p")
190
+ ]),
191
+ Menu.new("Input", "i", [
192
+ Menu.new("Text", "t", [
193
+ Entry.new("TextField", :build_text_field, "t"),
194
+ Entry.new("TextArea", :build_text_area, "a"),
195
+ Entry.new("PasswordField", :build_password_field, "p"),
196
+ Entry.new("Paste", :build_paste_demo, "e"),
197
+ Entry.new("Slash menu", :build_slash_demo, "l")
198
+ ]),
199
+ Menu.new("Typed", "y", [
200
+ Entry.new("IntegerField", :build_integer_field, "i"),
201
+ Entry.new("FloatField", :build_float_field, "f"),
202
+ Entry.new("BigDecimalField", :build_big_decimal_field, "b")
203
+ ]),
204
+ Menu.new("Choose", "c", [
205
+ Entry.new("Checkbox", :build_checkboxes, "c"),
206
+ Entry.new("CheckboxGroup", :build_checkbox_group, "g"),
207
+ Entry.new("RadioGroup", :build_radio_group, "r"),
208
+ Entry.new("Select", :build_select, "s"),
209
+ Entry.new("ComboBox", :build_combo_box, "o"),
210
+ Entry.new("List", :build_list, "l")
211
+ ])
212
+ ]),
213
+ # One entry, so it is the item and not a menu — a top-level leaf on the
214
+ # strip is a button, which nothing else here demos.
215
+ Entry.new("Button", :build_buttons, "b"),
216
+ Menu.new("Overlay", "o", [
217
+ Entry.new("Popup", :build_popup_launcher, "p"),
218
+ Entry.new("Notification", :build_notification_launcher, "n"),
219
+ Entry.new("ConfirmWindow", :build_confirm_launcher, "c"),
220
+ Entry.new("InfoWindow", :build_info_launcher, "i"),
221
+ Entry.new("PickerWindow", :build_picker_launcher, "k"),
222
+ Entry.new("LogWindow", :build_log_window, "l")
223
+ ]),
224
+ Menu.new("Shell", "h", [
225
+ Entry.new("TabSheet", :build_tab_sheet, "t"),
226
+ Entry.new("MenuBar", :build_menu_bar, "m"),
227
+ Entry.new("Narrow strips", :build_narrow_strips, "n"),
228
+ Entry.new("Layout", :build_layout, "l"),
229
+ Entry.new("Background", :build_background, "b"),
230
+ Entry.new("Focus & Tab", :build_focus_demo, "f")
231
+ ])
143
232
  ].freeze
144
233
 
145
- def build_entry_list
146
- list = Tuile::Component::List.new
147
- list.cursor = Tuile::Component::List::Cursor.new
148
- list.lines = ENTRIES.map(&:first)
149
- list.on_cursor_changed = ->(idx, _line) { load_entry(idx) if idx >= 0 }
150
- list
234
+ # Every {Entry} in strip order — what the jump box offers.
235
+ ENTRIES = MENUS.flat_map { |node| node.is_a?(Entry) ? [node] : node.items }
236
+ .flat_map { |node| node.is_a?(Entry) ? [node] : node.items }
237
+ .freeze
238
+
239
+ # The shell: the strip takes what it needs, the jump box a fixed column at
240
+ # the right end. The bar paints only its {Tuile::Component::MenuBar#extent},
241
+ # so its `Expand` tail is the gap between the two.
242
+ def shell_row
243
+ Tuile::Component::Layout::Horizontal.new.tap do |r|
244
+ r.add(@menu_bar, Expand[1])
245
+ r.add(@jump_box, Fixed[JUMP_BOX_WIDTH])
246
+ end
247
+ end
248
+
249
+ JUMP_BOX_WIDTH = 26
250
+
251
+ def build_shell_bar
252
+ bar = Tuile::Component::MenuBar.new
253
+ MENUS.each { |node| add_menu_node(bar, node) }
254
+ bar
255
+ end
256
+
257
+ # Mints `node` under `parent` — the bar, or an {Tuile::Component::MenuBar::Item}
258
+ # holding a submenu. The two `add_item`s share a signature, so nesting is
259
+ # this one recursion.
260
+ def add_menu_node(parent, node)
261
+ if node.is_a?(Entry)
262
+ parent.add_item(node.caption, mnemonic: node.mnemonic) { select_entry(node) }
263
+ else
264
+ holder = parent.add_item(node.caption, mnemonic: node.mnemonic)
265
+ node.items.each { |child| add_menu_node(holder, child) }
266
+ end
151
267
  end
152
268
 
153
- def load_entry(idx)
269
+ # Type a few letters of a demo's name and Enter to jump to it. Its `value`
270
+ # is the {Entry} — the selected *item*, never the typed text — which is what
271
+ # lets it be the selection model the menu also writes to.
272
+ def build_jump_box
273
+ combo = Tuile::Component::ComboBox.new(items: ENTRIES)
274
+ combo.item_label = :caption.to_proc
275
+ combo.on_value_change = ->(entry) { load_entry(entry) if entry }
276
+ combo
277
+ end
278
+
279
+ # Fires from the jump box's `on_value_change`, and from nowhere else.
280
+ def load_entry(entry)
154
281
  # The slash-menu demo parks a non-modal overlay on the pane (it lives
155
- # outside the right pane's content tree), so close it before swapping
282
+ # outside the demo pane's content tree), so close it before swapping
156
283
  # demos or it would linger over the next one.
157
284
  @slash_overlay.close if @slash_overlay&.open?
158
285
  @slash_overlay = nil
159
- caption, builder = ENTRIES[idx]
160
- @right_window.caption = caption
161
- @right_window.content = send(builder)
286
+ @demo_window.caption = entry.caption
287
+ @demo_window.content = send(entry.builder)
288
+ # Focus goes home to the strip after every load, whichever navigator ran:
289
+ # a no-op on the menu path (the bar holds focus through the cascade), and
290
+ # what pulls focus back out of the jump box after a commit. Guarded
291
+ # because the first load runs from the constructor, before attach — at
292
+ # startup it is the runner's own `menu_bar.focus` that lands.
293
+ @menu_bar.focus if attached?
162
294
  end
163
295
 
164
296
  # --- Tileable demos ----------------------------------------------------
@@ -360,24 +492,21 @@ module SamplerExample
360
492
  # Slash commands the demo offers; the menu filters these by what's typed.
361
493
  SLASH_COMMANDS = %w[/help /list /open /save /clear /quit].freeze
362
494
 
363
- # A non-modal Popup used as an autocomplete menu. Focus (and the caret)
364
- # stays in the TextArea the whole time: an `on_change` listener refills the
365
- # menu, an `on_key` interceptor forwards Up/Down/Enter/ESC to it while it's
366
- # open, and the menu floats above the field, anchored to the caret. None of
367
- # this is baked into TextArea — it's all assembled here from stock hooks.
495
+ # A ListDropdown driven from a TextArea — the same shape {ComboBox} and
496
+ # {Select} use, but wired by app code onto a field that knows nothing about
497
+ # it. Focus (and the caret) stays in the TextArea the whole time: an
498
+ # `on_change` listener refills the menu, and an `on_key` interceptor hands
499
+ # movement keys to `#move` and Enter to `#choose` while it is open. None of
500
+ # this is baked into TextArea.
368
501
  def build_slash_demo
369
502
  prompt = Tuile::Component::Label.new
370
- prompt.text = "Non-modal Popup as an autocomplete menu. Type a slash command\n" \
371
- "(try \"/\" or \"/s\"). The menu floats above the field without taking\n" \
503
+ prompt.text = "A ListDropdown driven from a TextArea. Type a slash command\n" \
504
+ "(try \"/\" or \"/s\"). The menu floats over the field without taking\n" \
372
505
  "focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
373
506
  "ordinary typing keeps editing the field and refilters the menu."
374
507
  area = Tuile::Component::TextArea.new
375
508
 
376
- list = Tuile::Component::List.new
377
- list.cursor = Tuile::Component::List::Cursor.new
378
- list.show_cursor_when_inactive = true # highlight the selection though focus stays in the field
379
- window = Tuile::Component::Window.new("Commands").tap { _1.content = list }
380
- overlay = Tuile::Component::Popup.new(content: window, modal: false)
509
+ overlay = Tuile::Component::ListDropdown.new
381
510
  @slash_overlay = overlay
382
511
 
383
512
  refill = lambda do
@@ -385,20 +514,23 @@ module SamplerExample
385
514
  if matches.empty?
386
515
  overlay.close if overlay.open?
387
516
  else
517
+ overlay.items = matches
388
518
  overlay.open unless overlay.open?
389
- list.lines = matches
390
- anchor_overlay(overlay, area)
519
+ # Width is the driver's call, never the dropdown's: measure the
520
+ # commands rather than inherit the full-width TextArea's columns.
521
+ overlay.anchor_to(area.rect, rows: matches.size, width: slash_menu_width(matches))
391
522
  end
392
523
  end
393
524
 
394
525
  area.on_change = ->(_text) { refill.call }
395
- list.on_item_chosen = ->(_idx, line) { accept_slash_command(area, line.to_s) }
526
+ overlay.on_item_chosen = ->(_idx, item) { accept_slash_command(area, item.to_s) }
396
527
  area.on_key = lambda do |key|
397
528
  next false unless overlay.open?
529
+ next true if overlay.move(key) # Up/Down/PgUp/PgDn/^U/^D
398
530
 
399
531
  case key
400
- when Tuile::Keys::UP_ARROW, Tuile::Keys::DOWN_ARROW, Tuile::Keys::ENTER
401
- list.handle_key(key) # works though the list is unfocused — dispatch gates on focus, not the list
532
+ when Tuile::Keys::ENTER
533
+ overlay.choose
402
534
  when Tuile::Keys::ESC
403
535
  overlay.close
404
536
  true
@@ -413,6 +545,43 @@ module SamplerExample
413
545
  end
414
546
  end
415
547
 
548
+ # Paste vs. Enter: the prompt submits on ENTER, so the two are only
549
+ # distinguishable because the terminal brackets a paste. Type a line and
550
+ # press Enter — `submits` ticks. Paste several lines — `submits` doesn't.
551
+ def build_paste_demo
552
+ prompt = Tuile::Component::Label.new
553
+ prompt.text = "Enter submits the draft; a paste stays a draft.\n" \
554
+ "Tab here, type a line, press Enter: it moves to\n" \
555
+ "the log. Now paste several lines — they land as\n" \
556
+ "one draft, and \"submits\" does not move."
557
+ stats = Tuile::Component::Label.new
558
+ log = Tuile::Component::TextView.new
559
+ area = PromptTextArea.new
560
+ submits = 0
561
+ pastes = 0
562
+
563
+ refresh = lambda do
564
+ stats.text = "submits: #{submits} pastes: #{pastes} rows in draft: #{area.row_count}"
565
+ end
566
+ area.on_change = ->(_text) { refresh.call }
567
+ area.on_paste = lambda do |text|
568
+ pastes += 1
569
+ log.add_line(Rainbow("pasted #{text.lines.size} line(s), #{text.length} chars").cyan)
570
+ end
571
+ area.on_submit = lambda do |text|
572
+ submits += 1
573
+ log.add_line(Rainbow("submitted: #{text.inspect}").green)
574
+ end
575
+ refresh.call
576
+
577
+ form do |f|
578
+ f.add(prompt, Fixed[4])
579
+ f.add(stats, Fixed[1])
580
+ f.add(area, Fixed[5])
581
+ f.add(Tuile::Component::Window.new("Log").tap { _1.content = log }, Expand[1])
582
+ end
583
+ end
584
+
416
585
  def build_text_view
417
586
  prompt = Tuile::Component::Label.new
418
587
  prompt.text = "Read-only viewer for prose. Word-wraps to width; ANSI formatting passes through.\n" \
@@ -760,6 +929,21 @@ module SamplerExample
760
929
  BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
761
930
  ].freeze
762
931
 
932
+ # The one choice that can't be a constant: the terminal's *own* background
933
+ # stepped +10 per channel — the borderless-pane tint, which only sits right
934
+ # when it's derived from the real background. Nil on a terminal that
935
+ # reported none, which is the branch most users will actually see.
936
+ def terminal_tint_choice
937
+ bg = Tuile::Screen.instance.background_color
938
+ return BgChoice.new("Terminal background — none reported", nil) if bg.nil?
939
+
940
+ BgChoice.new("Terminal background +10 (derived)",
941
+ Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }))
942
+ end
943
+
944
+ # @param derived [BgChoice] the live terminal-derived tint, offered second.
945
+ def bg_choices(derived) = [BG_CHOICES.first, derived, *BG_CHOICES[1..]]
946
+
763
947
  def build_background
764
948
  intro = Tuile::Component::Label.new
765
949
  intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
@@ -783,7 +967,8 @@ module SamplerExample
783
967
  # their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
784
968
  # the hard-coded Colors are fixed by design, so no on_theme_changed here.
785
969
  outer = nil
786
- combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
970
+ derived = terminal_tint_choice
971
+ combo = Tuile::Component::ComboBox.new(items: bg_choices(derived))
787
972
  combo.item_label = :label.to_proc
788
973
  combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
789
974
 
@@ -792,6 +977,17 @@ module SamplerExample
792
977
  f.add(combo, Fixed[1], cross: Fixed[40])
793
978
  f.add(box, Expand[1])
794
979
  end
980
+ # The derived tint is the one pick whose *color* moves under it: a flip
981
+ # re-probes the terminal, so rebuild the choice and re-apply it if it is
982
+ # the current one. Expect it to correct itself a frame late — the flip
983
+ # report carries no RGB, so this hook runs once on the old background and
984
+ # again when the re-probe answers.
985
+ outer.on_theme_changed = lambda do
986
+ was_derived = combo.value.equal?(derived)
987
+ derived = terminal_tint_choice
988
+ combo.items = bg_choices(derived)
989
+ combo.value = derived if was_derived
990
+ end
795
991
  combo.value = BG_CHOICES.first # show "None" as the resting selection
796
992
  outer
797
993
  end
@@ -819,6 +1015,155 @@ module SamplerExample
819
1015
  # seconds (and the grow-only width), and a long one shows the three-row wrap
820
1016
  # ending in an ellipsis. Focus stays on whichever button you pressed
821
1017
  # throughout — that is the whole point of the widget.
1018
+ TAB_PROSE = "A TabSheet keeps only the selected tab's pane in the component tree; the others are " \
1019
+ "detached. That is how Tuile hides a component — there is no visibility flag, and an empty " \
1020
+ "rect gates painting only.\n\n" \
1021
+ "Detaching is what makes the rest fall out for free. A hidden pane is invisible to the Tab " \
1022
+ "cycle, to the focus cascades, to repaint and to the cursor, with no gate anywhere in the " \
1023
+ "framework. Its state survives regardless, because state is ivars: scroll position, caret, " \
1024
+ "list cursor and text are all exactly as you left them.\n\n" \
1025
+ "Scroll down here, switch to another tab with ←→, and come back: this view is still on the " \
1026
+ "row you left it on, and the status line below the sheet reports every pane's state as you " \
1027
+ "switch. A pane that must keep a resource alive while hidden cannot — that resource belongs " \
1028
+ "in the model the pane renders, not in the pane itself."
1029
+
1030
+ # TabSheet: the strip is one tab stop driven by ←→, and switching swaps the
1031
+ # pane below it. The status line reads each pane's state on every switch,
1032
+ # which is the property most likely to be doubted: a hidden pane is detached
1033
+ # from the tree, and it still comes back exactly as it was left.
1034
+ def build_tab_sheet
1035
+ prompt = Tuile::Component::Label.new
1036
+ prompt.text = "Tab here to focus the strip, then ←→ to switch tabs — selection is immediate, and the " \
1037
+ "selected caption stays bold once focus moves on.\n" \
1038
+ "Tab again to enter the pane. Enter, Space, Up/Down and Home/End are left to the app, " \
1039
+ "so they bubble past the strip."
1040
+
1041
+ field = Tuile::Component::TextField.new
1042
+ field.text = "type here"
1043
+ checkbox = Tuile::Component::Checkbox.new("Remember me", value: true)
1044
+ list = Tuile::Component::List.new
1045
+ list.cursor = Tuile::Component::List::Cursor.new
1046
+ list.lines = (1..40).map { |i| "Row #{i}" }
1047
+ view = Tuile::Component::TextView.new
1048
+ view.text = TAB_PROSE
1049
+
1050
+ sheet = Tuile::Component::TabSheet.new
1051
+ sheet.add_tab("Form", group do |g|
1052
+ g.add(field, Fixed[1])
1053
+ g.add(checkbox, Fixed[1])
1054
+ end)
1055
+ sheet.add_tab("List", list)
1056
+ sheet.add_tab("Prose", view)
1057
+
1058
+ status = Tuile::Component::Label.new
1059
+ report = lambda do
1060
+ status.text = "Form: #{field.text.inspect}, #{checkbox.checked? ? "checked" : "unchecked"} · " \
1061
+ "List row #{list.cursor.position} · Prose row #{view.scroll_top_row}"
1062
+ end
1063
+ report.call
1064
+ sheet.on_tab_selected = ->(_index, _tab) { report.call }
1065
+
1066
+ form do |f|
1067
+ f.add(prompt, Fixed[3])
1068
+ f.add(sheet, Expand[1])
1069
+ f.add(status, Fixed[1])
1070
+ end
1071
+ end
1072
+
1073
+ def build_menu_bar
1074
+ status = Tuile::Component::Label.new
1075
+ status.text = "Nothing activated yet."
1076
+ activate = ->(path) { status.text = "Activated: #{path}" }
1077
+
1078
+ bar = Tuile::Component::MenuBar.new
1079
+ file = bar.add_item("File", mnemonic: "f")
1080
+ file.add_item("New", mnemonic: "n") { activate.call("File ▸ New") }
1081
+ file.add_item("Open", mnemonic: "o") { activate.call("File ▸ Open") }
1082
+ recent = file.add_item("Open recent", mnemonic: "r")
1083
+ %w[notes.txt report.md sampler.rb].each_with_index do |name, index|
1084
+ # A mnemonic the caption doesn't contain: it fires, it just draws no cue.
1085
+ recent.add_item(name, mnemonic: (index + 1).to_s) { activate.call("File ▸ Open recent ▸ #{name}") }
1086
+ end
1087
+ # Three deep, to show the cascade actually cascading.
1088
+ archive = recent.add_item("Archive", mnemonic: "a")
1089
+ %w[2024.zip 2025.zip].each do |name|
1090
+ archive.add_item(name) { activate.call("… ▸ Archive ▸ #{name}") }
1091
+ end
1092
+ file.add_item("Quit", mnemonic: "q") { activate.call("File ▸ Quit") }
1093
+
1094
+ edit = bar.add_item("Edit", mnemonic: "e")
1095
+ # "Cut" takes 'c' here; "Copy" can't, so it takes 'o'. Only siblings compete.
1096
+ { "Cut" => "c", "Copy" => "o", "Paste" => "p", "Select all" => "s" }.each do |name, mnemonic|
1097
+ edit.add_item(name, mnemonic: mnemonic) { activate.call("Edit ▸ #{name}") }
1098
+ end
1099
+ # A top-level item with no children is a button, not a menu.
1100
+ bar.add_item("About", mnemonic: "a") { activate.call("About (a top-level leaf)") }
1101
+ # …and one with neither children nor a listener is legal and inert.
1102
+ bar.add_item("Inert")
1103
+
1104
+ prompt = Tuile::Component::Label.new
1105
+ prompt.text = "Tab here to focus the bar, then ←→ to pick a menu and Enter/Space/Down to open it.\n" \
1106
+ "Inside: ↑↓ moves, → (or Enter) opens a submenu, ← goes back, ESC closes one level.\n" \
1107
+ "← at the first level and → on a plain row step to the neighbouring menu.\n" \
1108
+ "The underlined letters are mnemonics: press f then q for File ▸ Quit. Each level has\n" \
1109
+ "its own set, so 'o' is File ▸ Open and also Edit ▸ Copy — try f,o then e,o.\n" \
1110
+ "A letter that matches nothing in the open menu just beeps; it won't switch menus.\n" \
1111
+ "The panels overdraw this window and the nav list — they are overlays, not children.\n" \
1112
+ "\"About\" is a top-level leaf, so it acts as a button; \"Inert\" does nothing at all."
1113
+
1114
+ form do |f|
1115
+ f.add(bar, Fixed[1])
1116
+ f.add(prompt, Fixed[9])
1117
+ f.add(status, Fixed[1])
1118
+ f.add(Tuile::Component::Label.new, Expand[1])
1119
+ end
1120
+ end
1121
+
1122
+ # The same four tabs twice — at the width their captions need, and starved
1123
+ # into sixteen columns — plus a menu bar given eighteen. A strip too narrow
1124
+ # scrolls to keep the selection whole in view; the status line is the part
1125
+ # worth watching, because it reports a selection that used to be able to
1126
+ # walk off the edge and leave the visible strip unchanged.
1127
+ def build_narrow_strips
1128
+ captions = %w[Details Payment Shipping Billing]
1129
+ wide = Tuile::Component::Tabs.new
1130
+ narrow = Tuile::Component::Tabs.new
1131
+ [wide, narrow].each { |strip| captions.each { |caption| strip.add_tab(caption) } }
1132
+
1133
+ status = Tuile::Component::Label.new
1134
+ report = lambda do
1135
+ status.text = "Starved strip: #{narrow.selected.caption} " \
1136
+ "(#{narrow.selected_index + 1} of #{narrow.tabs.size})"
1137
+ end
1138
+ report.call
1139
+ narrow.on_tab_selected = ->(_index, _tab) { report.call }
1140
+
1141
+ bar = Tuile::Component::MenuBar.new
1142
+ %w[File Edit View Window Help].each do |caption|
1143
+ menu = bar.add_item(caption)
1144
+ %w[First Second Third].each { |item| menu.add_item("#{caption} #{item}") }
1145
+ end
1146
+
1147
+ prompt = Tuile::Component::Label.new
1148
+ prompt.text = "Tab to a strip, then ←→. The starved one scrolls by the minimum needed to show the\n" \
1149
+ "selected tab whole, so the selection can never hide off an edge — and it scrolls\n" \
1150
+ "back to column 0 the moment everything fits again.\n" \
1151
+ "< and > over the edge columns say there is more strip that way; the captions cut\n" \
1152
+ "under them say the same thing, but only when the cut lands mid-caption. They are\n" \
1153
+ "not buttons: clicking one selects the half-visible tab beneath it, which reveals it.\n" \
1154
+ "The menu bar scrolls the same way — ←→ along it, and a menu opens under its own\n" \
1155
+ "segment wherever the scrolling has put it."
1156
+
1157
+ form do |f|
1158
+ f.add(prompt, Fixed[8])
1159
+ f.add(labelled("Natural width", wide, field_width: 40), Fixed[1])
1160
+ f.add(labelled("16 columns", narrow, field_width: 16), Fixed[1])
1161
+ f.add(status, Fixed[1])
1162
+ f.add(labelled("Menu bar (18)", bar, field_width: 18), Fixed[1])
1163
+ f.add(Tuile::Component::Label.new, Expand[1])
1164
+ end
1165
+ end
1166
+
822
1167
  def build_notification_launcher
823
1168
  label = Tuile::Component::Label.new
824
1169
  label.text = "Notification.show puts a toast in the top-right corner for 3 seconds.\n" \
@@ -849,6 +1194,61 @@ module SamplerExample
849
1194
  end
850
1195
  end
851
1196
 
1197
+ # The three factories, the layer-1 builder (3-way), and a message long
1198
+ # enough to scroll. The status row makes the one-dismissal-channel contract
1199
+ # visible: every route out of a dialog lands in exactly one callback.
1200
+ def build_confirm_launcher
1201
+ label = Tuile::Component::Label.new
1202
+ label.text = "ConfirmWindow asks a question with a row of buttons, in a popup sized to\n" \
1203
+ "its content (capped at half the screen). Every button closes the dialog;\n" \
1204
+ "ESC, q or an outside click dismiss it instead. An underlined letter presses\n" \
1205
+ "its button from anywhere; Up/Down scroll a long message meanwhile."
1206
+ status = Tuile::Component::Label.new("Outcome: none yet")
1207
+ report = ->(outcome) { status.text = "Outcome: #{outcome}" }
1208
+ buttons = [
1209
+ Tuile::Component::Button.new("Confirm") do
1210
+ Tuile::Component::ConfirmWindow.confirm(
1211
+ "Delete Report Q4?", "This cannot be undone.",
1212
+ confirm: "Delete", on_dismiss: -> { report.call("kept the report") }
1213
+ ) { report.call("deleted the report") }
1214
+ end,
1215
+ Tuile::Component::Button.new("Yes/No") do
1216
+ Tuile::Component::ConfirmWindow.yes_no(
1217
+ "Overwrite draft.txt?", "The file already exists.",
1218
+ on_dismiss: -> { report.call("kept draft.txt") }
1219
+ ) { report.call("overwrote draft.txt") }
1220
+ end,
1221
+ Tuile::Component::Button.new("Alert") do
1222
+ Tuile::Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
1223
+ end,
1224
+ Tuile::Component::Button.new("3-way") do
1225
+ dialog = Tuile::Component::ConfirmWindow.new("Unsaved changes")
1226
+ dialog.message = "Save your changes before leaving?"
1227
+ dialog.button("Save") { report.call("saved") }
1228
+ dialog.button("Discard") { report.call("discarded") }
1229
+ dialog.button("Cancel")
1230
+ dialog.on_dismiss = -> { report.call("stayed put") }
1231
+ dialog.open
1232
+ end,
1233
+ Tuile::Component::Button.new("Long") do
1234
+ dialog = Tuile::Component::ConfirmWindow.new("Terms of Service")
1235
+ dialog.message = (1..40).map { "#{_1}. Clause #{_1} of the agreement, spelled out in full." }.join("\n")
1236
+ dialog.button("Accept") { report.call("accepted the terms") }
1237
+ dialog.button("Decline") { report.call("declined the terms") }
1238
+ dialog.on_dismiss = -> { report.call("left the terms unanswered") }
1239
+ dialog.open
1240
+ end
1241
+ ]
1242
+ strip = row do |r|
1243
+ buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
1244
+ end
1245
+ form do |f|
1246
+ f.add(label, Fixed[4])
1247
+ f.add(strip, Fixed[1])
1248
+ f.add(status, Fixed[1])
1249
+ end
1250
+ end
1251
+
852
1252
  def build_popup_launcher
853
1253
  launcher(
854
1254
  "Popup is a modal overlay wrapping any Component.\n" \
@@ -862,17 +1262,39 @@ module SamplerExample
862
1262
  end
863
1263
 
864
1264
  def build_info_launcher
865
- launcher(
866
- "InfoWindow is a Window of read-only text lines, openable as a popup.",
867
- "Open InfoWindow"
868
- ) do
869
- Tuile::Component::InfoWindow.open(
870
- "Hello",
871
- ["InfoWindow displays static text",
872
- "inside a popup.",
873
- "",
874
- "Press ESC or q to close."]
875
- )
1265
+ label = Tuile::Component::Label.new
1266
+ label.text = "InfoWindow is a Window with a read-only body: prose (message=) wraps in\n" \
1267
+ "a TextView, rows (lines=) stay one per row in a List, truncating. The\n" \
1268
+ "constructor picks the presentation by the body's type."
1269
+ buttons = [
1270
+ Tuile::Component::Button.new("Prose") do
1271
+ Tuile::Component::InfoWindow.open(
1272
+ "About",
1273
+ "InfoWindow renders a String as wrapping prose: this sentence is long " \
1274
+ "enough to wrap to the popup's width, and it scrolls when it outgrows " \
1275
+ "the box. Press ESC or q to close."
1276
+ )
1277
+ end,
1278
+ Tuile::Component::Button.new("Rows") do
1279
+ Tuile::Component::InfoWindow.open(
1280
+ "Files",
1281
+ ["drwxr-xr-x src/",
1282
+ "drwxr-xr-x spec/",
1283
+ "-rw-r--r-- README.md 4.1k",
1284
+ "-rw-r--r-- Rakefile 812",
1285
+ "",
1286
+ "Rows never wrap: a long row like this one is truncated at the popup's edge, keeping columns aligned.",
1287
+ "",
1288
+ "Press ESC or q to close."]
1289
+ )
1290
+ end
1291
+ ]
1292
+ strip = row do |r|
1293
+ buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
1294
+ end
1295
+ form do |f|
1296
+ f.add(label, Fixed[3])
1297
+ f.add(strip, Fixed[1])
876
1298
  end
877
1299
  end
878
1300
 
@@ -890,7 +1312,7 @@ module SamplerExample
890
1312
 
891
1313
  def build_log_window
892
1314
  log = Tuile::Component::LogWindow.new("Log")
893
- ["LogWindow is a Window wrapping an auto-scrolling TextView.",
1315
+ ["LogWindow is a Window framing an auto-scrolling LogTextView.",
894
1316
  "Lines are appended via #log (safe from any thread).",
895
1317
  "Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
896
1318
  log
@@ -998,18 +1420,13 @@ module SamplerExample
998
1420
  area.caret = start + command.length + 1
999
1421
  end
1000
1422
 
1001
- # Positions the overlay just below the caret, flipping above when there's
1002
- # no room beneath, and clamps it to the screen.
1003
- def anchor_overlay(overlay, area)
1004
- caret = area.cursor_position
1005
- return if caret.nil?
1006
-
1007
- screen_size = Tuile::Screen.instance.size
1008
- size = overlay.rect
1009
- top = caret.y + 1
1010
- top = [caret.y - size.height, 0].max if top + size.height > screen_size.height - 1
1011
- left = caret.x.clamp(0, [screen_size.width - size.width, 0].max)
1012
- overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
1423
+ # The slash menu's width: the widest command plus List's two row gutters,
1424
+ # clamped to the screen. ListDropdown places itself but never measures — the
1425
+ # width policy stays with the driver, exactly as it does for Select.
1426
+ # @return [Integer]
1427
+ def slash_menu_width(matches)
1428
+ widest = matches.map { Tuile::StyledString.plain(_1).display_width }.max || 0
1429
+ [widest + 2, Tuile::Screen.instance.size.width].min
1013
1430
  end
1014
1431
 
1015
1432
  # A button's natural width — enough to show "[ caption ]".
@@ -1023,7 +1440,9 @@ if $PROGRAM_NAME == __FILE__
1023
1440
  screen = Tuile::Screen.new
1024
1441
  sampler = SamplerExample::Sampler.new
1025
1442
  screen.content = sampler
1026
- sampler.entry_list.focus
1443
+ screen.on_focus_changed = -> { sampler.refresh_status }
1444
+ sampler.refresh_status
1445
+ sampler.menu_bar.focus
1027
1446
  begin
1028
1447
  screen.run_event_loop
1029
1448
  ensure