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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -80
  3. data/README.md +28 -12
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +95 -26
  6. data/book/06-theming.md +58 -26
  7. data/book/07-components.md +61 -6
  8. data/book/08-testing.md +24 -22
  9. data/book/10-locale.md +2 -2
  10. data/book/README.md +6 -5
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +392 -18
  14. data/lib/tuile/component/abstract_string_field.rb +16 -18
  15. data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
  16. data/lib/tuile/component/button.rb +8 -8
  17. data/lib/tuile/component/checkbox.rb +9 -9
  18. data/lib/tuile/component/checkbox_group.rb +6 -5
  19. data/lib/tuile/component/combo_box.rb +50 -35
  20. data/lib/tuile/component/confirm_window.rb +7 -5
  21. data/lib/tuile/component/date_field.rb +28 -3
  22. data/lib/tuile/component/date_time_field.rb +275 -0
  23. data/lib/tuile/component/has_bad_input.rb +2 -2
  24. data/lib/tuile/component/has_content.rb +3 -3
  25. data/lib/tuile/component/has_placeholder.rb +1 -1
  26. data/lib/tuile/component/has_validation.rb +2 -2
  27. data/lib/tuile/component/has_value.rb +1 -1
  28. data/lib/tuile/component/label.rb +1 -1
  29. data/lib/tuile/component/layout/box.rb +4 -1
  30. data/lib/tuile/component/layout.rb +3 -3
  31. data/lib/tuile/component/list.rb +42 -32
  32. data/lib/tuile/component/list_dropdown.rb +3 -3
  33. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  34. data/lib/tuile/component/menu_bar.rb +18 -18
  35. data/lib/tuile/component/notification.rb +32 -18
  36. data/lib/tuile/component/overlay.rb +9 -8
  37. data/lib/tuile/component/picker_window.rb +27 -8
  38. data/lib/tuile/component/popup.rb +2 -2
  39. data/lib/tuile/component/progress_bar.rb +10 -4
  40. data/lib/tuile/component/radio_group.rb +6 -5
  41. data/lib/tuile/component/select.rb +11 -12
  42. data/lib/tuile/component/slot.rb +3 -3
  43. data/lib/tuile/component/tab_sheet.rb +6 -6
  44. data/lib/tuile/component/tabs.rb +11 -11
  45. data/lib/tuile/component/text_area.rb +12 -10
  46. data/lib/tuile/component/text_field.rb +14 -12
  47. data/lib/tuile/component/text_view.rb +15 -11
  48. data/lib/tuile/component/time_field.rb +29 -4
  49. data/lib/tuile/component.rb +201 -93
  50. data/lib/tuile/event_queue.rb +4 -4
  51. data/lib/tuile/fake_event_queue.rb +1 -1
  52. data/lib/tuile/fake_screen.rb +84 -2
  53. data/lib/tuile/mouse/router.rb +217 -0
  54. data/lib/tuile/mouse.rb +177 -0
  55. data/lib/tuile/screen.rb +98 -61
  56. data/lib/tuile/screen_pane.rb +41 -36
  57. data/lib/tuile/styled_string.rb +5 -5
  58. data/lib/tuile/testing.rb +8 -8
  59. data/lib/tuile/theme.rb +22 -34
  60. data/lib/tuile/version.rb +1 -1
  61. data/lib/tuile/vertical_scroll_bar.rb +1 -1
  62. data/sig/tuile.rbs +1211 -427
  63. metadata +4 -16
  64. data/COMPARISON.md +0 -101
  65. data/DECISIONS.md +0 -8562
  66. data/TERMINOLOGY.md +0 -85
  67. data/ideas/arrow-key-navigation.md +0 -221
  68. data/ideas/binder.md +0 -177
  69. data/ideas/composite-field.md +0 -77
  70. data/ideas/focus-accent.md +0 -116
  71. data/ideas/form-layout.md +0 -151
  72. data/ideas/hover/probe.rb +0 -241
  73. data/ideas/hover/probe_spec.rb +0 -82
  74. data/ideas/hover.md +0 -909
  75. data/ideas/modal-backdrop.md +0 -24
  76. data/ideas/new-components.md +0 -144
  77. data/ideas/per-component-buffers.md +0 -55
  78. data/lib/tuile/mouse_event.rb +0 -68
@@ -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 (`DECISIONS.md` `D_box_layouts`), so a
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, {#handle_mouse} hit-tests
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?(event.point)` in a {#handle_mouse}, and
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
- on_width_changed if prev_width != new_rect.width
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
- # {#on_child_removed}), and does not hand it back on the way in.
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__(:on_child_visibility_changed, self)
201
+ parent&.__send__(:handle_child_visibility_changed, self)
179
202
  repair_focus_after_hiding unless value
180
- on_tree { |c| screen.invalidate(c) } if attached?
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 {#on_theme_changed} hook; a {Color} is fixed:
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
- on_tree { |c| screen.invalidate(c) } if attached?
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
- clear_outside_extent unless children.any? && children_tile_rect?
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 (the default reports every paste unhandled, and
310
- # unhandled text is dropped). Unlike a key, a paste does **not** bubble —
311
- # only the focused component is offered it, so an ancestor never sees one
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 [Boolean] true if the paste was consumed.
327
- def handle_paste(_text)
328
- false
329
- end
358
+ # @return [void]
359
+ def handle_paste(_text); end
330
360
 
331
- # Focuses this component when left-clicked (if {#focusable?}), then hands the
332
- # event down to every child whose {#rect} contains the point — which is how a
333
- # click descends the tiled tree to a leaf.
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
- # A widget that resolves clicks *inside* its own rect — mapping a point to a
336
- # row, or toggling an overlay — overrides this and does not call `super`.
337
- # Such an override hit-tests {#extent_rect} rather than {#rect}, so a click
338
- # on the tail it doesn't paint never activates it.
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
- # The walk stops at a hidden child, so a hidden component is never reached
341
- # and an override needs no `visible?` check of its own.
342
- # @param event [MouseEvent]
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 handle_mouse(event)
345
- screen.focused = self unless event.button != :left || active? || !focusable?
346
- # Snapshot: a handler may add or remove siblings (a click that swaps a
347
- # slot's occupant), and `each` over a mutating array skips an entry.
348
- # A hidden child is skipped whatever its rect says — it keeps the rect it
349
- # had, so in an Absolute the point still lands inside it.
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 on_tree(&block)
496
+ def walk_tree(&block)
419
497
  block.call(self)
420
- children.each { _1.on_tree(&block) }
498
+ children.each { _1.walk_tree(&block) }
421
499
  end
422
500
 
423
- # {#on_tree}, pruned: a hidden subtree is skipped whole, this component
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.on_shown_tree { |c| stops << c if c.tab_stop? }
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"**, {#on_tree} for
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 `on_tree` plus a `visible?` test is the trap: that is this walk
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 on_shown_tree(&block)
517
+ def walk_shown_tree(&block)
440
518
  return unless visible?
441
519
 
442
520
  block.call(self)
443
- children.each { _1.on_shown_tree(&block) }
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. {#on_blur} is the other half.
525
+ # on the ancestors that light up with it. {#handle_blur} is the other half.
448
526
  #
449
- # Unlike `on_blur` it is **not** edge-triggered: it fires on every
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 on_focus; end
532
+ def handle_focus; end
455
533
 
456
- # Optional zero-arg listener fired by the base {#on_theme_changed} — the
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
- attr_writer :on_theme_changed
541
+ attr_accessor :on_theme_changed
464
542
 
465
- # Optional zero-arg listener fired by the base {#on_locale_changed} — the
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
- attr_writer :on_locale_changed
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 on_child_removed(child)
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
- return
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 {#on_child_removed}.
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
- on_child_removed(child)
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 {#on_child_removed} call once the new occupant is wired:
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
- # on_child_removed(old) # focus repair cascades into the *new* content
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 on_attached
697
+ # def handle_attached
617
698
  # @ticker = screen.event_queue.tick_fps(10) { advance }
618
699
  # end
619
700
  #
620
- # def on_detached
701
+ # def handle_detached
621
702
  # @ticker&.cancel
622
703
  # @ticker = nil
623
704
  # end
624
705
  #
625
- # `on_attached` starts what `on_detached` stops; both must be cheap and
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 {#on_detached} — nothing else will. Not a destructor:
629
- # process teardown does *not* fire {#on_detached}.
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 {#on_detached}, where it no-ops). Do not read {#rect} —
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 on_attached; end
717
+ def handle_attached; end
637
718
 
638
- # Mirror of {#on_attached}, called once the tree has been unmounted — see
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 on_detached; end
725
+ def handle_detached; end
645
726
 
646
727
  # Rewires the parent pointer and, when that changes whether the component is
647
- # {#attached?}, fires {#on_attached} / {#on_detached} across the whole
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 `on_detached` at all. The reverse case (pulled out
678
- # during an *attach* walk) gets `on_detached` from its own `parent=` and no
679
- # `on_attached`, which is why the hooks are required to be idempotent: an
680
- # unpaired detach releases nothing, whereas firing `on_attached` at a
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 {#on_attached}, false for {#on_detached}.
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 ? on_attached : on_detached
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 on_width_changed; end
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 on_child_visibility_changed(_child) = relayout
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 needs no
707
- # `super`. Reached through `__send__`, so it may declare any visibility
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 on_child_visibility_changed(_child); end
795
+ def handle_child_visibility_changed(_child); end
712
796
 
713
- # Mirror of {#on_focus}: the component just lost focus, to another component
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 on_blur = (self.text = text.strip)
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 {#on_focus}
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 {#on_detached}, and {Screen#close} blurs on its way out. Keep
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 on_blur; end
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
- def on_theme_changed
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
- def on_locale_changed
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 {#on_theme_changed} hook.
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 {#on_child_removed} — see there for why hiding reuses the
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.on_child_removed(self) unless cursor.nil?
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
@@ -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
- # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, a `Proc` from {#submit},
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 on_loop_thread? = @run_lock.owned?
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
- MouseEvent.parse(key) || ColorSchemeEvent.parse(key) ||
366
+ Mouse.parse(key) || ColorSchemeEvent.parse(key) ||
367
367
  BackgroundColorEvent.parse(key) || KeyEvent.new(key)
368
368
  end
369
369
  post event
@@ -18,7 +18,7 @@ module Tuile
18
18
  # @return [Boolean] always false — {#run_loop} raises, so no loop ever runs.
19
19
  def running? = false
20
20
  # @return [Boolean] always true.
21
- def on_loop_thread? = true
21
+ def in_loop_thread? = true
22
22
  # @return [void]
23
23
  def stop; end
24
24