tuile 0.15.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  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 +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  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 +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -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
 
@@ -370,7 +382,7 @@ calendar is different: it answers what weekday the 17th is), and with your hands
370
382
  already on the keys, typing `1345` beats scrolling to it. Tuile is
371
383
  keyboard-first: the mouse gets what falls out of click routing for free and never
372
384
  motivates a widget on its own. The ranking behind that is `D_mouse` in
373
- `DECISIONS.md`.
385
+ `design/decisions.md`.
374
386
 
375
387
  What you get for it is that the two questions stay independent. Switching
376
388
  precision never touches the spelling, so a Finnish user sees `13.45` and
@@ -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)
@@ -540,6 +552,24 @@ not parse, and goes quiet again on your next edit. Only the *ink* waits.
540
552
  `bad_input?` is still answered from the current buffer the instant you ask it,
541
553
  which is what keeps the Save handler above correct with no change at all.
542
554
 
555
+ There is a second half to this, and it bites harder, because some prefixes of a
556
+ date do not merely fail to parse — they parse *cleanly*. In a `dd.mm.yyyy`
557
+ field, `1.1.2` on the way to `1.1.2024` is the first of January in the year 2:
558
+ a perfectly good `Date`, one `bad_input?` will never flag, and one your
559
+ listener would be handed while the user is still typing, along with whatever
560
+ recalculation hangs off it. So the date and time fields settle their *notice*
561
+ on those same two gestures: `on_value_change` fires when you leave the field or
562
+ press Enter, not as you type. Reading `value` is again unaffected, so a Save on
563
+ a keyboard shortcut that never moves focus still sees the date on screen — and
564
+ a value nobody had to type, a `value=` or an Up/Down step or a `clear`, is
565
+ announced the moment it happens.
566
+
567
+ The ink and the notice settle together because one question decides both, and
568
+ it is the prefix-closed question from a few pages back. Every buffer an
569
+ `IntegerField` passes through really is the number it shows, so `4` on the way
570
+ to `42` is worth announcing and worth reddening. A date's are neither. That one
571
+ property of the grammar settles the filter, the ink and the notice alike.
572
+
543
573
  The one discipline the writer owes is visible in those `: nil` branches: **set
544
574
  or clear on every pass.** Only assign the message where you validate, and a
545
575
  field that has been fixed goes back to normal on its own. Forget the clear and
@@ -549,7 +579,7 @@ To show the text, subscribe — and put it in cells you own:
549
579
 
550
580
  ```ruby
551
581
  error = Component::Label.new
552
- 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 }
553
583
  ```
554
584
 
555
585
  That listener is not decoration. The field repaints *itself* when its verdict
@@ -565,19 +595,75 @@ its parent, as do the rows of a `RadioGroup`'s list.
565
595
 
566
596
  Where does the caption go, then? The same rule answers it, in the other
567
597
  direction: a field can tint the row it has, but it cannot *add* a row for a
568
- label without displacing the value — so a field carries no caption at all. The
569
- `Label` beside it is yours (or, one day, a form layout's), which is why every
570
- 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.
571
657
 
572
658
  The sibling seam, one level up, is **which keys the field acts on at all**:
573
- override `handle_text_input_key` and call `super` for everything you don't
659
+ override `handle_text_input_key?` and call `super` for everything you don't
574
660
  claim.
575
661
 
576
662
  ```ruby
577
663
  class SubmitField < Tuile::Component::TextArea
578
664
  protected
579
665
 
580
- def handle_text_input_key(key)
666
+ def handle_text_input_key?(key)
581
667
  return super unless key == Tuile::Keys::ENTER
582
668
 
583
669
  submit(text) # Enter submits instead of inserting a newline
@@ -613,8 +699,44 @@ Turning a field's value into a domain model — parsing, validation, the
613
699
  box-holds-a-`String` ⟷ bean-holds-an-`Integer` conversion — is
614
700
  deliberately *not* the field's job; it belongs to a forms/binder layer
615
701
  that will one day sit above these components. So the seam is kept thin on
616
- purpose: `on_value_change` carries just the new value, and there's no
617
- 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.
705
+
706
+ ### Two fields, one value
707
+
708
+ {Tuile::Component::DateTimeField} is the first field made of *fields*: the date
709
+ field and the time field from earlier in this chapter, side by side on one row,
710
+ behind a single `DateTime`.
711
+
712
+ ```ruby
713
+ starts = Component::DateTimeField.new
714
+ starts.value = DateTime.new(2026, 9, 14, 13, 45) # [2026-09-14] [13:45]
715
+ starts.date_field.formats = "%d.%m.%Y" # tune a half in place…
716
+ starts.time_field.step = 900 # …rather than through a forwarder
717
+ ```
718
+
719
+ The halves are exposed read-only: a child you *tune* but never *supply* is
720
+ reached directly, so there is no second set of names to keep in step — and no
721
+ argument about whether `formats=` on the composite would mean the date's or the
722
+ time's.
723
+
724
+ The value is non-nil only when both halves parse, and a half going bad nils the
725
+ whole thing rather than holding the last good one: a field holds bad input **or**
726
+ a value, never both. What is genuinely new is the question of who goes red, and
727
+ the answer is one sentence — **the composite paints only the fault no half can
728
+ wear**. Garbage in the date half is attributable, so that half reddens itself on
729
+ the latch you saw a moment ago, and the composite paints nothing. A date with no
730
+ time is nobody else's fault, so the composite reddens *whole* — but only once you
731
+ leave it, so it judges you when you are done rather than while you are filling it
732
+ in. A rule's verdict is not attributable either, and reddens whole with no latch
733
+ at all.
734
+
735
+ One wrinkle follows from that. Pressing Enter over a half-filled field reports
736
+ `bad_input?` and its message but does not redden it; the ink waits for you to
737
+ leave. Latching on Enter would reopen exactly the window the rule closes — the
738
+ one where the field tells you that you are wrong when the truth is that you are
739
+ not finished.
618
740
 
619
741
  ## Choosing from a set
620
742
 
@@ -628,9 +750,9 @@ the text it drew for it.
628
750
  ```ruby
629
751
  list = Component::List.new
630
752
  list.items = User.all
631
- list.renderer = ->(u) { "#{u.name} #{u.email}" }
753
+ list.renderer = ->(u, _w) { "#{u.name} #{u.email}" }
632
754
  list.cursor = Component::List::Cursor.new
633
- list.on_item_chosen = ->(_index, user) { open(user) }
755
+ list.on_item_chosen { |e| open(e.item) }
634
756
  ```
635
757
 
636
758
  That's the same bargain the value seam struck earlier in this chapter: the
@@ -647,6 +769,25 @@ renderer a pure function of its item. It may be called on any frame, so it
647
769
  is the wrong place to reach for a database; do that work when you build
648
770
  the items.
649
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
+
650
791
  What makes the list flexible beyond that is that its *cursor behavior is a
651
792
  pluggable object* rather than a boolean. Assign one of three
652
793
  {Tuile::Component::List::Cursor} variants to fit the interaction:
@@ -660,9 +801,9 @@ pluggable object* rather than a boolean. Assign one of three
660
801
  lines. For a list where only some rows are selectable (headers
661
802
  interspersed with items, say), it skips the rest.
662
803
 
663
- Two callbacks cover the events you care about, and both are handed the
664
- `(index, item)` pair. `on_item_chosen` fires when the user commits to the
665
- 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.
666
807
  `on_cursor_changed` fires when the highlighted row *changes*, which is
667
808
  exactly what you wire to keep a details pane in sync with the selection.
668
809
  For a tailing list — a live log — set `auto_scroll`; it pins to the bottom
@@ -692,7 +833,7 @@ when it's near the bottom of the screen.
692
833
  combo = Component::ComboBox.new
693
834
  combo.items = User.all
694
835
  combo.item_label = ->(u) { u.full_name }
695
- combo.on_value_change = ->(u) { show(u) }
836
+ combo.on_value_change { |e| show(e.value) }
696
837
  ```
697
838
 
698
839
  When the choice is simply yes-or-no, {Tuile::Component::Checkbox} is a
@@ -707,7 +848,7 @@ however you flip it.
707
848
 
708
849
  ```ruby
709
850
  cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
710
- cb.on_value_change = ->(on) { config.syslog = on }
851
+ cb.on_value_change { |e| config.syslog = e.value }
711
852
  cb.toggle # unchecks it, firing the listener with false
712
853
  ```
713
854
 
@@ -756,7 +897,7 @@ is the `Set` of items you selected.
756
897
  ```ruby
757
898
  levels = Component::CheckboxGroup.new(items: LogLevel.all)
758
899
  levels.item_label = ->(l) { l.name }
759
- 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
760
901
  ```
761
902
 
762
903
  Notice what `value` holds: the *items*, exactly as the combo box does — a
@@ -814,7 +955,7 @@ the same widget with a single answer.
814
955
  ```ruby
815
956
  sort = Component::RadioGroup.new(items: SORT_ORDERS)
816
957
  sort.item_label = ->(order) { order.label }
817
- sort.on_value_change = ->(order) { resort(order) }
958
+ sort.on_value_change { |e| resort(e.value) }
818
959
  ```
819
960
 
820
961
  Its `value` is the selected item — the object, not its label, as always —
@@ -869,7 +1010,7 @@ you're choosing between them.
869
1010
 
870
1011
  ```ruby
871
1012
  level = Component::Select.new(items: %w[debug info warn error], value: "warn")
872
- level.on_value_change = ->(l) { logger.level = l }
1013
+ level.on_value_change { |e| logger.level = e.value }
873
1014
  ```
874
1015
 
875
1016
  Enter, Space or Down opens the dropdown, the arrows move the highlight,
@@ -938,14 +1079,14 @@ into a list. {Tuile::Component::Button} holds nothing. It runs a block:
938
1079
 
939
1080
  ```ruby
940
1081
  save = Component::Button.new("Save") { form.submit }
941
- save.on_click = -> { form.submit } # or assign it afterwards
1082
+ save.on_click { form.submit } # or register it afterwards
942
1083
  ```
943
1084
 
944
1085
  It paints as `[ Save ]` on one row, highlights its background while it is on
945
1086
  the focus chain, and is a tab stop, so Tab reaches it like any field. Enter,
946
- Space and a left click all fire `on_click`, and the callback takes no
947
- arguments — a button has nothing to report, because that it was pressed *is*
948
- 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.
949
1090
 
950
1091
  **Sizing it is your job**, exactly as chapter 3 promised: there is no channel
951
1092
  for a component to advertise the width it would like, so the caller does the
@@ -967,7 +1108,7 @@ looks like in practice.)
967
1108
  **A focused button consumes Enter**, and that matters the moment you have
968
1109
  more than one. Enter on a focused `Save` activates *that* button — not some
969
1110
  form-wide default, because Tuile has no notion of a default button at all.
970
- The form's Enter-to-submit is a `handle_key` on the ancestor that owns the
1111
+ The form's Enter-to-submit is a `handle_key?` on the ancestor that owns the
971
1112
  form (chapter 5), and it only ever sees Enter when the focused widget
972
1113
  declined it. So a dialog's two buttons are just two widgets, and which one
973
1114
  Enter hits is simply which one has focus.
@@ -1107,6 +1248,48 @@ A slot is invisible to input: it can't take focus, clicks pass straight
1107
1248
  through to the occupant, and when an occupant leaves, the focus repair is
1108
1249
  handed up to your container rather than stranding focus on the slot.
1109
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
+
1110
1293
  ## Switching between views
1111
1294
 
1112
1295
  When a screen has more content than fits and the parts are *alternatives*
@@ -1120,7 +1303,7 @@ is selected.
1120
1303
  sheet = Component::TabSheet.new
1121
1304
  sheet.add_tab("Details", details_form) # the first tab is selected
1122
1305
  sheet.add_tab("Payment", payment_form)
1123
- sheet.on_tab_selected = ->(index, tab) { status.text = "on #{tab&.caption}" }
1306
+ sheet.on_tab_selected { |e| status.text = "on #{e.tab&.caption}" }
1124
1307
  ```
1125
1308
 
1126
1309
  The strip is a component in its own right, {Tuile::Component::Tabs}, and
@@ -1234,7 +1417,7 @@ what a `TabSheet` does: only the selected tab's pane is a child of the
1234
1417
  sheet, the rest are detached. That is a deliberate choice rather than
1235
1418
  history, and the reason is the sentence above about hooks — inverted:
1236
1419
 
1237
- - **`on_detached` fires when a pane goes away, `on_attached` when it
1420
+ - **`handle_detached` fires when a pane goes away, `handle_attached` when it
1238
1421
  returns.** A {Tuile::Component::ProgressBar} in a background tab stops its
1239
1422
  ticker and restarts it on return, with no bookkeeping from you. Hiding
1240
1423
  would keep it ticking, unseen.
@@ -1366,11 +1549,28 @@ other TUI toolkit agree on it:
1366
1549
  | Left at the first level, Right on a plain row | step to the neighbouring menu |
1367
1550
  | a mnemonic letter | activate that row of *this* menu |
1368
1551
  | ESC | close one level |
1369
-
1370
- Stepping sideways *shows* the neighbour's menu; it never presses anything. So
1371
- arrowing onto a top-level button — an item with a listener and no menu — closes
1372
- whatever was open and highlights it, and it fires only when you press Enter or
1373
- 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.
1374
1574
 
1375
1575
  The last row of the first block matters for real apps: while the bar merely
1376
1576
  has focus, every other key **bubbles past it**, so a form's `s`-to-save or
@@ -1479,8 +1679,9 @@ input inside itself. For a layer that floats *without* taking focus — the
1479
1679
  autocomplete-list case from earlier, where the caller positions it against a
1480
1680
  field's caret and drives it from app code — use its base class, `Overlay`,
1481
1681
  directly. An `Overlay` is a Popup minus the modality: same open/close
1482
- lifecycle, same outside-click dismissal, but it sits at the rect you assign
1483
- 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.
1484
1685
 
1485
1686
  **A left click outside an overlay closes it**, modal or not — the same light
1486
1687
  dismissal a desktop dialog gives you. It's a per-overlay switch,
@@ -1573,7 +1774,7 @@ dialog.message = "Save your changes before leaving?"
1573
1774
  dialog.button("Save") { save! }
1574
1775
  dialog.button("Discard") { discard! }
1575
1776
  dialog.button("Cancel") # no block: pressing it dismisses
1576
- dialog.on_dismiss = -> { stay_put }
1777
+ dialog.on_dismiss { stay_put }
1577
1778
  dialog.open
1578
1779
  ```
1579
1780
 
@@ -1684,7 +1885,9 @@ layout) *or* as a popup (via a class-level `open`).
1684
1885
  presentation from the body's type (an Array is rows, text is prose).
1685
1886
  - {Tuile::Component::PickerWindow} — a menu of options each bound to a
1686
1887
  single key, firing your block with the picked key. Popped up via `open`,
1687
- it closes itself after a pick; ESC/`q` cancels without firing.
1888
+ it closes itself after a pick; ESC/`q` cancels without firing. Captions
1889
+ paint in the terminal's own foreground; hand in a {Tuile::StyledString}
1890
+ (or the ANSI string `theme.fg` returns) to color one, per option.
1688
1891
  - {Tuile::Component::LogWindow} — a Window framing a
1689
1892
  {Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
1690
1893
  TextView purpose-built for log output. The view is where the behavior