tuile 0.12.0 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. 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,215 @@ 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("InfoWindow", :build_info_launcher, "i"),
220
+ Entry.new("PickerWindow", :build_picker_launcher, "k"),
221
+ Entry.new("LogWindow", :build_log_window, "l")
222
+ ]),
223
+ Menu.new("Shell", "h", [
224
+ Entry.new("TabSheet", :build_tab_sheet, "t"),
225
+ Entry.new("MenuBar", :build_menu_bar, "m"),
226
+ Entry.new("Narrow strips", :build_narrow_strips, "n"),
227
+ Entry.new("Layout", :build_layout, "l"),
228
+ Entry.new("Background", :build_background, "b"),
229
+ Entry.new("Focus & Tab", :build_focus_demo, "f")
230
+ ])
143
231
  ].freeze
144
232
 
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
233
+ # Every {Entry} in strip order — what the jump box offers.
234
+ ENTRIES = MENUS.flat_map { |node| node.is_a?(Entry) ? [node] : node.items }
235
+ .flat_map { |node| node.is_a?(Entry) ? [node] : node.items }
236
+ .freeze
237
+
238
+ # The shell: the strip takes what it needs, the jump box a fixed column at
239
+ # the right end. The bar paints only its {Tuile::Component::MenuBar#extent},
240
+ # so its `Expand` tail is the gap between the two.
241
+ def shell_row
242
+ Tuile::Component::Layout::Horizontal.new.tap do |r|
243
+ r.add(@menu_bar, Expand[1])
244
+ r.add(@jump_box, Fixed[JUMP_BOX_WIDTH])
245
+ end
151
246
  end
152
247
 
153
- def load_entry(idx)
248
+ JUMP_BOX_WIDTH = 26
249
+
250
+ def build_shell_bar
251
+ bar = Tuile::Component::MenuBar.new
252
+ MENUS.each { |node| add_menu_node(bar, node) }
253
+ bar
254
+ end
255
+
256
+ # Mints `node` under `parent` — the bar, or an {Tuile::Component::MenuBar::Item}
257
+ # holding a submenu. The two `add_item`s share a signature, so nesting is
258
+ # this one recursion.
259
+ def add_menu_node(parent, node)
260
+ if node.is_a?(Entry)
261
+ parent.add_item(node.caption, mnemonic: node.mnemonic) { select_entry(node) }
262
+ else
263
+ holder = parent.add_item(node.caption, mnemonic: node.mnemonic)
264
+ node.items.each { |child| add_menu_node(holder, child) }
265
+ end
266
+ end
267
+
268
+ # Type a few letters of a demo's name and Enter to jump to it. Its `value`
269
+ # is the {Entry} — the selected *item*, never the typed text — which is what
270
+ # lets it be the selection model the menu also writes to.
271
+ def build_jump_box
272
+ combo = Tuile::Component::ComboBox.new(items: ENTRIES)
273
+ combo.item_label = :caption.to_proc
274
+ combo.on_value_change = ->(entry) { load_entry(entry) if entry }
275
+ combo
276
+ end
277
+
278
+ # Fires from the jump box's `on_value_change`, and from nowhere else.
279
+ def load_entry(entry)
154
280
  # 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
281
+ # outside the demo pane's content tree), so close it before swapping
156
282
  # demos or it would linger over the next one.
157
283
  @slash_overlay.close if @slash_overlay&.open?
158
284
  @slash_overlay = nil
159
- caption, builder = ENTRIES[idx]
160
- @right_window.caption = caption
161
- @right_window.content = send(builder)
285
+ @demo_window.caption = entry.caption
286
+ @demo_window.content = send(entry.builder)
287
+ # Focus goes home to the strip after every load, whichever navigator ran:
288
+ # a no-op on the menu path (the bar holds focus through the cascade), and
289
+ # what pulls focus back out of the jump box after a commit. Guarded
290
+ # because the first load runs from the constructor, before attach — at
291
+ # startup it is the runner's own `menu_bar.focus` that lands.
292
+ @menu_bar.focus if attached?
162
293
  end
163
294
 
164
295
  # --- Tileable demos ----------------------------------------------------
@@ -413,6 +544,43 @@ module SamplerExample
413
544
  end
414
545
  end
415
546
 
547
+ # Paste vs. Enter: the prompt submits on ENTER, so the two are only
548
+ # distinguishable because the terminal brackets a paste. Type a line and
549
+ # press Enter — `submits` ticks. Paste several lines — `submits` doesn't.
550
+ def build_paste_demo
551
+ prompt = Tuile::Component::Label.new
552
+ prompt.text = "Enter submits the draft; a paste stays a draft.\n" \
553
+ "Tab here, type a line, press Enter: it moves to\n" \
554
+ "the log. Now paste several lines — they land as\n" \
555
+ "one draft, and \"submits\" does not move."
556
+ stats = Tuile::Component::Label.new
557
+ log = Tuile::Component::TextView.new
558
+ area = PromptTextArea.new
559
+ submits = 0
560
+ pastes = 0
561
+
562
+ refresh = lambda do
563
+ stats.text = "submits: #{submits} pastes: #{pastes} rows in draft: #{area.row_count}"
564
+ end
565
+ area.on_change = ->(_text) { refresh.call }
566
+ area.on_paste = lambda do |text|
567
+ pastes += 1
568
+ log.add_line(Rainbow("pasted #{text.lines.size} line(s), #{text.length} chars").cyan)
569
+ end
570
+ area.on_submit = lambda do |text|
571
+ submits += 1
572
+ log.add_line(Rainbow("submitted: #{text.inspect}").green)
573
+ end
574
+ refresh.call
575
+
576
+ form do |f|
577
+ f.add(prompt, Fixed[4])
578
+ f.add(stats, Fixed[1])
579
+ f.add(area, Fixed[5])
580
+ f.add(Tuile::Component::Window.new("Log").tap { _1.content = log }, Expand[1])
581
+ end
582
+ end
583
+
416
584
  def build_text_view
417
585
  prompt = Tuile::Component::Label.new
418
586
  prompt.text = "Read-only viewer for prose. Word-wraps to width; ANSI formatting passes through.\n" \
@@ -819,6 +987,155 @@ module SamplerExample
819
987
  # seconds (and the grow-only width), and a long one shows the three-row wrap
820
988
  # ending in an ellipsis. Focus stays on whichever button you pressed
821
989
  # throughout — that is the whole point of the widget.
990
+ TAB_PROSE = "A TabSheet keeps only the selected tab's pane in the component tree; the others are " \
991
+ "detached. That is how Tuile hides a component — there is no visibility flag, and an empty " \
992
+ "rect gates painting only.\n\n" \
993
+ "Detaching is what makes the rest fall out for free. A hidden pane is invisible to the Tab " \
994
+ "cycle, to the focus cascades, to repaint and to the cursor, with no gate anywhere in the " \
995
+ "framework. Its state survives regardless, because state is ivars: scroll position, caret, " \
996
+ "list cursor and text are all exactly as you left them.\n\n" \
997
+ "Scroll down here, switch to another tab with ←→, and come back: this view is still on the " \
998
+ "row you left it on, and the status line below the sheet reports every pane's state as you " \
999
+ "switch. A pane that must keep a resource alive while hidden cannot — that resource belongs " \
1000
+ "in the model the pane renders, not in the pane itself."
1001
+
1002
+ # TabSheet: the strip is one tab stop driven by ←→, and switching swaps the
1003
+ # pane below it. The status line reads each pane's state on every switch,
1004
+ # which is the property most likely to be doubted: a hidden pane is detached
1005
+ # from the tree, and it still comes back exactly as it was left.
1006
+ def build_tab_sheet
1007
+ prompt = Tuile::Component::Label.new
1008
+ prompt.text = "Tab here to focus the strip, then ←→ to switch tabs — selection is immediate, and the " \
1009
+ "selected caption stays bold once focus moves on.\n" \
1010
+ "Tab again to enter the pane. Enter, Space, Up/Down and Home/End are left to the app, " \
1011
+ "so they bubble past the strip."
1012
+
1013
+ field = Tuile::Component::TextField.new
1014
+ field.text = "type here"
1015
+ checkbox = Tuile::Component::Checkbox.new("Remember me", value: true)
1016
+ list = Tuile::Component::List.new
1017
+ list.cursor = Tuile::Component::List::Cursor.new
1018
+ list.lines = (1..40).map { |i| "Row #{i}" }
1019
+ view = Tuile::Component::TextView.new
1020
+ view.text = TAB_PROSE
1021
+
1022
+ sheet = Tuile::Component::TabSheet.new
1023
+ sheet.add_tab("Form", group do |g|
1024
+ g.add(field, Fixed[1])
1025
+ g.add(checkbox, Fixed[1])
1026
+ end)
1027
+ sheet.add_tab("List", list)
1028
+ sheet.add_tab("Prose", view)
1029
+
1030
+ status = Tuile::Component::Label.new
1031
+ report = lambda do
1032
+ status.text = "Form: #{field.text.inspect}, #{checkbox.checked? ? "checked" : "unchecked"} · " \
1033
+ "List row #{list.cursor.position} · Prose row #{view.scroll_top_row}"
1034
+ end
1035
+ report.call
1036
+ sheet.on_tab_selected = ->(_index, _tab) { report.call }
1037
+
1038
+ form do |f|
1039
+ f.add(prompt, Fixed[3])
1040
+ f.add(sheet, Expand[1])
1041
+ f.add(status, Fixed[1])
1042
+ end
1043
+ end
1044
+
1045
+ def build_menu_bar
1046
+ status = Tuile::Component::Label.new
1047
+ status.text = "Nothing activated yet."
1048
+ activate = ->(path) { status.text = "Activated: #{path}" }
1049
+
1050
+ bar = Tuile::Component::MenuBar.new
1051
+ file = bar.add_item("File", mnemonic: "f")
1052
+ file.add_item("New", mnemonic: "n") { activate.call("File ▸ New") }
1053
+ file.add_item("Open", mnemonic: "o") { activate.call("File ▸ Open") }
1054
+ recent = file.add_item("Open recent", mnemonic: "r")
1055
+ %w[notes.txt report.md sampler.rb].each_with_index do |name, index|
1056
+ # A mnemonic the caption doesn't contain: it fires, it just draws no cue.
1057
+ recent.add_item(name, mnemonic: (index + 1).to_s) { activate.call("File ▸ Open recent ▸ #{name}") }
1058
+ end
1059
+ # Three deep, to show the cascade actually cascading.
1060
+ archive = recent.add_item("Archive", mnemonic: "a")
1061
+ %w[2024.zip 2025.zip].each do |name|
1062
+ archive.add_item(name) { activate.call("… ▸ Archive ▸ #{name}") }
1063
+ end
1064
+ file.add_item("Quit", mnemonic: "q") { activate.call("File ▸ Quit") }
1065
+
1066
+ edit = bar.add_item("Edit", mnemonic: "e")
1067
+ # "Cut" takes 'c' here; "Copy" can't, so it takes 'o'. Only siblings compete.
1068
+ { "Cut" => "c", "Copy" => "o", "Paste" => "p", "Select all" => "s" }.each do |name, mnemonic|
1069
+ edit.add_item(name, mnemonic: mnemonic) { activate.call("Edit ▸ #{name}") }
1070
+ end
1071
+ # A top-level item with no children is a button, not a menu.
1072
+ bar.add_item("About", mnemonic: "a") { activate.call("About (a top-level leaf)") }
1073
+ # …and one with neither children nor a listener is legal and inert.
1074
+ bar.add_item("Inert")
1075
+
1076
+ prompt = Tuile::Component::Label.new
1077
+ prompt.text = "Tab here to focus the bar, then ←→ to pick a menu and Enter/Space/Down to open it.\n" \
1078
+ "Inside: ↑↓ moves, → (or Enter) opens a submenu, ← goes back, ESC closes one level.\n" \
1079
+ "← at the first level and → on a plain row step to the neighbouring menu.\n" \
1080
+ "The underlined letters are mnemonics: press f then q for File ▸ Quit. Each level has\n" \
1081
+ "its own set, so 'o' is File ▸ Open and also Edit ▸ Copy — try f,o then e,o.\n" \
1082
+ "A letter that matches nothing in the open menu just beeps; it won't switch menus.\n" \
1083
+ "The panels overdraw this window and the nav list — they are overlays, not children.\n" \
1084
+ "\"About\" is a top-level leaf, so it acts as a button; \"Inert\" does nothing at all."
1085
+
1086
+ form do |f|
1087
+ f.add(bar, Fixed[1])
1088
+ f.add(prompt, Fixed[9])
1089
+ f.add(status, Fixed[1])
1090
+ f.add(Tuile::Component::Label.new, Expand[1])
1091
+ end
1092
+ end
1093
+
1094
+ # The same four tabs twice — at the width their captions need, and starved
1095
+ # into sixteen columns — plus a menu bar given eighteen. A strip too narrow
1096
+ # scrolls to keep the selection whole in view; the status line is the part
1097
+ # worth watching, because it reports a selection that used to be able to
1098
+ # walk off the edge and leave the visible strip unchanged.
1099
+ def build_narrow_strips
1100
+ captions = %w[Details Payment Shipping Billing]
1101
+ wide = Tuile::Component::Tabs.new
1102
+ narrow = Tuile::Component::Tabs.new
1103
+ [wide, narrow].each { |strip| captions.each { |caption| strip.add_tab(caption) } }
1104
+
1105
+ status = Tuile::Component::Label.new
1106
+ report = lambda do
1107
+ status.text = "Starved strip: #{narrow.selected.caption} " \
1108
+ "(#{narrow.selected_index + 1} of #{narrow.tabs.size})"
1109
+ end
1110
+ report.call
1111
+ narrow.on_tab_selected = ->(_index, _tab) { report.call }
1112
+
1113
+ bar = Tuile::Component::MenuBar.new
1114
+ %w[File Edit View Window Help].each do |caption|
1115
+ menu = bar.add_item(caption)
1116
+ %w[First Second Third].each { |item| menu.add_item("#{caption} #{item}") }
1117
+ end
1118
+
1119
+ prompt = Tuile::Component::Label.new
1120
+ prompt.text = "Tab to a strip, then ←→. The starved one scrolls by the minimum needed to show the\n" \
1121
+ "selected tab whole, so the selection can never hide off an edge — and it scrolls\n" \
1122
+ "back to column 0 the moment everything fits again.\n" \
1123
+ "< and > over the edge columns say there is more strip that way; the captions cut\n" \
1124
+ "under them say the same thing, but only when the cut lands mid-caption. They are\n" \
1125
+ "not buttons: clicking one selects the half-visible tab beneath it, which reveals it.\n" \
1126
+ "The menu bar scrolls the same way — ←→ along it, and a menu opens under its own\n" \
1127
+ "segment wherever the scrolling has put it."
1128
+
1129
+ form do |f|
1130
+ f.add(prompt, Fixed[8])
1131
+ f.add(labelled("Natural width", wide, field_width: 40), Fixed[1])
1132
+ f.add(labelled("16 columns", narrow, field_width: 16), Fixed[1])
1133
+ f.add(status, Fixed[1])
1134
+ f.add(labelled("Menu bar (18)", bar, field_width: 18), Fixed[1])
1135
+ f.add(Tuile::Component::Label.new, Expand[1])
1136
+ end
1137
+ end
1138
+
822
1139
  def build_notification_launcher
823
1140
  label = Tuile::Component::Label.new
824
1141
  label.text = "Notification.show puts a toast in the top-right corner for 3 seconds.\n" \
@@ -1023,7 +1340,9 @@ if $PROGRAM_NAME == __FILE__
1023
1340
  screen = Tuile::Screen.new
1024
1341
  sampler = SamplerExample::Sampler.new
1025
1342
  screen.content = sampler
1026
- sampler.entry_list.focus
1343
+ screen.on_focus_changed = -> { sampler.refresh_status }
1344
+ sampler.refresh_status
1345
+ sampler.menu_bar.focus
1027
1346
  begin
1028
1347
  screen.run_event_loop
1029
1348
  ensure
@@ -104,6 +104,12 @@ Recorded here so the open questions below stay narrow.
104
104
  stop. See open question on backwards entry.
105
105
  - **Mouse is untouched.** Popups are untouched — the bubble is already scoped
106
106
  to the topmost modal popup.
107
+ - **{Tuile::Component::Tabs} already left the vertical axis free for this.**
108
+ The strip claims Left/Right and *declines* Up/Down specifically so that this
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
111
+ key bubbles, the navigating ancestor moves. That is also the shape to copy
112
+ for any future one-axis widget — claim one axis, leave the other.
107
113
 
108
114
  ## The honest argument against
109
115
 
@@ -180,6 +186,16 @@ exactly virtui's shape, so the answer matters more there than in a form.
180
186
  (`combo_box.rb:181`) but declines Up. So Up would jump out of a closed combo
181
187
  while Down opens it. Browsers eat both. Deliberate choice or accident to fix?
182
188
 
189
+ **Q12 — Down out of a `Tabs` strip: to the pane, or past the whole
190
+ `TabSheet`?** The strip is a child of the sheet, not of the navigating layout,
191
+ so "walk direct children" sees the *sheet* holding focus and would move to the
192
+ sheet's next sibling — skipping the pane the user is looking at. Entering the
193
+ pane is almost certainly what a user means by Down here. Options: let a
194
+ `TabSheet` claim Down when focus is on its strip (a `handle_key` on the sheet,
195
+ no framework change, but a second place that binds an arrow); or have the
196
+ navigating walk descend into a child that holds focus deeper than its first
197
+ tab stop. Interacts with Q1's placement question.
198
+
183
199
  **Q9 — Naming.** "Form" is the wrong word for the behavior — it's *focus
184
200
  navigation*. `navigation` / `arrow_nav` / `key_navigation` /
185
201
  `focus_navigation`? Whatever it is, it must not imply validation or submit,
@@ -50,10 +50,9 @@ That leaves ~46 gaps.
50
50
  | ~~Notification~~ | `Popup` + `Ticker` | **built** 2026-08-17 (`D-notification`, book ch7); one non-modal top-right box, N messages, one 3 s ticker retiring the oldest. Corner anchor is its own `reposition` override, so `Popup` was untouched — and the `Popover` extraction still waits for a second *kind* of anchoring |
51
51
  | Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
52
52
  | Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
53
- | Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
54
- | Popover | `ListDropdown#anchor_to` (extracted 2026-08-12) | generalize the anchored non-modal overlay: `anchor_to` moves down to it and `ListDropdown` inherits it. Build it when the second *kind* of anchoring appears (a point; a right edge that flips) — not the second caller of the same kind. Gates the next two |
55
- | Menu Bar | `ListDropdown::Menu` + Popover | |
56
- | Context Menu | same | `:right` button already parses |
53
+ | ~~Tabs → TabSheet~~ | plain `Component` + the tree API | **built** 2026-08-23 (`D-tabs`, book ch7); a strip (one tab stop, Left/Right, immediate activation) plus a sheet whose `children` are `[strip, pane]`. Neither is `HasValue` — a tab selection is view state — and neither is `HasContent`; hiding a pane means *detaching* it, since Tuile has no visibility flag; the strip owns mutable `Tabs::Tab` handles rather than the `items`/`item_label` shell. Hidden/disabled/closeable tabs, lazy panes and a scrolling strip are deferred, each additive (`D-tabs`) |
54
+ | Popover | `ListDropdown#anchor_to` (extracted 2026-08-12) | generalize the anchored non-modal overlay: `anchor_to` moves down to it and `ListDropdown` inherits it. Build it when the second *kind* of anchoring appears (a point; a right edge that flips) — not the second caller of the same kind. Nothing gates on it today: Menu Bar shipped without it |
55
+ | ~~Menu Bar~~ | `ListDropdown` (+ `anchor_beside`) | **built** 2026-08-24 (`D-menu-bar`, book ch7), mnemonics the same day. Turned out *not* to need the Popover extraction: a focused strip drives a cascade of non-modal `ListDropdown`s the way `Select` drives one, so the additions were a side-anchor method, a highlighted-row rect and a cursor pass-through. Unlimited submenu depth; checkable/disabled items and global-shortcut activation deferred indefinitely |
57
56
  | Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
58
57
  | Breadcrumbs | `Label`/`StyledString` | clickable path segments |
59
58
  | Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
@@ -100,8 +99,10 @@ file when its cluster comes up:
100
99
  2. **Field label + helper text seam** → Form Layout. Note this is what Form
101
100
  Layout is actually blocked on — the layout half now exists.
102
101
  3. **Validation seam** → Email Field, forms generally.
103
- 4. **Anchored Popover extraction** → Menu Bar, Context Menu, pickers,
104
- Tooltip.
102
+ 4. **Anchored Popover extraction** → pickers, Tooltip. **No longer gates Menu
103
+ Bar** — `D-menu-bar` argues the side-anchor is a sibling method on
104
+ `ListDropdown`, since both callers still wrap a `List`; the extraction's
105
+ trigger is now the first non-`List` content that wants anchoring.
105
106
  5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
106
107
  divider, Slider drag, scrollbar drag.
107
108
  6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
data/lib/tuile/ansi.rb CHANGED
@@ -12,6 +12,16 @@ module Tuile
12
12
  # @return [String]
13
13
  RESET = "\e[0m"
14
14
 
15
+ # The bell (`BEL`, `\a`, 0x07) — the "that keystroke went nowhere" signal.
16
+ # Ring it with {Screen#beep} rather than printing it: the bell is terminal
17
+ # IO, which is {Screen}'s job.
18
+ #
19
+ # What the user gets is the *terminal's* business — an audible beep, a
20
+ # visual flash, or nothing at all — and Tuile keeps no preference of its
21
+ # own about that.
22
+ # @return [String]
23
+ BEL = "\a"
24
+
15
25
  # Begin Synchronized Update (DEC private mode 2026, "Synchronized
16
26
  # Output"). The terminal stops refreshing its display and buffers every
17
27
  # subsequent write until {SYNC_END}, then composites the whole batch
@@ -42,6 +42,8 @@ module Tuile
42
42
  #
43
43
  # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
44
44
  # fit `rect.width - 1`).
45
+ # - {#preprocess_paste} — the same for {#handle_paste}, which lands a
46
+ # whole clipboard at the caret in one mutation.
45
47
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
46
48
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
47
49
  # keep the caret visible).
@@ -155,8 +157,42 @@ module Tuile
155
157
  handle_text_input_key(key)
156
158
  end
157
159
 
160
+ # Inserts pasted text at the caret as **one** mutation, so {#on_change}
161
+ # fires once for the whole paste rather than once per character.
162
+ # {#preprocess_paste} filters it first.
163
+ # @param text [String]
164
+ # @return [Boolean] always true — a field consumes every paste, an empty
165
+ # one included.
166
+ def handle_paste(text)
167
+ insert_text(preprocess_paste(text))
168
+ true
169
+ end
170
+
158
171
  protected
159
172
 
173
+ # Input filter for {#handle_paste}, the paste-side counterpart of
174
+ # {#preprocess_text}. Strips the C0 control characters a text buffer
175
+ # cannot hold — a raw `\e` or `\t` reaching {Buffer} would move the real
176
+ # terminal cursor mid-frame — keeping `\n`, and turning a tab into a
177
+ # single space so pasted code keeps its word gaps. {TextField} narrows it
178
+ # further; an app wanting tab *expansion* overrides {#handle_paste}.
179
+ # @param text [String]
180
+ # @return [String]
181
+ def preprocess_paste(text) = text.tr("\t", " ").gsub(/[\x00-\x09\x0b-\x1f\x7f]/, "")
182
+
183
+ # Inserts `str` at the caret, leaving the caret behind it. The bulk
184
+ # counterpart of a subclass's per-key insert.
185
+ # @param str [String]
186
+ # @return [Boolean] true if the text changed.
187
+ def insert_text(str)
188
+ return false if str.empty?
189
+
190
+ new_text = @text.dup.insert(@caret, str)
191
+ @caret += str.length
192
+ self.text = new_text
193
+ true
194
+ end
195
+
160
196
  # Renders `text` on the field's background well, looked up from the
161
197
  # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
162
198
  # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —