tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/book/05-focus.md CHANGED
@@ -165,7 +165,7 @@ the tree — and neither needs machinery, because bubbling already has the
165
165
  right shape. **Put the key on the ancestor that owns the region.**
166
166
 
167
167
  ```ruby
168
- class AppLayout < Tuile::Component::Layout::Absolute
168
+ class AppLayout < Tuile::Component::Layout
169
169
  def handle_key?(key)
170
170
  case key
171
171
  when "1" then @files.focus; true
@@ -233,7 +233,7 @@ single `PasteEvent` — which never enters the ladder at all:
233
233
 
234
234
  The default `handle_paste` returns `false` and the text is dropped.
235
235
  {Tuile::Component::AbstractStringField} overrides it to insert at the caret
236
- as **one** mutation — so `on_change` fires once for the paste rather than
236
+ as **one** mutation — so `on_value_change` fires once for the paste rather than
237
237
  once per character, and a subclass that claims Enter needs no paste code of
238
238
  its own:
239
239
 
@@ -245,7 +245,7 @@ class PromptTextArea < Tuile::Component::TextArea
245
245
  return super unless key == Tuile::Keys::ENTER
246
246
 
247
247
  submit(text) # a typed Enter, and only ever a typed Enter
248
- self.text = ""
248
+ self.value = ""
249
249
  true
250
250
  end
251
251
  end
@@ -260,7 +260,7 @@ def handle_paste(text)
260
260
  return super if text.lines.size < 20
261
261
 
262
262
  attach_as_file(text)
263
- self.text = "#{text.lines.size} lines attached"
263
+ self.value = "#{text.lines.size} lines attached"
264
264
  true
265
265
  end
266
266
  ```
@@ -346,8 +346,11 @@ one surface, and is why that app asks for `:hover`.
346
346
  ## Where the cursor comes in — and where it doesn't
347
347
 
348
348
  A component signals cursor ownership through
349
- {Tuile::Component#cursor_position} — return a `Point` and the terminal
350
- cursor is shown there; return `nil` (the default) and there's no cursor. A
349
+ {Tuile::Component#cursor_position} — return a `Point` in your *own*
350
+ coordinates (the ones you paint in) and the terminal cursor is shown there;
351
+ return `nil` (the default) and there's no cursor. {Tuile::Screen} is what
352
+ converts the point to a screen position, so a caret is a column and a row and
353
+ nothing more. A
351
354
  {Tuile::Component::TextField} being edited returns its caret position, so
352
355
  the caret you see blinking is the focused component's answer to that one
353
356
  question.
@@ -378,7 +381,7 @@ to tidy up what was typed, this is where:
378
381
 
379
382
  ```ruby
380
383
  class TrimmedField < Tuile::Component::TextField
381
- protected def handle_blur = (self.text = text.strip)
384
+ protected def handle_blur = (self.value = text.strip)
382
385
  end
383
386
  ```
384
387
 
@@ -438,10 +441,10 @@ is text you write next to the registration.
438
441
 
439
442
  Some apps genuinely show different keys in different places — a window with
440
443
  a search mode, or a pane whose commands only apply to it. For those,
441
- {Tuile::Screen#on_focus_changed=} is the notification:
444
+ {Tuile::Screen#on_focus_changed} is the notification:
442
445
 
443
446
  ```ruby
444
- screen.on_focus_changed = -> { status.text = hint_for(screen.focused) }
447
+ screen.on_focus_changed { status.text = hint_for(screen.focused) }
445
448
  ```
446
449
 
447
450
  It fires after every focus *change* — to and from `nil` included, and after
data/book/06-theming.md CHANGED
@@ -90,17 +90,17 @@ prompt. You *can* name the panel's colour again on the field — but there is a
90
90
  shorter way to say "I have no background of my own, use whatever is behind me":
91
91
 
92
92
  ```ruby
93
- field.bg_color = Component::BG_INHERIT
93
+ field.bg_color = ComponentBackground::INHERIT
94
94
  ```
95
95
 
96
96
  That is CSS's `background: inherit`, and it is different from leaving
97
97
  `bg_color` unset: unset means "ask *my* default first", which for a field is
98
- its well. `BG_INHERIT` skips the well and goes straight to what surrounds it.
98
+ its well. `ComponentBackground::INHERIT` skips the well and goes straight to what surrounds it.
99
99
 
100
100
  It is the same mechanism Tuile uses internally. A
101
101
  {Tuile::Component::ComboBox} is one widget with one surface, built out of a
102
102
  {Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
103
- marks its inner field `BG_INHERIT`. Exactly one well per widget, which is what
103
+ marks its inner field `ComponentBackground::INHERIT`. Exactly one well per widget, which is what
104
104
  lets you tint the ComboBox and have the tint reach the cells the field draws.
105
105
 
106
106
  Backgrounds can differ by state. An input is brighter while it holds focus,
@@ -238,12 +238,30 @@ background, and Tuile has it: the OSC 11 reply carries the RGB, and
238
238
  {Tuile::Screen}`#background_color` hands it to you as a
239
239
  {Tuile::Color}.
240
240
 
241
+ The place to use it is the theme itself. A token may be a Proc of the
242
+ background instead of a fixed {Tuile::Color}, and the screen calls it for
243
+ you:
244
+
241
245
  ```ruby
242
- bg = Tuile::Screen.instance.background_color
243
- sidebar.bg_color =
244
- bg ? Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
246
+ LIFT = ->(color, by) { Tuile::Color.rgb(*color.rgb.map { (_1 + by).clamp(0, 255) }) }
247
+
248
+ APP_THEME = Tuile::ThemeDef.new(
249
+ dark: Tuile::Theme::DARK.with(custom: {
250
+ pane_bg: ->(bg) { bg ? LIFT.call(bg, 10) : FALLBACK_TINT },
251
+ pane_frame: ->(_bg, t) { LIFT.call(t[:pane_bg], 20) }
252
+ }),
253
+ light: …
254
+ )
255
+ screen.theme_def = APP_THEME
256
+ sidebar.bg_color = Tuile::Theme.ref(:pane_bg)
245
257
  ```
246
258
 
259
+ The arithmetic is yours — Tuile ships no `lighten`, because how far to
260
+ step, in which direction, and whether to step at all is a design choice,
261
+ not a fact about the terminal. The second parameter, when a Proc asks for
262
+ it, reads the other tokens, so a hairline can be derived from the pane it
263
+ sits on, whatever order you declared them in.
264
+
247
265
  That `FALLBACK_TINT` is not defensive padding — it's the branch you
248
266
  should expect to hit. Plenty of terminals answer neither probe, and the
249
267
  `COLORFGBG` fallback reports a palette *index* with no RGB behind it, so
@@ -256,12 +274,21 @@ more round trip than you might expect. The mode-2031 report says only
256
274
  "the OS is light now" — it carries no RGB — so when the screen sees one,
257
275
  it writes the OSC 11 query again, and the reply comes back through the
258
276
  key thread as another event. The new color therefore lands a frame after
259
- the new theme. When it does, Tuile fires
260
- {Tuile::Component}`#handle_theme_changed` across the tree exactly as a theme
261
- swap does, on the reasoning that a tint derived from the background *is*
262
- a theme-derived color, and that hook is already where you rebuild those.
263
- So the same override handles both halves of a flip, and you don't need to
264
- know which one woke you.
277
+ the new theme. When it does, the screen calls every derived token again,
278
+ producing a fresh, fully concrete {Tuile::Screen}`#theme`, and fires
279
+ {Tuile::Component}`#handle_theme_changed` across the tree once, exactly as a
280
+ theme swap does. A `Theme.ref` slot follows with no code of yours; content
281
+ you baked from the theme rebuilds in the same hook it always did, and you
282
+ don't need to know which half of the flip woke you.
283
+
284
+ Why a Proc in the theme, rather than a color that knows how to recompute
285
+ itself? Because {Tuile::Color} is a value: the back buffer decides whether a
286
+ cell changed by comparing colors, and a color that answered differently
287
+ depending on the terminal would compare equal to itself while painting
288
+ something new. Deriving once, when the inputs change, keeps every color
289
+ Tuile paints with a plain value — and the tree walks once per change,
290
+ instead of an app re-assigning its theme from inside the very hook the
291
+ walk is calling.
265
292
 
266
293
  ## Not every terminal can show what you computed
267
294
 
@@ -445,16 +472,27 @@ attached component whenever the theme changes. Your handler does exactly
445
472
  one thing: **re-run the code that rendered the content**, so it rebuilds
446
473
  the StyledString against the now-current theme.
447
474
 
448
- There are two ways to consume it, matching how you built the component,
449
- and they are two different method names — the `=` tells them apart. If you
450
- assembled stock components, assign the `on_theme_changed=` **listener slot**:
475
+ There are two ways to consume it, matching how you built the component, and
476
+ they are two different names: `on_` for the slot, `handle_` for the override. If
477
+ you assembled stock components, register on the `on_theme_changed` **listener
478
+ slot** — the reader *is* the registrar, and a slot holds as many listeners as
479
+ you give it:
480
+
481
+ ```ruby
482
+ label.on_theme_changed { label.text = render_status_line }
483
+ ```
484
+
485
+ There is no `on_theme_changed=`, deliberately: nothing you register can displace
486
+ what the widget — or another part of your app — already wired there. Hold what
487
+ `on_theme_changed` returns you if you mean to take it back off later:
451
488
 
452
489
  ```ruby
453
- label.on_theme_changed = -> { label.text = render_status_line }
490
+ cb = label.on_theme_changed { … }
491
+ label.on_theme_changed.remove(cb)
454
492
  ```
455
493
 
456
494
  If you subclassed, override `handle_theme_changed`, the **override point**
457
- — and call `super`, so an assigned listener still fires:
495
+ — and call `super`, so registered listeners still fire:
458
496
 
459
497
  ```ruby
460
498
  class StatusLabel < Tuile::Component::Label
@@ -117,9 +117,9 @@ protect the plaintext in memory: it's an ordinary Ruby string, and
117
117
  anything stronger is a job for a type the whole application cooperates
118
118
  with.
119
119
 
120
- Both inherit the same event hooks from the base. `on_change` fires whenever
121
- the text changes; `on_escape` handles ESC (with a sensible default);
122
- `on_enter`, `on_key_up` and `on_key_down` each claim one key. Notice what
120
+ Both inherit the same event hooks from the base. `on_value_change` fires
121
+ whenever the text changes; `on_escape` reacts to ESC; `on_enter`, `on_key_up` and
122
+ `on_key_down` each claim one key. Notice what
123
123
  they have in common: every one of them either *reports* something or takes a
124
124
  **single named key** whose meaning the field itself has no use for. There is
125
125
  deliberately no callback that intercepts keys in general — to change what
@@ -127,14 +127,26 @@ keys *do*, you subclass (see *Keeping input out of a field*, below).
127
127
 
128
128
  ```ruby
129
129
  field = Component::TextField.new
130
- field.on_change = ->(text) { filter_results(text) }
131
- field.on_enter = -> { submit } # nil (default) → Enter bubbles to the parent
130
+ field.on_value_change { |e| filter_results(e.value) }
131
+ field.on_enter { submit } # empty (default) → Enter bubbles to the parent
132
132
  ```
133
133
 
134
- Note that `on_enter` / `on_key_up` / `on_key_down` on a TextField, when
135
- left `nil`, let those keys *fall through* to the parent — that's how Enter
136
- in a search field can trigger the surrounding window's action while the
137
- field still handles ordinary typing.
134
+ Note that `on_enter` / `on_key_up` / `on_key_down` on a TextField, while
135
+ *empty*, let those keys *fall through* to the parent — that's how Enter in a
136
+ search field can trigger the surrounding window's action while the field still
137
+ handles ordinary typing. Register a listener and the field starts consuming the
138
+ key; remove it again and the key bubbles once more.
139
+
140
+ ESC is the one that does not start out falling through, because a field blurs
141
+ on it — cancelling text entry rather than quitting the app. That blur is
142
+ `escape_clears_focus`, a flag rather than a listener sitting in the slot, so an
143
+ `on_escape` listener runs *beside* it. Turn the flag off to give ESC another
144
+ meaning outright, and with the slot empty as well ESC bubbles like the rest:
145
+
146
+ ```ruby
147
+ field.escape_clears_focus = false
148
+ field.on_escape { close_search_row } # ESC closes the row; focus stays put
149
+ ```
138
150
 
139
151
  Three editing keys are worth knowing because nothing on screen advertises
140
152
  them. Ctrl+Left and Ctrl+Right jump by a word; **Ctrl+W** deletes the word
@@ -249,7 +261,7 @@ the value, and stays silent.
249
261
 
250
262
  ```ruby
251
263
  qty = Component::IntegerField.new
252
- qty.on_value_change = ->(n) { recompute(n) } # n is an Integer, or nil
264
+ qty.on_value_change { |e| recompute(e.value) } # e.value is an Integer, or nil
253
265
  qty.value = 3
254
266
  ```
255
267
 
@@ -501,7 +513,7 @@ cells beside the field, which belong to whatever laid the field out. Hence
501
513
  {Tuile::Component::HasValidation}, which every field has:
502
514
 
503
515
  ```ruby
504
- login.on_click = lambda do
516
+ login.on_click do
505
517
  username.error_message = username.empty? ? "Username is required" : nil
506
518
  password.error_message = password.empty? ? "Password is required" : nil
507
519
  next if [username, password].any?(&:error_message)
@@ -567,7 +579,7 @@ To show the text, subscribe — and put it in cells you own:
567
579
 
568
580
  ```ruby
569
581
  error = Component::Label.new
570
- username.on_error_message_change = ->(msg) { error.text = msg || StyledString::EMPTY }
582
+ username.on_error_message_change { |e| error.text = e.error_message || StyledString::EMPTY }
571
583
  ```
572
584
 
573
585
  That listener is not decoration. The field repaints *itself* when its verdict
@@ -583,9 +595,65 @@ its parent, as do the rows of a `RadioGroup`'s list.
583
595
 
584
596
  Where does the caption go, then? The same rule answers it, in the other
585
597
  direction: a field can tint the row it has, but it cannot *add* a row for a
586
- label without displacing the value — so a field carries no caption at all. The
587
- `Label` beside it is yours (or, one day, a form layout's), which is why every
588
- form in this book builds its own captions.
598
+ label without displacing the value — so a field carries no caption at all. Those
599
+ cells belong to whatever surrounds the field, and
600
+ {Tuile::Component::FormItem} is that surround: one row of a form, around one
601
+ field.
602
+
603
+ ```ruby
604
+ item = Component::FormItem.new(username, caption: "Username", required: true)
605
+ ```
606
+
607
+ ```
608
+ Username ∙ ← the caption, with the required marker
609
+ [________________] ← the field you wrapped
610
+ Must not be blank ← the message, when there is one
611
+ ```
612
+
613
+ The item does the subscribing you just saw — to the verdict *and* to the field's
614
+ own report of input it cannot parse — so the label-and-listener pair is what you
615
+ write when the message belongs somewhere else entirely, one status row for a
616
+ whole form, say. Otherwise wrap a field and drop the item wherever a component
617
+ goes: a `Vertical` of items is already a form.
618
+
619
+ It is always three rows, and the last is the message row *and* the gap row. That
620
+ fusion is the point: a form that grew a row when a field went invalid would push
621
+ everything below it down while you are typing into it, and on a 24-row terminal
622
+ that walks the focused field off the bottom edge. The price is that a form with
623
+ several errors looks tight exactly where it is least happy.
624
+
625
+ Two habits follow. A widget that paints its own text — a `Checkbox`, a `Button` —
626
+ is wrapped *without* a caption and reserves no caption row, because the form owns
627
+ a column and the widget owns its face; move `[x] Enable logging` into the column
628
+ and the checkbox has nothing left to say. And hide the **item**, never the field:
629
+ `item.visible = false` takes the caption and the message with it, where hiding
630
+ the field alone strands its caption above a gap.
631
+
632
+ Stack items and you have a form. {Tuile::Component::FormLayout} is that column,
633
+ and it does the wrapping for you:
634
+
635
+ ```ruby
636
+ form = Component::FormLayout.new
637
+ form.add(username, caption: "Username", required: true)
638
+ item = form.add(notes, caption: "Notes", rows: 5) # rows: is the *content* height
639
+ form.add(logging) # a Checkbox, so no caption row
640
+ form.add(save) # nor does a Button
641
+ ```
642
+
643
+ Everything it holds is a `FormItem` — `add` returns the one it built, so
644
+ `item.required = true` later goes to the right receiver — and the arithmetic is
645
+ one line: a captioned item is `1 + rows + 1` rows tall, a captionless one
646
+ `rows + 1`. There is no `spacing`, because the message row already *is* the gap;
647
+ a looser form is a `Vertical` of several `FormLayout`s, and a tighter one isn't
648
+ available.
649
+
650
+ Two things it deliberately won't do. It **measures nothing** — `rows:` is yours
651
+ to declare, exactly as `Fixed[n]` is in a `Vertical`, because a field that could
652
+ ask for a height is the bottom-up channel chapter 3 doesn't have. And it
653
+ **doesn't scroll**: items are laid from the top, the one straddling the bottom
654
+ edge keeps the rows that are left, and anything past it is clipped away. A form
655
+ taller than its rect either wants splitting — across a `TabSheet`, say — or
656
+ wants wrapping in a `Scroller`, later in this chapter.
589
657
 
590
658
  The sibling seam, one level up, is **which keys the field acts on at all**:
591
659
  override `handle_text_input_key?` and call `super` for everything you don't
@@ -631,8 +699,9 @@ Turning a field's value into a domain model — parsing, validation, the
631
699
  box-holds-a-`String` ⟷ bean-holds-an-`Integer` conversion — is
632
700
  deliberately *not* the field's job; it belongs to a forms/binder layer
633
701
  that will one day sit above these components. So the seam is kept thin on
634
- purpose: `on_value_change` carries just the new value, and there's no
635
- read-only or required flag yet. Room left for that layer to grow into.
702
+ purpose: the event `on_value_change` carries just the new value and its
703
+ source, and there's no read-only or required flag yet. Room left for that
704
+ layer to grow into.
636
705
 
637
706
  ### Two fields, one value
638
707
 
@@ -681,9 +750,9 @@ the text it drew for it.
681
750
  ```ruby
682
751
  list = Component::List.new
683
752
  list.items = User.all
684
- list.renderer = ->(u) { "#{u.name} #{u.email}" }
753
+ list.renderer = ->(u, _w) { "#{u.name} #{u.email}" }
685
754
  list.cursor = Component::List::Cursor.new
686
- list.on_item_chosen = ->(_index, user) { open(user) }
755
+ list.on_item_chosen { |e| open(e.item) }
687
756
  ```
688
757
 
689
758
  That's the same bargain the value seam struck earlier in this chapter: the
@@ -700,6 +769,25 @@ renderer a pure function of its item. It may be called on any frame, so it
700
769
  is the wrong place to reach for a database; do that work when you build
701
770
  the items.
702
771
 
772
+ That second parameter is the width, in columns, that the row you return
773
+ will get — the list's own width, less a gutter either side and less the
774
+ scrollbar's column when one is showing. Most renderers ignore it and let
775
+ the list ellipsize what doesn't fit, which is why it is usually spelled
776
+ `_w`. Take it when the *shape* of a row depends on how much room there is:
777
+ a right-hand column that has to line up down the pane, or a path you want
778
+ cut from the *left* so its tail survives (`ellipsize(n, at: :start)` —
779
+ `…/shared/markdown/` tells you more than `lib/shared/mar…`). Note the
780
+ direction: the list divides up the space it was given and tells you your
781
+ share, and never asks how much you would like.
782
+
783
+ There is one place that can bite. The list re-runs the renderer on every
784
+ width change — that is what keeps the columns lined up after a resize, and
785
+ it costs only the rows on screen — but it re-runs it *per row*. So a
786
+ measurement over the whole collection, like "how wide is the widest
787
+ `+12/-3` in this snapshot", is computed once where you assign the items and
788
+ closed over. Compute it inside the renderer and you have written an
789
+ `items²` pass that fires on every drag of the terminal's edge.
790
+
703
791
  What makes the list flexible beyond that is that its *cursor behavior is a
704
792
  pluggable object* rather than a boolean. Assign one of three
705
793
  {Tuile::Component::List::Cursor} variants to fit the interaction:
@@ -713,9 +801,9 @@ pluggable object* rather than a boolean. Assign one of three
713
801
  lines. For a list where only some rows are selectable (headers
714
802
  interspersed with items, say), it skips the rest.
715
803
 
716
- Two callbacks cover the events you care about, and both are handed the
717
- `(index, item)` pair. `on_item_chosen` fires when the user commits to the
718
- cursor's row — Enter or a left-click — and is the "open this" signal.
804
+ Two slots cover the events you care about, and both are handed an event
805
+ carrying `position` and `item`. `on_item_chosen` fires when the user commits to
806
+ the cursor's row — Enter or a left-click — and is the "open this" signal.
719
807
  `on_cursor_changed` fires when the highlighted row *changes*, which is
720
808
  exactly what you wire to keep a details pane in sync with the selection.
721
809
  For a tailing list — a live log — set `auto_scroll`; it pins to the bottom
@@ -745,7 +833,7 @@ when it's near the bottom of the screen.
745
833
  combo = Component::ComboBox.new
746
834
  combo.items = User.all
747
835
  combo.item_label = ->(u) { u.full_name }
748
- combo.on_value_change = ->(u) { show(u) }
836
+ combo.on_value_change { |e| show(e.value) }
749
837
  ```
750
838
 
751
839
  When the choice is simply yes-or-no, {Tuile::Component::Checkbox} is a
@@ -760,7 +848,7 @@ however you flip it.
760
848
 
761
849
  ```ruby
762
850
  cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
763
- cb.on_value_change = ->(on) { config.syslog = on }
851
+ cb.on_value_change { |e| config.syslog = e.value }
764
852
  cb.toggle # unchecks it, firing the listener with false
765
853
  ```
766
854
 
@@ -809,7 +897,7 @@ is the `Set` of items you selected.
809
897
  ```ruby
810
898
  levels = Component::CheckboxGroup.new(items: LogLevel.all)
811
899
  levels.item_label = ->(l) { l.name }
812
- levels.on_value_change = ->(set) { refilter(set) } # a Set of LogLevels
900
+ levels.on_value_change { |e| refilter(e.value) } # e.value is a Set of LogLevels
813
901
  ```
814
902
 
815
903
  Notice what `value` holds: the *items*, exactly as the combo box does — a
@@ -867,7 +955,7 @@ the same widget with a single answer.
867
955
  ```ruby
868
956
  sort = Component::RadioGroup.new(items: SORT_ORDERS)
869
957
  sort.item_label = ->(order) { order.label }
870
- sort.on_value_change = ->(order) { resort(order) }
958
+ sort.on_value_change { |e| resort(e.value) }
871
959
  ```
872
960
 
873
961
  Its `value` is the selected item — the object, not its label, as always —
@@ -922,7 +1010,7 @@ you're choosing between them.
922
1010
 
923
1011
  ```ruby
924
1012
  level = Component::Select.new(items: %w[debug info warn error], value: "warn")
925
- level.on_value_change = ->(l) { logger.level = l }
1013
+ level.on_value_change { |e| logger.level = e.value }
926
1014
  ```
927
1015
 
928
1016
  Enter, Space or Down opens the dropdown, the arrows move the highlight,
@@ -991,14 +1079,14 @@ into a list. {Tuile::Component::Button} holds nothing. It runs a block:
991
1079
 
992
1080
  ```ruby
993
1081
  save = Component::Button.new("Save") { form.submit }
994
- save.on_click = -> { form.submit } # or assign it afterwards
1082
+ save.on_click { form.submit } # or register it afterwards
995
1083
  ```
996
1084
 
997
1085
  It paints as `[ Save ]` on one row, highlights its background while it is on
998
1086
  the focus chain, and is a tab stop, so Tab reaches it like any field. Enter,
999
- Space and a left click all fire `on_click`, and the callback takes no
1000
- arguments — a button has nothing to report, because that it was pressed *is*
1001
- the event.
1087
+ Space and a left click all fire `on_click`, and its event carries nothing but
1088
+ its `source` — a button has nothing else to report, because that it was pressed
1089
+ *is* the event.
1002
1090
 
1003
1091
  **Sizing it is your job**, exactly as chapter 3 promised: there is no channel
1004
1092
  for a component to advertise the width it would like, so the caller does the
@@ -1160,6 +1248,48 @@ A slot is invisible to input: it can't take focus, clicks pass straight
1160
1248
  through to the occupant, and when an occupant leaves, the focus repair is
1161
1249
  handed up to your container rather than stranding focus on the slot.
1162
1250
 
1251
+ ### Showing more than fits: `Scroller`
1252
+
1253
+ A slot sizes its occupant to itself. A {Tuile::Component::Scroller} does the
1254
+ opposite: it gives its content *more* rows than it has, and shows a window onto
1255
+ them.
1256
+
1257
+ ```ruby
1258
+ form = Component::FormLayout.new
1259
+ # …nine fields, 27 rows of them…
1260
+ window.content = Component::Scroller.new(form, content_rows: 27)
1261
+ ```
1262
+
1263
+ `content_rows` is the part that looks odd at first, and it is the same rule as
1264
+ everywhere else in this book: **nothing measures**, so the number is yours to
1265
+ supply, exactly as `rows:` and `Fixed[n]` are. The form is then handed a rect 27
1266
+ rows tall inside a viewport that may be 12 — the first rect in Tuile *meant* not
1267
+ to fit its parent, and, once you scroll, the first with a negative `top`.
1268
+ Nothing goes wrong, because every component is bounded by the rect it was given
1269
+ and its ancestors': the rows above and below the viewport are painted into
1270
+ nothing (chapter 2). Below the viewport height the number is ignored and the
1271
+ content simply fills the viewport.
1272
+
1273
+ What makes it usable is that it scrolls **on focus**. A field scrolled out of
1274
+ sight is not hidden — it keeps its rect, its keys and its place in the Tab cycle
1275
+ (chapter 5) — so Tab reaches the eighth field as readily as the first, and
1276
+ `Screen#focused=` then makes the same request your own code can make:
1277
+
1278
+ ```ruby
1279
+ field.scroll_to_visible # "show me this"
1280
+ editor.scroll_to_visible(caret_row_rect) # or just this much of me
1281
+ ```
1282
+
1283
+ It climbs the parent chain to the nearest scroller, which scrolls the minimum
1284
+ distance that makes the rect visible and passes the request on to whatever
1285
+ scrolls above it.
1286
+
1287
+ The wheel scrolls it too, and `scroll_half_page_up` / `scroll_half_page_down`
1288
+ are there for app code. What it deliberately does **not** do is claim keys: the
1289
+ arrows and PgUp/PgDn belong to the field you are typing in. Content with no tab
1290
+ stop anywhere in it — a long label, a read-only panel — therefore has no
1291
+ keyboard way to scroll; bind one yourself on the container around it.
1292
+
1163
1293
  ## Switching between views
1164
1294
 
1165
1295
  When a screen has more content than fits and the parts are *alternatives*
@@ -1173,7 +1303,7 @@ is selected.
1173
1303
  sheet = Component::TabSheet.new
1174
1304
  sheet.add_tab("Details", details_form) # the first tab is selected
1175
1305
  sheet.add_tab("Payment", payment_form)
1176
- sheet.on_tab_selected = ->(index, tab) { status.text = "on #{tab&.caption}" }
1306
+ sheet.on_tab_selected { |e| status.text = "on #{e.tab&.caption}" }
1177
1307
  ```
1178
1308
 
1179
1309
  The strip is a component in its own right, {Tuile::Component::Tabs}, and
@@ -1419,11 +1549,28 @@ other TUI toolkit agree on it:
1419
1549
  | Left at the first level, Right on a plain row | step to the neighbouring menu |
1420
1550
  | a mnemonic letter | activate that row of *this* menu |
1421
1551
  | ESC | close one level |
1422
-
1423
- Stepping sideways *shows* the neighbour's menu; it never presses anything. So
1424
- arrowing onto a top-level button — an item with a listener and no menu — closes
1425
- whatever was open and highlights it, and it fires only when you press Enter or
1426
- Space. Otherwise walking the strip would trigger every button on it.
1552
+ | **In a menu you stepped to** | |
1553
+ | Down, Enter, Space | move onto its first row |
1554
+ | Up | move onto its last row |
1555
+ | Left, Right | keep walking the strip |
1556
+ | ESC, on an item with no menu | leave menu mode |
1557
+
1558
+ Stepping sideways only *shows* the neighbour's menu: it arrives with no row
1559
+ highlighted, so the next Right goes on walking the strip instead of drilling
1560
+ into whatever that menu happens to list first. Down, Enter or Space moves onto
1561
+ its first row, Up onto its last.
1562
+
1563
+ It never presses anything, either. So arrowing onto a top-level button — an item
1564
+ with a listener and no menu — closes whatever was open and highlights it, and it
1565
+ fires only when you press Enter or Space. Otherwise walking the strip would
1566
+ trigger every button on it.
1567
+
1568
+ A button has no menu to show, and the walk carries on across it. What the arrows
1569
+ follow is *menu mode*: the bar enters it when a menu opens and leaves it when the
1570
+ last panel goes — or when you press ESC at a button, where there is no panel left
1571
+ to close. That ESC is the one the strip keeps from your app, which matters
1572
+ because an unhandled one quits (chapter 5). Enter on a button fires it and enters
1573
+ no mode at all.
1427
1574
 
1428
1575
  The last row of the first block matters for real apps: while the bar merely
1429
1576
  has focus, every other key **bubbles past it**, so a form's `s`-to-save or
@@ -1532,8 +1679,9 @@ input inside itself. For a layer that floats *without* taking focus — the
1532
1679
  autocomplete-list case from earlier, where the caller positions it against a
1533
1680
  field's caret and drives it from app code — use its base class, `Overlay`,
1534
1681
  directly. An `Overlay` is a Popup minus the modality: same open/close
1535
- lifecycle, same outside-click dismissal, but it sits at the rect you assign
1536
- and never disturbs focus or key dispatch.
1682
+ lifecycle, same outside-click dismissal, but it sits where you open it —
1683
+ `overlay.open(Overlay::At[rect])` — and never disturbs focus or key
1684
+ dispatch.
1537
1685
 
1538
1686
  **A left click outside an overlay closes it**, modal or not — the same light
1539
1687
  dismissal a desktop dialog gives you. It's a per-overlay switch,
@@ -1626,7 +1774,7 @@ dialog.message = "Save your changes before leaving?"
1626
1774
  dialog.button("Save") { save! }
1627
1775
  dialog.button("Discard") { discard! }
1628
1776
  dialog.button("Cancel") # no block: pressing it dismisses
1629
- dialog.on_dismiss = -> { stay_put }
1777
+ dialog.on_dismiss { stay_put }
1630
1778
  dialog.open
1631
1779
  ```
1632
1780