tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -8,49 +8,70 @@ module Tuile
8
8
  # isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
9
9
  # any stale invalidation entries are filtered out at drain time. Subclasses
10
10
  # can paint freely in {#repaint} without re-asserting attachment.
11
+ #
12
+ # == Handlers and listener slots
13
+ #
14
+ # Two families, told apart by the `=`:
15
+ #
16
+ # class Trimmed < Component::TextField
17
+ # def handle_blur # handle_ — the override point
18
+ # super
19
+ # self.text = text.strip
20
+ # end
21
+ # end
22
+ #
23
+ # label.on_theme_changed = -> { … } # on_…= — the listener slot
24
+ #
25
+ # **What a handler returns is per hook**, declared in its own rdoc. Only the
26
+ # ones a dispatcher routes answer at all — {#handle_key?},
27
+ # {#handle_text_input_key?}, `MenuBar#handle_mnemonic?` — where `true` means "I
28
+ # took this, stop bubbling". The rest, {#handle_paste} included, return `void`.
29
+ #
30
+ # An override calls `super`, even where the base body is empty: that is what
31
+ # lets a hook grow an `on_foo=` slot without breaking you. The one carve-out is
32
+ # {#handle_child_removed}, whose base does real work and whose overrides
33
+ # replace it. `D_handler_naming` carries the argument.
11
34
  class Component
12
- # The methods that *define* the tree, and so may never be overridden —
13
- # {#attached?} walks the parent chain while every subtree walk uses
14
- # {#children}, and an override that makes those two disagree leaves a
15
- # component attached but never painted, with nothing raising (`D_final_tree`).
16
- # Enforced by {.verify_final!}; reparent through {#add_child} /
17
- # {#remove_child} / {#detach_child}, and hold a {Slot} for a swappable region.
18
- # @return [Array<Symbol>]
19
- FINAL_METHODS = %i[children parent parent= add_child remove_child detach_child].freeze
20
-
21
- @final_verified = {}
22
-
23
- # Raises unless `klass` inherits every {FINAL_METHODS} entry from
24
- # {Component}; memoized, so it costs one hash lookup per construction.
25
- #
26
- # Comparing each *resolved* method's `owner` is what makes this catch all
27
- # four routes in — `def`, `define_method`, an included module, a `prepend`.
28
- # A `method_added` hook would fire earlier but see only the first two.
29
- # @param klass [Class] the class being instantiated.
30
- # @raise [Error] if any final method has been overridden.
31
- # @return [void]
32
- def self.verify_final!(klass)
33
- return if @final_verified.key?(klass)
35
+ extend Final
34
36
 
35
- overridden = FINAL_METHODS.reject { klass.instance_method(_1).owner == Component }
36
- unless overridden.empty?
37
- raise Error, "#{klass} overrides #{overridden.join(", ")}, which are final on Component. " \
38
- "The tree is the framework's single source of truth — #attached? walks the " \
39
- "parent chain while subtree walks use #children, so an override desyncs them " \
40
- "silently. Reparent through add_child / remove_child / detach_child; for a " \
41
- "swappable region, hold a Component::Slot."
42
- end
43
-
44
- @final_verified[klass] = true
45
- end
37
+ # Each method's own rdoc says what an override would break; `D_final_tree`
38
+ # carries the full argument.
39
+ final :children, :parent, :parent=, :add_child, :remove_child, :detach_child,
40
+ :effective_bg_color
46
41
 
47
42
  def initialize
48
43
  Component.verify_final!(self.class)
49
44
  @rect = Rect.new(0, 0, 0, 0)
45
+ @visible = true
50
46
  @active = false
51
47
  @on_theme_changed = nil
48
+ @on_locale_changed = nil
52
49
  @bg_color = nil
53
50
  @children = []
51
+ @id = nil
52
+ end
53
+
54
+ # A tag for finding this component again — nothing paints it, and the
55
+ # framework never reads it:
56
+ #
57
+ # field.id = :name
58
+ # Testing.get(id: :name).value = "Zaphod"
59
+ #
60
+ # **Nothing enforces uniqueness**, here or anywhere in production; two
61
+ # components may carry the same id, and only {Testing.get} — which raises
62
+ # on an ambiguous match — will ever say so.
63
+ # @return [Symbol, nil]
64
+ attr_reader :id
65
+
66
+ # A `String` is refused rather than coerced: `id = "name"` would never
67
+ # match a `get(id: :name)`, silently.
68
+ # @param new_id [Symbol, nil]
69
+ # @raise [TypeError] unless `new_id` is a Symbol or nil.
70
+ # @return [void]
71
+ def id=(new_id)
72
+ raise TypeError, "expected Symbol or nil, got #{new_id.inspect}" unless new_id.nil? || new_id.is_a?(Symbol)
73
+
74
+ @id = new_id
54
75
  end
55
76
 
56
77
  # @return [Rect] the rectangle the component occupies on screen.
@@ -59,7 +80,7 @@ module Tuile
59
80
  # The three readers below report the geometry a parent *assigned*, as
60
81
  # shorthand for the matching {#rect} field. They are reports, not requests:
61
82
  # no container consults them when dividing space, and there is deliberately
62
- # no writer — layout is top-down (`DECISIONS.md` `D_box_layouts`), so a
83
+ # no writer — layout is top-down (`design/decisions.md` `D_box_layouts`), so a
63
84
  # component says how big it *is*, never how big it wants to be.
64
85
 
65
86
  # @return [Size] `rect.size`.
@@ -94,7 +115,7 @@ module Tuile
94
115
  # It flows **downward only**: no container consults it when dividing space,
95
116
  # so {#rect} still means exactly what the parent assigned (`D_extent`). Three
96
117
  # things read it, all of them this component or the framework painting it:
97
- # {#clear_outside_extent} blanks the dead tail, {#handle_mouse} hit-tests
118
+ # {#clear_outside_extent} blanks the dead tail, {Mouse::Router} hit-tests
98
119
  # against it so a click on that tail doesn't activate the widget, and a
99
120
  # dropdown anchors under it rather than under unused space.
100
121
  #
@@ -106,7 +127,7 @@ module Tuile
106
127
  def extent = nil
107
128
 
108
129
  # {#extent} placed at {#rect}'s top-left, for the consumers that need
109
- # coordinates: `extent_rect.contains?(event.point)` in a {#handle_mouse}, and
130
+ # coordinates: `extent_rect.contains?(point)` in {Mouse::Router}, and
110
131
  # the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
111
132
  # the whole {#rect}, so a generic caller never sees `nil`.
112
133
  # @return [Rect]
@@ -131,10 +152,57 @@ module Tuile
131
152
 
132
153
  prev_width = @rect.width
133
154
  @rect = new_rect
134
- on_width_changed if prev_width != new_rect.width
155
+ handle_width_changed if prev_width != new_rect.width
135
156
  invalidate
136
157
  end
137
158
 
159
+ # This component's own flag — **not** whether the user can see it, which
160
+ # also depends on its ancestors: a shown field inside a hidden panel
161
+ # answers `true`.
162
+ # @return [Boolean]
163
+ def visible? = @visible
164
+
165
+ # Hides or shows the component: `false` means **as if detached — but it
166
+ # stays in the tree**.
167
+ #
168
+ # company.visible = business_customer.checked? # a conditional form field
169
+ #
170
+ # Hidden, it paints nothing, takes no space in a
171
+ # {Component::Layout::Box} (nor the `spacing` around it), and is
172
+ # unreachable by focus, Tab, keys, the cursor, the mouse and
173
+ # {Testing.find}. Unlike a *detached* component it keeps its {#parent},
174
+ # {#rect}, box constraints, state and any resource it holds, and **fires no
175
+ # lifecycle hook** — so a hidden pane may go on running a job. When you
176
+ # want the hooks, remove it from the tree instead.
177
+ #
178
+ # Two contracts worth knowing before you meet them as bugs:
179
+ #
180
+ # - **Ancestor-inclusive.** Hiding a container hides its whole subtree
181
+ # whatever those components' own flags say; the flags are remembered, so
182
+ # showing it restores exactly the subtree that was showing before.
183
+ # - **Focus never stays on what the user cannot see.** Hiding the subtree
184
+ # holding focus repairs it exactly as removing that subtree would (see
185
+ # {#handle_child_removed}), and does not hand it back on the way in.
186
+ #
187
+ # For "invisible but still occupying its space", use a
188
+ # {Component::Slot} with no content (`D_slots`).
189
+ # @param value [Boolean]
190
+ # @raise [Tuile::Error] when the UI is locked, or always from
191
+ # {Component::Overlay#visible=}.
192
+ # @return [void]
193
+ def visible=(value)
194
+ value = value ? true : false
195
+ return if @visible == value
196
+
197
+ screen.check_locked if attached?
198
+ @visible = value
199
+ # `__send__` for the same reason `Screen#theme=` uses it: the hook is
200
+ # protected (`D_hook_visibility`).
201
+ parent&.__send__(:handle_child_visibility_changed, self)
202
+ repair_focus_after_hiding unless value
203
+ walk_tree { |c| screen.invalidate(c) } if attached?
204
+ end
205
+
138
206
  # @return [Screen] the screen which owns this component.
139
207
  def screen = Screen.instance
140
208
 
@@ -144,11 +212,29 @@ module Tuile
144
212
  screen.focused = self
145
213
  end
146
214
 
147
- # @return [Color, Theme::Ref, nil] this component's own background — the
148
- # value as set, so a {Theme::Ref} comes back unresolved; `nil` when unset,
149
- # in which case it inherits from the parent (see {#effective_bg_color}),
150
- # ultimately the terminal default. {#effective_bg_color} is the resolved
151
- # {Color} to paint.
215
+ # The states a background may be keyed by. Closed and framework-defined:
216
+ # a key is added when Tuile grows the state, never to let an app invent one.
217
+ # @return [Array<Symbol>]
218
+ BG_STATES = %i[normal active].freeze
219
+
220
+ # Assign to {#bg_color} to say "I contribute no background of my own" —
221
+ # resolution skips this component's {#default_bg_color} and takes whatever
222
+ # surrounds it. CSS's `background: inherit`, and the reason a widget with a
223
+ # well can be made to sit flush in a tinted panel:
224
+ #
225
+ # field.bg_color = Component::BG_INHERIT # no well; take the pane's tint
226
+ #
227
+ # Distinct from `nil`, which falls through to {#default_bg_color} *first*.
228
+ # There is deliberately no counterpart forcing the terminal default despite
229
+ # a tinted ancestor (`D_bg_inherit`).
230
+ # @return [Symbol]
231
+ BG_INHERIT = :inherit
232
+
233
+ # @return [Color, Theme::Ref, Hash{Symbol => Color, Theme::Ref}, nil] this
234
+ # component's own background — the value as set, so a {Theme::Ref} comes
235
+ # back unresolved and a state map comes back a Hash; `nil` when unset, in
236
+ # which case the component falls back to {#default_bg_color} and then to
237
+ # its parent. {#effective_bg_color} is the resolved {Color} to paint.
152
238
  attr_reader :bg_color
153
239
 
154
240
  # Tints this component and every descendant that doesn't set its own
@@ -157,35 +243,42 @@ module Tuile
157
243
  # subtree so it repaints.
158
244
  #
159
245
  # A {Theme::Ref} is re-resolved against the theme each paint, so it tracks
160
- # light/dark flips with no {#on_theme_changed} hook; a {Color} is fixed:
246
+ # light/dark flips with no {#handle_theme_changed} hook; a {Color} is fixed:
161
247
  #
162
248
  # panel.bg_color = Theme.ref(:panel_bg) # theme-tracked
163
249
  # panel.bg_color = Color::GREY27 # fixed
164
250
  #
165
- # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil] a
166
- # {Theme::Ref}, else a color coerced via {Color.coerce}; `nil` unsets
167
- # (inherit from the parent).
251
+ # A Hash keyed by {BG_STATES} gives a color per state — the shape a widget
252
+ # that highlights itself on focus needs, and the reason setting a flat color
253
+ # on one is a *choice* rather than a trap:
254
+ #
255
+ # field.bg_color = grey # flat: focused or not
256
+ # field.bg_color = { normal: grey, active: blue } # the pair
257
+ # field.bg_color = { active: blue } # keep the widget's own
258
+ # # well, override focus
259
+ #
260
+ # A state whose key is absent is not answered here at all: resolution falls
261
+ # through to {#default_bg_color} and then to the parent, exactly as `nil`
262
+ # does. That is what makes the third line above mean what it reads as.
263
+ #
264
+ # This does *not* win over a validation error: {#error_bg_color} resolves
265
+ # first, so tinting a panel cannot switch off the error well on the fields
266
+ # inside it.
267
+ #
268
+ # @param color [Color, Theme::Ref, Hash, Symbol, Integer, Array<Integer>, nil]
269
+ # a {Theme::Ref}, {BG_INHERIT}, a Hash keyed by {BG_STATES}, else a color
270
+ # coerced via {Color.coerce}; `nil` unsets (fall through to
271
+ # {#default_bg_color}, then the parent).
272
+ # @raise [ArgumentError] when a Hash carries a key outside {BG_STATES}.
168
273
  # @raise [KeyError] when a {Theme::Ref} names an absent custom token —
169
274
  # validated eagerly at assignment, not deferred to paint.
170
275
  # @return [void]
171
276
  def bg_color=(color)
172
- color = Color.coerce(color) unless color.is_a?(Theme::Ref)
277
+ color = coerce_bg_color(color)
173
278
  return if @bg_color == color
174
279
 
175
- color.resolve(screen.theme) if color.is_a?(Theme::Ref) # fail fast on a bad token
176
280
  @bg_color = color
177
- on_tree { |c| screen.invalidate(c) } if attached?
178
- end
179
-
180
- # @return [Color, nil] the background actually painted: this component's own
181
- # {#bg_color} if set (a {Theme::Ref} resolved against the current theme),
182
- # else the nearest ancestor's, else `nil` (terminal default). Resolved at
183
- # paint time — never cached, so the subtree tracks both an ancestor's
184
- # {#bg_color=} and a {Screen#theme=} on its next repaint.
185
- def effective_bg_color
186
- own = @bg_color
187
- own = own.resolve(screen.theme) if own.is_a?(Theme::Ref)
188
- own || parent&.effective_bg_color
281
+ walk_tree { |c| screen.invalidate(c) } if attached?
189
282
  end
190
283
 
191
284
  # Repaints the component. The default does the bookkeeping most components
@@ -203,6 +296,9 @@ module Tuile
203
296
  # skipping `super`.** The clear then covers only what is outside it, so the
204
297
  # cells it is about to repaint are not blanked first — blanking them would
205
298
  # mark them dirty and make {Buffer#flush} re-emit them (`D_progress_bar`).
299
+ # That saving is a *leaf*'s: a container's children paint its extent for it,
300
+ # so a cell among them that none covers still gets blanked — an extent
301
+ # narrows which cells are yours, never whether your gaps are wiped.
206
302
  #
207
303
  # **The children are re-invalidated whether or not they tile.** A container
208
304
  # that paints nothing of its own can only redraw its area *through* them, so
@@ -212,12 +308,19 @@ module Tuile
212
308
  # the notice has to keep travelling down or the cleared cells are never
213
309
  # repainted. Cheap by construction: repainting the same glyphs leaves
214
310
  # {Buffer::Cell} unchanged, so nothing extra reaches the wire.
311
+ #
312
+ # A container that skips `super` because it paints its own rect must still
313
+ # call {#invalidate_children} — that is the half of this that cannot be
314
+ # dropped.
215
315
  # @return [void]
216
316
  def repaint
217
317
  return if rect.empty?
218
318
 
219
- clear_outside_extent unless children.any? && children_tile_rect?
220
- children.each { |c| screen.invalidate(c) }
319
+ unless children.any? && children_tile_rect?
320
+ clear_outside_extent
321
+ clear_inside_extent if extent && children.any?
322
+ end
323
+ invalidate_children
221
324
  end
222
325
 
223
326
  # Called when a key is pressed; override to act on keys you care about (the
@@ -225,49 +328,105 @@ module Tuile
225
328
  # it's on the focus chain — or when app code hands it one directly — so act
226
329
  # on the key alone and never gate on your own {#active?} state. See book ch5
227
330
  # for how a keystroke is routed to reach here.
331
+ #
332
+ # The `?` reads like `Set#add?`: calling it *delivers* the key and reports
333
+ # whether it was taken, so it is never a "would you handle this?" probe.
228
334
  # @param _key [String] a key.
229
335
  # @return [Boolean] true if the key was handled, false if not.
230
- def handle_key(_key)
336
+ def handle_key?(_key)
231
337
  false
232
338
  end
233
339
 
234
- # Called when text is pasted while this component is on the focus chain;
235
- # override to accept it (the default reports every paste unhandled, and
236
- # unhandled text is dropped). Arrives whole and `\n`-normalized, so
237
- # `text.lines.size` is the paste's line count and a single mutation can
238
- # absorb it:
340
+ # Called when text is pasted while this component is {Screen#focused};
341
+ # override to accept it. The default drops the text. It arrives whole and
342
+ # `\n`-normalized, so `text.lines.size` is the paste's line count and a
343
+ # single mutation can absorb it:
239
344
  #
240
345
  # def handle_paste(text)
241
346
  # self.caption = "[Pasted #{text.lines.size} lines]"
242
- # true
243
347
  # end
244
348
  #
349
+ # **No verdict, unlike {#handle_key?}**: a paste reaches the focused component
350
+ # and stops, so one that declines has nowhere to hand it on to
351
+ # (`D_bracketed_paste`).
352
+ #
245
353
  # Reaching here means the terminal said "this came from the clipboard" —
246
354
  # {Component::AbstractStringField} inserts it at the caret, which is why a
247
355
  # subclass that rebinds ENTER to submit needs no paste handling of its own
248
356
  # to stop firing once per pasted line.
249
357
  # @param _text [String] the pasted text.
250
- # @return [Boolean] true if the paste was consumed.
251
- def handle_paste(_text)
252
- false
253
- end
358
+ # @return [void]
359
+ def handle_paste(_text); end
254
360
 
255
- # Focuses this component when left-clicked (if {#focusable?}), then hands the
256
- # event down to every child whose {#rect} contains the point — which is how a
257
- # click descends the tiled tree to a leaf.
361
+ # Called when a mouse button goes down over this component; answer `true` to
362
+ # claim the press. The default claims nothing.
363
+ #
364
+ # def handle_mouse_down?(event)
365
+ # return false unless event.button == :left
366
+ #
367
+ # @on_click&.call
368
+ # true
369
+ # end
258
370
  #
259
- # A widget that resolves clicks *inside* its own rect — mapping a point to a
260
- # row, or toggling an overlay — overrides this and does not call `super`.
261
- # Such an override hit-tests {#extent_rect} rather than {#rect}, so a click
262
- # on the tail it doesn't paint never activates it.
263
- # @param event [MouseEvent]
371
+ # {Mouse::Router} delivers it to the innermost component under the pointer
372
+ # and bubbles it up the ancestors until one answers `true`; the claimant
373
+ # then holds the *grab*, and the button's {#handle_mouse_drag} and
374
+ # {#handle_mouse_up} go to it alone. The press has already moved focus by
375
+ # the time it arrives, and it only arrives where {#extent_rect} contains the
376
+ # point, so an override needs neither `super` nor a hit test of its own.
377
+ #
378
+ # Activate here, on the press: Tuile synthesizes no click, because a release
379
+ # is losable over ssh and tmux (`D_mouse_dispatch`).
380
+ # @param _event [Mouse::DownEvent]
381
+ # @return [Boolean] whether this component claimed the press.
382
+ def handle_mouse_down?(_event) = false
383
+
384
+ # Called when the wheel turns over this component; answer `true` to consume
385
+ # the notch. Bubbles exactly as {#handle_mouse_down?} does, but grabs
386
+ # nothing — so a scroller already at its limit answers `false` and its
387
+ # ancestor scrolls instead.
388
+ # @param _event [Mouse::ScrollEvent]
389
+ # @return [Boolean] whether this component consumed the notch.
390
+ def handle_mouse_scroll?(_event) = false
391
+
392
+ # Called when the pointer moves over this component with nothing grabbed;
393
+ # answer `true` to consume the move. Bubbles as {#handle_mouse_down?} does.
394
+ # Arrives only under `run_event_loop(capture_mouse: :hover)`, at up to ~84
395
+ # events a second, which is why the default passes it on untouched.
396
+ # @param _event [Mouse::MoveEvent]
397
+ # @return [Boolean] whether this component consumed the move.
398
+ def handle_mouse_move?(_event) = false
399
+
400
+ # Called on the component that claimed a press when the button comes up,
401
+ # ending the grab. For press feedback and for ending a drag — never for
402
+ # activation: a release may never arrive, and any key or the next press
403
+ # ends the grab without it.
404
+ # @param _event [Mouse::UpEvent]
264
405
  # @return [void]
265
- def handle_mouse(event)
266
- screen.focused = self unless event.button != :left || active? || !focusable?
267
- # Snapshot: a handler may add or remove siblings (a click that swaps a
268
- # slot's occupant), and `each` over a mutating array skips an entry.
269
- children.dup.each { |c| c.handle_mouse(event) if c.rect.contains?(event.point) }
270
- end
406
+ def handle_mouse_up(_event); end
407
+
408
+ # Called on the component that claimed a press whenever the pointer moves
409
+ # while the button is held, wherever the pointer is. Needs
410
+ # `capture_mouse: :drag` or `:hover`.
411
+ # @param _event [Mouse::DragEvent] its point may lie outside {#rect}.
412
+ # @return [void]
413
+ def handle_mouse_drag(_event); end
414
+
415
+ # Called when the pointer comes over this component or any of its
416
+ # descendants — down the chain, root first, and after every
417
+ # {#handle_mouse_exit} the same move fires. Needs `capture_mouse: :hover`,
418
+ # and is suspended while a press is grabbed.
419
+ #
420
+ # **Never a commit point**: no terminal reports the pointer leaving the
421
+ # window, so the matching {#handle_mouse_exit} may arrive late or not at
422
+ # all. Anything done here must be cosmetic and survive that.
423
+ # @return [void]
424
+ def handle_mouse_enter; end
425
+
426
+ # The other half of {#handle_mouse_enter}; also fires when this component is
427
+ # detached or hidden while hovered, innermost first.
428
+ # @return [void]
429
+ def handle_mouse_exit; end
271
430
 
272
431
  # @return [Boolean] true if the component is on the active chain — i.e. it
273
432
  # is the focused component or an ancestor of it. Set by {Screen#focused=}.
@@ -302,6 +461,11 @@ module Tuile
302
461
  # @return [Boolean] true if Tab / Shift+Tab should land on this component.
303
462
  def tab_stop? = false
304
463
 
464
+ # Final: the parent chain is one half of the tree's single source of truth
465
+ # — {#attached?} walks it while every subtree walk uses {#children}, so a
466
+ # derived pointer leaves a component attached but never painted, with
467
+ # nothing raising (`D_final_tree`). Reparent through {#add_child} /
468
+ # {#remove_child} / {#detach_child}.
305
469
  # @return [Component, nil] the parent component or nil if the component has
306
470
  # no parent.
307
471
  attr_reader :parent
@@ -316,10 +480,10 @@ module Tuile
316
480
  # Child components in paint order (siblings left to right, earlier ones
317
481
  # painted under later ones), maintained by {#add_child} / {#remove_child}.
318
482
  #
319
- # Not meant to be overridden: a container that computed this from its own
320
- # slots could disagree with the parent pointers, and {#attached?} walks the
321
- # chain while subtree walks use this list. Named slots are readers *over*
322
- # the array (`Window#footer`), never a second copy of it.
483
+ # Final: a container that computed this from its own slots would disagree
484
+ # with the parent pointers {#attached?} walks, silently (`D_final_tree`).
485
+ # Named slots are readers *over* this array (`Window#footer`), never a
486
+ # second copy of it; for a swappable region hold a {Slot}.
323
487
  # @return [Array<Component>] child components. Must not be mutated by
324
488
  # callers! May be empty.
325
489
  attr_reader :children
@@ -329,23 +493,61 @@ module Tuile
329
493
  # @yieldparam component [Component]
330
494
  # @yieldreturn [void]
331
495
  # @return [void]
332
- def on_tree(&block)
496
+ def walk_tree(&block)
333
497
  block.call(self)
334
- children.each { _1.on_tree(&block) }
498
+ children.each { _1.walk_tree(&block) }
335
499
  end
336
500
 
337
- # Called when the component receives focus.
501
+ # {#walk_tree}, pruned: a hidden subtree is skipped whole, this component
502
+ # included when it is itself hidden (in which case nothing is yielded).
503
+ #
504
+ # stops = []
505
+ # scope.walk_shown_tree { |c| stops << c if c.tab_stop? }
506
+ #
507
+ # **Use this for anything asking "can the user reach it"**, {#walk_tree} for
508
+ # what the framework does *to* a component regardless — lifecycle, theme
509
+ # fan-out, invalidation — which a hidden component still gets. Writing the
510
+ # first as `walk_tree` plus a `visible?` test is the trap: that is this walk
511
+ # with the ancestor case missing, so a field under a hidden panel is back
512
+ # in the Tab cycle (`D_visibility`).
513
+ # @yield [component]
514
+ # @yieldparam component [Component]
515
+ # @yieldreturn [void]
338
516
  # @return [void]
339
- def on_focus; end
517
+ def walk_shown_tree(&block)
518
+ return unless visible?
340
519
 
341
- # Optional zero-arg listener fired by the base {#on_theme_changed} — the
520
+ block.call(self)
521
+ children.each { _1.walk_shown_tree(&block) }
522
+ end
523
+
524
+ # Called when the component receives focus — on this component alone, never
525
+ # on the ancestors that light up with it. {#handle_blur} is the other half.
526
+ #
527
+ # Unlike `handle_blur` it is **not** edge-triggered: it fires on every
528
+ # {Screen#focused=}, re-assigning the component that already has focus
529
+ # included, which is what lets a container forward focus into its content
530
+ # from here.
531
+ # @return [void]
532
+ def handle_focus; end
533
+
534
+ # Optional zero-arg listener fired by the base {#handle_theme_changed} — the
342
535
  # composition-style alternative to overriding the method, for apps that
343
536
  # assemble stock components rather than subclass:
344
537
  #
345
538
  # label.on_theme_changed = -> { label.text = render_status_line }
346
539
  #
347
540
  # @return [Proc, nil]
348
- attr_writer :on_theme_changed
541
+ attr_accessor :on_theme_changed
542
+
543
+ # Optional zero-arg listener fired by the base {#handle_locale_changed} — the
544
+ # composition-style alternative to overriding the method, for an app that
545
+ # rendered a date or a number into a stock component:
546
+ #
547
+ # label.on_locale_changed = -> { label.text = due_date.strftime(fmt) }
548
+ #
549
+ # @return [Proc, nil]
550
+ attr_accessor :on_locale_changed
349
551
 
350
552
  # Whether this component's tree is mounted on a UI, {ScreenPane} being the
351
553
  # root of every displayed tree.
@@ -366,9 +568,18 @@ module Tuile
366
568
  # component lived inside the removed subtree, focus shifts to `self` so the
367
569
  # cursor doesn't dangle on a detached component. No-op if `self` is not
368
570
  # attached to the screen — focus state in a detached subtree is moot.
369
- # @param child [Component] the just-detached child.
571
+ #
572
+ # **{#visible=} reuses this when it hides a subtree holding focus** — the
573
+ # same question, answered once so hide and remove can't drift apart. It
574
+ # passes the *hidden* child, which is still in `children` with `self` as
575
+ # its parent: an override may repair focus however it likes, but must not
576
+ # assume the child is gone. Removal bookkeeping belongs in the remover.
577
+ #
578
+ # The one hook whose base body does real work, so an override *replaces* it
579
+ # (as {Component::Slot} and {ScreenPane} do) instead of calling `super`.
580
+ # @param child [Component] the just-detached, or just-hidden, child.
370
581
  # @return [void]
371
- def on_child_removed(child)
582
+ def handle_child_removed(child)
372
583
  return unless attached?
373
584
 
374
585
  f = screen.focused
@@ -378,7 +589,7 @@ module Tuile
378
589
  until cursor.nil?
379
590
  if cursor == child
380
591
  screen.focused = self
381
- return
592
+ break
382
593
  end
383
594
  cursor = cursor.parent
384
595
  end
@@ -391,8 +602,38 @@ module Tuile
391
602
  # @return [Point, nil] absolute screen coordinates, or nil to hide.
392
603
  def cursor_position = nil
393
604
 
605
+ # One line naming the component, its {#id} and its rect, plus whatever
606
+ # {#inspect_details} adds:
607
+ #
608
+ # #<Tuile::Component::Button id=:save rect=(2,3 8x1) caption="Save">
609
+ #
610
+ # Deliberately shallow — it never walks {#parent} or {#children}, so
611
+ # inspecting one component does not dump the whole UI.
612
+ # @return [String]
613
+ def inspect
614
+ parts = [self.class.to_s] # not .name — an anonymous class has none
615
+ parts << "id=#{@id.inspect}" unless @id.nil?
616
+ parts << "rect=(#{rect})"
617
+ "#<#{(parts + inspect_details).join(" ")}>"
618
+ end
619
+
394
620
  protected
395
621
 
622
+ # What this component adds to its {#inspect}, as `key=value` strings.
623
+ #
624
+ # def inspect_details = super + ["items=#{items.size}"]
625
+ #
626
+ # **Always `super`** — this is the seam several mixins share, and each one
627
+ # appends to what the last returned. Override {#inspect} instead of this
628
+ # and you drop whichever details the mixins contribute. They come out in
629
+ # reverse include order, the last-included module running first.
630
+ #
631
+ # The base contributes a bare `hidden` when {#visible?} is false, and
632
+ # nothing when it is true — so in a {Testing.dump} the marker sits on the
633
+ # hidden ancestor, not on each component under it.
634
+ # @return [Array<String>]
635
+ def inspect_details = @visible ? [] : ["hidden"]
636
+
396
637
  # Adopts `child`: places it in {#children} and wires its parent pointer.
397
638
  #
398
639
  # add_child(content, at: 0) # the tiled layer, painted beneath …
@@ -403,6 +644,9 @@ module Tuile
403
644
  # @raise [TypeError] if `child` is not a {Component}.
404
645
  # @raise [ArgumentError] if `child` already has a parent.
405
646
  # @return [void]
647
+ #
648
+ # Final: one of the three mutators that write {#children} and the parent
649
+ # pointer in the same call, which is what keeps them in agreement.
406
650
  def add_child(child, at: nil)
407
651
  raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
408
652
  raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
@@ -411,28 +655,34 @@ module Tuile
411
655
  child.parent = self
412
656
  end
413
657
 
414
- # Drops `child` and notifies {#on_child_removed}.
658
+ # Drops `child` and notifies {#handle_child_removed}.
415
659
  # @param child [Component]
416
660
  # @raise [ArgumentError] if `child` is not a child of this component.
417
661
  # @return [void]
662
+ #
663
+ # Final: one of the three mutators that write {#children} and the parent
664
+ # pointer in the same call, which is what keeps them in agreement.
418
665
  def remove_child(child)
419
666
  detach_child(child)
420
- on_child_removed(child)
667
+ handle_child_removed(child)
421
668
  end
422
669
 
423
670
  # Drops `child` *without* notifying — for a container swapping a named slot,
424
- # which owes the {#on_child_removed} call once the new occupant is wired:
671
+ # which owes the {#handle_child_removed} call once the new occupant is wired:
425
672
  #
426
673
  # detach_child(old)
427
674
  # @content = new
428
675
  # add_child(new, at: 0)
429
- # on_child_removed(old) # focus repair cascades into the *new* content
676
+ # handle_child_removed(old) # focus repair cascades into the *new* content
430
677
  #
431
678
  # The child leaves {#children} before its pointer is cleared, so nothing
432
679
  # observes a child whose parent has disowned it while still listing it.
433
680
  # @param child [Component]
434
681
  # @raise [ArgumentError] if `child` is not a child of this component.
435
682
  # @return [void]
683
+ #
684
+ # Final: one of the three mutators that write {#children} and the parent
685
+ # pointer in the same call, which is what keeps them in agreement.
436
686
  def detach_child(child)
437
687
  raise ArgumentError, "#{child} is not a child of #{self}" unless @children.include?(child)
438
688
 
@@ -444,38 +694,38 @@ module Tuile
444
694
  # i.e. when {#attached?} flips to true — the place to acquire whatever is
445
695
  # supposed to live for exactly as long as the component is on screen:
446
696
  #
447
- # def on_attached
697
+ # def handle_attached
448
698
  # @ticker = screen.event_queue.tick_fps(10) { advance }
449
699
  # end
450
700
  #
451
- # def on_detached
701
+ # def handle_detached
452
702
  # @ticker&.cancel
453
703
  # @ticker = nil
454
704
  # end
455
705
  #
456
- # `on_attached` starts what `on_detached` stops; both must be cheap and
706
+ # `handle_attached` starts what `handle_detached` stops; both must be cheap and
457
707
  # idempotent, since a component moved between parents is genuinely detached
458
708
  # in between and gets both, in that order. Whatever you acquire here you
459
- # must release in {#on_detached} — nothing else will. Not a destructor:
460
- # process teardown does *not* fire {#on_detached}.
709
+ # must release in {#handle_detached} — nothing else will. Not a destructor:
710
+ # process teardown does *not* fire {#handle_detached}.
461
711
  #
462
712
  # {#invalidate} needs no guard: {#attached?} is already true here (and
463
- # already false in {#on_detached}, where it no-ops). Do not read {#rect} —
713
+ # already false in {#handle_detached}, where it no-ops). Do not read {#rect} —
464
714
  # a parent assigns it *after* wiring, so it is still stale. Runs on the
465
715
  # thread that owns the UI.
466
716
  # @return [void]
467
- def on_attached; end
717
+ def handle_attached; end
468
718
 
469
- # Mirror of {#on_attached}, called once the tree has been unmounted — see
719
+ # Mirror of {#handle_attached}, called once the tree has been unmounted — see
470
720
  # there for the contract. Two things are still mid-flight when it runs, both
471
721
  # deliberate: {Screen#focused} may still point into this subtree (repair
472
722
  # happens after), and the ex-parent's own bookkeeping may not be finished.
473
723
  # So release resources here and don't inspect the tree around you.
474
724
  # @return [void]
475
- def on_detached; end
725
+ def handle_detached; end
476
726
 
477
727
  # Rewires the parent pointer and, when that changes whether the component is
478
- # {#attached?}, fires {#on_attached} / {#on_detached} across the whole
728
+ # {#attached?}, fires {#handle_attached} / {#handle_detached} across the whole
479
729
  # subtree. The sole firing site: `add_child` / `detach_child` are the only
480
730
  # callers, and they update {#children} *before* calling this, so a hook sees
481
731
  # a tree whose list and pointers already agree.
@@ -484,6 +734,9 @@ module Tuile
484
734
  # doesn't change), and neither does building a detached tree.
485
735
  # @param new_parent [Component, nil]
486
736
  # @return [void]
737
+ #
738
+ # Final: being the sole firing site is the whole contract — an override
739
+ # would fire the lifecycle hooks for the wrong set, or not at all.
487
740
  def parent=(new_parent)
488
741
  was_attached = attached?
489
742
  @parent = new_parent
@@ -502,23 +755,81 @@ module Tuile
502
755
  # current attachedness rather than on `parent.equal?(self)`: a child pulled
503
756
  # out during a detach walk is *already* detached, so its own `parent=` saw
504
757
  # no transition and stayed silent — a parentage check would skip it too and
505
- # it would never hear `on_detached` at all. The reverse case (pulled out
506
- # during an *attach* walk) gets `on_detached` from its own `parent=` and no
507
- # `on_attached`, which is why the hooks are required to be idempotent: an
508
- # unpaired detach releases nothing, whereas firing `on_attached` at a
758
+ # it would never hear `handle_detached` at all. The reverse case (pulled out
759
+ # during an *attach* walk) gets `handle_detached` from its own `parent=` and no
760
+ # `handle_attached`, which is why the hooks are required to be idempotent: an
761
+ # unpaired detach releases nothing, whereas firing `handle_attached` at a
509
762
  # component that is no longer attached would start a ticker nothing stops.
510
763
  #
511
- # @param attached [Boolean] true to fire {#on_attached}, false for {#on_detached}.
764
+ # @param attached [Boolean] true to fire {#handle_attached}, false for {#handle_detached}.
512
765
  # @return [void]
513
766
  def fire_lifecycle(attached)
514
767
  kids = children.dup
515
- attached ? on_attached : on_detached
768
+ attached ? handle_attached : handle_detached
516
769
  kids.each { _1.fire_lifecycle(attached) if _1.attached? == attached }
517
770
  end
518
771
 
519
772
  # Called whenever the component width changes. Does nothing by default.
520
773
  # @return [void]
521
- def on_width_changed; end
774
+ def handle_width_changed; end
775
+
776
+ # Called on the parent after a direct child's {#visible=} flipped, so a
777
+ # container that divides space can re-divide it:
778
+ #
779
+ # def handle_child_visibility_changed(_child)
780
+ # super
781
+ # relayout
782
+ # end
783
+ #
784
+ # **A container with layout arithmetic owes this override**, or a hidden
785
+ # child keeps its slot and its gap — the hole the flag exists to close.
786
+ # {Component::Layout::Absolute} owes nothing: its `rect=` is app
787
+ # arithmetic, and an app wanting the space back reads `visible?` there.
788
+ #
789
+ # Fires on the flip only, before the subtree is invalidated, never for a
790
+ # grandchild. {#visible=} repairs focus itself, so an override has nothing
791
+ # to inherit — it still calls `super`, per the class doc. Reached through
792
+ # `__send__`, so it may declare any visibility (`D_hook_visibility`).
793
+ # @param _child [Component] the direct child whose flag changed.
794
+ # @return [void]
795
+ def handle_child_visibility_changed(_child); end
796
+
797
+ # Mirror of {#handle_focus}: the component just lost focus, to another component
798
+ # or to nothing. The commit point a Tab-away still reaches — Tab is
799
+ # unconditional, so {Component::TextField#on_enter} never fires for a user
800
+ # who tabs out of a half-typed field:
801
+ #
802
+ # class TrimmedField < Component::TextField
803
+ # protected def handle_blur
804
+ # super
805
+ # self.text = text.strip
806
+ # false
807
+ # end
808
+ # end
809
+ #
810
+ # Edge-triggered, and fired on the blurred component alone — never on the
811
+ # ancestors leaving the active chain with it, so a composed widget asking
812
+ # "did focus leave me *and* my children" overrides {#active=} instead
813
+ # ({Component::ComboBox} closes its dropdown from there). Focus that merely
814
+ # *passes through* does blur: a container forwarding focus from {#handle_focus}
815
+ # is blurred by its own forward.
816
+ #
817
+ # A notification, not a veto — focus has already moved, and the active-flag
818
+ # cascade has already run. Reassigning {Screen#focused} from here is
819
+ # honored: that assignment wins, and the one that blurred you abandons the
820
+ # rest of its work.
821
+ #
822
+ # == Implementation details
823
+ #
824
+ # It fires wherever focus is *dropped*, not only where a user moved it, so
825
+ # two paths reach it with the tree mid-flight: the popup-close repair blurs
826
+ # an **already-detached** component, where {#invalidate} is the same silent
827
+ # no-op as in {#handle_detached}, and {Screen#close} blurs on its way out. Keep
828
+ # it cheap; a raise propagates out of {Screen#focused=}. Protected because
829
+ # the framework calls it and an app never does — {Screen} reaches it with
830
+ # `__send__`, so an override may declare any visibility (`D_hook_visibility`).
831
+ # @return [void]
832
+ def handle_blur; end
522
833
 
523
834
  # Called on every attached component (pre-order, popups included) when
524
835
  # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
@@ -536,10 +847,38 @@ module Tuile
536
847
  # {Screen}, not being a {Component}, fans it out through `__send__`, so an
537
848
  # override is free to declare any visibility (`D_hook_visibility`).
538
849
  # @return [void]
539
- def on_theme_changed
850
+ # is whatever the app's lambda happened to return.
851
+ def handle_theme_changed
540
852
  @on_theme_changed&.call
541
853
  end
542
854
 
855
+ # Called on every attached component (pre-order, popups included) when
856
+ # {Screen#locale} changes — for state derived from the old conventions and
857
+ # **pushed** somewhere, such as a date already rendered into a {Label}'s
858
+ # text. Anything read at paint or parse time needs no override: the locale
859
+ # change invalidates the whole tree.
860
+ #
861
+ # Runs on the UI thread with {Screen#locale} already updated. Subclasses
862
+ # overriding it must call `super` so an assigned {#on_locale_changed=}
863
+ # listener keeps firing.
864
+ #
865
+ # Plumbing an app overrides and never calls, hence protected — {Screen}
866
+ # fans it out through `__send__`, so an override may declare any visibility
867
+ # (`D_hook_visibility`).
868
+ # @return [void]
869
+ # is whatever the app's lambda happened to return.
870
+ def handle_locale_changed
871
+ @on_locale_changed&.call
872
+ end
873
+
874
+ # The formatting conventions to render and parse by ({Screen#locale}), or
875
+ # {Locale::ISO} when there is no screen in the process — which a tree
876
+ # assembled outside a UI legitimately is, and {Screen.instance} raises
877
+ # rather than answering. Read it here, at use time; never cache it, since
878
+ # {Screen#locale=} can replace it.
879
+ # @return [Locale]
880
+ def locale = Screen.instance? ? screen.locale : Locale::ISO
881
+
543
882
  # Invalidates the component: {Screen} records this component as
544
883
  # needs-repaint and once all events are processed, will call {#repaint}.
545
884
  #
@@ -562,9 +901,12 @@ module Tuile
562
901
  # area. Cheap, and correct as long as siblings don't overlap each other
563
902
  # — which Tuile already requires (no clipping in the tiled tree).
564
903
  # Children with empty rects contribute zero, since they paint nothing.
904
+ #
905
+ # A **hidden** child contributes zero for the same reason, and that is what
906
+ # erases it: its cells become a gap this component then blanks.
565
907
  # @return [Boolean]
566
908
  def children_tile_rect?
567
- total = children.sum { |c| c.rect.empty? ? 0 : c.rect.width * c.rect.height }
909
+ total = children.sum { |c| c.rect.empty? || !c.visible? ? 0 : c.rect.width * c.rect.height }
568
910
  total >= rect.width * rect.height
569
911
  end
570
912
 
@@ -581,8 +923,102 @@ module Tuile
581
923
 
582
924
  right = Rect.new(rect.left + e.width, rect.top, rect.width - e.width, e.height)
583
925
  below = Rect.new(rect.left, rect.top + e.height, rect.width, rect.height - e.height)
584
- clear_background(right) unless right.empty?
585
- clear_background(below) unless below.empty?
926
+ # Not this widget's own surface: a one-row Select handed a 25-row rect
927
+ # would otherwise flood the other 24 with its field well.
928
+ bg = ambient_bg_color
929
+ clear_background(right, bg) unless right.empty?
930
+ clear_background(below, bg) unless below.empty?
931
+ end
932
+
933
+ # Blanks the {#extent} itself, for a *container* whose children don't cover
934
+ # it — a {Component::Layout::Box}'s `spacing` column, the slack past the last
935
+ # child, the span a child abandoned by going hidden or by a narrowing resize.
936
+ #
937
+ # In the *ambient* background, the same answer {#clear_outside_extent} gives
938
+ # the dead tail: a gap between two children is not this widget's ink, so an
939
+ # app's {#bg_color} tint covers it but a well of its own — a field's, a
940
+ # validation error's — must not bleed into it.
941
+ #
942
+ # Called by the default {#repaint} for a container only; a leaf paints its
943
+ # extent itself, and blanking that first is the re-emit `D_progress_bar`
944
+ # bought back. Override it to decline when you paint your own ink into a
945
+ # face cell no child covers.
946
+ # @return [void]
947
+ def clear_inside_extent
948
+ clear_background(extent_rect, ambient_bg_color)
949
+ end
950
+
951
+ # The background this component paints when the app has set no {#bg_color} —
952
+ # `nil` by default, meaning "I have no surface of my own; whatever is behind
953
+ # me shows through". A widget that paints an opaque surface overrides it, and
954
+ # inheritance stops there: that is what keeps a form's fields looking like
955
+ # fields inside a tinted panel. Declare it unconditionally — a widget owned
956
+ # by a bigger one is told so with {BG_INHERIT}, and must not try to work it
957
+ # out from where it sits in the tree.
958
+ #
959
+ # # a field: its own well, brighter while focused
960
+ # def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
961
+ #
962
+ # Return whatever {#bg_color} accepts — a {Color}, a {Theme::Ref} or a state
963
+ # Hash. Branching on {#active?} and handing back one {Color}, as above, is
964
+ # the cheap form and allocates nothing on the paint path.
965
+ #
966
+ # Read the theme here rather than in an ivar: this runs at paint time, so a
967
+ # {Screen#theme=} restyles the widget with no {#handle_theme_changed} hook.
968
+ # @return [Color, Theme::Ref, Hash, nil]
969
+ def default_bg_color = nil
970
+
971
+ # Final, and protected: it answers what the *framework* paints with, and an
972
+ # app never needs it — {#clear_background} / {#draw_text} / {#draw_char}
973
+ # apply it already. A component states its own opinion by overriding
974
+ # {#default_bg_color}, an app by setting {#bg_color}; neither takes this
975
+ # over. Protected rather than private because the chain below is an
976
+ # explicit-receiver call, which Ruby forbids for a private method.
977
+ # @return [Color, nil] the background actually painted, for the state this
978
+ # component is in right now: its {#error_bg_color}, else its {#bg_color},
979
+ # else its {#default_bg_color}, else the nearest ancestor answering one of
980
+ # those, else `nil` (terminal default). Resolved at paint time — never
981
+ # cached, so the subtree tracks an ancestor's {#bg_color=}, a
982
+ # {Screen#theme=}, a focus change and a validation verdict on its next
983
+ # repaint.
984
+ def effective_bg_color
985
+ own = resolve_bg_color(error_bg_color) || resolve_bg_color(@bg_color) || resolve_bg_color(default_bg_color)
986
+ return parent&.effective_bg_color if own.nil? || own == BG_INHERIT
987
+
988
+ own
989
+ end
990
+
991
+ # The background a component paints while it is in an *error* state —
992
+ # `nil` by default, meaning "I am not signalling one". {HasValidation}
993
+ # overrides it, so every field has it and nothing else does.
994
+ #
995
+ # It sits **above** {#bg_color} in {#effective_bg_color} rather than under
996
+ # it, unlike {#default_bg_color}. That is deliberate: an app tinting a panel
997
+ # would otherwise switch the validation signal off on the fields inside it,
998
+ # silently. An app that wants different error colors changes the
999
+ # {Theme#error_bg_color} tokens.
1000
+ #
1001
+ # Read the theme here rather than in an ivar, and hand back one {Color}
1002
+ # rather than a state {Hash} — {#default_bg_color}'s reasons, and this runs
1003
+ # one level earlier than that on the same paint path.
1004
+ # @return [Color, Theme::Ref, Hash, nil]
1005
+ def error_bg_color = nil
1006
+
1007
+ # Passes the repaint cascade on to the direct children — the one thing a
1008
+ # container may never skip, whatever else its {#repaint} does. Named so a
1009
+ # self-painting container can drop the default's blanket clear without also
1010
+ # dropping this by accident ({Component::Window} is the case):
1011
+ #
1012
+ # def repaint
1013
+ # return if rect.empty?
1014
+ #
1015
+ # invalidate_children # never optional
1016
+ # paint_my_own_chrome
1017
+ # end
1018
+ #
1019
+ # @return [void]
1020
+ def invalidate_children
1021
+ children.each { |c| screen.invalidate(c) }
586
1022
  end
587
1023
 
588
1024
  # Clears the background: fills every cell with a blank in the
@@ -593,17 +1029,19 @@ module Tuile
593
1029
  # dirty, and {Buffer#flush} then re-emits it even though nothing visibly
594
1030
  # changed.
595
1031
  # @param area [Rect] the region to blank; defaults to the whole {#rect}.
1032
+ # @param bg [Color, nil] the color to blank with; defaults to
1033
+ # {#effective_bg_color}, i.e. this component's own surface.
596
1034
  # @return [void]
597
- def clear_background(area = rect)
598
- bg = effective_bg_color
1035
+ def clear_background(area = rect, bg = effective_bg_color)
599
1036
  screen.buffer.fill(area, bg ? StyledString::Style.new(bg:) : StyledString::Style::DEFAULT)
600
1037
  end
601
1038
 
602
1039
  # {Buffer#set_text} wrapper that fills {#effective_bg_color} behind any span
603
1040
  # with no bg of its own (via {StyledString#under_bg}), so an inherited
604
- # {#bg_color} shows through the content a component paints. A no-op layer
605
- # when nothing is inherited. Self-painters (those skipping the {#repaint}
606
- # auto-clear) paint through this instead of {Screen#buffer} directly.
1041
+ # {#bg_color} — or an invalid field's error well — shows through the content
1042
+ # a component paints. A no-op layer when none is inherited. Self-painters
1043
+ # (those skipping the {#repaint} auto-clear) paint through this instead of
1044
+ # {Screen#buffer} directly.
607
1045
  # @param x [Integer] starting column.
608
1046
  # @param y [Integer] row.
609
1047
  # @param styled [StyledString]
@@ -613,7 +1051,8 @@ module Tuile
613
1051
  end
614
1052
 
615
1053
  # {#draw_text}'s single-grapheme counterpart: writes `grapheme` at `(x, y)`,
616
- # filling {#effective_bg_color} when `style` carries no bg of its own.
1054
+ # filling {#effective_bg_color} when `style` carries no background of its
1055
+ # own.
617
1056
  # @param x [Integer] column.
618
1057
  # @param y [Integer] row.
619
1058
  # @param grapheme [String] one grapheme cluster.
@@ -624,5 +1063,70 @@ module Tuile
624
1063
  style = style.merge(bg:) if bg && style.bg.nil?
625
1064
  screen.buffer.set_char(x, y, grapheme, style)
626
1065
  end
1066
+
1067
+ private
1068
+
1069
+ # Hands focus out of the subtree just hidden, if it was in there, through
1070
+ # the parent's {#handle_child_removed} — see there for why hiding reuses the
1071
+ # removal repair instead of growing a second one.
1072
+ #
1073
+ # The parent is necessarily showing (focus was inside it a moment ago, and
1074
+ # {Screen#focused=} refuses a hidden target), so its assignment can't bounce.
1075
+ # @return [void]
1076
+ def repair_focus_after_hiding
1077
+ return unless attached?
1078
+
1079
+ cursor = screen.focused
1080
+ cursor = cursor.parent until cursor.nil? || cursor.equal?(self)
1081
+ parent.handle_child_removed(self) unless cursor.nil?
1082
+ end
1083
+
1084
+ # What surrounds this component — an app-set {#bg_color}, else whatever the
1085
+ # parent paints where this component is not. Skips {#default_bg_color}, the
1086
+ # one thing that colors this widget's *own* surface, which is what makes it
1087
+ # the right answer for the dead tail outside {#extent}.
1088
+ # @return [Color, nil]
1089
+ def ambient_bg_color
1090
+ own = resolve_bg_color(@bg_color)
1091
+ return parent&.effective_bg_color if own.nil? || own == BG_INHERIT
1092
+
1093
+ own
1094
+ end
1095
+
1096
+ # Collapses one level of the background chain to the {Color} it means right
1097
+ # now: picks the entry for this component's current state out of a state
1098
+ # Hash, and resolves a {Theme::Ref} against the live theme. An absent state
1099
+ # key yields `nil`, so resolution falls through to the next level — which is
1100
+ # what lets `bg_color = { active: … }` keep the widget's own normal well.
1101
+ # @param value [Color, Theme::Ref, Hash, nil]
1102
+ # @return [Color, nil]
1103
+ def resolve_bg_color(value)
1104
+ case value
1105
+ when nil then nil
1106
+ when Hash then resolve_bg_color(value[active? ? :active : :normal])
1107
+ when Theme::Ref then value.resolve(screen.theme)
1108
+ else value
1109
+ end
1110
+ end
1111
+
1112
+ # Validates and normalizes what {#bg_color=} was handed, so a bad token or a
1113
+ # misspelled state raises at the assignment rather than deep in a repaint.
1114
+ # @param value [Object]
1115
+ # @return [Color, Theme::Ref, Hash, nil]
1116
+ # @raise [ArgumentError] on a Hash key outside {BG_STATES}.
1117
+ # @raise [KeyError] on a {Theme::Ref} naming an absent custom token.
1118
+ def coerce_bg_color(value)
1119
+ case value
1120
+ when nil, Color, BG_INHERIT then value
1121
+ when Theme::Ref then value.tap { _1.resolve(screen.theme) }
1122
+ when Hash
1123
+ unknown = value.keys - BG_STATES
1124
+ raise ArgumentError, "unknown background state(s) #{unknown.join(", ")}; known: #{BG_STATES.join(", ")}" \
1125
+ unless unknown.empty?
1126
+
1127
+ value.to_h { |state, color| [state, coerce_bg_color(color)] }.freeze
1128
+ else Color.coerce(value)
1129
+ end
1130
+ end
627
1131
  end
628
1132
  end