tuile 0.15.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 +121 -80
- data/README.md +28 -12
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +95 -26
- data/book/06-theming.md +58 -26
- data/book/07-components.md +61 -6
- data/book/08-testing.md +24 -22
- data/book/10-locale.md +2 -2
- data/book/README.md +6 -5
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +392 -18
- data/lib/tuile/component/abstract_string_field.rb +16 -18
- data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +6 -5
- data/lib/tuile/component/combo_box.rb +50 -35
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +28 -3
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/has_bad_input.rb +2 -2
- data/lib/tuile/component/has_content.rb +3 -3
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +2 -2
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/label.rb +1 -1
- data/lib/tuile/component/layout/box.rb +4 -1
- data/lib/tuile/component/layout.rb +3 -3
- data/lib/tuile/component/list.rb +42 -32
- data/lib/tuile/component/list_dropdown.rb +3 -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 +9 -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 +6 -5
- data/lib/tuile/component/select.rb +11 -12
- 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 +12 -10
- data/lib/tuile/component/text_field.rb +14 -12
- data/lib/tuile/component/text_view.rb +15 -11
- data/lib/tuile/component/time_field.rb +29 -4
- data/lib/tuile/component.rb +201 -93
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +84 -2
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +98 -61
- data/lib/tuile/screen_pane.rb +41 -36
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +8 -8
- data/lib/tuile/theme.rb +22 -34
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +1 -1
- data/sig/tuile.rbs +1211 -427
- metadata +4 -16
- 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/component.rb
CHANGED
|
@@ -8,6 +8,29 @@ 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
35
|
extend Final
|
|
13
36
|
|
|
@@ -57,7 +80,7 @@ module Tuile
|
|
|
57
80
|
# The three readers below report the geometry a parent *assigned*, as
|
|
58
81
|
# shorthand for the matching {#rect} field. They are reports, not requests:
|
|
59
82
|
# no container consults them when dividing space, and there is deliberately
|
|
60
|
-
# no writer — layout is top-down (`
|
|
83
|
+
# no writer — layout is top-down (`design/decisions.md` `D_box_layouts`), so a
|
|
61
84
|
# component says how big it *is*, never how big it wants to be.
|
|
62
85
|
|
|
63
86
|
# @return [Size] `rect.size`.
|
|
@@ -92,7 +115,7 @@ module Tuile
|
|
|
92
115
|
# It flows **downward only**: no container consults it when dividing space,
|
|
93
116
|
# so {#rect} still means exactly what the parent assigned (`D_extent`). Three
|
|
94
117
|
# things read it, all of them this component or the framework painting it:
|
|
95
|
-
# {#clear_outside_extent} blanks the dead tail, {
|
|
118
|
+
# {#clear_outside_extent} blanks the dead tail, {Mouse::Router} hit-tests
|
|
96
119
|
# against it so a click on that tail doesn't activate the widget, and a
|
|
97
120
|
# dropdown anchors under it rather than under unused space.
|
|
98
121
|
#
|
|
@@ -104,7 +127,7 @@ module Tuile
|
|
|
104
127
|
def extent = nil
|
|
105
128
|
|
|
106
129
|
# {#extent} placed at {#rect}'s top-left, for the consumers that need
|
|
107
|
-
# coordinates: `extent_rect.contains?(
|
|
130
|
+
# coordinates: `extent_rect.contains?(point)` in {Mouse::Router}, and
|
|
108
131
|
# the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
|
|
109
132
|
# the whole {#rect}, so a generic caller never sees `nil`.
|
|
110
133
|
# @return [Rect]
|
|
@@ -129,7 +152,7 @@ module Tuile
|
|
|
129
152
|
|
|
130
153
|
prev_width = @rect.width
|
|
131
154
|
@rect = new_rect
|
|
132
|
-
|
|
155
|
+
handle_width_changed if prev_width != new_rect.width
|
|
133
156
|
invalidate
|
|
134
157
|
end
|
|
135
158
|
|
|
@@ -159,7 +182,7 @@ module Tuile
|
|
|
159
182
|
# showing it restores exactly the subtree that was showing before.
|
|
160
183
|
# - **Focus never stays on what the user cannot see.** Hiding the subtree
|
|
161
184
|
# holding focus repairs it exactly as removing that subtree would (see
|
|
162
|
-
# {#
|
|
185
|
+
# {#handle_child_removed}), and does not hand it back on the way in.
|
|
163
186
|
#
|
|
164
187
|
# For "invisible but still occupying its space", use a
|
|
165
188
|
# {Component::Slot} with no content (`D_slots`).
|
|
@@ -175,9 +198,9 @@ module Tuile
|
|
|
175
198
|
@visible = value
|
|
176
199
|
# `__send__` for the same reason `Screen#theme=` uses it: the hook is
|
|
177
200
|
# protected (`D_hook_visibility`).
|
|
178
|
-
parent&.__send__(:
|
|
201
|
+
parent&.__send__(:handle_child_visibility_changed, self)
|
|
179
202
|
repair_focus_after_hiding unless value
|
|
180
|
-
|
|
203
|
+
walk_tree { |c| screen.invalidate(c) } if attached?
|
|
181
204
|
end
|
|
182
205
|
|
|
183
206
|
# @return [Screen] the screen which owns this component.
|
|
@@ -220,7 +243,7 @@ module Tuile
|
|
|
220
243
|
# subtree so it repaints.
|
|
221
244
|
#
|
|
222
245
|
# A {Theme::Ref} is re-resolved against the theme each paint, so it tracks
|
|
223
|
-
# light/dark flips with no {#
|
|
246
|
+
# light/dark flips with no {#handle_theme_changed} hook; a {Color} is fixed:
|
|
224
247
|
#
|
|
225
248
|
# panel.bg_color = Theme.ref(:panel_bg) # theme-tracked
|
|
226
249
|
# panel.bg_color = Color::GREY27 # fixed
|
|
@@ -255,7 +278,7 @@ module Tuile
|
|
|
255
278
|
return if @bg_color == color
|
|
256
279
|
|
|
257
280
|
@bg_color = color
|
|
258
|
-
|
|
281
|
+
walk_tree { |c| screen.invalidate(c) } if attached?
|
|
259
282
|
end
|
|
260
283
|
|
|
261
284
|
# Repaints the component. The default does the bookkeeping most components
|
|
@@ -273,6 +296,9 @@ module Tuile
|
|
|
273
296
|
# skipping `super`.** The clear then covers only what is outside it, so the
|
|
274
297
|
# cells it is about to repaint are not blanked first — blanking them would
|
|
275
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.
|
|
276
302
|
#
|
|
277
303
|
# **The children are re-invalidated whether or not they tile.** A container
|
|
278
304
|
# that paints nothing of its own can only redraw its area *through* them, so
|
|
@@ -290,7 +316,10 @@ module Tuile
|
|
|
290
316
|
def repaint
|
|
291
317
|
return if rect.empty?
|
|
292
318
|
|
|
293
|
-
|
|
319
|
+
unless children.any? && children_tile_rect?
|
|
320
|
+
clear_outside_extent
|
|
321
|
+
clear_inside_extent if extent && children.any?
|
|
322
|
+
end
|
|
294
323
|
invalidate_children
|
|
295
324
|
end
|
|
296
325
|
|
|
@@ -299,56 +328,105 @@ module Tuile
|
|
|
299
328
|
# it's on the focus chain — or when app code hands it one directly — so act
|
|
300
329
|
# on the key alone and never gate on your own {#active?} state. See book ch5
|
|
301
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.
|
|
302
334
|
# @param _key [String] a key.
|
|
303
335
|
# @return [Boolean] true if the key was handled, false if not.
|
|
304
|
-
def handle_key(_key)
|
|
336
|
+
def handle_key?(_key)
|
|
305
337
|
false
|
|
306
338
|
end
|
|
307
339
|
|
|
308
340
|
# Called when text is pasted while this component is {Screen#focused};
|
|
309
|
-
# override to accept it
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
# its descendant declined. The text arrives whole and `\n`-normalized, so
|
|
313
|
-
# `text.lines.size` is the paste's line count and a single mutation can
|
|
314
|
-
# absorb it:
|
|
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:
|
|
315
344
|
#
|
|
316
345
|
# def handle_paste(text)
|
|
317
346
|
# self.caption = "[Pasted #{text.lines.size} lines]"
|
|
318
|
-
# true
|
|
319
347
|
# end
|
|
320
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
|
+
#
|
|
321
353
|
# Reaching here means the terminal said "this came from the clipboard" —
|
|
322
354
|
# {Component::AbstractStringField} inserts it at the caret, which is why a
|
|
323
355
|
# subclass that rebinds ENTER to submit needs no paste handling of its own
|
|
324
356
|
# to stop firing once per pasted line.
|
|
325
357
|
# @param _text [String] the pasted text.
|
|
326
|
-
# @return [
|
|
327
|
-
def handle_paste(_text)
|
|
328
|
-
false
|
|
329
|
-
end
|
|
358
|
+
# @return [void]
|
|
359
|
+
def handle_paste(_text); end
|
|
330
360
|
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
#
|
|
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
|
|
334
370
|
#
|
|
335
|
-
#
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
#
|
|
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]
|
|
405
|
+
# @return [void]
|
|
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.
|
|
339
419
|
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
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.
|
|
343
423
|
# @return [void]
|
|
344
|
-
def
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
children.dup.each { |c| c.handle_mouse(event) if c.visible? && c.rect.contains?(event.point) }
|
|
351
|
-
end
|
|
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
|
|
352
430
|
|
|
353
431
|
# @return [Boolean] true if the component is on the active chain — i.e. it
|
|
354
432
|
# is the focused component or an ancestor of it. Set by {Screen#focused=}.
|
|
@@ -415,61 +493,61 @@ module Tuile
|
|
|
415
493
|
# @yieldparam component [Component]
|
|
416
494
|
# @yieldreturn [void]
|
|
417
495
|
# @return [void]
|
|
418
|
-
def
|
|
496
|
+
def walk_tree(&block)
|
|
419
497
|
block.call(self)
|
|
420
|
-
children.each { _1.
|
|
498
|
+
children.each { _1.walk_tree(&block) }
|
|
421
499
|
end
|
|
422
500
|
|
|
423
|
-
# {#
|
|
501
|
+
# {#walk_tree}, pruned: a hidden subtree is skipped whole, this component
|
|
424
502
|
# included when it is itself hidden (in which case nothing is yielded).
|
|
425
503
|
#
|
|
426
504
|
# stops = []
|
|
427
|
-
# scope.
|
|
505
|
+
# scope.walk_shown_tree { |c| stops << c if c.tab_stop? }
|
|
428
506
|
#
|
|
429
|
-
# **Use this for anything asking "can the user reach it"**, {#
|
|
507
|
+
# **Use this for anything asking "can the user reach it"**, {#walk_tree} for
|
|
430
508
|
# what the framework does *to* a component regardless — lifecycle, theme
|
|
431
509
|
# fan-out, invalidation — which a hidden component still gets. Writing the
|
|
432
|
-
# first as `
|
|
510
|
+
# first as `walk_tree` plus a `visible?` test is the trap: that is this walk
|
|
433
511
|
# with the ancestor case missing, so a field under a hidden panel is back
|
|
434
512
|
# in the Tab cycle (`D_visibility`).
|
|
435
513
|
# @yield [component]
|
|
436
514
|
# @yieldparam component [Component]
|
|
437
515
|
# @yieldreturn [void]
|
|
438
516
|
# @return [void]
|
|
439
|
-
def
|
|
517
|
+
def walk_shown_tree(&block)
|
|
440
518
|
return unless visible?
|
|
441
519
|
|
|
442
520
|
block.call(self)
|
|
443
|
-
children.each { _1.
|
|
521
|
+
children.each { _1.walk_shown_tree(&block) }
|
|
444
522
|
end
|
|
445
523
|
|
|
446
524
|
# Called when the component receives focus — on this component alone, never
|
|
447
|
-
# on the ancestors that light up with it. {#
|
|
525
|
+
# on the ancestors that light up with it. {#handle_blur} is the other half.
|
|
448
526
|
#
|
|
449
|
-
# Unlike `
|
|
527
|
+
# Unlike `handle_blur` it is **not** edge-triggered: it fires on every
|
|
450
528
|
# {Screen#focused=}, re-assigning the component that already has focus
|
|
451
529
|
# included, which is what lets a container forward focus into its content
|
|
452
530
|
# from here.
|
|
453
531
|
# @return [void]
|
|
454
|
-
def
|
|
532
|
+
def handle_focus; end
|
|
455
533
|
|
|
456
|
-
# Optional zero-arg listener fired by the base {#
|
|
534
|
+
# Optional zero-arg listener fired by the base {#handle_theme_changed} — the
|
|
457
535
|
# composition-style alternative to overriding the method, for apps that
|
|
458
536
|
# assemble stock components rather than subclass:
|
|
459
537
|
#
|
|
460
538
|
# label.on_theme_changed = -> { label.text = render_status_line }
|
|
461
539
|
#
|
|
462
540
|
# @return [Proc, nil]
|
|
463
|
-
|
|
541
|
+
attr_accessor :on_theme_changed
|
|
464
542
|
|
|
465
|
-
# Optional zero-arg listener fired by the base {#
|
|
543
|
+
# Optional zero-arg listener fired by the base {#handle_locale_changed} — the
|
|
466
544
|
# composition-style alternative to overriding the method, for an app that
|
|
467
545
|
# rendered a date or a number into a stock component:
|
|
468
546
|
#
|
|
469
547
|
# label.on_locale_changed = -> { label.text = due_date.strftime(fmt) }
|
|
470
548
|
#
|
|
471
549
|
# @return [Proc, nil]
|
|
472
|
-
|
|
550
|
+
attr_accessor :on_locale_changed
|
|
473
551
|
|
|
474
552
|
# Whether this component's tree is mounted on a UI, {ScreenPane} being the
|
|
475
553
|
# root of every displayed tree.
|
|
@@ -496,9 +574,12 @@ module Tuile
|
|
|
496
574
|
# passes the *hidden* child, which is still in `children` with `self` as
|
|
497
575
|
# its parent: an override may repair focus however it likes, but must not
|
|
498
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`.
|
|
499
580
|
# @param child [Component] the just-detached, or just-hidden, child.
|
|
500
581
|
# @return [void]
|
|
501
|
-
def
|
|
582
|
+
def handle_child_removed(child)
|
|
502
583
|
return unless attached?
|
|
503
584
|
|
|
504
585
|
f = screen.focused
|
|
@@ -508,7 +589,7 @@ module Tuile
|
|
|
508
589
|
until cursor.nil?
|
|
509
590
|
if cursor == child
|
|
510
591
|
screen.focused = self
|
|
511
|
-
|
|
592
|
+
break
|
|
512
593
|
end
|
|
513
594
|
cursor = cursor.parent
|
|
514
595
|
end
|
|
@@ -574,7 +655,7 @@ module Tuile
|
|
|
574
655
|
child.parent = self
|
|
575
656
|
end
|
|
576
657
|
|
|
577
|
-
# Drops `child` and notifies {#
|
|
658
|
+
# Drops `child` and notifies {#handle_child_removed}.
|
|
578
659
|
# @param child [Component]
|
|
579
660
|
# @raise [ArgumentError] if `child` is not a child of this component.
|
|
580
661
|
# @return [void]
|
|
@@ -583,16 +664,16 @@ module Tuile
|
|
|
583
664
|
# pointer in the same call, which is what keeps them in agreement.
|
|
584
665
|
def remove_child(child)
|
|
585
666
|
detach_child(child)
|
|
586
|
-
|
|
667
|
+
handle_child_removed(child)
|
|
587
668
|
end
|
|
588
669
|
|
|
589
670
|
# Drops `child` *without* notifying — for a container swapping a named slot,
|
|
590
|
-
# which owes the {#
|
|
671
|
+
# which owes the {#handle_child_removed} call once the new occupant is wired:
|
|
591
672
|
#
|
|
592
673
|
# detach_child(old)
|
|
593
674
|
# @content = new
|
|
594
675
|
# add_child(new, at: 0)
|
|
595
|
-
#
|
|
676
|
+
# handle_child_removed(old) # focus repair cascades into the *new* content
|
|
596
677
|
#
|
|
597
678
|
# The child leaves {#children} before its pointer is cleared, so nothing
|
|
598
679
|
# observes a child whose parent has disowned it while still listing it.
|
|
@@ -613,38 +694,38 @@ module Tuile
|
|
|
613
694
|
# i.e. when {#attached?} flips to true — the place to acquire whatever is
|
|
614
695
|
# supposed to live for exactly as long as the component is on screen:
|
|
615
696
|
#
|
|
616
|
-
# def
|
|
697
|
+
# def handle_attached
|
|
617
698
|
# @ticker = screen.event_queue.tick_fps(10) { advance }
|
|
618
699
|
# end
|
|
619
700
|
#
|
|
620
|
-
# def
|
|
701
|
+
# def handle_detached
|
|
621
702
|
# @ticker&.cancel
|
|
622
703
|
# @ticker = nil
|
|
623
704
|
# end
|
|
624
705
|
#
|
|
625
|
-
# `
|
|
706
|
+
# `handle_attached` starts what `handle_detached` stops; both must be cheap and
|
|
626
707
|
# idempotent, since a component moved between parents is genuinely detached
|
|
627
708
|
# in between and gets both, in that order. Whatever you acquire here you
|
|
628
|
-
# must release in {#
|
|
629
|
-
# 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}.
|
|
630
711
|
#
|
|
631
712
|
# {#invalidate} needs no guard: {#attached?} is already true here (and
|
|
632
|
-
# already false in {#
|
|
713
|
+
# already false in {#handle_detached}, where it no-ops). Do not read {#rect} —
|
|
633
714
|
# a parent assigns it *after* wiring, so it is still stale. Runs on the
|
|
634
715
|
# thread that owns the UI.
|
|
635
716
|
# @return [void]
|
|
636
|
-
def
|
|
717
|
+
def handle_attached; end
|
|
637
718
|
|
|
638
|
-
# Mirror of {#
|
|
719
|
+
# Mirror of {#handle_attached}, called once the tree has been unmounted — see
|
|
639
720
|
# there for the contract. Two things are still mid-flight when it runs, both
|
|
640
721
|
# deliberate: {Screen#focused} may still point into this subtree (repair
|
|
641
722
|
# happens after), and the ex-parent's own bookkeeping may not be finished.
|
|
642
723
|
# So release resources here and don't inspect the tree around you.
|
|
643
724
|
# @return [void]
|
|
644
|
-
def
|
|
725
|
+
def handle_detached; end
|
|
645
726
|
|
|
646
727
|
# Rewires the parent pointer and, when that changes whether the component is
|
|
647
|
-
# {#attached?}, fires {#
|
|
728
|
+
# {#attached?}, fires {#handle_attached} / {#handle_detached} across the whole
|
|
648
729
|
# subtree. The sole firing site: `add_child` / `detach_child` are the only
|
|
649
730
|
# callers, and they update {#children} *before* calling this, so a hook sees
|
|
650
731
|
# a tree whose list and pointers already agree.
|
|
@@ -674,28 +755,31 @@ module Tuile
|
|
|
674
755
|
# current attachedness rather than on `parent.equal?(self)`: a child pulled
|
|
675
756
|
# out during a detach walk is *already* detached, so its own `parent=` saw
|
|
676
757
|
# no transition and stayed silent — a parentage check would skip it too and
|
|
677
|
-
# it would never hear `
|
|
678
|
-
# during an *attach* walk) gets `
|
|
679
|
-
# `
|
|
680
|
-
# 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
|
|
681
762
|
# component that is no longer attached would start a ticker nothing stops.
|
|
682
763
|
#
|
|
683
|
-
# @param attached [Boolean] true to fire {#
|
|
764
|
+
# @param attached [Boolean] true to fire {#handle_attached}, false for {#handle_detached}.
|
|
684
765
|
# @return [void]
|
|
685
766
|
def fire_lifecycle(attached)
|
|
686
767
|
kids = children.dup
|
|
687
|
-
attached ?
|
|
768
|
+
attached ? handle_attached : handle_detached
|
|
688
769
|
kids.each { _1.fire_lifecycle(attached) if _1.attached? == attached }
|
|
689
770
|
end
|
|
690
771
|
|
|
691
772
|
# Called whenever the component width changes. Does nothing by default.
|
|
692
773
|
# @return [void]
|
|
693
|
-
def
|
|
774
|
+
def handle_width_changed; end
|
|
694
775
|
|
|
695
776
|
# Called on the parent after a direct child's {#visible=} flipped, so a
|
|
696
777
|
# container that divides space can re-divide it:
|
|
697
778
|
#
|
|
698
|
-
# def
|
|
779
|
+
# def handle_child_visibility_changed(_child)
|
|
780
|
+
# super
|
|
781
|
+
# relayout
|
|
782
|
+
# end
|
|
699
783
|
#
|
|
700
784
|
# **A container with layout arithmetic owes this override**, or a hidden
|
|
701
785
|
# child keeps its slot and its gap — the hole the flag exists to close.
|
|
@@ -703,27 +787,31 @@ module Tuile
|
|
|
703
787
|
# arithmetic, and an app wanting the space back reads `visible?` there.
|
|
704
788
|
#
|
|
705
789
|
# Fires on the flip only, before the subtree is invalidated, never for a
|
|
706
|
-
# grandchild. {#visible=} repairs focus itself, so an override
|
|
707
|
-
#
|
|
708
|
-
# (`D_hook_visibility`).
|
|
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`).
|
|
709
793
|
# @param _child [Component] the direct child whose flag changed.
|
|
710
794
|
# @return [void]
|
|
711
|
-
def
|
|
795
|
+
def handle_child_visibility_changed(_child); end
|
|
712
796
|
|
|
713
|
-
# Mirror of {#
|
|
797
|
+
# Mirror of {#handle_focus}: the component just lost focus, to another component
|
|
714
798
|
# or to nothing. The commit point a Tab-away still reaches — Tab is
|
|
715
799
|
# unconditional, so {Component::TextField#on_enter} never fires for a user
|
|
716
800
|
# who tabs out of a half-typed field:
|
|
717
801
|
#
|
|
718
802
|
# class TrimmedField < Component::TextField
|
|
719
|
-
# protected def
|
|
803
|
+
# protected def handle_blur
|
|
804
|
+
# super
|
|
805
|
+
# self.text = text.strip
|
|
806
|
+
# false
|
|
807
|
+
# end
|
|
720
808
|
# end
|
|
721
809
|
#
|
|
722
810
|
# Edge-triggered, and fired on the blurred component alone — never on the
|
|
723
811
|
# ancestors leaving the active chain with it, so a composed widget asking
|
|
724
812
|
# "did focus leave me *and* my children" overrides {#active=} instead
|
|
725
813
|
# ({Component::ComboBox} closes its dropdown from there). Focus that merely
|
|
726
|
-
# *passes through* does blur: a container forwarding focus from {#
|
|
814
|
+
# *passes through* does blur: a container forwarding focus from {#handle_focus}
|
|
727
815
|
# is blurred by its own forward.
|
|
728
816
|
#
|
|
729
817
|
# A notification, not a veto — focus has already moved, and the active-flag
|
|
@@ -736,12 +824,12 @@ module Tuile
|
|
|
736
824
|
# It fires wherever focus is *dropped*, not only where a user moved it, so
|
|
737
825
|
# two paths reach it with the tree mid-flight: the popup-close repair blurs
|
|
738
826
|
# an **already-detached** component, where {#invalidate} is the same silent
|
|
739
|
-
# no-op as in {#
|
|
827
|
+
# no-op as in {#handle_detached}, and {Screen#close} blurs on its way out. Keep
|
|
740
828
|
# it cheap; a raise propagates out of {Screen#focused=}. Protected because
|
|
741
829
|
# the framework calls it and an app never does — {Screen} reaches it with
|
|
742
830
|
# `__send__`, so an override may declare any visibility (`D_hook_visibility`).
|
|
743
831
|
# @return [void]
|
|
744
|
-
def
|
|
832
|
+
def handle_blur; end
|
|
745
833
|
|
|
746
834
|
# Called on every attached component (pre-order, popups included) when
|
|
747
835
|
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
|
|
@@ -759,7 +847,8 @@ module Tuile
|
|
|
759
847
|
# {Screen}, not being a {Component}, fans it out through `__send__`, so an
|
|
760
848
|
# override is free to declare any visibility (`D_hook_visibility`).
|
|
761
849
|
# @return [void]
|
|
762
|
-
|
|
850
|
+
# is whatever the app's lambda happened to return.
|
|
851
|
+
def handle_theme_changed
|
|
763
852
|
@on_theme_changed&.call
|
|
764
853
|
end
|
|
765
854
|
|
|
@@ -777,7 +866,8 @@ module Tuile
|
|
|
777
866
|
# fans it out through `__send__`, so an override may declare any visibility
|
|
778
867
|
# (`D_hook_visibility`).
|
|
779
868
|
# @return [void]
|
|
780
|
-
|
|
869
|
+
# is whatever the app's lambda happened to return.
|
|
870
|
+
def handle_locale_changed
|
|
781
871
|
@on_locale_changed&.call
|
|
782
872
|
end
|
|
783
873
|
|
|
@@ -840,6 +930,24 @@ module Tuile
|
|
|
840
930
|
clear_background(below, bg) unless below.empty?
|
|
841
931
|
end
|
|
842
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
|
+
|
|
843
951
|
# The background this component paints when the app has set no {#bg_color} —
|
|
844
952
|
# `nil` by default, meaning "I have no surface of my own; whatever is behind
|
|
845
953
|
# me shows through". A widget that paints an opaque surface overrides it, and
|
|
@@ -856,7 +964,7 @@ module Tuile
|
|
|
856
964
|
# the cheap form and allocates nothing on the paint path.
|
|
857
965
|
#
|
|
858
966
|
# Read the theme here rather than in an ivar: this runs at paint time, so a
|
|
859
|
-
# {Screen#theme=} restyles the widget with no {#
|
|
967
|
+
# {Screen#theme=} restyles the widget with no {#handle_theme_changed} hook.
|
|
860
968
|
# @return [Color, Theme::Ref, Hash, nil]
|
|
861
969
|
def default_bg_color = nil
|
|
862
970
|
|
|
@@ -959,7 +1067,7 @@ module Tuile
|
|
|
959
1067
|
private
|
|
960
1068
|
|
|
961
1069
|
# Hands focus out of the subtree just hidden, if it was in there, through
|
|
962
|
-
# the parent's {#
|
|
1070
|
+
# the parent's {#handle_child_removed} — see there for why hiding reuses the
|
|
963
1071
|
# removal repair instead of growing a second one.
|
|
964
1072
|
#
|
|
965
1073
|
# The parent is necessarily showing (focus was inside it a moment ago, and
|
|
@@ -970,7 +1078,7 @@ module Tuile
|
|
|
970
1078
|
|
|
971
1079
|
cursor = screen.focused
|
|
972
1080
|
cursor = cursor.parent until cursor.nil? || cursor.equal?(self)
|
|
973
|
-
parent.
|
|
1081
|
+
parent.handle_child_removed(self) unless cursor.nil?
|
|
974
1082
|
end
|
|
975
1083
|
|
|
976
1084
|
# What surrounds this component — an app-set {#bg_color}, else whatever the
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -109,7 +109,7 @@ module Tuile
|
|
|
109
109
|
# event-handler error, instead of bypassing it.
|
|
110
110
|
# @yield [event] called for each posted event.
|
|
111
111
|
# @yieldparam event [Object] a posted event — typically a {KeyEvent},
|
|
112
|
-
# {
|
|
112
|
+
# {Mouse::Event}, {TTYSizeEvent}, {EmptyQueueEvent}, a `Proc` from {#submit},
|
|
113
113
|
# or any object pushed via {#post}. {ErrorEvent}s are not yielded — they
|
|
114
114
|
# terminate the loop directly.
|
|
115
115
|
# @yieldreturn [void]
|
|
@@ -147,7 +147,7 @@ module Tuile
|
|
|
147
147
|
def running? = @run_lock.locked?
|
|
148
148
|
|
|
149
149
|
# @return [Boolean] true if this thread is the one running {#run_loop}.
|
|
150
|
-
def
|
|
150
|
+
def in_loop_thread? = @run_lock.owned?
|
|
151
151
|
|
|
152
152
|
# Stops ongoing {#run_loop}. The stop may not be immediate: {#run_loop} may
|
|
153
153
|
# process a bunch of events before terminating.
|
|
@@ -175,7 +175,7 @@ module Tuile
|
|
|
175
175
|
#
|
|
176
176
|
# {Screen#event_loop} routes it to {Component#handle_paste} down the focus
|
|
177
177
|
# chain. It never enters the key ladder: no Tab traversal, no global
|
|
178
|
-
# shortcut, no {Component#handle_key}.
|
|
178
|
+
# shortcut, no {Component#handle_key?}.
|
|
179
179
|
#
|
|
180
180
|
# @!attribute [r] text
|
|
181
181
|
# @return [String] the pasted text, `\n`-normalized by
|
|
@@ -363,7 +363,7 @@ module Tuile
|
|
|
363
363
|
event = if key == Keys::PASTE_START
|
|
364
364
|
PasteEvent.new(Keys.read_paste)
|
|
365
365
|
else
|
|
366
|
-
|
|
366
|
+
Mouse.parse(key) || ColorSchemeEvent.parse(key) ||
|
|
367
367
|
BackgroundColorEvent.parse(key) || KeyEvent.new(key)
|
|
368
368
|
end
|
|
369
369
|
post event
|