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.
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
- # runs a caller-supplied block on `rect=` to position its children.
20
- # Sampler demos sometimes have a 1-row Label sitting in a tall pane,
21
- # but the stock layout's auto-clear already handles those gaps for us
22
- # — Panel just needs the rect-callback to drive child positioning.
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 {Panel} that runs {#on_tick} on every frame while it is on screen. The
36
- # ticker is started on attach and cancelled on detach, so selecting another
37
- # demo — which detaches this pane — cannot leave one firing at the old pane
38
- # forever. Owning a mounted-lifetime resource this way is the whole point of
39
- # the attach hooks.
40
- class TickingPanel < Panel
41
- def initialize(fps, &layout_block)
42
- super(&layout_block)
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
- panel(prompt, field) do |r|
152
- inner = inner_rect(r)
153
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 1)
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
- panel(prompt, area) do |r|
169
- inner = inner_rect(r)
170
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
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
- panel(prompt, combo, status) do |r|
188
- inner = inner_rect(r)
189
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
190
- combo.rect = Tuile::Rect.new(inner.left, inner.top + 5, [inner.width, 30].min, 1)
191
- status.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, 1)
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
- panel(prompt, field, status) do |r|
207
- inner = inner_rect(r)
208
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
209
- field.rect = Tuile::Rect.new(inner.left, inner.top + 4, [inner.width, 20].min, 1)
210
- status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
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
- panel(prompt, user, password, reveal, status) do |r|
233
- inner = inner_rect(r)
234
- width = [inner.width, 30].min
235
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
236
- user.rect = Tuile::Rect.new(inner.left, inner.top + 6, width, 1)
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
- panel(prompt, area) do |r|
294
- inner = inner_rect(r)
295
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
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
- panel(prompt, window) do |r|
326
- inner = inner_rect(r)
327
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
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
- panel(label, ok, cancel, result) do |r|
349
- inner = inner_rect(r)
350
- label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
351
- ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, button_width(ok), 1)
352
- cancel.rect = Tuile::Rect.new(inner.left + button_width(ok) + 2, inner.top + 4,
353
- button_width(cancel), 1)
354
- result.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
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 the
362
- # full column width, which is what makes the extent visible — the highlight
363
- # and the click target stop at the end of the caption, not at the rect's.
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
- panel(prompt, *boxes, status) do |r|
378
- inner = inner_rect(r)
379
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
380
- boxes.each_with_index do |box, i|
381
- box.rect = Tuile::Rect.new(inner.left, inner.top + 5 + i, inner.width, 1)
382
- end
383
- status.rect = Tuile::Rect.new(inner.left, inner.top + 6 + boxes.size, inner.width, 1)
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
- panel(prompt, group, log, status) do |r|
456
- inner = inner_rect(r)
457
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
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
- status.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
461
- top = inner.top + 8
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
- panel(prompt, group, files, status) do |r|
540
- inner = inner_rect(r)
541
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
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, inner.width / 3].min
550
- group.rect = Tuile::Rect.new(inner.left, top, group_width, SORT_ORDERS.size)
551
- files.rect = Tuile::Rect.new(inner.left + group_width + 2, top,
552
- [inner.width - group_width - 2, 4].max, body_height)
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
- pane = TickingPanel.new(PROGRESS_FPS) do |r|
599
- inner = inner_rect(r)
600
- prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
601
- bar.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
602
- status.rect = Tuile::Rect.new(inner.left, inner.top + 7, inner.width, 1)
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
- pane.add([prompt, bar, status, spinner, spinner_caption])
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 = panel(list, field) do |r|
652
- list_h = [r.height - 2, 1].max
653
- list.rect = Tuile::Rect.new(r.left, r.top, r.width, list_h)
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 = panel(intro, combo, box) do |r|
668
- inner = inner_rect(r)
669
- intro.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
670
- combo.rect = Tuile::Rect.new(inner.left, inner.top + 5, [inner.width, 40].min, 1)
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 { _1.text = "Nested left window." }
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 { _1.text = "Nested right window." }
682
- panel(left, right) do |r|
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
- panel(label, a, b, field) do |r|
748
- inner = inner_rect(r)
749
- label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
750
- a.rect = Tuile::Rect.new(inner.left, inner.top + 4, button_width(a), 1)
751
- b.rect = Tuile::Rect.new(inner.left + button_width(a) + 2, inner.top + 4,
752
- button_width(b), 1)
753
- field.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
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
- panel(label, button) do |r|
770
- inner = inner_rect(r)
771
- label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
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