tuile 0.11.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. 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,85 +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
- ["Popup", :build_popup_launcher],
138
- ["InfoWindow", :build_info_launcher],
139
- ["PickerWindow", :build_picker_launcher],
140
- ["LogWindow", :build_log_window],
141
- ["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
+ ])
142
231
  ].freeze
143
232
 
144
- def build_entry_list
145
- list = Tuile::Component::List.new
146
- list.cursor = Tuile::Component::List::Cursor.new
147
- list.lines = ENTRIES.map(&:first)
148
- list.on_cursor_changed = ->(idx, _line) { load_entry(idx) if idx >= 0 }
149
- 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
150
246
  end
151
247
 
152
- 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)
153
280
  # The slash-menu demo parks a non-modal overlay on the pane (it lives
154
- # 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
155
282
  # demos or it would linger over the next one.
156
283
  @slash_overlay.close if @slash_overlay&.open?
157
284
  @slash_overlay = nil
158
- caption, builder = ENTRIES[idx]
159
- @right_window.caption = caption
160
- @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?
161
293
  end
162
294
 
163
295
  # --- Tileable demos ----------------------------------------------------
@@ -412,6 +544,43 @@ module SamplerExample
412
544
  end
413
545
  end
414
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
+
415
584
  def build_text_view
416
585
  prompt = Tuile::Component::Label.new
417
586
  prompt.text = "Read-only viewer for prose. Word-wraps to width; ANSI formatting passes through.\n" \
@@ -561,7 +730,7 @@ module SamplerExample
561
730
  # The Set iterates in *toggle* order, so intersect with items to report
562
731
  # it in the order the rows are shown — the documented idiom.
563
732
  shown = (LOG_LEVELS & selected.to_a).map(&:label)
564
- status.text = "value: {#{shown.join(", ")}} — #{log.lines.size} of #{entries.size} lines"
733
+ status.text = "value: {#{shown.join(", ")}} — #{log.items.size} of #{entries.size} lines"
565
734
  end
566
735
  refresh.call
567
736
  group.on_value_change = ->(_set) { refresh.call }
@@ -812,6 +981,191 @@ module SamplerExample
812
981
 
813
982
  # --- Modal launchers ---------------------------------------------------
814
983
 
984
+ # Four buttons, because the interesting things about a notification are all
985
+ # about *several* of them: one short toast shows the box hugging its content
986
+ # in the corner, a burst shows the stack draining one message every three
987
+ # seconds (and the grow-only width), and a long one shows the three-row wrap
988
+ # ending in an ellipsis. Focus stays on whichever button you pressed
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
+
1139
+ def build_notification_launcher
1140
+ label = Tuile::Component::Label.new
1141
+ label.text = "Notification.show puts a toast in the top-right corner for 3 seconds.\n" \
1142
+ "It never takes focus; a left-click on the box dismisses it.\n" \
1143
+ "Raise several and watch them drain one at a time."
1144
+ counter = 0
1145
+ buttons = [
1146
+ Tuile::Component::Button.new("Short") { Tuile::Component::Notification.show("Saved") },
1147
+ Tuile::Component::Button.new("Burst") do
1148
+ 5.times { Tuile::Component::Notification.show("Job #{counter += 1} finished") }
1149
+ end,
1150
+ Tuile::Component::Button.new("Long") do
1151
+ Tuile::Component::Notification.show(
1152
+ "Could not connect to the build server at 10.0.0.1: connection refused after " \
1153
+ "three attempts, giving up and falling back to the local cache"
1154
+ )
1155
+ end,
1156
+ Tuile::Component::Button.new("Colored") do
1157
+ Tuile::Component::Notification.show("Disk almost full", color: Tuile::Color::RED)
1158
+ end
1159
+ ]
1160
+ strip = row do |r|
1161
+ buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
1162
+ end
1163
+ form do |f|
1164
+ f.add(label, Fixed[3])
1165
+ f.add(strip, Fixed[1])
1166
+ end
1167
+ end
1168
+
815
1169
  def build_popup_launcher
816
1170
  launcher(
817
1171
  "Popup is a modal overlay wrapping any Component.\n" \
@@ -986,7 +1340,9 @@ if $PROGRAM_NAME == __FILE__
986
1340
  screen = Tuile::Screen.new
987
1341
  sampler = SamplerExample::Sampler.new
988
1342
  screen.content = sampler
989
- sampler.entry_list.focus
1343
+ screen.on_focus_changed = -> { sampler.refresh_status }
1344
+ sampler.refresh_status
1345
+ sampler.menu_bar.focus
990
1346
  begin
991
1347
  screen.run_event_loop
992
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,
@@ -25,8 +25,9 @@ Seven of the 54 have a counterpart: Button, Text Field, Text Area,
25
25
  Integer Field, Combo Box (1:1), Dialog ({Tuile::Component::Popup} plus
26
26
  `Window`/`InfoWindow`/`PickerWindow`), Themable Mixin
27
27
  ({Tuile::Theme}/{Tuile::ThemeDef}). **List Box** is half-there:
28
- {Tuile::Component::List} is line-based, with no typed items and no
29
- multi-select.
28
+ {Tuile::Component::List} takes typed items and a renderer since 2026-08-14
29
+ (`D-list-items`), but has no multi-select — {Tuile::Component::CheckboxGroup}
30
+ is the nearest thing.
30
31
 
31
32
  Tuile also has pieces Vaadin doesn't name as components — `ListDropdown`,
32
33
  `TextView`, `LogWindow`, `VerticalScrollBar` — so the gap is not
@@ -46,13 +47,12 @@ That leaves ~46 gaps.
46
47
  | ~~Password Field~~ | `TextField` | **built** 2026-08-02 (`D-integer-field`'s taxonomy — subclass, since a password's value *is* its text; mask default in `D-ambiguous-width`); a `display_text` seam, one mask glyph per character |
47
48
  | ~~Number Field~~ | `IntegerField` twin | **built** 2026-08-07 as `FloatField` (`D-float-field`) and `BigDecimalField` (`D-bigdecimal-field`, on Tuile's first optional dep); each named for its Ruby value type, deliberate copies of `IntegerField` |
48
49
  | ~~Progress Bar~~ | `draw_line` + `EventQueue#tick_fps` | **built** 2026-08-02 (`D-progress-bar`, book ch7); a `value` that stays out of `HasValue`, ticker synced from `attached? && indeterminate?` |
49
- | Notification | `Popup` + `Ticker` | needs corner-anchored (non-centered) popup placement |
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 |
50
51
  | Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
51
52
  | Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
52
- | Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
53
- | 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 |
54
- | Menu Bar | `ListDropdown::Menu` + Popover | |
55
- | 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 |
56
56
  | Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
57
57
  | Breadcrumbs | `Label`/`StyledString` | clickable path segments |
58
58
  | Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
@@ -99,12 +99,18 @@ file when its cluster comes up:
99
99
  2. **Field label + helper text seam** → Form Layout. Note this is what Form
100
100
  Layout is actually blocked on — the layout half now exists.
101
101
  3. **Validation seam** → Email Field, forms generally.
102
- 4. **Anchored Popover extraction** → Menu Bar, Context Menu, pickers,
103
- 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.
104
106
  5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
105
107
  divider, Slider drag, scrollbar drag.
106
108
  6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
107
- List.
109
+ List. **Half done** 2026-08-14 (`D-list-items`): `List` takes `items` +
110
+ a `renderer` and renders only the visible rows, and the five composers
111
+ are folded onto it. The remaining half is *sourcing* items lazily (a
112
+ data provider behind `items`), which lazy rendering was chosen to keep
113
+ reachable without a redesign.
108
114
 
109
115
  Vaadin's `Binder` is the natural companion for the forms cluster but is
110
116
  not a component; `D-has-value` already parks the forms-layer questions