tuile 0.10.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 +77 -64
- data/DECISIONS.md +619 -14
- data/README.md +6 -0
- data/book/03-layout.md +153 -8
- data/book/05-focus.md +2 -0
- data/book/07-components.md +105 -10
- data/book/README.md +3 -1
- data/examples/sampler.rb +282 -132
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +17 -8
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/checkbox.rb +10 -9
- data/lib/tuile/component/combo_box.rb +8 -26
- data/lib/tuile/component/float_field.rb +161 -0
- 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 -1
- data/lib/tuile/component/list_dropdown.rb +69 -18
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/styled_string.rb +13 -3
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +882 -29
- metadata +8 -1
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,14 +33,14 @@ module SamplerExample
|
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
# A {
|
|
36
|
-
# ticker is started on attach and cancelled on
|
|
37
|
-
# demo — which detaches this pane — cannot leave
|
|
38
|
-
# forever. Owning a mounted-lifetime resource this
|
|
39
|
-
# the attach hooks.
|
|
40
|
-
class
|
|
41
|
-
def initialize(fps,
|
|
42
|
-
super(
|
|
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(**)
|
|
43
44
|
@fps = fps
|
|
44
45
|
end
|
|
45
46
|
|
|
@@ -56,6 +57,28 @@ module SamplerExample
|
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
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
|
+
|
|
59
82
|
# Top-level sampler component. Splits the screen into a left entry list
|
|
60
83
|
# and a right demo pane; each `load_entry` rebuilds the demo from
|
|
61
84
|
# scratch so it always starts in a clean state.
|
|
@@ -72,6 +95,10 @@ module SamplerExample
|
|
|
72
95
|
|
|
73
96
|
attr_reader :left_window, :right_window, :entry_list
|
|
74
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
|
+
|
|
75
102
|
def rect=(new_rect)
|
|
76
103
|
super
|
|
77
104
|
return if rect.empty?
|
|
@@ -92,7 +119,10 @@ module SamplerExample
|
|
|
92
119
|
["TextField", :build_text_field],
|
|
93
120
|
["TextArea", :build_text_area],
|
|
94
121
|
["ComboBox", :build_combo_box],
|
|
122
|
+
["Select", :build_select],
|
|
95
123
|
["IntegerField", :build_integer_field],
|
|
124
|
+
["FloatField", :build_float_field],
|
|
125
|
+
["BigDecimalField", :build_big_decimal_field],
|
|
96
126
|
["PasswordField", :build_password_field],
|
|
97
127
|
["Slash menu", :build_slash_demo],
|
|
98
128
|
["TextView", :build_text_view],
|
|
@@ -148,10 +178,9 @@ module SamplerExample
|
|
|
148
178
|
prompt = Tuile::Component::Label.new
|
|
149
179
|
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
|
|
150
180
|
field = Tuile::Component::TextField.new
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
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])
|
|
155
184
|
end
|
|
156
185
|
end
|
|
157
186
|
|
|
@@ -165,11 +194,9 @@ module SamplerExample
|
|
|
165
194
|
area.text = "The quick brown fox jumps over the lazy dog. " \
|
|
166
195
|
"Edit me — the text wraps to the area's width and scrolls vertically " \
|
|
167
196
|
"once the cursor leaves the visible rows."
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
area_height = [inner.height - 6, 4].max
|
|
172
|
-
area.rect = Tuile::Rect.new(inner.left, inner.top + 5, inner.width, area_height)
|
|
197
|
+
form do |f|
|
|
198
|
+
f.add(prompt, Fixed[3])
|
|
199
|
+
f.add(area, Expand[1])
|
|
173
200
|
end
|
|
174
201
|
end
|
|
175
202
|
|
|
@@ -184,12 +211,59 @@ module SamplerExample
|
|
|
184
211
|
combo = Tuile::Component::ComboBox.new(items: items)
|
|
185
212
|
status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
|
|
186
213
|
combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
192
265
|
end
|
|
266
|
+
pane
|
|
193
267
|
end
|
|
194
268
|
|
|
195
269
|
# IntegerField: its value is a typed Integer (or nil), parsed from the
|
|
@@ -203,14 +277,59 @@ module SamplerExample
|
|
|
203
277
|
field = Tuile::Component::IntegerField.new
|
|
204
278
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
205
279
|
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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])
|
|
211
302
|
end
|
|
212
303
|
end
|
|
213
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
|
+
|
|
214
333
|
# PasswordField: a TextField that paints a mask instead of its text. The
|
|
215
334
|
# plaintext stays in `value` throughout — the status line proves it by
|
|
216
335
|
# reporting the length — and the "Show password" box flips `revealed`,
|
|
@@ -229,14 +348,11 @@ module SamplerExample
|
|
|
229
348
|
refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
|
|
230
349
|
refresh.call
|
|
231
350
|
[user, password].each { _1.on_change = ->(_) { refresh.call } }
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
password.rect = Tuile::Rect.new(inner.left, inner.top + 8, width, 1)
|
|
238
|
-
reveal.rect = Tuile::Rect.new(inner.left, inner.top + 10, inner.width, 1)
|
|
239
|
-
status.rect = Tuile::Rect.new(inner.left, inner.top + 12, inner.width, 1)
|
|
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])
|
|
240
356
|
end
|
|
241
357
|
end
|
|
242
358
|
|
|
@@ -290,11 +406,9 @@ module SamplerExample
|
|
|
290
406
|
end
|
|
291
407
|
end
|
|
292
408
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
area_height = [inner.height - 7, 4].max
|
|
297
|
-
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])
|
|
298
412
|
end
|
|
299
413
|
end
|
|
300
414
|
|
|
@@ -322,11 +436,9 @@ module SamplerExample
|
|
|
322
436
|
"freely."
|
|
323
437
|
window.content = view
|
|
324
438
|
window.scrollbar = true
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
view_height = [inner.height - 5, 4].max
|
|
329
|
-
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])
|
|
330
442
|
end
|
|
331
443
|
end
|
|
332
444
|
|
|
@@ -345,25 +457,26 @@ module SamplerExample
|
|
|
345
457
|
counters[:cancel] += 1
|
|
346
458
|
refresh.call
|
|
347
459
|
end
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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])
|
|
355
468
|
end
|
|
356
469
|
end
|
|
357
470
|
|
|
358
471
|
CHECKBOX_OPTIONS = ["Enable syslog forwarding", "Rotate logs daily", "Compress archives",
|
|
359
472
|
"Email on failure", "Verbose output"].freeze
|
|
360
473
|
|
|
361
|
-
# Checkbox: Space (or a click on the label) toggles. Each box is handed
|
|
362
|
-
# full column width, which is what makes the extent visible — the
|
|
363
|
-
# and the click target stop at the end of the caption, not
|
|
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.
|
|
364
477
|
def build_checkboxes
|
|
365
478
|
prompt = Tuile::Component::Label.new
|
|
366
|
-
prompt.text = "Tab here, then Space to toggle; a left-click on a label toggles too.\n" \
|
|
479
|
+
prompt.text = "Tab here, then Space or Enter to toggle; a left-click on a label toggles too.\n" \
|
|
367
480
|
"Each box spans the whole column, but only the caption highlights —\n" \
|
|
368
481
|
"clicking the empty space to its right just moves focus."
|
|
369
482
|
status = Tuile::Component::Label.new
|
|
@@ -374,13 +487,14 @@ module SamplerExample
|
|
|
374
487
|
end
|
|
375
488
|
refresh.call
|
|
376
489
|
boxes.each { _1.on_value_change = ->(_) { refresh.call } }
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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])
|
|
384
498
|
end
|
|
385
499
|
end
|
|
386
500
|
|
|
@@ -452,18 +566,21 @@ module SamplerExample
|
|
|
452
566
|
refresh.call
|
|
453
567
|
group.on_value_change = ->(_set) { refresh.call }
|
|
454
568
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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])
|
|
458
580
|
# Status above the body, so it stays next to the group however tall the
|
|
459
581
|
# pane gets; the log takes whatever height is left.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
body_height = [inner.height - 9, LOG_LEVELS.size].max
|
|
463
|
-
group_width = [16, inner.width / 3].min
|
|
464
|
-
group.rect = Tuile::Rect.new(inner.left, top, group_width, LOG_LEVELS.size)
|
|
465
|
-
log.rect = Tuile::Rect.new(inner.left + group_width + 2, top,
|
|
466
|
-
[inner.width - group_width - 2, 4].max, body_height)
|
|
582
|
+
f.add(status, Fixed[1])
|
|
583
|
+
f.add(body, Expand[1])
|
|
467
584
|
end
|
|
468
585
|
end
|
|
469
586
|
|
|
@@ -536,20 +653,22 @@ module SamplerExample
|
|
|
536
653
|
# Watching it is what makes the chrome/value split visible above.
|
|
537
654
|
group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
|
|
538
655
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
# Status above the body, so it stays next to the group however tall the
|
|
543
|
-
# pane gets; the file list takes whatever height is left.
|
|
544
|
-
status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
545
|
-
top = inner.top + 8
|
|
546
|
-
body_height = [inner.height - 9, SORT_ORDERS.size].max
|
|
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|
|
|
547
659
|
# List pads a column either side of a row, so a label needs
|
|
548
660
|
# `width - 2`; the file rows lose one more to their scrollbar.
|
|
549
|
-
group_width = [14,
|
|
550
|
-
group.rect = Tuile::Rect.new(
|
|
551
|
-
files.rect = Tuile::Rect.new(
|
|
552
|
-
[
|
|
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])
|
|
553
672
|
end
|
|
554
673
|
end
|
|
555
674
|
|
|
@@ -595,15 +714,20 @@ module SamplerExample
|
|
|
595
714
|
refresh = -> { status.text = "#{bar.percent}% — #{done}/#{PROGRESS_TOTAL} files" }
|
|
596
715
|
refresh.call
|
|
597
716
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
spinner.rect = Tuile::Rect.new(inner.left, inner.top + 9, inner.width, 1)
|
|
604
|
-
spinner_caption.rect = Tuile::Rect.new(inner.left, inner.top + 10, inner.width, 2)
|
|
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])
|
|
605
722
|
end
|
|
606
|
-
|
|
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])
|
|
607
731
|
pane.on_tick = lambda do
|
|
608
732
|
done = done < PROGRESS_TOTAL ? done + 1 : 0
|
|
609
733
|
bar.value = done
|
|
@@ -648,11 +772,9 @@ module SamplerExample
|
|
|
648
772
|
field.text = "TextField keeps its own background"
|
|
649
773
|
|
|
650
774
|
# A borderless sub-box holding the list + field; it inherits the tint too.
|
|
651
|
-
box =
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
field.rect = Tuile::Rect.new(r.left, r.top + list_h + 1, r.width, 1)
|
|
655
|
-
end
|
|
775
|
+
box = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
776
|
+
box.add(list, Expand[1])
|
|
777
|
+
box.add(field, Fixed[1])
|
|
656
778
|
|
|
657
779
|
# A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
|
|
658
780
|
# the tint flows down to every descendant without its own background — the
|
|
@@ -664,26 +786,28 @@ module SamplerExample
|
|
|
664
786
|
combo.item_label = :label.to_proc
|
|
665
787
|
combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
|
|
666
788
|
|
|
667
|
-
outer =
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
box.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, [inner.height - 8, 2].max)
|
|
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])
|
|
672
793
|
end
|
|
673
794
|
combo.value = BG_CHOICES.first # show "None" as the resting selection
|
|
674
795
|
outer
|
|
675
796
|
end
|
|
676
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.
|
|
677
801
|
def build_layout
|
|
678
802
|
left = Tuile::Component::Window.new("Left")
|
|
679
|
-
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
|
|
680
806
|
right = Tuile::Component::Window.new("Right")
|
|
681
|
-
right.content = Tuile::Component::Label.new.tap
|
|
682
|
-
|
|
683
|
-
half = r.width / 2
|
|
684
|
-
left.rect = Tuile::Rect.new(r.left, r.top, half, r.height)
|
|
685
|
-
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."
|
|
686
809
|
end
|
|
810
|
+
Tuile::Component::Layout::Horizontal.new.tap { _1.add([left, right], Expand[1]) }
|
|
687
811
|
end
|
|
688
812
|
|
|
689
813
|
# --- Modal launchers ---------------------------------------------------
|
|
@@ -744,13 +868,14 @@ module SamplerExample
|
|
|
744
868
|
a = Tuile::Component::Button.new("Button A")
|
|
745
869
|
b = Tuile::Component::Button.new("Button B")
|
|
746
870
|
field = Tuile::Component::TextField.new
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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])
|
|
754
879
|
end
|
|
755
880
|
end
|
|
756
881
|
|
|
@@ -762,14 +887,46 @@ module SamplerExample
|
|
|
762
887
|
p
|
|
763
888
|
end
|
|
764
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
|
+
|
|
765
923
|
def launcher(description, button_caption, &on_click)
|
|
766
924
|
label = Tuile::Component::Label.new
|
|
767
925
|
label.text = description
|
|
768
926
|
button = Tuile::Component::Button.new(button_caption, &on_click)
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button_width(button), 1)
|
|
927
|
+
form do |f|
|
|
928
|
+
f.add(label, Fixed[3])
|
|
929
|
+
f.add(button, Fixed[1], cross: Fixed[button_width(button)])
|
|
773
930
|
end
|
|
774
931
|
end
|
|
775
932
|
|
|
@@ -820,13 +977,6 @@ module SamplerExample
|
|
|
820
977
|
|
|
821
978
|
# A button's natural width — enough to show "[ caption ]".
|
|
822
979
|
def button_width(button) = button.caption.display_width + 4
|
|
823
|
-
|
|
824
|
-
# Carves a 2-column padding out of the panel rect so the demo content
|
|
825
|
-
# doesn't run flush to the window border.
|
|
826
|
-
def inner_rect(rect)
|
|
827
|
-
pad = 2
|
|
828
|
-
Tuile::Rect.new(rect.left + pad, rect.top, [rect.width - (pad * 2), 0].max, rect.height)
|
|
829
|
-
end
|
|
830
980
|
end
|
|
831
981
|
end
|
|
832
982
|
|