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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -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#
|
|
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
|
|
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
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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#
|
|
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
|
|
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
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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 =
|
|
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 [
|
|
235
|
+
# @return [void]
|
|
192
236
|
def handle_paste(text)
|
|
193
|
-
scope =
|
|
194
|
-
return
|
|
237
|
+
scope = key_scope
|
|
238
|
+
return if scope.nil?
|
|
195
239
|
|
|
196
240
|
chain = focus_chain(scope)
|
|
197
|
-
return
|
|
241
|
+
return if chain.nil?
|
|
198
242
|
|
|
199
243
|
chain.first.handle_paste(text)
|
|
200
244
|
end
|
|
201
245
|
|
|
202
|
-
# Mouse
|
|
203
|
-
#
|
|
204
|
-
# open
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
#
|
|
212
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
# @
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
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#
|
|
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
|
|
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.
|
|
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
|