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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/DECISIONS.md +1970 -14
- data/README.md +136 -491
- data/TERMINOLOGY.md +70 -0
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +19 -6
- data/book/03-layout.md +12 -11
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +6 -3
- data/book/07-components.md +498 -38
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +27 -20
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +422 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +16 -10
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +13 -8
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +313 -216
- data/lib/tuile/component/list_dropdown.rb +100 -10
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +320 -0
- data/lib/tuile/component/picker_window.rb +3 -8
- data/lib/tuile/component/popup.rb +83 -19
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +10 -8
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +84 -277
- data/lib/tuile/component/text_field.rb +24 -7
- data/lib/tuile/component/text_view.rb +197 -180
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +43 -18
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +95 -78
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +52 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +2307 -516
- metadata +9 -3
- 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.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
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
|
-
#
|
|
83
|
-
# and
|
|
84
|
-
#
|
|
85
|
-
|
|
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
|
-
@
|
|
89
|
-
@
|
|
90
|
-
@
|
|
91
|
-
|
|
92
|
-
add(
|
|
93
|
-
|
|
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 :
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
159
|
-
@
|
|
160
|
-
|
|
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.
|
|
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.
|
|
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,
|
data/ideas/new-components.md
CHANGED
|
@@ -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}
|
|
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` |
|
|
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 →
|
|
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.
|
|
54
|
-
| Menu Bar | `ListDropdown
|
|
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** →
|
|
103
|
-
|
|
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
|