tuile 0.9.0 → 0.11.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 +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
data/examples/sampler.rb
CHANGED
|
@@ -15,11 +15,12 @@ require "rainbow"
|
|
|
15
15
|
require "tuile"
|
|
16
16
|
|
|
17
17
|
module SamplerExample
|
|
18
|
-
# Sampler-local container: a {Tuile::Component::Layout::Absolute} that
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
18
|
+
# Sampler-local container: a {Tuile::Component::Layout::Absolute} that runs a
|
|
19
|
+
# caller-supplied block on `rect=` to position its children. Most demos are
|
|
20
|
+
# plain stacks and use the box layouts instead; this is what's left for the
|
|
21
|
+
# two that aren't — a sidebar whose width is `min(16, width / 3)`, which is a
|
|
22
|
+
# cap on a proportion and so outside {Tuile::Component::Layout::Box}'s
|
|
23
|
+
# Fixed/Percent/Expand vocabulary by design.
|
|
23
24
|
class Panel < Tuile::Component::Layout::Absolute
|
|
24
25
|
def initialize(&layout_block)
|
|
25
26
|
super()
|
|
@@ -32,6 +33,52 @@ module SamplerExample
|
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
# A {Tuile::Component::Layout::Vertical} that runs {#on_tick} on every frame
|
|
37
|
+
# while it is on screen. The ticker is started on attach and cancelled on
|
|
38
|
+
# detach, so selecting another demo — which detaches this pane — cannot leave
|
|
39
|
+
# one firing at the old pane forever. Owning a mounted-lifetime resource this
|
|
40
|
+
# way is the whole point of the attach hooks.
|
|
41
|
+
class TickingBox < Tuile::Component::Layout::Vertical
|
|
42
|
+
def initialize(fps, **)
|
|
43
|
+
super(**)
|
|
44
|
+
@fps = fps
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Proc, nil] called with no arguments on each frame.
|
|
48
|
+
attr_writer :on_tick
|
|
49
|
+
|
|
50
|
+
def on_attached
|
|
51
|
+
@ticker = screen.event_queue.tick_fps(@fps) { @on_tick&.call }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def on_detached
|
|
55
|
+
@ticker&.cancel
|
|
56
|
+
@ticker = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A {Tuile::Component::Layout::Vertical} that claims one key for itself. An
|
|
61
|
+
# ancestor's `handle_key` is where a scope-wide binding belongs (key-dispatch
|
|
62
|
+
# rung 3); the Select demo uses one to show the letter still arriving while a
|
|
63
|
+
# Select has focus — the capability a ComboBox, which eats every printable
|
|
64
|
+
# unconditionally, cannot offer.
|
|
65
|
+
class ShortcutBox < Tuile::Component::Layout::Vertical
|
|
66
|
+
def initialize(shortcut, **)
|
|
67
|
+
super(**)
|
|
68
|
+
@shortcut = shortcut
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Proc, nil] called with no arguments when the shortcut arrives.
|
|
72
|
+
attr_writer :on_shortcut
|
|
73
|
+
|
|
74
|
+
def handle_key(key)
|
|
75
|
+
return false unless key == @shortcut
|
|
76
|
+
|
|
77
|
+
@on_shortcut&.call
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
35
82
|
# Top-level sampler component. Splits the screen into a left entry list
|
|
36
83
|
# and a right demo pane; each `load_entry` rebuilds the demo from
|
|
37
84
|
# scratch so it always starts in a clean state.
|
|
@@ -48,6 +95,10 @@ module SamplerExample
|
|
|
48
95
|
|
|
49
96
|
attr_reader :left_window, :right_window, :entry_list
|
|
50
97
|
|
|
98
|
+
# Chrome for a demo pane: a blank row top and bottom, two columns either
|
|
99
|
+
# side, so content doesn't run flush to the window border.
|
|
100
|
+
FORM_PADDING = Insets[top: 1, bottom: 1, left: 2, right: 2]
|
|
101
|
+
|
|
51
102
|
def rect=(new_rect)
|
|
52
103
|
super
|
|
53
104
|
return if rect.empty?
|
|
@@ -67,10 +118,21 @@ module SamplerExample
|
|
|
67
118
|
["Label", :build_label],
|
|
68
119
|
["TextField", :build_text_field],
|
|
69
120
|
["TextArea", :build_text_area],
|
|
121
|
+
["ComboBox", :build_combo_box],
|
|
122
|
+
["Select", :build_select],
|
|
123
|
+
["IntegerField", :build_integer_field],
|
|
124
|
+
["FloatField", :build_float_field],
|
|
125
|
+
["BigDecimalField", :build_big_decimal_field],
|
|
126
|
+
["PasswordField", :build_password_field],
|
|
70
127
|
["Slash menu", :build_slash_demo],
|
|
71
128
|
["TextView", :build_text_view],
|
|
72
129
|
["Button", :build_buttons],
|
|
130
|
+
["Checkbox", :build_checkboxes],
|
|
131
|
+
["CheckboxGroup", :build_checkbox_group],
|
|
132
|
+
["RadioGroup", :build_radio_group],
|
|
73
133
|
["List", :build_list],
|
|
134
|
+
["ProgressBar", :build_progress_bar],
|
|
135
|
+
["Background", :build_background],
|
|
74
136
|
["Layout", :build_layout],
|
|
75
137
|
["Popup", :build_popup_launcher],
|
|
76
138
|
["InfoWindow", :build_info_launcher],
|
|
@@ -116,10 +178,9 @@ module SamplerExample
|
|
|
116
178
|
prompt = Tuile::Component::Label.new
|
|
117
179
|
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
|
|
118
180
|
field = Tuile::Component::TextField.new
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
field.rect = Tuile::Rect.new(inner.left, inner.top + 3, inner.width, 1)
|
|
181
|
+
form do |f|
|
|
182
|
+
f.add(prompt, Fixed[1])
|
|
183
|
+
f.add(field, Fixed[1])
|
|
123
184
|
end
|
|
124
185
|
end
|
|
125
186
|
|
|
@@ -133,11 +194,165 @@ module SamplerExample
|
|
|
133
194
|
area.text = "The quick brown fox jumps over the lazy dog. " \
|
|
134
195
|
"Edit me — the text wraps to the area's width and scrolls vertically " \
|
|
135
196
|
"once the cursor leaves the visible rows."
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
197
|
+
form do |f|
|
|
198
|
+
f.add(prompt, Fixed[3])
|
|
199
|
+
f.add(area, Expand[1])
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# ComboBox: its value is the selected item, not the typed text — the status
|
|
204
|
+
# line echoes it as it commits. The dropdown closes itself on blur, so no
|
|
205
|
+
# overlay bookkeeping is needed here (unlike the slash demo below).
|
|
206
|
+
def build_combo_box
|
|
207
|
+
prompt = Tuile::Component::Label.new
|
|
208
|
+
prompt.text = "Tab here, then type to filter. ↑↓ move the highlight, Enter accepts, ESC dismisses.\n" \
|
|
209
|
+
"The dropdown floats above or below the field and tints itself apart from the content."
|
|
210
|
+
items = %w[Ruby Python JavaScript TypeScript Rust Go Elixir Crystal Haskell Kotlin Swift Zig]
|
|
211
|
+
combo = Tuile::Component::ComboBox.new(items: items)
|
|
212
|
+
status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
|
|
213
|
+
combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
|
|
214
|
+
form do |f|
|
|
215
|
+
f.add(prompt, Fixed[3])
|
|
216
|
+
# A cross constraint clamps to the pane, so this is 30 columns or fewer.
|
|
217
|
+
f.add(combo, Fixed[1], cross: Fixed[30])
|
|
218
|
+
f.add(status, Fixed[1])
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# One line-ending choice: the item type the second Select below holds, so its
|
|
223
|
+
# `value` is a LineEnding carrying the bytes to write — never a label to look
|
|
224
|
+
# a separator back up from.
|
|
225
|
+
LineEnding = Data.define(:label, :bytes)
|
|
226
|
+
|
|
227
|
+
LINE_ENDINGS = [
|
|
228
|
+
LineEnding.new("LF (Unix)", "\n"),
|
|
229
|
+
LineEnding.new("CRLF (Windows)", "\r\n"),
|
|
230
|
+
LineEnding.new("CR (classic Mac)", "\r")
|
|
231
|
+
].freeze
|
|
232
|
+
|
|
233
|
+
# Two Selects, an enum each — developer-authored labels, which is what a
|
|
234
|
+
# Select is for and a ComboBox isn't. Three things worth watching: the
|
|
235
|
+
# dropdown is measured to its widest label rather than to the field (so the
|
|
236
|
+
# line-endings menu is wider than its face), a nil value is a legal blank
|
|
237
|
+
# face rather than a placeholder, and `r` reaches the *pane* while a Select
|
|
238
|
+
# has focus — no printable but Space belongs to the widget.
|
|
239
|
+
def build_select
|
|
240
|
+
prompt = Tuile::Component::Label.new
|
|
241
|
+
prompt.text = "Tab between the two Selects. Enter, Space or ↓ opens;\n" \
|
|
242
|
+
"↑↓ move the highlight, Enter or Space commits, ESC cancels.\n" \
|
|
243
|
+
"Press r to reset — the pane gets the letter, not the Select."
|
|
244
|
+
|
|
245
|
+
level = Tuile::Component::Select.new(items: %w[debug info warn error fatal], value: "warn")
|
|
246
|
+
endings = Tuile::Component::Select.new(items: LINE_ENDINGS)
|
|
247
|
+
endings.item_label = :label.to_proc
|
|
248
|
+
|
|
249
|
+
status = Tuile::Component::Label.new
|
|
250
|
+
update = lambda do
|
|
251
|
+
status.text = "level: #{level.value.inspect} endings: #{endings.value&.label.inspect}"
|
|
252
|
+
end
|
|
253
|
+
update.call
|
|
254
|
+
[level, endings].each { _1.on_value_change = ->(_v) { update.call } }
|
|
255
|
+
|
|
256
|
+
pane = ShortcutBox.new("r", spacing: 1, padding: FORM_PADDING)
|
|
257
|
+
pane.add(prompt, Fixed[3])
|
|
258
|
+
pane.add(labelled("Log level", level), Fixed[1])
|
|
259
|
+
pane.add(labelled("Line endings", endings), Fixed[1])
|
|
260
|
+
pane.add(status, Fixed[1])
|
|
261
|
+
pane.on_shortcut = lambda do
|
|
262
|
+
level.value = "warn"
|
|
263
|
+
endings.value = nil
|
|
264
|
+
update.call
|
|
265
|
+
end
|
|
266
|
+
pane
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# IntegerField: its value is a typed Integer (or nil), parsed from the
|
|
270
|
+
# digits you type — the status line echoes it. Up/Down step it like a
|
|
271
|
+
# spinner. Only the value seam shows on its face; the digit filtering and
|
|
272
|
+
# parsing are internal.
|
|
273
|
+
def build_integer_field
|
|
274
|
+
prompt = Tuile::Component::Label.new
|
|
275
|
+
prompt.text = "Tab here, then type digits (and a leading -). Non-digits are ignored.\n" \
|
|
276
|
+
"Up/Down step the value by one; an empty field counts as 0."
|
|
277
|
+
field = Tuile::Component::IntegerField.new
|
|
278
|
+
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
279
|
+
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
280
|
+
form do |f|
|
|
281
|
+
f.add(prompt, Fixed[2])
|
|
282
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
283
|
+
f.add(status, Fixed[1])
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# FloatField: the IntegerField one Ruby type over — a single decimal point
|
|
288
|
+
# is allowed too, and the value is a Float. The status line echoes the
|
|
289
|
+
# value, which is where the generous parse shows: a buffer of "1." already
|
|
290
|
+
# reads as 1.0 rather than blinking to nil while you reach for a digit.
|
|
291
|
+
def build_float_field
|
|
292
|
+
prompt = Tuile::Component::Label.new
|
|
293
|
+
prompt.text = "Tab here, then type digits, one '.' and a leading -. Anything else is ignored.\n" \
|
|
294
|
+
"Up/Down step the value by one. Watch the value while you type '1.5'."
|
|
295
|
+
field = Tuile::Component::FloatField.new
|
|
296
|
+
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
297
|
+
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
298
|
+
form do |f|
|
|
299
|
+
f.add(prompt, Fixed[2])
|
|
300
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
301
|
+
f.add(status, Fixed[1])
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# BigDecimalField: the same shape again, holding an exact decimal. The
|
|
306
|
+
# status line multiplies by three both ways, so typing "0.1" shows the
|
|
307
|
+
# difference the field exists for. Needs the bigdecimal gem — Tuile's one
|
|
308
|
+
# optional dependency, required by this component and nothing else.
|
|
309
|
+
def build_big_decimal_field
|
|
310
|
+
prompt = Tuile::Component::Label.new
|
|
311
|
+
prompt.text = "Tab here and type 0.1 — then compare the two products below.\n" \
|
|
312
|
+
"This is the field for money: no binary rounding, and nothing pads or trims\n" \
|
|
313
|
+
"what you typed (19.90 keeps its zero)."
|
|
314
|
+
field = Tuile::Component::BigDecimalField.new
|
|
315
|
+
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
316
|
+
field.on_value_change = ->(value) { status.text = triple_report(value) }
|
|
317
|
+
form do |f|
|
|
318
|
+
f.add(prompt, Fixed[3])
|
|
319
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
320
|
+
f.add(status, Fixed[2])
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# @param value [BigDecimal, nil]
|
|
325
|
+
# @return [String] the value tripled exactly, next to the same sum in Float.
|
|
326
|
+
def triple_report(value)
|
|
327
|
+
return "value: nil" if value.nil?
|
|
328
|
+
|
|
329
|
+
"value: #{value.to_s("F")}\n" \
|
|
330
|
+
"×3 exact: #{(value * 3).to_s("F")} ×3 as Float: #{value.to_f * 3}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# PasswordField: a TextField that paints a mask instead of its text. The
|
|
334
|
+
# plaintext stays in `value` throughout — the status line proves it by
|
|
335
|
+
# reporting the length — and the "Show password" box flips `revealed`,
|
|
336
|
+
# since a TTY field has no room for an in-field reveal button.
|
|
337
|
+
def build_password_field
|
|
338
|
+
prompt = Tuile::Component::Label.new
|
|
339
|
+
prompt.text = "Tab through the two fields and type. The password paints one * per character,\n" \
|
|
340
|
+
"whatever you type — try a CJK passphrase: the caret still tracks the mask.\n" \
|
|
341
|
+
"Ctrl+Left/Right jump to the ends while masked, so the caret can't give away\n" \
|
|
342
|
+
"where the spaces are; they resume word jumping once revealed."
|
|
343
|
+
user = Tuile::Component::TextField.new
|
|
344
|
+
password = Tuile::Component::PasswordField.new
|
|
345
|
+
reveal = Tuile::Component::Checkbox.new("Show password")
|
|
346
|
+
reveal.on_value_change = ->(on) { password.revealed = on }
|
|
347
|
+
status = Tuile::Component::Label.new
|
|
348
|
+
refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
|
|
349
|
+
refresh.call
|
|
350
|
+
[user, password].each { _1.on_change = ->(_) { refresh.call } }
|
|
351
|
+
form do |f|
|
|
352
|
+
f.add(prompt, Fixed[4])
|
|
353
|
+
f.add([user, password], Fixed[1], cross: Fixed[30]) # one constraint, both fields
|
|
354
|
+
f.add(reveal, Fixed[1])
|
|
355
|
+
f.add(status, Fixed[1])
|
|
141
356
|
end
|
|
142
357
|
end
|
|
143
358
|
|
|
@@ -191,11 +406,9 @@ module SamplerExample
|
|
|
191
406
|
end
|
|
192
407
|
end
|
|
193
408
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
area_height = [inner.height - 7, 4].max
|
|
198
|
-
area.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, area_height)
|
|
409
|
+
form do |f|
|
|
410
|
+
f.add(prompt, Fixed[4])
|
|
411
|
+
f.add(area, Expand[1])
|
|
199
412
|
end
|
|
200
413
|
end
|
|
201
414
|
|
|
@@ -223,11 +436,9 @@ module SamplerExample
|
|
|
223
436
|
"freely."
|
|
224
437
|
window.content = view
|
|
225
438
|
window.scrollbar = true
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
view_height = [inner.height - 5, 4].max
|
|
230
|
-
window.rect = Tuile::Rect.new(inner.left, inner.top + 4, inner.width, view_height)
|
|
439
|
+
form do |f|
|
|
440
|
+
f.add(prompt, Fixed[2])
|
|
441
|
+
f.add(window, Expand[1])
|
|
231
442
|
end
|
|
232
443
|
end
|
|
233
444
|
|
|
@@ -246,13 +457,218 @@ module SamplerExample
|
|
|
246
457
|
counters[:cancel] += 1
|
|
247
458
|
refresh.call
|
|
248
459
|
end
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
460
|
+
buttons = row do |r|
|
|
461
|
+
r.add(ok, Fixed[button_width(ok)])
|
|
462
|
+
r.add(cancel, Fixed[button_width(cancel)])
|
|
463
|
+
end
|
|
464
|
+
form do |f|
|
|
465
|
+
f.add(label, Fixed[2])
|
|
466
|
+
f.add(buttons, Fixed[1])
|
|
467
|
+
f.add(result, Fixed[1])
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
CHECKBOX_OPTIONS = ["Enable syslog forwarding", "Rotate logs daily", "Compress archives",
|
|
472
|
+
"Email on failure", "Verbose output"].freeze
|
|
473
|
+
|
|
474
|
+
# Checkbox: Space, Enter (or a click on the label) toggles. Each box is handed
|
|
475
|
+
# the full column width, which is what makes the extent visible — the
|
|
476
|
+
# highlight and the click target stop at the end of the caption, not the rect's.
|
|
477
|
+
def build_checkboxes
|
|
478
|
+
prompt = Tuile::Component::Label.new
|
|
479
|
+
prompt.text = "Tab here, then Space or Enter to toggle; a left-click on a label toggles too.\n" \
|
|
480
|
+
"Each box spans the whole column, but only the caption highlights —\n" \
|
|
481
|
+
"clicking the empty space to its right just moves focus."
|
|
482
|
+
status = Tuile::Component::Label.new
|
|
483
|
+
boxes = CHECKBOX_OPTIONS.map { Tuile::Component::Checkbox.new(_1) }
|
|
484
|
+
refresh = lambda do
|
|
485
|
+
on = boxes.select(&:checked?).map { _1.caption.to_s }
|
|
486
|
+
status.text = "checked: #{on.empty? ? "(none)" : on.join(", ")}"
|
|
487
|
+
end
|
|
488
|
+
refresh.call
|
|
489
|
+
boxes.each { _1.on_value_change = ->(_) { refresh.call } }
|
|
490
|
+
# The boxes sit flush against each other while the form keeps a blank row
|
|
491
|
+
# around the block: a spacing-0 group nested in the spacing-1 form, rather
|
|
492
|
+
# than a per-child gap the framework deliberately doesn't offer.
|
|
493
|
+
rows = group { |g| g.add(boxes, Fixed[1]) }
|
|
494
|
+
form do |f|
|
|
495
|
+
f.add(prompt, Fixed[3])
|
|
496
|
+
f.add(rows, Fixed[boxes.size])
|
|
497
|
+
f.add(status, Fixed[1])
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# One filterable log level: the item type a CheckboxGroup holds. Its `value`
|
|
502
|
+
# is a Set of *these*, never of the labels shown on the rows.
|
|
503
|
+
LogLevel = Data.define(:label, :tag, :color)
|
|
504
|
+
|
|
505
|
+
LOG_LEVELS = [
|
|
506
|
+
LogLevel.new("Debug", "DEBUG", :cyan),
|
|
507
|
+
LogLevel.new("Info", "INFO", :green),
|
|
508
|
+
LogLevel.new("Warnings", "WARN", :yellow),
|
|
509
|
+
LogLevel.new("Errors", "ERROR", :red)
|
|
510
|
+
].freeze
|
|
511
|
+
|
|
512
|
+
# `[tag, message]` pairs; the tag names the LogLevel that owns the line.
|
|
513
|
+
SAMPLE_LOG = [
|
|
514
|
+
["DEBUG", "config loaded from /etc/tuile.conf"],
|
|
515
|
+
["INFO", "listening on 0.0.0.0:8080"],
|
|
516
|
+
["DEBUG", "cache warm: 128 entries"],
|
|
517
|
+
["WARN", "TLS certificate expires in 6 days"],
|
|
518
|
+
["INFO", "GET /health 200 (1.2ms)"],
|
|
519
|
+
["DEBUG", "pool checkout: 3/16 busy"],
|
|
520
|
+
["ERROR", "upstream timeout after 5000ms"],
|
|
521
|
+
["INFO", "GET /index 200 (18ms)"],
|
|
522
|
+
["WARN", "slow query: 1.8s SELECT * FROM tiles"],
|
|
523
|
+
["DEBUG", "gc pause 4ms"],
|
|
524
|
+
["ERROR", "connection reset by peer (retrying)"],
|
|
525
|
+
["INFO", "POST /tiles 201 (32ms)"],
|
|
526
|
+
["DEBUG", "pool checkout: 11/16 busy"],
|
|
527
|
+
["WARN", "queue depth 240, above the 200 mark"],
|
|
528
|
+
["INFO", "GET /tiles/42 200 (7ms)"],
|
|
529
|
+
["ERROR", "failed to write /var/log/tuile.log: no space left"],
|
|
530
|
+
["DEBUG", "flush wrote 96 cells"],
|
|
531
|
+
["INFO", "shutdown signal received"]
|
|
532
|
+
].freeze
|
|
533
|
+
|
|
534
|
+
# CheckboxGroup filtering an adjacent log. Its value is a Set of the selected
|
|
535
|
+
# *items* — LogLevel objects, not their labels — so the filter below is plain
|
|
536
|
+
# set membership, no lookup table. Rows come from `item_label`, which may
|
|
537
|
+
# return styled text (these colors are inherent to the data, not theme
|
|
538
|
+
# accents, so they need no on_theme_changed hook).
|
|
539
|
+
def build_checkbox_group
|
|
540
|
+
prompt = Tuile::Component::Label.new
|
|
541
|
+
# Kept under 48 columns a line, so an 80-column terminal shows it whole.
|
|
542
|
+
prompt.text = "Tab here. ↑↓ moves the cursor, Space toggles.\n" \
|
|
543
|
+
"Enter or a click anywhere on a row toggles too —\n" \
|
|
544
|
+
"in a list, the whole row is the target.\n" \
|
|
545
|
+
"The log redraws from the value on every toggle."
|
|
546
|
+
levels_by_tag = LOG_LEVELS.to_h { [_1.tag, _1] }
|
|
547
|
+
entries = SAMPLE_LOG.map { |tag, message| [levels_by_tag.fetch(tag), message] }
|
|
548
|
+
|
|
549
|
+
group = Tuile::Component::CheckboxGroup.new(items: LOG_LEVELS, value: LOG_LEVELS.last(2))
|
|
550
|
+
group.item_label = ->(level) { Rainbow(level.label).color(level.color) }
|
|
551
|
+
|
|
552
|
+
log = Tuile::Component::List.new
|
|
553
|
+
log.cursor = Tuile::Component::List::Cursor.new
|
|
554
|
+
log.scrollbar_visibility = :visible
|
|
555
|
+
status = Tuile::Component::Label.new
|
|
556
|
+
|
|
557
|
+
refresh = lambda do
|
|
558
|
+
selected = group.value
|
|
559
|
+
log.lines = entries.select { |level, _| selected.include?(level) }
|
|
560
|
+
.map { |level, message| "#{Rainbow(level.tag.ljust(5)).color(level.color)} #{message}" }
|
|
561
|
+
# The Set iterates in *toggle* order, so intersect with items to report
|
|
562
|
+
# it in the order the rows are shown — the documented idiom.
|
|
563
|
+
shown = (LOG_LEVELS & selected.to_a).map(&:label)
|
|
564
|
+
status.text = "value: {#{shown.join(", ")}} — #{log.lines.size} of #{entries.size} lines"
|
|
565
|
+
end
|
|
566
|
+
refresh.call
|
|
567
|
+
group.on_value_change = ->(_set) { refresh.call }
|
|
568
|
+
|
|
569
|
+
# The body keeps a rect-callback {Panel}: its sidebar is `min(16, width/3)`
|
|
570
|
+
# — a cap on a proportion, which Fixed/Percent/Expand can't say. The stack
|
|
571
|
+
# around it is a box, so only the part that needs arithmetic has any.
|
|
572
|
+
body = panel(group, log) do |r|
|
|
573
|
+
group_width = [16, r.width / 3].min
|
|
574
|
+
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [LOG_LEVELS.size, r.height].min)
|
|
575
|
+
log.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
576
|
+
[r.width - group_width - 2, 4].max, r.height)
|
|
577
|
+
end
|
|
578
|
+
form do |f|
|
|
579
|
+
f.add(prompt, Fixed[4])
|
|
580
|
+
# Status above the body, so it stays next to the group however tall the
|
|
581
|
+
# pane gets; the log takes whatever height is left.
|
|
582
|
+
f.add(status, Fixed[1])
|
|
583
|
+
f.add(body, Expand[1])
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# One sort order: the item type a RadioGroup holds. Its `value` is one of
|
|
588
|
+
# *these*, and the chosen object carries the behavior — so re-sorting is
|
|
589
|
+
# `value.sorter.call(files)`, never a lookup from a label back to a
|
|
590
|
+
# comparator.
|
|
591
|
+
SortOrder = Data.define(:label, :sorter)
|
|
592
|
+
|
|
593
|
+
SORT_ORDERS = [
|
|
594
|
+
SortOrder.new("Name A-Z", ->(files) { files.sort_by(&:name) }),
|
|
595
|
+
SortOrder.new("Name Z-A", ->(files) { files.sort_by(&:name).reverse }),
|
|
596
|
+
SortOrder.new("Biggest", ->(files) { files.sort_by { -_1.size } }),
|
|
597
|
+
SortOrder.new("Newest", ->(files) { files.sort_by(&:date).reverse })
|
|
598
|
+
].freeze
|
|
599
|
+
|
|
600
|
+
SampleFile = Data.define(:name, :size, :date)
|
|
601
|
+
|
|
602
|
+
# Names stay under 14 columns and sizes round to distinct k values, so the
|
|
603
|
+
# rows fit the pane at 80 columns and every sort order reorders visibly.
|
|
604
|
+
SAMPLE_FILES = [
|
|
605
|
+
SampleFile.new("AGENTS.md", 31_402, "2026-07-30"),
|
|
606
|
+
SampleFile.new("CHANGELOG.md", 4118, "2026-07-05"),
|
|
607
|
+
SampleFile.new("DECISIONS.md", 48_990, "2026-07-31"),
|
|
608
|
+
SampleFile.new("Gemfile", 312, "2026-06-18"),
|
|
609
|
+
SampleFile.new("README.md", 9674, "2026-07-12"),
|
|
610
|
+
SampleFile.new("Rakefile", 2118, "2026-06-18"),
|
|
611
|
+
SampleFile.new("list.rb", 21_006, "2026-07-24"),
|
|
612
|
+
SampleFile.new("sampler.rb", 22_180, "2026-07-31"),
|
|
613
|
+
SampleFile.new("screen.rb", 18_442, "2026-07-28"),
|
|
614
|
+
SampleFile.new("text_view.rb", 14_338, "2026-07-19"),
|
|
615
|
+
SampleFile.new("theme.rb", 6512, "2026-07-23"),
|
|
616
|
+
SampleFile.new("tuile.gemspec", 1284, "2026-06-20")
|
|
617
|
+
].freeze
|
|
618
|
+
|
|
619
|
+
# RadioGroup driving an adjacent file list. Two things worth watching: the
|
|
620
|
+
# value is the selected *item* (a SortOrder carrying its own comparator),
|
|
621
|
+
# and the cursor is chrome — arrows move it without touching the value, so
|
|
622
|
+
# the status line's two halves drift apart until you press Space.
|
|
623
|
+
def build_radio_group
|
|
624
|
+
prompt = Tuile::Component::Label.new
|
|
625
|
+
# Kept under 48 columns a line, so an 80-column terminal shows it whole.
|
|
626
|
+
prompt.text = "Tab here. ↑↓ move the cursor only.\n" \
|
|
627
|
+
"Space, Enter or a click selects — and only\n" \
|
|
628
|
+
"then does the list re-sort. Picking another\n" \
|
|
629
|
+
"clears the previous; there is no deselect."
|
|
630
|
+
|
|
631
|
+
group = Tuile::Component::RadioGroup.new(items: SORT_ORDERS, value: SORT_ORDERS.first)
|
|
632
|
+
group.item_label = :label.to_proc
|
|
633
|
+
|
|
634
|
+
files = Tuile::Component::List.new
|
|
635
|
+
files.cursor = Tuile::Component::List::Cursor.new
|
|
636
|
+
files.scrollbar_visibility = :visible
|
|
637
|
+
status = Tuile::Component::Label.new
|
|
638
|
+
short_size = ->(bytes) { bytes < 1024 ? bytes.to_s : "#{(bytes / 1024.0).round}k" }
|
|
639
|
+
|
|
640
|
+
update_status = lambda do
|
|
641
|
+
under_cursor = SORT_ORDERS[group.content.cursor.position]
|
|
642
|
+
status.text = "value: #{group.value.label} — cursor: #{under_cursor&.label}"
|
|
643
|
+
end
|
|
644
|
+
resort = lambda do
|
|
645
|
+
files.lines = group.value.sorter.call(SAMPLE_FILES).map do |file|
|
|
646
|
+
"#{file.name.ljust(13)} #{short_size.call(file.size).rjust(4)} #{file.date}"
|
|
647
|
+
end
|
|
648
|
+
update_status.call
|
|
649
|
+
end
|
|
650
|
+
resort.call
|
|
651
|
+
group.on_value_change = ->(_order) { resort.call }
|
|
652
|
+
# `content` is the composed List, which is where the cursor lives.
|
|
653
|
+
# Watching it is what makes the chrome/value split visible above.
|
|
654
|
+
group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
|
|
655
|
+
|
|
656
|
+
# Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
|
|
657
|
+
# demo — the sidebar width is a capped proportion, not a constraint.
|
|
658
|
+
body = panel(group, files) do |r|
|
|
659
|
+
# List pads a column either side of a row, so a label needs
|
|
660
|
+
# `width - 2`; the file rows lose one more to their scrollbar.
|
|
661
|
+
group_width = [14, r.width / 3].min
|
|
662
|
+
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [SORT_ORDERS.size, r.height].min)
|
|
663
|
+
files.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
664
|
+
[r.width - group_width - 2, 4].max, r.height)
|
|
665
|
+
end
|
|
666
|
+
form do |f|
|
|
667
|
+
f.add(prompt, Fixed[4])
|
|
668
|
+
# Status above the body, so it stays next to the group however tall the
|
|
669
|
+
# pane gets; the file list takes whatever height is left.
|
|
670
|
+
f.add(status, Fixed[1])
|
|
671
|
+
f.add(body, Expand[1])
|
|
256
672
|
end
|
|
257
673
|
end
|
|
258
674
|
|
|
@@ -264,16 +680,134 @@ module SamplerExample
|
|
|
264
680
|
list
|
|
265
681
|
end
|
|
266
682
|
|
|
683
|
+
# Files the fake job in the ProgressBar demo pretends to process.
|
|
684
|
+
PROGRESS_TOTAL = 50
|
|
685
|
+
|
|
686
|
+
# Frames per second of the demo's fake job — its own pace, unrelated to
|
|
687
|
+
# {Tuile::Component::ProgressBar::INDETERMINATE_FPS}, which paces only the
|
|
688
|
+
# animation the bar runs for itself.
|
|
689
|
+
PROGRESS_FPS = 8
|
|
690
|
+
|
|
691
|
+
# Two bars: a determinate one whose value a pane-owned ticker walks up and
|
|
692
|
+
# wraps around, and an indeterminate one that animates itself. Neither
|
|
693
|
+
# paints text — the lines beneath them are sibling Labels fed from
|
|
694
|
+
# {Tuile::Component::ProgressBar#percent}, which is what lets the app word
|
|
695
|
+
# the progress ("42% — 21/50 files") instead of taking whatever the widget
|
|
696
|
+
# would have formatted.
|
|
697
|
+
def build_progress_bar
|
|
698
|
+
prompt = Tuile::Component::Label.new
|
|
699
|
+
prompt.text = "A ProgressBar paints no text of its own —\n" \
|
|
700
|
+
"the line below it is a sibling Label fed\n" \
|
|
701
|
+
"from bar.percent. A ticker owned by this\n" \
|
|
702
|
+
"pane advances the value while it's on screen."
|
|
703
|
+
|
|
704
|
+
bar = Tuile::Component::ProgressBar.new(range: 0..PROGRESS_TOTAL)
|
|
705
|
+
bar.bar_color = Tuile::Color::GREEN
|
|
706
|
+
status = Tuile::Component::Label.new
|
|
707
|
+
|
|
708
|
+
spinner = Tuile::Component::ProgressBar.new(indeterminate: true)
|
|
709
|
+
spinner_caption = Tuile::Component::Label.new
|
|
710
|
+
spinner_caption.text = "Indeterminate: no total yet, so the bar owns\n" \
|
|
711
|
+
"its own animation — no ticker in the app."
|
|
712
|
+
|
|
713
|
+
done = 0
|
|
714
|
+
refresh = -> { status.text = "#{bar.percent}% — #{done}/#{PROGRESS_TOTAL} files" }
|
|
715
|
+
refresh.call
|
|
716
|
+
|
|
717
|
+
# Each bar sits flush against its caption, with a blank row between the two
|
|
718
|
+
# pairs — two spacing-0 groups inside the spacing-1 stack.
|
|
719
|
+
determinate = group do |g|
|
|
720
|
+
g.add(bar, Fixed[1])
|
|
721
|
+
g.add(status, Fixed[1])
|
|
722
|
+
end
|
|
723
|
+
indeterminate = group do |g|
|
|
724
|
+
g.add(spinner, Fixed[1])
|
|
725
|
+
g.add(spinner_caption, Fixed[2])
|
|
726
|
+
end
|
|
727
|
+
pane = TickingBox.new(PROGRESS_FPS, spacing: 1, padding: FORM_PADDING)
|
|
728
|
+
pane.add(prompt, Fixed[4])
|
|
729
|
+
pane.add(determinate, Fixed[2])
|
|
730
|
+
pane.add(indeterminate, Fixed[3])
|
|
731
|
+
pane.on_tick = lambda do
|
|
732
|
+
done = done < PROGRESS_TOTAL ? done + 1 : 0
|
|
733
|
+
bar.value = done
|
|
734
|
+
refresh.call
|
|
735
|
+
end
|
|
736
|
+
pane
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# One background the Background demo offers: a display label and the value
|
|
740
|
+
# handed to {Component#bg_color=} — a live {Tuile::Theme::Ref}, a hard-coded
|
|
741
|
+
# {Tuile::Color}, or nil (terminal default).
|
|
742
|
+
BgChoice = Data.define(:label, :color)
|
|
743
|
+
|
|
744
|
+
# The palette the Background combo filters over: theme refs first (they
|
|
745
|
+
# re-resolve on a light/dark flip, so they track the scheme), then a spread
|
|
746
|
+
# of hard-coded ANSI / 256-palette / RGB colors that stay put across flips.
|
|
747
|
+
BG_CHOICES = [
|
|
748
|
+
BgChoice.new("None (terminal default)", nil),
|
|
749
|
+
BgChoice.new("Theme: input well", Tuile::Theme.ref(:input_bg_color)),
|
|
750
|
+
BgChoice.new("Theme: active", Tuile::Theme.ref(:active_bg_color)),
|
|
751
|
+
BgChoice.new("Theme: active border", Tuile::Theme.ref(:active_border_color)),
|
|
752
|
+
BgChoice.new("ANSI blue", Tuile::Color::BLUE),
|
|
753
|
+
BgChoice.new("ANSI magenta", Tuile::Color::MAGENTA),
|
|
754
|
+
BgChoice.new("ANSI bright black", Tuile::Color::BRIGHT_BLACK),
|
|
755
|
+
BgChoice.new("Palette 236 (charcoal)", Tuile::Color.palette(236)),
|
|
756
|
+
BgChoice.new("Palette 22 (deep green)", Tuile::Color.palette(22)),
|
|
757
|
+
BgChoice.new("Deep purple (RGB)", Tuile::Color.rgb(48, 25, 82)),
|
|
758
|
+
BgChoice.new("Midnight teal (RGB)", Tuile::Color.rgb(10, 40, 45)),
|
|
759
|
+
BgChoice.new("Hot pink (RGB)", Tuile::Color.rgb(120, 20, 70))
|
|
760
|
+
].freeze
|
|
761
|
+
|
|
762
|
+
def build_background
|
|
763
|
+
intro = Tuile::Component::Label.new
|
|
764
|
+
intro.text = "bg_color tints a component and every descendant that doesn't set its own.\n" \
|
|
765
|
+
"Pick one below — this label and the list inherit it; input widgets keep their own well.\n" \
|
|
766
|
+
"Theme refs track light/dark flips; hard-coded colors stay put."
|
|
767
|
+
|
|
768
|
+
list = Tuile::Component::List.new
|
|
769
|
+
list.cursor = Tuile::Component::List::Cursor.new
|
|
770
|
+
list.lines = (1..12).map { |i| "List row #{i}" }
|
|
771
|
+
field = Tuile::Component::TextField.new
|
|
772
|
+
field.text = "TextField keeps its own background"
|
|
773
|
+
|
|
774
|
+
# A borderless sub-box holding the list + field; it inherits the tint too.
|
|
775
|
+
box = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
776
|
+
box.add(list, Expand[1])
|
|
777
|
+
box.add(field, Fixed[1])
|
|
778
|
+
|
|
779
|
+
# A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
|
|
780
|
+
# the tint flows down to every descendant without its own background — the
|
|
781
|
+
# label and the list — while the input widgets (the combo, the field) keep
|
|
782
|
+
# their own well. Theme::Ref picks re-resolve on a scheme flip with no hook;
|
|
783
|
+
# the hard-coded Colors are fixed by design, so no on_theme_changed here.
|
|
784
|
+
outer = nil
|
|
785
|
+
combo = Tuile::Component::ComboBox.new(items: BG_CHOICES)
|
|
786
|
+
combo.item_label = :label.to_proc
|
|
787
|
+
combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
|
|
788
|
+
|
|
789
|
+
outer = form do |f|
|
|
790
|
+
f.add(intro, Fixed[3])
|
|
791
|
+
f.add(combo, Fixed[1], cross: Fixed[40])
|
|
792
|
+
f.add(box, Expand[1])
|
|
793
|
+
end
|
|
794
|
+
combo.value = BG_CHOICES.first # show "None" as the resting selection
|
|
795
|
+
outer
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Horizontal splitting a row between two equal Expand shares. Resize the
|
|
799
|
+
# terminal to watch it recompute: on an odd width the spare column goes to
|
|
800
|
+
# the left pane, since the remainder is handed to the earliest Expand first.
|
|
267
801
|
def build_layout
|
|
268
802
|
left = Tuile::Component::Window.new("Left")
|
|
269
|
-
left.content = Tuile::Component::Label.new.tap
|
|
803
|
+
left.content = Tuile::Component::Label.new.tap do
|
|
804
|
+
_1.text = "Horizontal splits the row\nbetween two Expand[1] panes."
|
|
805
|
+
end
|
|
270
806
|
right = Tuile::Component::Window.new("Right")
|
|
271
|
-
right.content = Tuile::Component::Label.new.tap
|
|
272
|
-
|
|
273
|
-
half = r.width / 2
|
|
274
|
-
left.rect = Tuile::Rect.new(r.left, r.top, half, r.height)
|
|
275
|
-
right.rect = Tuile::Rect.new(r.left + half, r.top, r.width - half, r.height)
|
|
807
|
+
right.content = Tuile::Component::Label.new.tap do
|
|
808
|
+
_1.text = "No arithmetic here — the\nlayout does it."
|
|
276
809
|
end
|
|
810
|
+
Tuile::Component::Layout::Horizontal.new.tap { _1.add([left, right], Expand[1]) }
|
|
277
811
|
end
|
|
278
812
|
|
|
279
813
|
# --- Modal launchers ---------------------------------------------------
|
|
@@ -319,11 +853,9 @@ module SamplerExample
|
|
|
319
853
|
|
|
320
854
|
def build_log_window
|
|
321
855
|
log = Tuile::Component::LogWindow.new("Log")
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
"Used with Logger::IO it captures arbitrary log output."
|
|
326
|
-
])
|
|
856
|
+
["LogWindow is a Window wrapping an auto-scrolling TextView.",
|
|
857
|
+
"Lines are appended via #log (safe from any thread).",
|
|
858
|
+
"Used with Logger::IO it captures arbitrary log output."].each { |line| log.log(line) }
|
|
327
859
|
log
|
|
328
860
|
end
|
|
329
861
|
|
|
@@ -336,13 +868,14 @@ module SamplerExample
|
|
|
336
868
|
a = Tuile::Component::Button.new("Button A")
|
|
337
869
|
b = Tuile::Component::Button.new("Button B")
|
|
338
870
|
field = Tuile::Component::TextField.new
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
871
|
+
buttons = row do |r|
|
|
872
|
+
r.add(a, Fixed[button_width(a)])
|
|
873
|
+
r.add(b, Fixed[button_width(b)])
|
|
874
|
+
end
|
|
875
|
+
form do |f|
|
|
876
|
+
f.add(label, Fixed[2])
|
|
877
|
+
f.add(buttons, Fixed[1])
|
|
878
|
+
f.add(field, Fixed[1])
|
|
346
879
|
end
|
|
347
880
|
end
|
|
348
881
|
|
|
@@ -354,14 +887,46 @@ module SamplerExample
|
|
|
354
887
|
p
|
|
355
888
|
end
|
|
356
889
|
|
|
890
|
+
# The standard demo shell: children stacked with a blank row between them,
|
|
891
|
+
# inset from the window border. Every constraint below reads unqualified —
|
|
892
|
+
# `Fixed`, `Expand`, `Insets` all live on {Tuile::Component::Layout}, which
|
|
893
|
+
# is an ancestor of this class.
|
|
894
|
+
#
|
|
895
|
+
# form do |f|
|
|
896
|
+
# f.add(prompt, Fixed[3])
|
|
897
|
+
# f.add(field, Fixed[1], cross: Fixed[20])
|
|
898
|
+
# f.add(log, Expand[1]) # takes whatever height is left
|
|
899
|
+
# end
|
|
900
|
+
#
|
|
901
|
+
# @return [Tuile::Component::Layout::Vertical]
|
|
902
|
+
def form(&) = Tuile::Component::Layout::Vertical.new(spacing: 1, padding: FORM_PADDING).tap(&)
|
|
903
|
+
|
|
904
|
+
# A tight sub-stack for rows that belong together, nested inside a {#form} to
|
|
905
|
+
# suppress its blank row between them — the grouped-gap idiom, and the reason
|
|
906
|
+
# spacing is a property of the box rather than of each child.
|
|
907
|
+
# @return [Tuile::Component::Layout::Vertical]
|
|
908
|
+
def group(&) = Tuile::Component::Layout::Vertical.new.tap(&)
|
|
909
|
+
|
|
910
|
+
# Widgets side by side, two columns apart.
|
|
911
|
+
# @return [Tuile::Component::Layout::Horizontal]
|
|
912
|
+
def row(&) = Tuile::Component::Layout::Horizontal.new(spacing: 2).tap(&)
|
|
913
|
+
|
|
914
|
+
# One form row: a caption in a fixed left column, then the field.
|
|
915
|
+
# @return [Tuile::Component::Layout::Horizontal]
|
|
916
|
+
def labelled(caption, field, caption_width: 14, field_width: 22)
|
|
917
|
+
row do |r|
|
|
918
|
+
r.add(Tuile::Component::Label.new(caption), Fixed[caption_width])
|
|
919
|
+
r.add(field, Fixed[field_width])
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
357
923
|
def launcher(description, button_caption, &on_click)
|
|
358
924
|
label = Tuile::Component::Label.new
|
|
359
925
|
label.text = description
|
|
360
926
|
button = Tuile::Component::Button.new(button_caption, &on_click)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button.content_size.width, 1)
|
|
927
|
+
form do |f|
|
|
928
|
+
f.add(label, Fixed[3])
|
|
929
|
+
f.add(button, Fixed[1], cross: Fixed[button_width(button)])
|
|
365
930
|
end
|
|
366
931
|
end
|
|
367
932
|
|
|
@@ -410,21 +975,21 @@ module SamplerExample
|
|
|
410
975
|
overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
|
|
411
976
|
end
|
|
412
977
|
|
|
413
|
-
#
|
|
414
|
-
|
|
415
|
-
def inner_rect(rect)
|
|
416
|
-
pad = 2
|
|
417
|
-
Tuile::Rect.new(rect.left + pad, rect.top, [rect.width - (pad * 2), 0].max, rect.height)
|
|
418
|
-
end
|
|
978
|
+
# A button's natural width — enough to show "[ caption ]".
|
|
979
|
+
def button_width(button) = button.caption.display_width + 4
|
|
419
980
|
end
|
|
420
981
|
end
|
|
421
982
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
screen.
|
|
428
|
-
|
|
429
|
-
|
|
983
|
+
# Guard the runner so specs can `require` this file to unit-test the Sampler
|
|
984
|
+
# component tree without spinning up the real event loop.
|
|
985
|
+
if $PROGRAM_NAME == __FILE__
|
|
986
|
+
screen = Tuile::Screen.new
|
|
987
|
+
sampler = SamplerExample::Sampler.new
|
|
988
|
+
screen.content = sampler
|
|
989
|
+
sampler.entry_list.focus
|
|
990
|
+
begin
|
|
991
|
+
screen.run_event_loop
|
|
992
|
+
ensure
|
|
993
|
+
screen.close
|
|
994
|
+
end
|
|
430
995
|
end
|