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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -64
  3. data/DECISIONS.md +1299 -22
  4. data/README.md +18 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +154 -9
  8. data/book/05-focus.md +2 -0
  9. data/book/06-theming.md +1 -1
  10. data/book/07-components.md +202 -37
  11. data/book/README.md +3 -1
  12. data/examples/file_commander.rb +5 -4
  13. data/examples/sampler.rb +320 -133
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +26 -12
  16. data/lib/tuile/buffer.rb +7 -7
  17. data/lib/tuile/component/big_decimal_field.rb +199 -0
  18. data/lib/tuile/component/button.rb +1 -1
  19. data/lib/tuile/component/checkbox.rb +11 -10
  20. data/lib/tuile/component/checkbox_group.rb +31 -26
  21. data/lib/tuile/component/combo_box.rb +18 -33
  22. data/lib/tuile/component/float_field.rb +161 -0
  23. data/lib/tuile/component/info_window.rb +1 -1
  24. data/lib/tuile/component/label.rb +14 -14
  25. data/lib/tuile/component/layout/box.rb +316 -0
  26. data/lib/tuile/component/layout/horizontal.rb +40 -0
  27. data/lib/tuile/component/layout/vertical.rb +41 -0
  28. data/lib/tuile/component/layout.rb +149 -1
  29. data/lib/tuile/component/list.rb +291 -216
  30. data/lib/tuile/component/list_dropdown.rb +82 -24
  31. data/lib/tuile/component/notification.rb +317 -0
  32. data/lib/tuile/component/picker_window.rb +3 -3
  33. data/lib/tuile/component/popup.rb +8 -10
  34. data/lib/tuile/component/progress_bar.rb +1 -1
  35. data/lib/tuile/component/radio_group.rb +32 -30
  36. data/lib/tuile/component/select.rb +251 -0
  37. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  38. data/lib/tuile/component/text_area.rb +79 -273
  39. data/lib/tuile/component/text_field.rb +1 -1
  40. data/lib/tuile/component/text_view.rb +191 -177
  41. data/lib/tuile/component/window.rb +8 -8
  42. data/lib/tuile/component.rb +5 -5
  43. data/lib/tuile/screen.rb +1 -1
  44. data/lib/tuile/styled_string.rb +25 -15
  45. data/lib/tuile/version.rb +1 -1
  46. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  47. data/lib/tuile.rb +4 -0
  48. data/sig/tuile.rbs +1670 -406
  49. 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
- # 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],
@@ -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
- 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)
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
- 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)
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
- 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)
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
- 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)
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
- 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)
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
- 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)
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
- 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)
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
- 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)
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 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.
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
- 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)
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.lines.size} of #{entries.size} lines"
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
- 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)
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
- 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)
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
- 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
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, 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)
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
- 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)
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.add([prompt, bar, status, spinner, spinner_caption])
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 = 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
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 = 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)
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 { _1.text = "Nested left window." }
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 { _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)
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
- 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)
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
- 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)
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