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