tuile 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -8,12 +8,12 @@ module Tuile
8
8
  # {#content} and the {#popups} stack. Putting them under a single Component
9
9
  # parent gives focus traversal a real root, makes {Component#attached?} a
10
10
  # one-liner, and lets popup-focus repair fall out of the standard
11
- # {Component#on_child_removed} hook.
11
+ # {Component#handle_child_removed} hook.
12
12
  #
13
13
  # The pane owns no chrome of its own — no status bar, no reserved row.
14
14
  # {#content} gets the full pane rect, and an app that wants a status line
15
15
  # builds one into its own layout and drives it from
16
- # {Screen#on_focus_changed=} (`D_status_bar`).
16
+ # {Screen#on_focus_changed} (`D_status_bar`).
17
17
  #
18
18
  # The pane is not a {Component::Layout}: popups deliberately overlap content
19
19
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
@@ -27,6 +27,10 @@ module Tuile
27
27
  # user was, instead of falling through to {#content} and getting
28
28
  # cascaded to the first focusable child.
29
29
  @popup_prior_focus = {}
30
+ # Where each open popup wants to be, and the anchor rect its last
31
+ # placement used (`:lost` once the anchor went away) — see #relayout.
32
+ @placements = {}.compare_by_identity
33
+ @placed_anchors = {}.compare_by_identity
30
34
  end
31
35
 
32
36
  # @return [Component, nil] the tiled content component.
@@ -52,14 +56,13 @@ module Tuile
52
56
  remove_child(@content) unless @content.nil?
53
57
  @content = content
54
58
  add_child(content, at: 0) # the tiled layer paints beneath everything else
55
- layout
56
59
  end
57
60
 
58
- # Adds an overlay and invalidates it for repaint. A {Component::Popup} is
59
- # centered and grabs focus; a bare {Component::Overlay} is left wherever
60
- # the caller positioned it and does *not* take focus, so the component that
61
- # was focused keeps the cursor and keeps receiving keys — the overlay
62
- # floats above the content, driven from app code.
61
+ # Adds an overlay at `placement` and invalidates it for repaint; the next
62
+ # settle gives it its rect. A {Component::Popup} grabs focus; a bare
63
+ # {Component::Overlay} does *not*, so the component that was focused keeps
64
+ # the cursor and keeps receiving keys — the overlay floats above the
65
+ # content, driven from app code.
63
66
  #
64
67
  # The *whole subtree* is invalidated, not just the overlay wrapper (which
65
68
  # paints nothing on its own): a reopened popup may land on cells that the
@@ -67,19 +70,46 @@ module Tuile
67
70
  # last time its content components won't re-invalidate themselves — so
68
71
  # without this the overlay's contents would stay blank on reopen.
69
72
  # @param window [Component::Overlay] any overlay, modal or not.
73
+ # @param placement [Component::Overlay::Placement, nil] where it wants to
74
+ # be; `nil` takes its {Component::Overlay#default_placement}.
75
+ # @raise [TypeError] if `placement` does not include
76
+ # {Component::Overlay::Placement}.
70
77
  # @return [void]
71
- def add_popup(window)
78
+ def add_popup(window, placement = nil)
72
79
  raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay
73
80
  raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
74
81
 
82
+ placement ||= window.default_placement
83
+ check_placement(placement)
75
84
  @popup_prior_focus[window] = screen.focused
85
+ @placements[window] = placement
76
86
  @popups << window
77
87
  add_child(window) # appended: popups paint over the tiled content
78
- if window.modal?
79
- window.center
80
- screen.focused = window
81
- end
82
- window.on_tree { |c| screen.invalidate(c) }
88
+ screen.focused = window if window.modal?
89
+ window.walk_tree { |c| screen.invalidate(c) }
90
+ end
91
+
92
+ # @param popup [Component::Overlay] an open popup.
93
+ # @return [Component::Overlay::Placement, nil] where it wants to be; `nil`
94
+ # if it isn't open here.
95
+ def placement(popup) = @placements[popup]
96
+
97
+ # Moves an open popup; it takes the new rect on the next settle.
98
+ # {Component::Overlay#placement=} is the usual way in.
99
+ # @param popup [Component::Overlay] an open popup.
100
+ # @param placement [Component::Overlay::Placement] where it wants to be.
101
+ # @raise [ArgumentError] if `popup` isn't open on this pane.
102
+ # @raise [TypeError] if `placement` does not include
103
+ # {Component::Overlay::Placement}.
104
+ # @return [void]
105
+ def constrain(popup, placement)
106
+ raise ArgumentError, "#{popup} is not an open popup on this pane" unless has_popup?(popup)
107
+
108
+ check_placement(placement)
109
+ return if @placements[popup] == placement
110
+
111
+ @placements[popup] = placement
112
+ invalidate_layout
83
113
  end
84
114
 
85
115
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
@@ -91,6 +121,8 @@ module Tuile
91
121
  raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window)
92
122
 
93
123
  prior = @popup_prior_focus.delete(window)
124
+ @placements.delete(window)
125
+ @placed_anchors.delete(window)
94
126
  @removing_popup_prior = prior
95
127
  remove_child(window)
96
128
  # Runs after the detach, so a prior pointing *inside* the removed popup is
@@ -102,7 +134,7 @@ module Tuile
102
134
  @removing_popup_prior = nil
103
135
  end
104
136
 
105
- # Unmounts everything: each child is detached — firing {Component#on_detached}
137
+ # Unmounts everything: each child is detached — firing {Component#handle_detached}
106
138
  # down its subtree — and every slot is emptied. Terminal; the pane isn't
107
139
  # reusable afterwards, and {Screen#close} is its only caller.
108
140
  #
@@ -113,11 +145,13 @@ module Tuile
113
145
  # components, which is the desync the tree API exists to prevent.
114
146
  # @return [void]
115
147
  def detach_all
116
- screen.focused = nil # …so the focus repair in on_child_removed has nothing to do
148
+ screen.focused = nil # …so the focus repair in handle_child_removed has nothing to do
117
149
  children.dup.each { detach_child(_1) }
118
150
  @content = nil
119
151
  @popups.clear
120
152
  @popup_prior_focus.clear
153
+ @placements.clear
154
+ @placed_anchors.clear
121
155
  end
122
156
 
123
157
  # @param window [Component]
@@ -131,33 +165,43 @@ module Tuile
131
165
  # content without capturing input.
132
166
  def modal_popup = @popups.reverse_each.find(&:modal?)
133
167
 
134
- # Re-lays out children whenever the pane's own rect changes.
135
- # @param new_rect [Rect]
136
- # @return [void]
137
- def rect=(new_rect)
138
- super
139
- layout
140
- end
168
+ # The root of the current **key scope**: the topmost modal popup when one
169
+ # is open, else the tiled {#content}. Keys bubble up to it and no further,
170
+ # a paste reaches only a focus chain inside it, and Tab cycles only the
171
+ # stops beneath it — so a component outside it is one the keyboard cannot
172
+ # reach at all. The mouse resolves against a *point* instead
173
+ # ({#mouse_root_at}).
174
+ # @return [Component, nil] nil when the pane holds neither.
175
+ def key_scope = modal_popup || @content
141
176
 
142
177
  # Gives {#content} the whole pane rect — the pane reserves nothing for
143
- # itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
144
- # screen via {Component::Popup#reposition} — so a {Fraction} size tracks
145
- # resize — repositioning itself (modal popups recenter; non-modal overlays
146
- # keep the top-left their owner assigned).
178
+ # itself — and each popup the rect its placement asks for, in stacking
179
+ # order, so a submenu is placed after the panel it hangs from.
180
+ #
181
+ # Re-running it moves nothing that stood still: a placement is a rule, so
182
+ # a second popup opening re-derives the first one's rect unchanged.
183
+ #
184
+ # An empty pane collapses every popup rather than placing it — an `At` rect
185
+ # would otherwise stand in a pane with no cells — and has no `return if
186
+ # rect.empty?` guard, which would strand the content at its last rect
187
+ # (`D_empty_ancestor`).
147
188
  # @return [void]
148
- def layout
149
- return if rect.empty?
150
-
151
- @content&.rect = rect
152
- @popups.each(&:reposition)
189
+ def relayout
190
+ @content&.rect = local_rect
191
+ if rect.empty?
192
+ @popups.each { _1.rect = Rect.new(0, 0, 0, 0) }
193
+ else
194
+ @popups.each { place(_1) }
195
+ end
153
196
  end
154
197
 
155
198
  # Pane paints nothing itself; its children paint over the entire rect.
199
+ # @param _canvas [Canvas] see {Component#repaint}.
156
200
  # @return [void]
157
- def repaint; end
201
+ def repaint(_canvas); end
158
202
 
159
203
  # Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
160
- # the first component whose `handle_key` returns true wins.
204
+ # the first component whose `handle_key?` returns true wins.
161
205
  #
162
206
  # Bubbling stops at the *scope* root: the topmost *modal* popup when one is
163
207
  # open, else the tiled {#content}. Focus that is nil or sits outside the
@@ -173,8 +217,8 @@ module Tuile
173
217
  # hijacked).
174
218
  # @param key [String]
175
219
  # @return [Boolean] true if the key was handled.
176
- def handle_key(key)
177
- scope = modal_popup || @content
220
+ def handle_key?(key)
221
+ scope = key_scope
178
222
  return false if scope.nil?
179
223
 
180
224
  bubble_key(key, scope)
@@ -182,38 +226,39 @@ module Tuile
182
226
 
183
227
  # Delivers pasted text to {Screen#focused} — and to nobody else.
184
228
  #
185
- # Scoped exactly like {#handle_key} (focus that is nil or sits outside the
229
+ # Scoped exactly like {#handle_key?} (focus that is nil or sits outside the
186
230
  # modal scope receives nothing, which is what keeps a popup modal) but
187
231
  # **not bubbled**: an ancestor is never offered a paste its descendant
188
232
  # declined, and unhandled text is dropped. Why keys bubble and pastes
189
233
  # don't: `D_bracketed_paste`.
190
234
  # @param text [String]
191
- # @return [Boolean] true if the focused component consumed it.
235
+ # @return [void]
192
236
  def handle_paste(text)
193
- scope = modal_popup || @content
194
- return false if scope.nil?
237
+ scope = key_scope
238
+ return if scope.nil?
195
239
 
196
240
  chain = focus_chain(scope)
197
- return false if chain.nil?
241
+ return if chain.nil?
198
242
 
199
243
  chain.first.handle_paste(text)
200
244
  end
201
245
 
202
- # Mouse events check popups in reverse stacking order (topmost first), and
203
- # fall through to content only when no popup is hit *and* no modal popup is
204
- # open. This preserves modal click-blocking — an open modal eats clicks
205
- # even outside its rect — while a non-modal overlay blocks nothing: clicks
206
- # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
207
- # content beneath.
208
- #
209
- # A left click also *dismisses* the open popups it landed outside of that
210
- # asked for it ({Component::Overlay#close_on_outside_click?}). That is a
211
- # second thing happening on a click, but not a second dispatch: the click is
212
- # still delivered exactly once, down one chain, and a dismissed popup is
246
+ # Where {Mouse::Router} starts its walk for a pointer at `point`: the
247
+ # topmost popup containing it, else {#content} — unless a modal popup is
248
+ # open, which eats the event even outside its rect. A non-modal overlay
249
+ # blocks nothing: a point outside it reaches the content beneath.
250
+ # @api private
251
+ # @param point [Point]
252
+ # @return [Component, nil]
253
+ def mouse_root_at(point) = popup_at(point) || (@content if modal_popup.nil?)
254
+
255
+ # Runs the press delivery in the block, then *dismisses* the open popups a
256
+ # left press landed outside of that asked for it
257
+ # ({Component::Overlay#close_on_outside_click?}). A dismissed popup is
213
258
  # closed rather than told.
214
259
  #
215
260
  # "Outside" is measured against the {Component::Overlay#owner} chain, not
216
- # against one rect and not against stacking order: the popup the click hit
261
+ # against one rect and not against stacking order: the popup the press hit
217
262
  # is kept, and so is every popup that one *belongs to*, transitively. That
218
263
  # is what stops a dialog being dismissed by a click on a dropdown its own
219
264
  # field opened, and a menu cascade being dismissed by a click on one of its
@@ -223,10 +268,10 @@ module Tuile
223
268
  #
224
269
  # Two halves of the ordering are load-bearing, and both are specced:
225
270
  #
226
- # - **Snapshot before routing.** A popup the delivered click *opens* must
271
+ # - **Snapshot before the block.** A popup the delivered press *opens* must
227
272
  # not be in the set (it would immediately dismiss itself — every
228
273
  # {Component::Select} would be unopenable by mouse).
229
- # - **Close after routing.** A widget toggling its own overlay from a click
274
+ # - **Close after the block.** A widget toggling its own overlay from a press
230
275
  # on its face closes it during delivery, and {Component::Popup#close} is
231
276
  # idempotent, so the dismissal no-ops. Close *first* and the widget sees
232
277
  # a shut overlay and reopens it — a Select's dropdown could then never be
@@ -234,19 +279,19 @@ module Tuile
234
279
  #
235
280
  # The snapshot is a fresh array for a third reason: a handler may close
236
281
  # further popups, and `@popups` must not be mutated mid-iteration.
237
- # @param event [MouseEvent]
282
+ # @api private
283
+ # @param point [Point]
284
+ # @param left [Boolean] whether the press was the left button; no other
285
+ # button dismisses.
286
+ # @yield the press delivery.
238
287
  # @return [void]
239
- def handle_mouse(event)
240
- hit = @popups.reverse_each.find { _1.rect.contains?(event.point) }
241
- dismissable = event.button == :left ? @popups - kept_by(hit) : []
242
-
243
- clicked = hit || (@content if modal_popup.nil?)
244
- clicked&.handle_mouse(event)
245
-
288
+ def dismissing_popups_outside(point, left:)
289
+ dismissable = left ? @popups - kept_by(popup_at(point)) : []
290
+ yield
246
291
  dismissable.each { _1.close if _1.close_on_outside_click? }
247
292
  end
248
293
 
249
- # Focus repair when a child detaches. Default {Component#on_child_removed}
294
+ # Focus repair when a child detaches. Default {Component#handle_child_removed}
250
295
  # would refocus to `self` (the pane), which isn't a useful focus target.
251
296
  # Instead, route focus to the first interactable widget in the now-topmost
252
297
  # modal popup; falling back to the focus snapshotted when this popup was opened
@@ -259,7 +304,7 @@ module Tuile
259
304
  # `q`/ESC still has somewhere to dispatch from.
260
305
  # @param child [Component]
261
306
  # @return [void]
262
- def on_child_removed(child)
307
+ def handle_child_removed(child)
263
308
  return unless attached?
264
309
 
265
310
  f = screen.focused
@@ -282,6 +327,75 @@ module Tuile
282
327
 
283
328
  private
284
329
 
330
+ # Rejects a non-placement where the app named it, rather than mid-pass: the
331
+ # layout is deferred, so the `NoMethodError` from a missing `rect_for` would
332
+ # otherwise surface a turn later, under this file's backtrace.
333
+ # @param placement [Object]
334
+ # @raise [TypeError] unless it includes {Component::Overlay::Placement}.
335
+ # @return [void]
336
+ def check_placement(placement)
337
+ return if placement.is_a?(Component::Overlay::Placement)
338
+
339
+ raise TypeError, "#{placement.class} must include Tuile::Component::Overlay::Placement"
340
+ end
341
+
342
+ # A placement's {Component::Overlay::Placement#anchor} in screen
343
+ # coordinates: a `Rect` passes through, a component resolves while it is on
344
+ # screen, and `nil` means either unanchored or gone — {#place} tells those
345
+ # apart by whether an anchor was declared at all.
346
+ # @param anchor [Component, Rect, nil]
347
+ # @return [Rect, nil]
348
+ def resolve_anchor(anchor)
349
+ return anchor if anchor.nil? || anchor.is_a?(Rect)
350
+
351
+ ComponentUtil.effectively_visible?(anchor) ? anchor.absolute_extent_rect : nil
352
+ end
353
+
354
+ # @param popup [Component::Overlay]
355
+ # @return [void]
356
+ def place(popup)
357
+ placement = @placements.fetch(popup)
358
+ anchor = placement.anchor
359
+ anchor_rect = resolve_anchor(anchor)
360
+ unless anchor.nil?
361
+ return lose_anchor(popup) if anchor_rect.nil?
362
+
363
+ @placed_anchors[popup] = anchor_rect
364
+ end
365
+ popup.rect = placement.rect_for(popup, rect.size, anchor_rect)
366
+ end
367
+
368
+ # Leaves a popup whose anchor was detached or hidden at its last rect. It
369
+ # warns, once, because closing the popup is its owner's job and an owner
370
+ # that forgot leaves it floating over nothing.
371
+ # @param popup [Component::Overlay]
372
+ # @return [void]
373
+ def lose_anchor(popup)
374
+ return if @placed_anchors[popup] == :lost
375
+
376
+ @placed_anchors[popup] = :lost
377
+ Tuile.logger.warn("#{popup} lost its anchor; left where it was — close it when the anchor goes")
378
+ end
379
+
380
+ # Re-marks the pane when an anchored popup's anchor now resolves elsewhere
381
+ # than where its last placement read it. {Screen#flush_layout} asks once
382
+ # its drain is empty, because this pane's pass runs *before* the content an
383
+ # anchor sits in, so a settle that moved the content handed the pass a
384
+ # stale anchor. Anchors in `lib/` converge in one more round — the content
385
+ # never moves for a popup, and a cascade anchors to a `Rect` — but a popup
386
+ # anchored inside another takes one round per level.
387
+ # @return [Boolean] whether it marked, so the screen drains again.
388
+ def remark_moved_anchors
389
+ return false if rect.empty? # #relayout collapses rather than places then, so nothing records
390
+
391
+ moved = @popups.any? do |popup|
392
+ anchor = @placements[popup].anchor
393
+ !anchor.nil? && (resolve_anchor(anchor) || :lost) != @placed_anchors[popup]
394
+ end
395
+ invalidate_layout if moved
396
+ moved
397
+ end
398
+
285
399
  # The overlays a click counts as landing *inside*: the one it hit, plus
286
400
  # every overlay that one belongs to, up the {Component::Overlay#owner}
287
401
  # chain. An owner is any component, so it is resolved to the overlay
@@ -301,6 +415,10 @@ module Tuile
301
415
  kept
302
416
  end
303
417
 
418
+ # @param point [Point]
419
+ # @return [Component::Overlay, nil] the topmost popup containing `point`.
420
+ def popup_at(point) = @popups.reverse_each.find { _1.rect.contains?(point) }
421
+
304
422
  # @param component [Component, nil]
305
423
  # @return [Component::Overlay, nil] `component` itself when it is an
306
424
  # overlay, else the nearest overlay above it, else nil.
@@ -321,7 +439,7 @@ module Tuile
321
439
  chain = focus_chain(scope)
322
440
  return false if chain.nil?
323
441
 
324
- chain.each { |c| return true if c.handle_key(key) }
442
+ chain.each { |c| return true if c.handle_key?(key) }
325
443
  false
326
444
  end
327
445
 
@@ -350,7 +468,7 @@ module Tuile
350
468
  def first_tab_stop_or_root(root)
351
469
  return nil if root.nil? || !root.visible?
352
470
 
353
- root.on_shown_tree { |c| return c if c.tab_stop? }
471
+ root.walk_shown_tree { |c| return c if c.tab_stop? }
354
472
  root
355
473
  end
356
474
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # The stale-rect diagnostic: a {Component#rect} read taken while an ancestor
5
+ # still owes a {Component#relayout} says so, instead of handing back the
6
+ # previous pass's rectangle in silence. **On wherever a {FakeScreen} is the
7
+ # installed screen** — a spec suite needs no setup to get it — and off in an
8
+ # app, which {Tuile.strict_layout} overrides either way:
9
+ #
10
+ # holder.constrain(pane, Rect.new(0, 0, 100, 26))
11
+ # pane.left.rect # => Tuile::Error: read the rect of #<Tuile::Component::Label
12
+ # # rect=(0,0 80x50)> while #<TwoPane rect=(0,0 100x26)> owes a
13
+ # # relayout … at spec/two_pane_spec.rb:42
14
+ #
15
+ # `:warn` logs the same line to {Tuile.logger} and hands the rectangle over,
16
+ # for watching a running app; `:raise` is the default and what a spec wants,
17
+ # since the backtrace names the read and a suite that never set a logger
18
+ # would see nothing at all. For the read that is pre-settle *on purpose*,
19
+ # {Tuile.without_strict_layout}.
20
+ #
21
+ # Reading `size`, `width`, `height`, `local_rect`, `absolute_rect` or
22
+ # `to_screen` reports too — they all go through the one reader.
23
+ #
24
+ # **Only reads the app makes are reported** ({PLUMBING}): a read `lib/` makes
25
+ # on the app's behalf mid-handler is not the app's to fix.
26
+ #
27
+ # == Implementation details
28
+ #
29
+ # {Tuile.strict_layout=} and {FakeScreen} prepend this module into
30
+ # {Component}, so `rect` stays the bare `attr_reader` — the hottest read in
31
+ # the toolkit — in every process that never asks. Nothing unprepends; turning
32
+ # the mode off leaves the check inert. See `D_strict_layout`.
33
+ module StrictLayout
34
+ # Where the gem's own frames live, so the site the message names is the
35
+ # app's — `to_screen` and the `size` / `width` / `height` trio all read
36
+ # `rect` from inside `component.rb`.
37
+ # @return [String]
38
+ LIB_DIR = File.expand_path("..", __dir__)
39
+
40
+ # The thread-local marking a report in progress: {Component#inspect} prints
41
+ # the rect, so building the message re-enters `rect` on the very component
42
+ # that is being complained about.
43
+ # @return [Symbol]
44
+ REPORTING = :tuile_strict_layout_reporting
45
+
46
+ # The readers that only forward to `rect`. A frame of one of these between
47
+ # the read and the app's own code carries no decision of the framework's, so
48
+ # the read still counts as the app's.
49
+ # @return [Array<String>]
50
+ PLUMBING = %w[rect size width height local_rect local_extent_rect absolute_rect absolute_extent_rect
51
+ to_screen to_local].freeze
52
+
53
+ class << self
54
+ # Makes {Component#rect} consult {Tuile.strict_layout} — idempotent, and
55
+ # permanent for the process.
56
+ # @return [void]
57
+ def install = Component.prepend(self)
58
+
59
+ # Reports `component`'s rect read as stale, the way `mode` asks for.
60
+ #
61
+ # @param component [Component] the component whose rect was read.
62
+ # @param ancestor [Component] the ancestor owing the relayout.
63
+ # @param mode [Symbol] `:raise` or `:warn`.
64
+ # @raise [Error] in `:raise` mode, unless the read was the framework's own.
65
+ # @return [void]
66
+ def report(component, ancestor, mode)
67
+ return unless app_read?
68
+
69
+ message = message_for(component, ancestor)
70
+ raise Error, message if mode == :raise
71
+
72
+ Tuile.logger.warn(message)
73
+ end
74
+
75
+ private
76
+
77
+ # @param component [Component]
78
+ # @param ancestor [Component]
79
+ # @return [String]
80
+ def message_for(component, ancestor)
81
+ Thread.current[REPORTING] = true
82
+ "Tuile: read the rect of #{component.inspect} while #{ancestor.inspect} owes a relayout — " \
83
+ "that rectangle is the previous pass's. Call flush_layout before reading it (a spec), or " \
84
+ "read it after the next event (an app)#{site}"
85
+ ensure
86
+ Thread.current[REPORTING] = false
87
+ end
88
+
89
+ # Whether the app asked the question rather than the framework on its
90
+ # behalf: the frames between the read and the first one outside the gem
91
+ # are {PLUMBING} and nothing else.
92
+ # @return [Boolean]
93
+ def app_read?
94
+ frames.each do |frame|
95
+ return true unless frame.path.start_with?(LIB_DIR)
96
+ return false unless PLUMBING.include?(frame.base_label)
97
+ end
98
+ false
99
+ end
100
+
101
+ # @return [String] ` at <path>:<line>` for the frame that made the read,
102
+ # or `""` when there is none outside the gem.
103
+ def site
104
+ frame = frames.find { !_1.path.start_with?(LIB_DIR) }
105
+ frame.nil? ? "" : " at #{frame.path}:#{frame.lineno}"
106
+ end
107
+
108
+ # The stack above this file, asked from wherever in it — dropping our own
109
+ # frames by path rather than by a `caller_locations` offset, which would
110
+ # be two different numbers and would drift on any refactor here.
111
+ # @return [Array<Object>] `Thread::Backtrace::Location`s.
112
+ def frames = caller_locations.drop_while { _1.path == __FILE__ }
113
+ end
114
+
115
+ # {Component#rect}, reporting first when it is about to answer the previous
116
+ # pass's rectangle.
117
+ # @return [Rect]
118
+ def rect
119
+ mode = Tuile.strict_layout
120
+ if mode && !Thread.current[REPORTING]
121
+ ancestor = stale_layout_ancestor
122
+ StrictLayout.report(self, ancestor, mode) unless ancestor.nil?
123
+ end
124
+ super
125
+ end
126
+ end
127
+ end