tuile 0.10.0 → 0.12.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 +109 -64
- data/DECISIONS.md +1299 -22
- data/README.md +18 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +154 -9
- data/book/05-focus.md +2 -0
- data/book/06-theming.md +1 -1
- data/book/07-components.md +202 -37
- data/book/README.md +3 -1
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +320 -133
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +26 -12
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +11 -10
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +18 -33
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- 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.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +82 -24
- data/lib/tuile/component/notification.rb +317 -0
- data/lib/tuile/component/picker_window.rb +3 -3
- data/lib/tuile/component/popup.rb +8 -10
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +79 -273
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_view.rb +191 -177
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +5 -5
- data/lib/tuile/screen.rb +1 -1
- data/lib/tuile/styled_string.rb +25 -15
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +1670 -406
- metadata +11 -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],
|
|
@@ -104,6 +134,7 @@ module SamplerExample
|
|
|
104
134
|
["ProgressBar", :build_progress_bar],
|
|
105
135
|
["Background", :build_background],
|
|
106
136
|
["Layout", :build_layout],
|
|
137
|
+
["Notification", :build_notification_launcher],
|
|
107
138
|
["Popup", :build_popup_launcher],
|
|
108
139
|
["InfoWindow", :build_info_launcher],
|
|
109
140
|
["PickerWindow", :build_picker_launcher],
|
|
@@ -148,10 +179,9 @@ module SamplerExample
|
|
|
148
179
|
prompt = Tuile::Component::Label.new
|
|
149
180
|
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
|
|
150
181
|
field = Tuile::Component::TextField.new
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
field.rect = Tuile::Rect.new(inner.left, inner.top + 3, inner.width, 1)
|
|
182
|
+
form do |f|
|
|
183
|
+
f.add(prompt, Fixed[1])
|
|
184
|
+
f.add(field, Fixed[1])
|
|
155
185
|
end
|
|
156
186
|
end
|
|
157
187
|
|
|
@@ -165,11 +195,9 @@ module SamplerExample
|
|
|
165
195
|
area.text = "The quick brown fox jumps over the lazy dog. " \
|
|
166
196
|
"Edit me — the text wraps to the area's width and scrolls vertically " \
|
|
167
197
|
"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)
|
|
198
|
+
form do |f|
|
|
199
|
+
f.add(prompt, Fixed[3])
|
|
200
|
+
f.add(area, Expand[1])
|
|
173
201
|
end
|
|
174
202
|
end
|
|
175
203
|
|
|
@@ -184,14 +212,61 @@ module SamplerExample
|
|
|
184
212
|
combo = Tuile::Component::ComboBox.new(items: items)
|
|
185
213
|
status = Tuile::Component::Label.new.tap { _1.text = "(nothing selected)" }
|
|
186
214
|
combo.on_value_change = ->(value) { status.text = "Selected: #{value}" }
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
215
|
+
form do |f|
|
|
216
|
+
f.add(prompt, Fixed[3])
|
|
217
|
+
# A cross constraint clamps to the pane, so this is 30 columns or fewer.
|
|
218
|
+
f.add(combo, Fixed[1], cross: Fixed[30])
|
|
219
|
+
f.add(status, Fixed[1])
|
|
192
220
|
end
|
|
193
221
|
end
|
|
194
222
|
|
|
223
|
+
# One line-ending choice: the item type the second Select below holds, so its
|
|
224
|
+
# `value` is a LineEnding carrying the bytes to write — never a label to look
|
|
225
|
+
# a separator back up from.
|
|
226
|
+
LineEnding = Data.define(:label, :bytes)
|
|
227
|
+
|
|
228
|
+
LINE_ENDINGS = [
|
|
229
|
+
LineEnding.new("LF (Unix)", "\n"),
|
|
230
|
+
LineEnding.new("CRLF (Windows)", "\r\n"),
|
|
231
|
+
LineEnding.new("CR (classic Mac)", "\r")
|
|
232
|
+
].freeze
|
|
233
|
+
|
|
234
|
+
# Two Selects, an enum each — developer-authored labels, which is what a
|
|
235
|
+
# Select is for and a ComboBox isn't. Three things worth watching: the
|
|
236
|
+
# dropdown is measured to its widest label rather than to the field (so the
|
|
237
|
+
# line-endings menu is wider than its face), a nil value is a legal blank
|
|
238
|
+
# face rather than a placeholder, and `r` reaches the *pane* while a Select
|
|
239
|
+
# has focus — no printable but Space belongs to the widget.
|
|
240
|
+
def build_select
|
|
241
|
+
prompt = Tuile::Component::Label.new
|
|
242
|
+
prompt.text = "Tab between the two Selects. Enter, Space or ↓ opens;\n" \
|
|
243
|
+
"↑↓ move the highlight, Enter or Space commits, ESC cancels.\n" \
|
|
244
|
+
"Press r to reset — the pane gets the letter, not the Select."
|
|
245
|
+
|
|
246
|
+
level = Tuile::Component::Select.new(items: %w[debug info warn error fatal], value: "warn")
|
|
247
|
+
endings = Tuile::Component::Select.new(items: LINE_ENDINGS)
|
|
248
|
+
endings.item_label = :label.to_proc
|
|
249
|
+
|
|
250
|
+
status = Tuile::Component::Label.new
|
|
251
|
+
update = lambda do
|
|
252
|
+
status.text = "level: #{level.value.inspect} endings: #{endings.value&.label.inspect}"
|
|
253
|
+
end
|
|
254
|
+
update.call
|
|
255
|
+
[level, endings].each { _1.on_value_change = ->(_v) { update.call } }
|
|
256
|
+
|
|
257
|
+
pane = ShortcutBox.new("r", spacing: 1, padding: FORM_PADDING)
|
|
258
|
+
pane.add(prompt, Fixed[3])
|
|
259
|
+
pane.add(labelled("Log level", level), Fixed[1])
|
|
260
|
+
pane.add(labelled("Line endings", endings), Fixed[1])
|
|
261
|
+
pane.add(status, Fixed[1])
|
|
262
|
+
pane.on_shortcut = lambda do
|
|
263
|
+
level.value = "warn"
|
|
264
|
+
endings.value = nil
|
|
265
|
+
update.call
|
|
266
|
+
end
|
|
267
|
+
pane
|
|
268
|
+
end
|
|
269
|
+
|
|
195
270
|
# IntegerField: its value is a typed Integer (or nil), parsed from the
|
|
196
271
|
# digits you type — the status line echoes it. Up/Down step it like a
|
|
197
272
|
# spinner. Only the value seam shows on its face; the digit filtering and
|
|
@@ -203,14 +278,59 @@ module SamplerExample
|
|
|
203
278
|
field = Tuile::Component::IntegerField.new
|
|
204
279
|
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
205
280
|
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
281
|
+
form do |f|
|
|
282
|
+
f.add(prompt, Fixed[2])
|
|
283
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
284
|
+
f.add(status, Fixed[1])
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# FloatField: the IntegerField one Ruby type over — a single decimal point
|
|
289
|
+
# is allowed too, and the value is a Float. The status line echoes the
|
|
290
|
+
# value, which is where the generous parse shows: a buffer of "1." already
|
|
291
|
+
# reads as 1.0 rather than blinking to nil while you reach for a digit.
|
|
292
|
+
def build_float_field
|
|
293
|
+
prompt = Tuile::Component::Label.new
|
|
294
|
+
prompt.text = "Tab here, then type digits, one '.' and a leading -. Anything else is ignored.\n" \
|
|
295
|
+
"Up/Down step the value by one. Watch the value while you type '1.5'."
|
|
296
|
+
field = Tuile::Component::FloatField.new
|
|
297
|
+
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
298
|
+
field.on_value_change = ->(value) { status.text = "value: #{value.inspect}" }
|
|
299
|
+
form do |f|
|
|
300
|
+
f.add(prompt, Fixed[2])
|
|
301
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
302
|
+
f.add(status, Fixed[1])
|
|
211
303
|
end
|
|
212
304
|
end
|
|
213
305
|
|
|
306
|
+
# BigDecimalField: the same shape again, holding an exact decimal. The
|
|
307
|
+
# status line multiplies by three both ways, so typing "0.1" shows the
|
|
308
|
+
# difference the field exists for. Needs the bigdecimal gem — Tuile's one
|
|
309
|
+
# optional dependency, required by this component and nothing else.
|
|
310
|
+
def build_big_decimal_field
|
|
311
|
+
prompt = Tuile::Component::Label.new
|
|
312
|
+
prompt.text = "Tab here and type 0.1 — then compare the two products below.\n" \
|
|
313
|
+
"This is the field for money: no binary rounding, and nothing pads or trims\n" \
|
|
314
|
+
"what you typed (19.90 keeps its zero)."
|
|
315
|
+
field = Tuile::Component::BigDecimalField.new
|
|
316
|
+
status = Tuile::Component::Label.new.tap { _1.text = "value: nil" }
|
|
317
|
+
field.on_value_change = ->(value) { status.text = triple_report(value) }
|
|
318
|
+
form do |f|
|
|
319
|
+
f.add(prompt, Fixed[3])
|
|
320
|
+
f.add(field, Fixed[1], cross: Fixed[20])
|
|
321
|
+
f.add(status, Fixed[2])
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# @param value [BigDecimal, nil]
|
|
326
|
+
# @return [String] the value tripled exactly, next to the same sum in Float.
|
|
327
|
+
def triple_report(value)
|
|
328
|
+
return "value: nil" if value.nil?
|
|
329
|
+
|
|
330
|
+
"value: #{value.to_s("F")}\n" \
|
|
331
|
+
"×3 exact: #{(value * 3).to_s("F")} ×3 as Float: #{value.to_f * 3}"
|
|
332
|
+
end
|
|
333
|
+
|
|
214
334
|
# PasswordField: a TextField that paints a mask instead of its text. The
|
|
215
335
|
# plaintext stays in `value` throughout — the status line proves it by
|
|
216
336
|
# reporting the length — and the "Show password" box flips `revealed`,
|
|
@@ -229,14 +349,11 @@ module SamplerExample
|
|
|
229
349
|
refresh = -> { status.text = "user: #{user.text.inspect} password: #{password.value.length} chars" }
|
|
230
350
|
refresh.call
|
|
231
351
|
[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)
|
|
352
|
+
form do |f|
|
|
353
|
+
f.add(prompt, Fixed[4])
|
|
354
|
+
f.add([user, password], Fixed[1], cross: Fixed[30]) # one constraint, both fields
|
|
355
|
+
f.add(reveal, Fixed[1])
|
|
356
|
+
f.add(status, Fixed[1])
|
|
240
357
|
end
|
|
241
358
|
end
|
|
242
359
|
|
|
@@ -290,11 +407,9 @@ module SamplerExample
|
|
|
290
407
|
end
|
|
291
408
|
end
|
|
292
409
|
|
|
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)
|
|
410
|
+
form do |f|
|
|
411
|
+
f.add(prompt, Fixed[4])
|
|
412
|
+
f.add(area, Expand[1])
|
|
298
413
|
end
|
|
299
414
|
end
|
|
300
415
|
|
|
@@ -322,11 +437,9 @@ module SamplerExample
|
|
|
322
437
|
"freely."
|
|
323
438
|
window.content = view
|
|
324
439
|
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)
|
|
440
|
+
form do |f|
|
|
441
|
+
f.add(prompt, Fixed[2])
|
|
442
|
+
f.add(window, Expand[1])
|
|
330
443
|
end
|
|
331
444
|
end
|
|
332
445
|
|
|
@@ -345,25 +458,26 @@ module SamplerExample
|
|
|
345
458
|
counters[:cancel] += 1
|
|
346
459
|
refresh.call
|
|
347
460
|
end
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
461
|
+
buttons = row do |r|
|
|
462
|
+
r.add(ok, Fixed[button_width(ok)])
|
|
463
|
+
r.add(cancel, Fixed[button_width(cancel)])
|
|
464
|
+
end
|
|
465
|
+
form do |f|
|
|
466
|
+
f.add(label, Fixed[2])
|
|
467
|
+
f.add(buttons, Fixed[1])
|
|
468
|
+
f.add(result, Fixed[1])
|
|
355
469
|
end
|
|
356
470
|
end
|
|
357
471
|
|
|
358
472
|
CHECKBOX_OPTIONS = ["Enable syslog forwarding", "Rotate logs daily", "Compress archives",
|
|
359
473
|
"Email on failure", "Verbose output"].freeze
|
|
360
474
|
|
|
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
|
|
475
|
+
# Checkbox: Space, Enter (or a click on the label) toggles. Each box is handed
|
|
476
|
+
# the full column width, which is what makes the extent visible — the
|
|
477
|
+
# highlight and the click target stop at the end of the caption, not the rect's.
|
|
364
478
|
def build_checkboxes
|
|
365
479
|
prompt = Tuile::Component::Label.new
|
|
366
|
-
prompt.text = "Tab here, then Space to toggle; a left-click on a label toggles too.\n" \
|
|
480
|
+
prompt.text = "Tab here, then Space or Enter to toggle; a left-click on a label toggles too.\n" \
|
|
367
481
|
"Each box spans the whole column, but only the caption highlights —\n" \
|
|
368
482
|
"clicking the empty space to its right just moves focus."
|
|
369
483
|
status = Tuile::Component::Label.new
|
|
@@ -374,13 +488,14 @@ module SamplerExample
|
|
|
374
488
|
end
|
|
375
489
|
refresh.call
|
|
376
490
|
boxes.each { _1.on_value_change = ->(_) { refresh.call } }
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
491
|
+
# The boxes sit flush against each other while the form keeps a blank row
|
|
492
|
+
# around the block: a spacing-0 group nested in the spacing-1 form, rather
|
|
493
|
+
# than a per-child gap the framework deliberately doesn't offer.
|
|
494
|
+
rows = group { |g| g.add(boxes, Fixed[1]) }
|
|
495
|
+
form do |f|
|
|
496
|
+
f.add(prompt, Fixed[3])
|
|
497
|
+
f.add(rows, Fixed[boxes.size])
|
|
498
|
+
f.add(status, Fixed[1])
|
|
384
499
|
end
|
|
385
500
|
end
|
|
386
501
|
|
|
@@ -447,23 +562,26 @@ module SamplerExample
|
|
|
447
562
|
# The Set iterates in *toggle* order, so intersect with items to report
|
|
448
563
|
# it in the order the rows are shown — the documented idiom.
|
|
449
564
|
shown = (LOG_LEVELS & selected.to_a).map(&:label)
|
|
450
|
-
status.text = "value: {#{shown.join(", ")}} — #{log.
|
|
565
|
+
status.text = "value: {#{shown.join(", ")}} — #{log.items.size} of #{entries.size} lines"
|
|
451
566
|
end
|
|
452
567
|
refresh.call
|
|
453
568
|
group.on_value_change = ->(_set) { refresh.call }
|
|
454
569
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
570
|
+
# The body keeps a rect-callback {Panel}: its sidebar is `min(16, width/3)`
|
|
571
|
+
# — a cap on a proportion, which Fixed/Percent/Expand can't say. The stack
|
|
572
|
+
# around it is a box, so only the part that needs arithmetic has any.
|
|
573
|
+
body = panel(group, log) do |r|
|
|
574
|
+
group_width = [16, r.width / 3].min
|
|
575
|
+
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [LOG_LEVELS.size, r.height].min)
|
|
576
|
+
log.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
577
|
+
[r.width - group_width - 2, 4].max, r.height)
|
|
578
|
+
end
|
|
579
|
+
form do |f|
|
|
580
|
+
f.add(prompt, Fixed[4])
|
|
458
581
|
# Status above the body, so it stays next to the group however tall the
|
|
459
582
|
# 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)
|
|
583
|
+
f.add(status, Fixed[1])
|
|
584
|
+
f.add(body, Expand[1])
|
|
467
585
|
end
|
|
468
586
|
end
|
|
469
587
|
|
|
@@ -536,20 +654,22 @@ module SamplerExample
|
|
|
536
654
|
# Watching it is what makes the chrome/value split visible above.
|
|
537
655
|
group.content.on_cursor_changed = ->(_idx, _line) { update_status.call }
|
|
538
656
|
|
|
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
|
|
657
|
+
# Side-by-side body on a rect-callback {Panel}, as in the CheckboxGroup
|
|
658
|
+
# demo — the sidebar width is a capped proportion, not a constraint.
|
|
659
|
+
body = panel(group, files) do |r|
|
|
547
660
|
# List pads a column either side of a row, so a label needs
|
|
548
661
|
# `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
|
-
[
|
|
662
|
+
group_width = [14, r.width / 3].min
|
|
663
|
+
group.rect = Tuile::Rect.new(r.left, r.top, group_width, [SORT_ORDERS.size, r.height].min)
|
|
664
|
+
files.rect = Tuile::Rect.new(r.left + group_width + 2, r.top,
|
|
665
|
+
[r.width - group_width - 2, 4].max, r.height)
|
|
666
|
+
end
|
|
667
|
+
form do |f|
|
|
668
|
+
f.add(prompt, Fixed[4])
|
|
669
|
+
# Status above the body, so it stays next to the group however tall the
|
|
670
|
+
# pane gets; the file list takes whatever height is left.
|
|
671
|
+
f.add(status, Fixed[1])
|
|
672
|
+
f.add(body, Expand[1])
|
|
553
673
|
end
|
|
554
674
|
end
|
|
555
675
|
|
|
@@ -595,15 +715,20 @@ module SamplerExample
|
|
|
595
715
|
refresh = -> { status.text = "#{bar.percent}% — #{done}/#{PROGRESS_TOTAL} files" }
|
|
596
716
|
refresh.call
|
|
597
717
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
718
|
+
# Each bar sits flush against its caption, with a blank row between the two
|
|
719
|
+
# pairs — two spacing-0 groups inside the spacing-1 stack.
|
|
720
|
+
determinate = group do |g|
|
|
721
|
+
g.add(bar, Fixed[1])
|
|
722
|
+
g.add(status, Fixed[1])
|
|
723
|
+
end
|
|
724
|
+
indeterminate = group do |g|
|
|
725
|
+
g.add(spinner, Fixed[1])
|
|
726
|
+
g.add(spinner_caption, Fixed[2])
|
|
605
727
|
end
|
|
606
|
-
pane.
|
|
728
|
+
pane = TickingBox.new(PROGRESS_FPS, spacing: 1, padding: FORM_PADDING)
|
|
729
|
+
pane.add(prompt, Fixed[4])
|
|
730
|
+
pane.add(determinate, Fixed[2])
|
|
731
|
+
pane.add(indeterminate, Fixed[3])
|
|
607
732
|
pane.on_tick = lambda do
|
|
608
733
|
done = done < PROGRESS_TOTAL ? done + 1 : 0
|
|
609
734
|
bar.value = done
|
|
@@ -648,11 +773,9 @@ module SamplerExample
|
|
|
648
773
|
field.text = "TextField keeps its own background"
|
|
649
774
|
|
|
650
775
|
# 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
|
|
776
|
+
box = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
777
|
+
box.add(list, Expand[1])
|
|
778
|
+
box.add(field, Fixed[1])
|
|
656
779
|
|
|
657
780
|
# A ComboBox over BG_CHOICES swaps the whole panel's bg_color on commit, so
|
|
658
781
|
# the tint flows down to every descendant without its own background — the
|
|
@@ -664,30 +787,68 @@ module SamplerExample
|
|
|
664
787
|
combo.item_label = :label.to_proc
|
|
665
788
|
combo.on_value_change = ->(choice) { outer.bg_color = choice.color }
|
|
666
789
|
|
|
667
|
-
outer =
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
box.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, [inner.height - 8, 2].max)
|
|
790
|
+
outer = form do |f|
|
|
791
|
+
f.add(intro, Fixed[3])
|
|
792
|
+
f.add(combo, Fixed[1], cross: Fixed[40])
|
|
793
|
+
f.add(box, Expand[1])
|
|
672
794
|
end
|
|
673
795
|
combo.value = BG_CHOICES.first # show "None" as the resting selection
|
|
674
796
|
outer
|
|
675
797
|
end
|
|
676
798
|
|
|
799
|
+
# Horizontal splitting a row between two equal Expand shares. Resize the
|
|
800
|
+
# terminal to watch it recompute: on an odd width the spare column goes to
|
|
801
|
+
# the left pane, since the remainder is handed to the earliest Expand first.
|
|
677
802
|
def build_layout
|
|
678
803
|
left = Tuile::Component::Window.new("Left")
|
|
679
|
-
left.content = Tuile::Component::Label.new.tap
|
|
804
|
+
left.content = Tuile::Component::Label.new.tap do
|
|
805
|
+
_1.text = "Horizontal splits the row\nbetween two Expand[1] panes."
|
|
806
|
+
end
|
|
680
807
|
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)
|
|
808
|
+
right.content = Tuile::Component::Label.new.tap do
|
|
809
|
+
_1.text = "No arithmetic here — the\nlayout does it."
|
|
686
810
|
end
|
|
811
|
+
Tuile::Component::Layout::Horizontal.new.tap { _1.add([left, right], Expand[1]) }
|
|
687
812
|
end
|
|
688
813
|
|
|
689
814
|
# --- Modal launchers ---------------------------------------------------
|
|
690
815
|
|
|
816
|
+
# Four buttons, because the interesting things about a notification are all
|
|
817
|
+
# about *several* of them: one short toast shows the box hugging its content
|
|
818
|
+
# in the corner, a burst shows the stack draining one message every three
|
|
819
|
+
# seconds (and the grow-only width), and a long one shows the three-row wrap
|
|
820
|
+
# ending in an ellipsis. Focus stays on whichever button you pressed
|
|
821
|
+
# throughout — that is the whole point of the widget.
|
|
822
|
+
def build_notification_launcher
|
|
823
|
+
label = Tuile::Component::Label.new
|
|
824
|
+
label.text = "Notification.show puts a toast in the top-right corner for 3 seconds.\n" \
|
|
825
|
+
"It never takes focus; a left-click on the box dismisses it.\n" \
|
|
826
|
+
"Raise several and watch them drain one at a time."
|
|
827
|
+
counter = 0
|
|
828
|
+
buttons = [
|
|
829
|
+
Tuile::Component::Button.new("Short") { Tuile::Component::Notification.show("Saved") },
|
|
830
|
+
Tuile::Component::Button.new("Burst") do
|
|
831
|
+
5.times { Tuile::Component::Notification.show("Job #{counter += 1} finished") }
|
|
832
|
+
end,
|
|
833
|
+
Tuile::Component::Button.new("Long") do
|
|
834
|
+
Tuile::Component::Notification.show(
|
|
835
|
+
"Could not connect to the build server at 10.0.0.1: connection refused after " \
|
|
836
|
+
"three attempts, giving up and falling back to the local cache"
|
|
837
|
+
)
|
|
838
|
+
end,
|
|
839
|
+
Tuile::Component::Button.new("Colored") do
|
|
840
|
+
Tuile::Component::Notification.show("Disk almost full", color: Tuile::Color::RED)
|
|
841
|
+
end
|
|
842
|
+
]
|
|
843
|
+
strip = row do |r|
|
|
844
|
+
buttons.each { |b| r.add(b, Fixed[button_width(b)]) }
|
|
845
|
+
end
|
|
846
|
+
form do |f|
|
|
847
|
+
f.add(label, Fixed[3])
|
|
848
|
+
f.add(strip, Fixed[1])
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
691
852
|
def build_popup_launcher
|
|
692
853
|
launcher(
|
|
693
854
|
"Popup is a modal overlay wrapping any Component.\n" \
|
|
@@ -744,13 +905,14 @@ module SamplerExample
|
|
|
744
905
|
a = Tuile::Component::Button.new("Button A")
|
|
745
906
|
b = Tuile::Component::Button.new("Button B")
|
|
746
907
|
field = Tuile::Component::TextField.new
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
908
|
+
buttons = row do |r|
|
|
909
|
+
r.add(a, Fixed[button_width(a)])
|
|
910
|
+
r.add(b, Fixed[button_width(b)])
|
|
911
|
+
end
|
|
912
|
+
form do |f|
|
|
913
|
+
f.add(label, Fixed[2])
|
|
914
|
+
f.add(buttons, Fixed[1])
|
|
915
|
+
f.add(field, Fixed[1])
|
|
754
916
|
end
|
|
755
917
|
end
|
|
756
918
|
|
|
@@ -762,14 +924,46 @@ module SamplerExample
|
|
|
762
924
|
p
|
|
763
925
|
end
|
|
764
926
|
|
|
927
|
+
# The standard demo shell: children stacked with a blank row between them,
|
|
928
|
+
# inset from the window border. Every constraint below reads unqualified —
|
|
929
|
+
# `Fixed`, `Expand`, `Insets` all live on {Tuile::Component::Layout}, which
|
|
930
|
+
# is an ancestor of this class.
|
|
931
|
+
#
|
|
932
|
+
# form do |f|
|
|
933
|
+
# f.add(prompt, Fixed[3])
|
|
934
|
+
# f.add(field, Fixed[1], cross: Fixed[20])
|
|
935
|
+
# f.add(log, Expand[1]) # takes whatever height is left
|
|
936
|
+
# end
|
|
937
|
+
#
|
|
938
|
+
# @return [Tuile::Component::Layout::Vertical]
|
|
939
|
+
def form(&) = Tuile::Component::Layout::Vertical.new(spacing: 1, padding: FORM_PADDING).tap(&)
|
|
940
|
+
|
|
941
|
+
# A tight sub-stack for rows that belong together, nested inside a {#form} to
|
|
942
|
+
# suppress its blank row between them — the grouped-gap idiom, and the reason
|
|
943
|
+
# spacing is a property of the box rather than of each child.
|
|
944
|
+
# @return [Tuile::Component::Layout::Vertical]
|
|
945
|
+
def group(&) = Tuile::Component::Layout::Vertical.new.tap(&)
|
|
946
|
+
|
|
947
|
+
# Widgets side by side, two columns apart.
|
|
948
|
+
# @return [Tuile::Component::Layout::Horizontal]
|
|
949
|
+
def row(&) = Tuile::Component::Layout::Horizontal.new(spacing: 2).tap(&)
|
|
950
|
+
|
|
951
|
+
# One form row: a caption in a fixed left column, then the field.
|
|
952
|
+
# @return [Tuile::Component::Layout::Horizontal]
|
|
953
|
+
def labelled(caption, field, caption_width: 14, field_width: 22)
|
|
954
|
+
row do |r|
|
|
955
|
+
r.add(Tuile::Component::Label.new(caption), Fixed[caption_width])
|
|
956
|
+
r.add(field, Fixed[field_width])
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
|
|
765
960
|
def launcher(description, button_caption, &on_click)
|
|
766
961
|
label = Tuile::Component::Label.new
|
|
767
962
|
label.text = description
|
|
768
963
|
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)
|
|
964
|
+
form do |f|
|
|
965
|
+
f.add(label, Fixed[3])
|
|
966
|
+
f.add(button, Fixed[1], cross: Fixed[button_width(button)])
|
|
773
967
|
end
|
|
774
968
|
end
|
|
775
969
|
|
|
@@ -820,13 +1014,6 @@ module SamplerExample
|
|
|
820
1014
|
|
|
821
1015
|
# A button's natural width — enough to show "[ caption ]".
|
|
822
1016
|
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
1017
|
end
|
|
831
1018
|
end
|
|
832
1019
|
|