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