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
@@ -11,16 +11,20 @@ module Tuile
11
11
  #
12
12
  # == Handlers and listener slots
13
13
  #
14
- # Two families, told apart by the `=`:
14
+ # Two families — `handle_` is the override point, `on_` the listener slot:
15
15
  #
16
16
  # class Trimmed < Component::TextField
17
17
  # def handle_blur # handle_ — the override point
18
18
  # super
19
- # self.text = text.strip
19
+ # self.value = text.strip
20
20
  # end
21
21
  # end
22
22
  #
23
- # label.on_theme_changed = -> { … } # on_…= — the listener slot
23
+ # label.on_theme_changed { … } # on_… — the listener slot, a {Listeners}
24
+ #
25
+ # A slot holds *many* listeners and has no setter: register with the reader,
26
+ # remove with {Listeners#remove}, and nothing you add can displace what the
27
+ # widget or another app wired there.
24
28
  #
25
29
  # **What a handler returns is per hook**, declared in its own rdoc. Only the
26
30
  # ones a dispatcher routes answer at all — {#handle_key?},
@@ -28,27 +32,27 @@ module Tuile
28
32
  # took this, stop bubbling". The rest, {#handle_paste} included, return `void`.
29
33
  #
30
34
  # 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
35
+ # lets a hook grow an `on_foo` slot without breaking you. The one carve-out is
32
36
  # {#handle_child_removed}, whose base does real work and whose overrides
33
37
  # replace it. `D_handler_naming` carries the argument.
34
38
  class Component
35
39
  extend Final
40
+ extend Listeners::Declare
36
41
 
37
42
  # Each method's own rdoc says what an override would break; `D_final_tree`
38
43
  # carries the full argument.
39
44
  final :children, :parent, :parent=, :add_child, :remove_child, :detach_child,
40
- :effective_bg_color
45
+ :bg
41
46
 
42
47
  def initialize
43
48
  Component.verify_final!(self.class)
44
49
  @rect = Rect.new(0, 0, 0, 0)
45
50
  @visible = true
46
51
  @active = false
47
- @on_theme_changed = nil
48
- @on_locale_changed = nil
49
- @bg_color = nil
52
+ @bg = ComponentBackground.new(self)
50
53
  @children = []
51
54
  @id = nil
55
+ @layout_dirty = false
52
56
  end
53
57
 
54
58
  # A tag for finding this component again — nothing paints it, and the
@@ -74,7 +78,17 @@ module Tuile
74
78
  @id = new_id
75
79
  end
76
80
 
77
- # @return [Rect] the rectangle the component occupies on screen.
81
+ # The rectangle the component occupies **inside its parent**: `(0, 0)` is
82
+ # the parent's top-left, not the screen's. {#absolute_rect} is where that
83
+ # lands on screen, and {#local_rect} is this same rectangle with the
84
+ # position taken out.
85
+ #
86
+ # **Layout is deferred, so a rect read in the same turn that dirtied it is
87
+ # the *previous* pass's** — a plausible rectangle, not zeros.
88
+ # {#flush_layout} brings it up to date (`D_deferred_layout`),
89
+ # {#rect_stale?} answers whether it is one, and {Tuile.strict_layout} makes
90
+ # every such read say so.
91
+ # @return [Rect]
78
92
  attr_reader :rect
79
93
 
80
94
  # The three readers below report the geometry a parent *assigned*, as
@@ -101,7 +115,9 @@ module Tuile
101
115
  #
102
116
  # It always sits at {#rect}'s top-left — which is why this is a {Size} and
103
117
  # not a {Rect}: an offset extent is not merely unsupported, it is
104
- # unrepresentable. Use {#extent_rect} where coordinates are wanted.
118
+ # unrepresentable. Use {#local_extent_rect} or {#absolute_extent_rect} where
119
+ # coordinates are wanted — there is no parent-space form, because nothing
120
+ # asks the question in that space.
105
121
  #
106
122
  # **`nil` is not the same as `rect.size`.** `nil` says "I have not declared
107
123
  # what I paint, so clear everything before I do", which is what a
@@ -116,8 +132,9 @@ module Tuile
116
132
  # so {#rect} still means exactly what the parent assigned (`D_extent`). Three
117
133
  # things read it, all of them this component or the framework painting it:
118
134
  # {#clear_outside_extent} blanks the dead tail, {Mouse::Router} hit-tests
119
- # against it so a click on that tail doesn't activate the widget, and a
120
- # dropdown anchors under it rather than under unused space.
135
+ # against {#local_extent_rect} so a click on that tail doesn't activate the
136
+ # widget, and a dropdown anchors under {#absolute_extent_rect} rather than
137
+ # under unused space.
121
138
  #
122
139
  # **An override promises to paint the extent in full**, so `super` in
123
140
  # {#repaint} blanks only what is outside it. The arithmetic is each widget's
@@ -126,36 +143,198 @@ module Tuile
126
143
  # @return [Size, nil]
127
144
  def extent = nil
128
145
 
129
- # {#extent} placed at {#rect}'s top-left, for the consumers that need
130
- # coordinates: `extent_rect.contains?(point)` in {Mouse::Router}, and
131
- # the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
132
- # the whole {#rect}, so a generic caller never sees `nil`.
146
+ # {#rect} with the position taken out: the same size at `(0, 0)`.
147
+ #
148
+ # **Two things live in these coordinates**, and that is the whole point of
149
+ # them being one space: what this component *paints* ({Screen#canvas_for}
150
+ # puts the canvas here), and what its children's {#rect}s are measured in.
151
+ # So a container divides `local_rect` among its children and blanks its own
152
+ # gaps in the very same numbers:
153
+ #
154
+ # private def relayout
155
+ # half = width / 2 # no `rect.left +` anywhere:
156
+ # left.rect = Rect.new(0, 0, half, height)
157
+ # right.rect = Rect.new(half, 0, width - half, height)
158
+ # end
159
+ #
160
+ # @return [Rect]
161
+ def local_rect = Rect.new(0, 0, rect.width, rect.height)
162
+
163
+ # {#extent} at `(0, 0)`, {#local_rect}'s counterpart — what
164
+ # {#clear_inside_extent} blanks and what {Mouse::Router} hit-tests. Total:
165
+ # an undeclared {#extent} yields {#local_rect}, so a generic caller never
166
+ # sees `nil`.
133
167
  # @return [Rect]
134
- def extent_rect
168
+ def local_extent_rect
135
169
  e = extent
136
- e.nil? ? rect : Rect.new(rect.left, rect.top, e.width, e.height)
170
+ e.nil? ? local_rect : Rect.new(0, 0, e.width, e.height)
137
171
  end
138
172
 
139
- # Sets new position of the component. This is the absolute component
140
- # positioning on screen, not a relative positioning relative to component's
141
- # {#parent}.
173
+ # {#rect} in **screen** coordinates — every ancestor's offset summed in.
174
+ # The form the three consumers outside this component's own frame need: the
175
+ # {Canvas#origin} {Screen#canvas_for} builds, an overlay's anchor (an
176
+ # overlay hangs off {ScreenPane}, so it shares no offset with its driver),
177
+ # and a spec clicking a component by where it sits.
142
178
  #
143
- # The component must not stick outside of {#parent}'s rect.
179
+ # Derived on every call and never cached — a parent may move this subtree
180
+ # between two reads, and nothing announces it (`D_relative_rect`).
181
+ # @return [Rect]
182
+ def absolute_rect = rect.at(to_screen(Point::ZERO))
183
+
184
+ # {#local_extent_rect} in screen coordinates, {#absolute_rect}'s
185
+ # counterpart — what a dropdown anchors against.
186
+ # @return [Rect]
187
+ def absolute_extent_rect = local_extent_rect.at(to_screen(Point::ZERO))
188
+
189
+ # Converts a point in *this component's own* coordinates — the ones it
190
+ # paints in, the ones a {Mouse::Event} reaches it in — to screen
191
+ # coordinates, by walking up and adding each ancestor's offset.
192
+ #
193
+ # # a strip anchoring a panel under one of its own segments
194
+ # Rect.new(0, 0, width, 1).at(to_screen(Point.new(column, 0)))
195
+ #
196
+ # Iterative and summing into two locals rather than recursing through a
197
+ # {Point} per level: this runs once per component per repaint, from
198
+ # {Screen#canvas_for}.
199
+ # @param point [Point] in this component's coordinates.
200
+ # @return [Point] in screen coordinates.
201
+ def to_screen(point)
202
+ x = point.x
203
+ y = point.y
204
+ node = self
205
+ until node.nil?
206
+ x += node.rect.left
207
+ y += node.rect.top
208
+ node = node.parent
209
+ end
210
+ Point.new(x, y)
211
+ end
212
+
213
+ # {#to_screen}'s inverse: a screen point in this component's own
214
+ # coordinates. The result may be negative or past {#size} — a point outside
215
+ # the component converts perfectly well, which is what a grabbed component's
216
+ # {Mouse::DragEvent} relies on.
217
+ # @param point [Point] in screen coordinates.
218
+ # @return [Point] in this component's coordinates.
219
+ def to_local(point)
220
+ x = point.x
221
+ y = point.y
222
+ node = self
223
+ until node.nil?
224
+ x -= node.rect.left
225
+ y -= node.rect.top
226
+ node = node.parent
227
+ end
228
+ Point.new(x, y)
229
+ end
230
+
231
+ # Places the component **inside its parent**: `(0, 0)` is the parent's
232
+ # top-left, so a container divides its own {#local_rect} and never adds its
233
+ # own position in. {#absolute_rect} is where the result lands on screen.
234
+ #
235
+ # A component that sticks outside its parent's {#local_rect}, or paints
236
+ # outside this rectangle, is cut to it — {Screen#canvas_for} bounds every
237
+ # component by its own rect and every ancestor's (`D_clip`). Overrunning is
238
+ # still a bug; it now shows as truncation rather than as a corrupt neighbour.
239
+ #
240
+ # The component is invalidated and will paint over the new rectangle, and
241
+ # so is its parent, which owns the cells the old position vacated.
242
+ #
243
+ # **The children do not move yet**: this only marks a {#relayout}, so a
244
+ # child's rect read back in the same turn is still the previous pass's —
245
+ # {#flush_layout} first.
144
246
  #
145
- # The component is invalidated and will paint over the new rectangle. It is
146
- # parent's job to paint over the old component position.
247
+ # **Only the parent's {#relayout} may call this**, and it raises from
248
+ # anywhere else, a component with no parent included. To move a child,
249
+ # change what its parent places it by — {Layout::Absolute#constrain},
250
+ # {Layout::Box#constrain}, {Component::Overlay#placement=} — and to size a
251
+ # tree that has no screen, hold it in a {Layout::Absolute}:
252
+ #
253
+ # holder = Component::Layout::Absolute.new
254
+ # holder.add(tree, Rect.new(0, 0, 40, 10))
255
+ # holder.flush_layout
256
+ #
257
+ # A subclass reacts to a new rect in {#handle_rect_changed}; it cannot
258
+ # override this, because a `protected` override is callable only from its
259
+ # own class, and the parent calling it is not one.
147
260
  # @param new_rect [Rect] new position. Does nothing if the new rectangle is
148
261
  # the same as the old one.
262
+ # @raise [Tuile::Error] unless the parent's {#relayout} is running.
149
263
  def rect=(new_rect)
150
264
  raise TypeError, "expected Rect, got #{new_rect.inspect}" unless new_rect.is_a? Rect
265
+
266
+ LayoutPass.check(self)
267
+ LayoutPass.note_placement(self)
151
268
  return if @rect == new_rect
152
269
 
153
- prev_width = @rect.width
270
+ old_rect = @rect
154
271
  @rect = new_rect
155
- handle_width_changed if prev_width != new_rect.width
272
+ handle_rect_changed(old_rect)
156
273
  invalidate
274
+ # Nothing else blanks the cells the old rect vacated — the same reason
275
+ # {#visible=} invalidates the parent.
276
+ parent&.invalidate
277
+ invalidate_layout
278
+ end
279
+ protected :rect=
280
+
281
+ # Runs every {#relayout} this component's *tree* owes, so its rects are
282
+ # current — the force-now that makes a detached tree measurable:
283
+ #
284
+ # layout = Component::Layout::Vertical.new # no Screen in the process
285
+ # layout.add(label, Fixed[1])
286
+ # layout.rect = Rect.new(0, 0, 20, 10)
287
+ # layout.flush_layout
288
+ # label.rect # => Rect(0, 0, 20, 1)
289
+ #
290
+ # Attached, {Screen#dispatch} already flushes after every event, so ask for
291
+ # this by hand only to read a rect in the *same* turn that dirtied it —
292
+ # what Swing spells `validate()` and Tk `update idletasks`.
293
+ #
294
+ # **Overwhelmingly that means a spec**: an app mutates and lets the settle
295
+ # run, so a `flush_layout` in app code is usually a sign the *read* wants
296
+ # deferring instead.
297
+ #
298
+ # == Implementation details
299
+ #
300
+ # The whole tree, never this subtree, whichever end it is asked from: a
301
+ # pending ancestor pass would overwrite whatever a narrower one wrote.
302
+ # Attached that is {Screen#flush_layout}; detached it is {LayoutPass.drain}
303
+ # from {#root}, the same fixpoint the screen runs.
304
+ # @raise [Tuile::Error] when the tree has not settled after
305
+ # {LayoutPass::MAX_ROUNDS} rounds — a relayout feeding its own input — or
306
+ # when called from inside a {#relayout}.
307
+ # @return [void]
308
+ def flush_layout
309
+ return screen.flush_layout if attached?
310
+
311
+ LayoutPass.refuse_nested
312
+ LayoutPass.drain(root)
157
313
  end
158
314
 
315
+ # Whether this container owes a {#relayout} — that its *children*'s rects
316
+ # are out of date.
317
+ #
318
+ # **It says nothing about this component's own {#rect}**, which no pass of
319
+ # its own touches: the flag that makes *this* rect stale sits on the parent
320
+ # that assigns it, and {#rect_stale?} is that question asked from here.
321
+ # Survives detaching, so {#handle_attached} can hand the mark to the {Screen}.
322
+ # @return [Boolean]
323
+ def layout_dirty? = @layout_dirty
324
+
325
+ # Whether {#rect} is the *previous* pass's rectangle, because some ancestor
326
+ # owes a {#relayout} and that pass reassigns every rect below it — or is
327
+ # running it right now and has not reached this branch yet.
328
+ #
329
+ # holder.constrain(pane, Rect.new(0, 0, 100, 26))
330
+ # pane.left.rect_stale? # => true — `pane.left.rect` is still the old half
331
+ #
332
+ # {#flush_layout} makes it false; {Tuile.strict_layout} reports every read
333
+ # taken while it is true. One walk to {#root} per call, which is why no
334
+ # framework read consults it outside strict mode.
335
+ # @return [Boolean]
336
+ def rect_stale? = !stale_layout_ancestor.nil?
337
+
159
338
  # This component's own flag — **not** whether the user can see it, which
160
339
  # also depends on its ancestors: a shown field inside a hidden panel
161
340
  # answers `true`.
@@ -185,7 +364,9 @@ module Tuile
185
364
  # {#handle_child_removed}), and does not hand it back on the way in.
186
365
  #
187
366
  # For "invisible but still occupying its space", use a
188
- # {Component::Slot} with no content (`D_slots`).
367
+ # {Component::Slot} with no content (`D_slots`). The parent's own
368
+ # {#relayout} may flip it — before placing any child, or it runs twice
369
+ # ({#invalidate_layout}).
189
370
  # @param value [Boolean]
190
371
  # @raise [Tuile::Error] when the UI is locked, or always from
191
372
  # {Component::Overlay#visible=}.
@@ -196,9 +377,12 @@ module Tuile
196
377
 
197
378
  screen.check_locked if attached?
198
379
  @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)
380
+ # Both on the parent, because the child just vacated (or re-claimed) cells
381
+ # the parent owns *and* may have changed how it divides its space. A
382
+ # hidden component paints nothing itself, so nothing else would blank what
383
+ # it left behind.
384
+ parent&.invalidate
385
+ parent&.invalidate_layout
202
386
  repair_focus_after_hiding unless value
203
387
  walk_tree { |c| screen.invalidate(c) } if attached?
204
388
  end
@@ -212,35 +396,54 @@ module Tuile
212
396
  screen.focused = self
213
397
  end
214
398
 
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:
399
+ # Asks whatever scrolls above to bring `rect` into view — "show me this",
400
+ # made by the component that wants to be seen, never polled for by a
401
+ # container:
402
+ #
403
+ # row.scroll_to_visible # all of me
404
+ # editor.scroll_to_visible(caret_row_rect) # this much of me
224
405
  #
225
- # field.bg_color = Component::BG_INHERIT # no well; take the pane's tint
406
+ # The request climbs the parent chain re-expressed a level at a time, so
407
+ # with nothing scrolling on the way it reaches the root and does nothing.
408
+ # {Screen#focused=} makes the no-argument call on every focus assignment,
409
+ # which is the whole of making Tab follow the view: a child scrolled out of
410
+ # sight keeps its rect, its keys and its tab stop (`D_empty_ancestor`).
226
411
  #
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
412
+ # A container that scrolls overrides it, and the shape is the contract:
413
+ #
414
+ # def scroll_to_visible(rect = local_extent_rect)
415
+ # delta = ... # the minimum that makes rect visible
416
+ # move_scroll_top_row_by(delta)
417
+ # super(rect.moved_by(Point.new(0, -delta)))
418
+ # end
419
+ #
420
+ # **The minimum** distance, so the far edge of the viewport is the one
421
+ # allowed to cut a child in half. And `super` takes the rect **where the
422
+ # scroll left it**, so an outer scroller is asked about cells that exist and
423
+ # nested scrollers settle inner-first.
424
+ #
425
+ # A hidden component raises, checked a level at a time as the request
426
+ # climbs — so it fails late: a scroller below a hidden ancestor has
427
+ # already scrolled.
428
+ # @param rect [Rect] in *this* component's coordinates; defaults to
429
+ # {#local_extent_rect}, so a widget asks for what it paints.
430
+ # @raise [Tuile::Error] when this component or an ancestor is hidden.
431
+ # @return [void]
432
+ def scroll_to_visible(rect = local_extent_rect)
433
+ raise Tuile::Error, "#{self} is hidden; it cannot be scrolled into view" unless visible?
232
434
 
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.
238
- attr_reader :bg_color
435
+ parent&.scroll_to_visible(rect.moved_by(self.rect.top_left))
436
+ end
437
+
438
+ # @return [Color, Theme::Ref, Hash{Symbol => Color, Theme::Ref}, Symbol, nil]
439
+ # this component's own background — the value as set, so a {Theme::Ref}
440
+ # comes back unresolved and a state map comes back a Hash; `nil` when
441
+ # unset, in which case the widget's own well and then the parent answer.
442
+ def bg_color = @bg.color
239
443
 
240
444
  # Tints this component and every descendant that doesn't set its own
241
- # background (they re-resolve via {#effective_bg_color}) — set it once on a
242
- # container / {Component::Popup} to tint a whole subtree. Invalidates the
243
- # subtree so it repaints.
445
+ # background — set it once on a container / {Component::Popup} to tint a
446
+ # whole subtree. Invalidates the subtree so it repaints.
244
447
  #
245
448
  # A {Theme::Ref} is re-resolved against the theme each paint, so it tracks
246
449
  # light/dark flips with no {#handle_theme_changed} hook; a {Color} is fixed:
@@ -248,9 +451,9 @@ module Tuile
248
451
  # panel.bg_color = Theme.ref(:panel_bg) # theme-tracked
249
452
  # panel.bg_color = Color::GREY27 # fixed
250
453
  #
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:
454
+ # A Hash keyed by {ComponentBackground::STATES} gives a color per state —
455
+ # the shape a widget that highlights itself on focus needs, and the reason
456
+ # setting a flat color on one is a *choice* rather than a trap:
254
457
  #
255
458
  # field.bg_color = grey # flat: focused or not
256
459
  # field.bg_color = { normal: grey, active: blue } # the pair
@@ -258,27 +461,23 @@ module Tuile
258
461
  # # well, override focus
259
462
  #
260
463
  # 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`
464
+ # through to the widget's own well and then to the parent, exactly as `nil`
262
465
  # does. That is what makes the third line above mean what it reads as.
263
466
  #
264
467
  # This does *not* win over a validation error: {#error_bg_color} resolves
265
468
  # first, so tinting a panel cannot switch off the error well on the fields
266
- # inside it.
469
+ # inside it. {ComponentBackground} carries the whole chain.
267
470
  #
268
471
  # @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}.
472
+ # a {Theme::Ref}, {ComponentBackground::INHERIT}, a state Hash, else a
473
+ # color coerced via {Color.coerce}; `nil` unsets.
474
+ # @raise [ArgumentError] when a Hash carries a key outside
475
+ # {ComponentBackground::STATES}.
273
476
  # @raise [KeyError] when a {Theme::Ref} names an absent custom token —
274
477
  # validated eagerly at assignment, not deferred to paint.
275
478
  # @return [void]
276
479
  def bg_color=(color)
277
- color = coerce_bg_color(color)
278
- return if @bg_color == color
279
-
280
- @bg_color = color
281
- walk_tree { |c| screen.invalidate(c) } if attached?
480
+ @bg.color = color
282
481
  end
283
482
 
284
483
  # Repaints the component. The default does the bookkeeping most components
@@ -303,7 +502,7 @@ module Tuile
303
502
  # **The children are re-invalidated whether or not they tile.** A container
304
503
  # that paints nothing of its own can only redraw its area *through* them, so
305
504
  # a tiling container that skipped this would be a dead end in the cascade: an
306
- # ancestor's `clear_background` wipes the whole ancestor rect — siblings and
505
+ # ancestor's background clear wipes the whole ancestor rect — siblings and
307
506
  # grandchildren included — and re-invalidates only its *direct* children, so
308
507
  # the notice has to keep travelling down or the cleared cells are never
309
508
  # repainted. Cheap by construction: repainting the same glyphs leaves
@@ -312,13 +511,25 @@ module Tuile
312
511
  # A container that skips `super` because it paints its own rect must still
313
512
  # call {#invalidate_children} — that is the half of this that cannot be
314
513
  # dropped.
514
+ #
515
+ # **Paint onto `canvas`, never onto {Screen#canvas} by name.** It arrives
516
+ # already loaded with this component's {ComponentBackground#effective}, so every write
517
+ # through it inherits; reach for the screen's own and inheritance silently
518
+ # stops (`D_canvas`).
519
+ #
520
+ # **The canvas paints in this component's own coordinates**: `(0, 0)` is {#rect}'s
521
+ # top-left, so `rect.left` has no place in a `repaint` — adding it lands
522
+ # the write at twice the offset, with nothing raising. {#local_rect} is
523
+ # the region argument to reach for; {Canvas} carries the two spaces.
524
+ # @param canvas [Canvas] the paint context, from {Screen#canvas_for}.
525
+ # Required: a canvas carries state, so there is no default worth inventing.
315
526
  # @return [void]
316
- def repaint
527
+ def repaint(canvas)
317
528
  return if rect.empty?
318
529
 
319
530
  unless children.any? && children_tile_rect?
320
- clear_outside_extent
321
- clear_inside_extent if extent && children.any?
531
+ clear_outside_extent(canvas)
532
+ clear_inside_extent(canvas) if extent && children.any?
322
533
  end
323
534
  invalidate_children
324
535
  end
@@ -364,7 +575,7 @@ module Tuile
364
575
  # def handle_mouse_down?(event)
365
576
  # return false unless event.button == :left
366
577
  #
367
- # @on_click&.call
578
+ # on_click.fire(ClickEvent.new(source: self))
368
579
  # true
369
580
  # end
370
581
  #
@@ -531,23 +742,37 @@ module Tuile
531
742
  # @return [void]
532
743
  def handle_focus; end
533
744
 
534
- # Optional zero-arg listener fired by the base {#handle_theme_changed} — the
535
- # composition-style alternative to overriding the method, for apps that
536
- # assemble stock components rather than subclass:
745
+ # What {#on_theme_changed} fires.
746
+ #
747
+ # @!attribute [r] source
748
+ # @return [Component] the component whose theme changed.
749
+ ThemeChangedEvent = Data.define(:source) { include Tuile::Event }
750
+
751
+ # What {#on_locale_changed} fires.
537
752
  #
538
- # label.on_theme_changed = -> { label.text = render_status_line }
753
+ # @!attribute [r] source
754
+ # @return [Component] the component whose locale changed.
755
+ LocaleChangedEvent = Data.define(:source) { include Tuile::Event }
756
+
757
+ # @!method on_theme_changed
758
+ # Fired by the base {#handle_theme_changed} — the composition-style
759
+ # alternative to overriding the method, for apps that assemble stock
760
+ # components rather than subclass:
761
+ #
762
+ # label.on_theme_changed { label.text = render_status_line }
539
763
  #
540
- # @return [Proc, nil]
541
- attr_accessor :on_theme_changed
764
+ # @return [Listeners]
765
+ listener :on_theme_changed
542
766
 
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:
767
+ # @!method on_locale_changed
768
+ # Fired by the base {#handle_locale_changed} — the composition-style
769
+ # alternative to overriding the method, for an app that rendered a date or
770
+ # a number into a stock component:
546
771
  #
547
- # label.on_locale_changed = -> { label.text = due_date.strftime(fmt) }
772
+ # label.on_locale_changed { label.text = due_date.strftime(fmt) }
548
773
  #
549
- # @return [Proc, nil]
550
- attr_accessor :on_locale_changed
774
+ # @return [Listeners]
775
+ listener :on_locale_changed
551
776
 
552
777
  # Whether this component's tree is mounted on a UI, {ScreenPane} being the
553
778
  # root of every displayed tree.
@@ -555,7 +780,7 @@ module Tuile
555
780
  # A property of the parent chain alone — no {Screen} is consulted, so
556
781
  # assembling a tree needs no screen in the process at all:
557
782
  #
558
- # layout = Component::Layout::Absolute.new
783
+ # layout = Component::Layout::Vertical.new
559
784
  # layout.add(label) # legal with no Screen; neither is attached yet
560
785
  # screen.content = layout # now both are
561
786
  #
@@ -596,10 +821,13 @@ module Tuile
596
821
  end
597
822
 
598
823
  # Where the hardware terminal cursor should sit when this component is the
599
- # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
600
- # {Screen} positions the hardware cursor after each repaint cycle by
824
+ # cursor owner, **in this component's own coordinates** — the ones it paints
825
+ # in, so a caret is `Point.new(column, row)` with no position added.
826
+ # {Screen#cursor_position} converts it. Returns `nil` to hide the cursor.
827
+ #
828
+ # The {Screen} positions the hardware cursor after each repaint cycle by
601
829
  # consulting the {Screen#focused} component only.
602
- # @return [Point, nil] absolute screen coordinates, or nil to hide.
830
+ # @return [Point, nil] in this component's coordinates, or nil to hide.
603
831
  def cursor_position = nil
604
832
 
605
833
  # One line naming the component, its {#id} and its rect, plus whatever
@@ -643,6 +871,8 @@ module Tuile
643
871
  # @param at [Integer, nil] index to insert at; appends when nil.
644
872
  # @raise [TypeError] if `child` is not a {Component}.
645
873
  # @raise [ArgumentError] if `child` already has a parent.
874
+ # @raise [Tuile::Error] if `child` is a {Component::Overlay} not being
875
+ # adopted as one of {ScreenPane#popups}.
646
876
  # @return [void]
647
877
  #
648
878
  # Final: one of the three mutators that write {#children} and the parent
@@ -651,8 +881,18 @@ module Tuile
651
881
  raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
652
882
  raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
653
883
 
884
+ # An overlay's placement, `open?`, `visible=` and outside-click dismissal
885
+ # all come from the pane's popup list, so anywhere else it would be
886
+ # unplaceable and undismissable. Membership, not the pane's identity,
887
+ # which would let the `content` slot through; checked before the push, so
888
+ # a refusal leaves the tree untouched.
889
+ if child.is_a?(Component::Overlay) && !(is_a?(ScreenPane) && has_popup?(child))
890
+ raise Tuile::Error, "#{child.class} belongs on the popup stack — open it (#{child.class}#open) " \
891
+ "rather than adding it to #{self.class}"
892
+ end
654
893
  at.nil? ? @children.push(child) : @children.insert(at, child)
655
894
  child.parent = self
895
+ invalidate_layout
656
896
  end
657
897
 
658
898
  # Drops `child` and notifies {#handle_child_removed}.
@@ -688,6 +928,7 @@ module Tuile
688
928
 
689
929
  @children.delete(child)
690
930
  child.parent = nil
931
+ invalidate_layout
691
932
  end
692
933
 
693
934
  # Called once this component's tree has been mounted on a {ScreenPane},
@@ -766,33 +1007,24 @@ module Tuile
766
1007
  def fire_lifecycle(attached)
767
1008
  kids = children.dup
768
1009
  attached ? handle_attached : handle_detached
1010
+ # A mark taken while detached had no screen to go to; hand it over now
1011
+ # that there is one (the `attached?` re-check covers a hook that detached
1012
+ # us again). Flutter's `RenderObject#attach` does the same.
1013
+ screen.invalidate_layout(self) if attached && @layout_dirty && attached?
769
1014
  kids.each { _1.fire_lifecycle(attached) if _1.attached? == attached }
770
1015
  end
771
1016
 
772
- # Called whenever the component width changes. Does nothing by default.
773
- # @return [void]
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
1017
+ # Called once the parent has given this component a different rect, before
1018
+ # anything repaints. Does nothing by default.
783
1019
  #
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.
1020
+ # The *edge*: for a reaction to the change itself, one that needs the old
1021
+ # rect or must not repeat — closing a menu, escalating a repaint. State
1022
+ # that merely *follows* from the size — a wrap, a scroll clamp — is
1023
+ # re-derived in {#relayout} instead, which runs after every rect change
1024
+ # on either axis and after every other mark too.
1025
+ # @param _old_rect [Rect] the rect it had.
794
1026
  # @return [void]
795
- def handle_child_visibility_changed(_child); end
1027
+ def handle_rect_changed(_old_rect); end
796
1028
 
797
1029
  # Mirror of {#handle_focus}: the component just lost focus, to another component
798
1030
  # or to nothing. The commit point a Tab-away still reaches — Tab is
@@ -802,7 +1034,7 @@ module Tuile
802
1034
  # class TrimmedField < Component::TextField
803
1035
  # protected def handle_blur
804
1036
  # super
805
- # self.text = text.strip
1037
+ # self.value = text.strip
806
1038
  # false
807
1039
  # end
808
1040
  # end
@@ -840,8 +1072,8 @@ module Tuile
840
1072
  #
841
1073
  # Runs on the UI thread with {Screen#theme} already updated, so mutating
842
1074
  # content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
843
- # here. Subclasses overriding this must call `super` so an assigned
844
- # {#on_theme_changed=} listener keeps firing.
1075
+ # here. Subclasses overriding this must call `super` so any
1076
+ # {#on_theme_changed} listener keeps firing.
845
1077
  #
846
1078
  # Plumbing an app overrides and never calls, hence protected — and
847
1079
  # {Screen}, not being a {Component}, fans it out through `__send__`, so an
@@ -849,7 +1081,7 @@ module Tuile
849
1081
  # @return [void]
850
1082
  # is whatever the app's lambda happened to return.
851
1083
  def handle_theme_changed
852
- @on_theme_changed&.call
1084
+ on_theme_changed.fire(ThemeChangedEvent.new(source: self))
853
1085
  end
854
1086
 
855
1087
  # Called on every attached component (pre-order, popups included) when
@@ -859,7 +1091,7 @@ module Tuile
859
1091
  # change invalidates the whole tree.
860
1092
  #
861
1093
  # Runs on the UI thread with {Screen#locale} already updated. Subclasses
862
- # overriding it must call `super` so an assigned {#on_locale_changed=}
1094
+ # overriding it must call `super` so any {#on_locale_changed}
863
1095
  # listener keeps firing.
864
1096
  #
865
1097
  # Plumbing an app overrides and never calls, hence protected — {Screen}
@@ -868,7 +1100,7 @@ module Tuile
868
1100
  # @return [void]
869
1101
  # is whatever the app's lambda happened to return.
870
1102
  def handle_locale_changed
871
- @on_locale_changed&.call
1103
+ on_locale_changed.fire(LocaleChangedEvent.new(source: self))
872
1104
  end
873
1105
 
874
1106
  # The formatting conventions to render and parse by ({Screen#locale}), or
@@ -894,12 +1126,89 @@ module Tuile
894
1126
  screen.invalidate(self)
895
1127
  end
896
1128
 
1129
+ # Marks this container as owing a {#relayout}: its children's rects are out
1130
+ # of date, and the next {#flush_layout} will bring them up to date.
1131
+ #
1132
+ # def spacing=(cells)
1133
+ # @spacing = cells
1134
+ # invalidate_layout # every input to the arithmetic ends here
1135
+ # end
1136
+ #
1137
+ # The framework marks after `rect=`, after the three tree mutators and
1138
+ # after a child's {#visible=} flips; a container marks for every *other*
1139
+ # input to its own arithmetic.
1140
+ #
1141
+ # **The mark never runs the pass** — not even detached, where there is no
1142
+ # settle to defer to and {#flush_layout} has to be asked for. That is what
1143
+ # lets a constructor `add_child` before its ivars are written, and a
1144
+ # container mutate its own bookkeeping in whatever order reads best: no
1145
+ # `relayout` ever observes a container mid-configuration.
1146
+ #
1147
+ # Unlike {#invalidate}, a detached mark is *remembered* rather than
1148
+ # dropped: attaching hands it to the {Screen}, so a tree assembled with no
1149
+ # screen lays out as soon as it is mounted.
1150
+ #
1151
+ # **A mark made during this container's own {#relayout}, before it places
1152
+ # its first child, is dropped** — the pass that would answer it is the one
1153
+ # running. So hide or add a child, or set your own `spacing`, *first*:
1154
+ #
1155
+ # def relayout
1156
+ # @sidebar.visible = width >= 60 # before `super`: one pass
1157
+ # super
1158
+ # end
1159
+ #
1160
+ # Once a child is placed, the division may already be stale, so a later
1161
+ # mark is kept and costs a second pass.
1162
+ # @return [void]
1163
+ def invalidate_layout
1164
+ return if LayoutPass.before_first_placement?(self)
1165
+
1166
+ @layout_dirty = true
1167
+ screen.invalidate_layout(self) if attached?
1168
+ end
1169
+
1170
+ # Assigns every child's rect, and is the only place a container may.
1171
+ #
1172
+ # private def relayout
1173
+ # half = width / 2 # `local_rect`, so no rect.left:
1174
+ # @left.rect = Rect.new(0, 0, half, height)
1175
+ # @right.rect = Rect.new(half, 0, width - half, height)
1176
+ # end
1177
+ #
1178
+ # **`relayout` : geometry :: {#repaint} : ink.** Invoked by the framework,
1179
+ # never called directly; derives every rect from current state, so it is
1180
+ # idempotent and safe to run twice. It assigns *every* child on every pass,
1181
+ # including when {#rect} is empty — a `return if rect.empty?` guard strands
1182
+ # children at stale coordinates that the next full repaint paints them at
1183
+ # (`D_empty_ancestor`).
1184
+ #
1185
+ # **It is also where a component re-derives what depends on its own size**
1186
+ # — a wrap, a padded row cache, a scroll offset clamped to the viewport —
1187
+ # because {#rect=} marks the component itself, so this runs after every
1188
+ # rect change, width *or* height. It runs after every other mark as well,
1189
+ # so a costly derivation keys itself on the size it was built at —
1190
+ # {Component::TextView}'s:
1191
+ #
1192
+ # def relayout
1193
+ # rewrap unless @wrapped_at == wrap_width # not on every append
1194
+ # update_scroll_top_row_if_auto_scroll # a taller viewport moves the bottom
1195
+ # @scrollbar.rect = …
1196
+ # end
1197
+ #
1198
+ # There is no width-changed hook; {#handle_rect_changed} is the edge, for a
1199
+ # reaction to the change itself.
1200
+ #
1201
+ # Reached through `__send__`, so an override may be protected or private
1202
+ # (`D_hook_visibility`). A leaf inherits the empty body and costs nothing.
1203
+ # @return [void]
1204
+ def relayout; end
1205
+
897
1206
  # Whether direct children fully tile {#rect}. Used by the default
898
1207
  # {#repaint} to decide whether the framework needs to wipe gaps.
899
1208
  #
900
1209
  # Approximated by area: sum of (non-empty) child areas vs the parent's
901
1210
  # area. Cheap, and correct as long as siblings don't overlap each other
902
- # — which Tuile already requires (no clipping in the tiled tree).
1211
+ # — which Tuile already requires of a tiled layout.
903
1212
  # Children with empty rects contribute zero, since they paint nothing.
904
1213
  #
905
1214
  # A **hidden** child contributes zero for the same reason, and that is what
@@ -916,18 +1225,20 @@ module Tuile
916
1225
  # below it. A `nil` extent declares nothing, so the whole rect is blanked.
917
1226
  # Called by the default {#repaint}; a self-painter that skips `super` calls
918
1227
  # it directly.
1228
+ # @param canvas [Canvas] the paint context, at this component's own background.
919
1229
  # @return [void]
920
- def clear_outside_extent
1230
+ def clear_outside_extent(canvas)
921
1231
  e = extent
922
- return clear_background if e.nil? # nothing declared: all of it is fair game
1232
+ return canvas.fill(local_rect) if e.nil? # nothing declared: all of it is fair game
923
1233
 
924
- right = Rect.new(rect.left + e.width, rect.top, rect.width - e.width, e.height)
925
- below = Rect.new(rect.left, rect.top + e.height, rect.width, rect.height - e.height)
1234
+ right = Rect.new(e.width, 0, rect.width - e.width, e.height)
1235
+ below = Rect.new(0, e.height, rect.width, rect.height - e.height)
926
1236
  # Not this widget's own surface: a one-row Select handed a 25-row rect
927
1237
  # 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?
1238
+ canvas.with(bg_color: bg.ambient) do |ambient|
1239
+ ambient.fill(right) unless right.empty?
1240
+ ambient.fill(below) unless below.empty?
1241
+ end
931
1242
  end
932
1243
 
933
1244
  # Blanks the {#extent} itself, for a *container* whose children don't cover
@@ -943,64 +1254,35 @@ module Tuile
943
1254
  # extent itself, and blanking that first is the re-emit `D_progress_bar`
944
1255
  # bought back. Override it to decline when you paint your own ink into a
945
1256
  # face cell no child covers.
1257
+ # @param canvas [Canvas] the paint context, at this component's own background.
946
1258
  # @return [void]
947
- def clear_inside_extent
948
- clear_background(extent_rect, ambient_bg_color)
1259
+ def clear_inside_extent(canvas)
1260
+ canvas.with(bg_color: bg.ambient) { _1.fill(local_extent_rect) }
949
1261
  end
950
1262
 
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
1263
+ # This component's background — protected, because stating a widget's own
1264
+ # well is the widget's business; an app tints through {#bg_color=}.
961
1265
  #
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.
1266
+ # bg.default_color = ComponentBackground::INPUT_WELL # in a field's initialize
965
1267
  #
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
1268
+ # Final: {Screen#canvas_for} and every descendant's chain read it.
1269
+ # @return [ComponentBackground]
1270
+ attr_reader :bg
990
1271
 
991
1272
  # The background a component paints while it is in an *error* state —
992
1273
  # `nil` by default, meaning "I am not signalling one". {HasValidation}
993
1274
  # overrides it, so every field has it and nothing else does.
994
1275
  #
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
1276
+ # It sits **above** {#bg_color} in the chain rather than under it, unlike
1277
+ # {ComponentBackground#default_color}. That is deliberate: an app tinting a panel
997
1278
  # would otherwise switch the validation signal off on the fields inside it,
998
1279
  # silently. An app that wants different error colors changes the
999
1280
  # {Theme#error_bg_color} tokens.
1000
1281
  #
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.
1282
+ # A hook rather than a {ComponentBackground} setter because it follows the
1283
+ # validation state, and a pulled answer cannot go stale. Read the theme
1284
+ # here rather than in an ivar, and hand back one {Color}: this runs first
1285
+ # on every paint.
1004
1286
  # @return [Color, Theme::Ref, Hash, nil]
1005
1287
  def error_bg_color = nil
1006
1288
 
@@ -1009,11 +1291,11 @@ module Tuile
1009
1291
  # self-painting container can drop the default's blanket clear without also
1010
1292
  # dropping this by accident ({Component::Window} is the case):
1011
1293
  #
1012
- # def repaint
1294
+ # def repaint(canvas)
1013
1295
  # return if rect.empty?
1014
1296
  #
1015
1297
  # invalidate_children # never optional
1016
- # paint_my_own_chrome
1298
+ # paint_my_own_chrome(canvas)
1017
1299
  # end
1018
1300
  #
1019
1301
  # @return [void]
@@ -1021,54 +1303,42 @@ module Tuile
1021
1303
  children.each { |c| screen.invalidate(c) }
1022
1304
  end
1023
1305
 
1024
- # Clears the background: fills every cell with a blank in the
1025
- # {#effective_bg_color} (the terminal default when none is inherited).
1026
- #
1027
- # A component that paints part of its {#rect} itself passes just the part it
1028
- # *doesn't* — blanking a cell it is about to overwrite anyway makes that cell
1029
- # dirty, and {Buffer#flush} then re-emits it even though nothing visibly
1030
- # changed.
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.
1034
- # @return [void]
1035
- def clear_background(area = rect, bg = effective_bg_color)
1036
- screen.buffer.fill(area, bg ? StyledString::Style.new(bg:) : StyledString::Style::DEFAULT)
1037
- end
1306
+ private
1038
1307
 
1039
- # {Buffer#set_text} wrapper that fills {#effective_bg_color} behind any span
1040
- # with no bg of its own (via {StyledString#under_bg}), so an inherited
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.
1045
- # @param x [Integer] starting column.
1046
- # @param y [Integer] row.
1047
- # @param styled [StyledString]
1308
+ # Clears the mark and runs {#relayout} — the sole invocation site of
1309
+ # `relayout`, reached only from {LayoutPass.drain}.
1048
1310
  # @return [void]
1049
- def draw_text(x, y, styled)
1050
- screen.buffer.set_text(x, y, styled.under_bg(effective_bg_color))
1311
+ def perform_relayout
1312
+ @layout_dirty = false
1313
+ LayoutPass.run(self) { relayout }
1051
1314
  end
1052
1315
 
1053
- # {#draw_text}'s single-grapheme counterpart: writes `grapheme` at `(x, y)`,
1054
- # filling {#effective_bg_color} when `style` carries no background of its
1055
- # own.
1056
- # @param x [Integer] column.
1057
- # @param y [Integer] row.
1058
- # @param grapheme [String] one grapheme cluster.
1059
- # @param style [StyledString::Style]
1060
- # @return [void]
1061
- def draw_char(x, y, grapheme, style = StyledString::Style::DEFAULT)
1062
- bg = effective_bg_color
1063
- style = style.merge(bg:) if bg && style.bg.nil?
1064
- screen.buffer.set_char(x, y, grapheme, style)
1316
+ # The nearest ancestor whose pending or running {#relayout} would rewrite
1317
+ # this component's {#rect}, or `nil` when the rect is current —
1318
+ # {#rect_stale?}'s answer, with the culprit kept for {StrictLayout}'s
1319
+ # message.
1320
+ #
1321
+ # Two ways to owe it: a marked ancestor, and one whose pass is running and
1322
+ # has not yet placed the child on the way down — a hook fired mid-pass, a
1323
+ # focus repair from a child hidden there, reading a sibling. Strictly
1324
+ # ancestors, never `self`: a container's own flag means its children are
1325
+ # stale, and {#perform_relayout} clears the flag before the body runs, so a
1326
+ # `relayout` reading its own `width` is asking a settled question.
1327
+ # @return [Component, nil]
1328
+ def stale_layout_ancestor
1329
+ node = self
1330
+ until node.parent.nil?
1331
+ return node.parent if node.parent.layout_dirty? || LayoutPass.unplaced?(node)
1332
+
1333
+ node = node.parent
1334
+ end
1335
+ nil
1065
1336
  end
1066
1337
 
1067
- private
1068
-
1069
1338
  # Hands focus out of the subtree just hidden, if it was in there, through
1070
1339
  # the parent's {#handle_child_removed} — see there for why hiding reuses the
1071
- # removal repair instead of growing a second one.
1340
+ # removal repair instead of growing a second one. {List#interactive=}
1341
+ # reuses it too, for a component that stays shown but stops taking focus.
1072
1342
  #
1073
1343
  # The parent is necessarily showing (focus was inside it a moment ago, and
1074
1344
  # {Screen#focused=} refuses a hidden target), so its assignment can't bounce.
@@ -1080,53 +1350,5 @@ module Tuile
1080
1350
  cursor = cursor.parent until cursor.nil? || cursor.equal?(self)
1081
1351
  parent.handle_child_removed(self) unless cursor.nil?
1082
1352
  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
1131
1353
  end
1132
1354
  end