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