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
data/sig/tuile.rbs CHANGED
@@ -454,6 +454,321 @@ module Tuile
454
454
  def verify_final!: (Class klass) -> void
455
455
  end
456
456
 
457
+ # The mouse: the events, their parser, and the tracking levels
458
+ # {Screen#run_event_loop} asks the terminal for. Who gets one is
459
+ # {Mouse::Router}'s story.
460
+ #
461
+ # Four of the five classes are what {.parse} reads off the wire; {DragEvent} is
462
+ # the router's own, a move while something holds the grab. Each is a
463
+ # `Data.define` including the {Event} marker — no inheritance, so a `case`
464
+ # matches either one class or `Mouse::Event` for all of them. Enter and exit
465
+ # have no class at all: nothing is parsed, they are the difference between two
466
+ # hovered chains.
467
+ #
468
+ # Coordinates are screen-absolute and 0-based everywhere.
469
+ module Mouse
470
+ LEVELS: ::Array[Symbol]
471
+ MODES: ::Hash[Symbol, Integer]
472
+ MODIFIER_BITS: Integer
473
+
474
+ # Normalizes a `capture_mouse:` argument to a level.
475
+ #
476
+ # _@param_ `capture_mouse` — `false`, `true` (== `:clicks`), or one of {LEVELS}.
477
+ #
478
+ # _@return_ — the level; nil when tracking is off.
479
+ def self.level: ((bool | Symbol) capture_mouse) -> Symbol?
480
+
481
+ # _@param_ `level` — one of {LEVELS}.
482
+ #
483
+ # _@return_ — the escape enabling that level.
484
+ def self.start_tracking: (Symbol level) -> String
485
+
486
+ # _@param_ `level` — one of {LEVELS}.
487
+ #
488
+ # _@return_ — the escape disabling that level.
489
+ def self.stop_tracking: (Symbol level) -> String
490
+
491
+ # Whether `key` is a mouse report. True on the X10 `\e[M` prefix
492
+ # regardless of length — {.parse} is the place that validates the full
493
+ # 6-byte shape and raises on malformed input.
494
+ #
495
+ # _@param_ `key` — key read via {Keys.getkey}
496
+ def self.report?: (String key) -> bool
497
+
498
+ # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y) into one of
499
+ # {DownEvent}, {UpEvent}, {ScrollEvent} or {MoveEvent}. Modifier bits are
500
+ # ignored.
501
+ #
502
+ # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
503
+ # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
504
+ # the upstream key-reader: a shorter prefix means the tail was lost on
505
+ # the way in, and a longer one means we over-consumed into the next
506
+ # escape sequence. We refuse to silently truncate either case because
507
+ # the trailing `\e` of an over-read corrupts the *next* getkey, and the
508
+ # corruption then surfaces as garbled keystrokes in focused inputs
509
+ # rather than as a parser failure pointing at the actual cause.
510
+ #
511
+ # _@param_ `key` — key read via {Keys.getkey}
512
+ #
513
+ # _@return_ — `nil` if `key` is not a mouse report, or reports
514
+ # a wheel button beyond the four directions.
515
+ def self.parse: (String key) -> Event?
516
+
517
+ # _@param_ `low` — the code's two low bits.
518
+ def self.button: (Integer low) -> Symbol?
519
+
520
+ # Included by every mouse event class: a marker for `case`/`is_a?`, plus the
521
+ # {#point} they share.
522
+ module Event
523
+ # _@return_ — the event's position.
524
+ def point: () -> Point
525
+ end
526
+
527
+ # A button went down.
528
+ #
529
+ # @!attribute [r] button
530
+ # @return [Symbol] `:left`, `:middle` or `:right`.
531
+ # @!attribute [r] x
532
+ # @return [Integer] 0-based column.
533
+ # @!attribute [r] y
534
+ # @return [Integer] 0-based row.
535
+ class DownEvent < Data
536
+ include Tuile::Mouse::Event
537
+
538
+ # _@return_ — the event's position.
539
+ def point: () -> Point
540
+
541
+ # _@return_ — `:left`, `:middle` or `:right`.
542
+ attr_reader button: Symbol
543
+
544
+ # _@return_ — 0-based column.
545
+ attr_reader x: Integer
546
+
547
+ # _@return_ — 0-based row.
548
+ attr_reader y: Integer
549
+ end
550
+
551
+ # A button came up. **Deliberately no `button`**: an up reaches only the
552
+ # component that claimed the press, and that grab already knows which button
553
+ # it holds (`D_mouse_dispatch`).
554
+ #
555
+ # @!attribute [r] x
556
+ # @return [Integer] 0-based column.
557
+ # @!attribute [r] y
558
+ # @return [Integer] 0-based row.
559
+ class UpEvent < Data
560
+ include Tuile::Mouse::Event
561
+
562
+ # _@return_ — the event's position.
563
+ def point: () -> Point
564
+
565
+ # _@return_ — 0-based column.
566
+ attr_reader x: Integer
567
+
568
+ # _@return_ — 0-based row.
569
+ attr_reader y: Integer
570
+ end
571
+
572
+ # One wheel notch. A notch is not a button, which is why this is not a
573
+ # {DownEvent}.
574
+ #
575
+ # @!attribute [r] direction
576
+ # @return [Symbol] `:up`, `:down`, `:left` or `:right`.
577
+ # @!attribute [r] x
578
+ # @return [Integer] 0-based column.
579
+ # @!attribute [r] y
580
+ # @return [Integer] 0-based row.
581
+ class ScrollEvent < Data
582
+ include Tuile::Mouse::Event
583
+
584
+ # _@return_ — the event's position.
585
+ def point: () -> Point
586
+
587
+ # _@return_ — `:up`, `:down`, `:left` or `:right`.
588
+ attr_reader direction: Symbol
589
+
590
+ # _@return_ — 0-based column.
591
+ attr_reader x: Integer
592
+
593
+ # _@return_ — 0-based row.
594
+ attr_reader y: Integer
595
+ end
596
+
597
+ # The pointer moved and nothing holds the grab.
598
+ #
599
+ # @!attribute [r] button
600
+ # @return [Symbol, nil] the button held while moving, or nil.
601
+ # @!attribute [r] x
602
+ # @return [Integer] 0-based column.
603
+ # @!attribute [r] y
604
+ # @return [Integer] 0-based row.
605
+ class MoveEvent < Data
606
+ include Tuile::Mouse::Event
607
+
608
+ # _@return_ — the event's position.
609
+ def point: () -> Point
610
+
611
+ # _@return_ — the button held while moving, or nil.
612
+ attr_reader button: Symbol?
613
+
614
+ # _@return_ — 0-based column.
615
+ attr_reader x: Integer
616
+
617
+ # _@return_ — 0-based row.
618
+ attr_reader y: Integer
619
+ end
620
+
621
+ # The pointer moved while a component holds the grab.
622
+ #
623
+ # The one event {Router} *makes* rather than routes, so posting one to
624
+ # {Screen#handle_mouse} raises: drive a grabbed component with a
625
+ # {MoveEvent} carrying the held button, or with {FakeScreen#drag}.
626
+ #
627
+ # @!attribute [r] button
628
+ # @return [Symbol] the button whose press took the grab.
629
+ # @!attribute [r] x
630
+ # @return [Integer] 0-based column — may lie outside the grabbed
631
+ # component's rect, and even outside the screen's last known size.
632
+ # @!attribute [r] y
633
+ # @return [Integer] 0-based row.
634
+ class DragEvent < Data
635
+ include Tuile::Mouse::Event
636
+
637
+ # _@return_ — the event's position.
638
+ def point: () -> Point
639
+
640
+ # _@return_ — the button whose press took the grab.
641
+ attr_reader button: Symbol
642
+
643
+ # _@return_ — 0-based column — may lie outside the grabbed
644
+ # component's rect, and even outside the screen's last known size.
645
+ attr_reader x: Integer
646
+
647
+ # _@return_ — 0-based row.
648
+ attr_reader y: Integer
649
+ end
650
+
651
+ # Delivers {Mouse::Event}s to the component tree. {Screen} owns one and hands
652
+ # it every event the terminal reports; a component only answers the handlers
653
+ # it calls, and walks nothing itself.
654
+ #
655
+ # screen.handle_mouse(Mouse::DownEvent.new(:left, 5, 2)) # what the loop does
656
+ #
657
+ # Every event resolves against one **path**: the topmost popup containing the
658
+ # point, else the tiled content unless a modal popup is open
659
+ # ({ScreenPane#mouse_root_at}), then down through the shown children whose
660
+ # {Component#rect} contains it.
661
+ #
662
+ # - **{DownEvent}** — focuses the innermost {Component#focusable?} on that
663
+ # path, then offers {Component#handle_mouse_down?} innermost-first until one
664
+ # component answers `true`. That claimant becomes {#grabbed}.
665
+ # - **{ScrollEvent}**, **{MoveEvent}** — bubble the same way through
666
+ # {Component#handle_mouse_scroll?} / {Component#handle_mouse_move?}, and grab
667
+ # nothing. A move fires the enter/exit hooks first, and arrives only at
668
+ # `:hover`.
669
+ # - **{DragEvent}**, **{UpEvent}** — skip the path entirely: they go to
670
+ # {#grabbed}, wherever the pointer is, and an up ends the grab.
671
+ #
672
+ # The two geometries are deliberately different. Focus follows `rect`, so a
673
+ # press on the dead tail a widget does not paint still focuses it; the
674
+ # handlers bubble only along the prefix whose {Component#extent_rect} contains
675
+ # the point, so that same press activates nothing (`D_extent`).
676
+ #
677
+ # UI-thread-confined.
678
+ #
679
+ # == Implementation details
680
+ #
681
+ # **The grab has three releases and no relinquishing**: the up, the next
682
+ # down (the up was lost — ssh and tmux do lose them), and any key
683
+ # ({#release_grab}). None of the last two tells the grabbed component. Hiding
684
+ # or detaching it does not end the grab either; the router just stops
685
+ # delivering to it until the release (`D_mouse_dispatch`).
686
+ #
687
+ # **The hovered chain is synced, not toggled**: a move re-resolves it and
688
+ # diffs, and {#sync_hover} — run by {Screen#repaint} — drops members that
689
+ # were detached, hidden or reparented since, firing their exits.
690
+ class Router
691
+ # _@param_ `screen`
692
+ def initialize: (Screen screen) -> void
693
+
694
+ # _@return_ — the innermost component under the pointer, as of
695
+ # the last move under `:hover`.
696
+ def hovered: () -> Component?
697
+
698
+ # _@param_ `event`
699
+ def dispatch: (Mouse::Event event) -> void
700
+
701
+ # Ends the grab without telling the grabbed component.
702
+ def release_grab: () -> void
703
+
704
+ # Drops the hovered-chain members that are no longer attached, shown, or
705
+ # children of the member before them, firing
706
+ # {Component#handle_mouse_exit} innermost first. Idempotent.
707
+ def sync_hover: () -> void
708
+
709
+ # _@param_ `event`
710
+ def press: (DownEvent event) -> void
711
+
712
+ # _@param_ `event`
713
+ def release: (UpEvent event) -> void
714
+
715
+ # _@param_ `event`
716
+ def move: (MoveEvent event) -> void
717
+
718
+ # A non-modal overlay is never focused into: it sits outside the key scope,
719
+ # so focus there would make every keystroke go dead (`D_overlay`).
720
+ #
721
+ # _@param_ `root`
722
+ #
723
+ # _@param_ `path`
724
+ def focus_innermost: (Component? root, ::Array[Component] path) -> void
725
+
726
+ # _@param_ `path` — root first.
727
+ #
728
+ # _@param_ `handler` — a routed `handle_mouse_…?`.
729
+ #
730
+ # _@param_ `event`
731
+ #
732
+ # _@return_ — the component that answered true.
733
+ def bubble: (::Array[Component] path, Symbol handler, Mouse::Event event) -> Component?
734
+
735
+ # _@param_ `point`
736
+ #
737
+ # _@return_ — the shown components under `point` whose
738
+ # extent contains it, root first.
739
+ def extent_path: (Point point) -> ::Array[Component]
740
+
741
+ # _@param_ `root`
742
+ #
743
+ # _@param_ `point`
744
+ #
745
+ # _@return_ — the shown components whose rect contains
746
+ # `point`, root first. Tiled siblings never overlap, so at most one child
747
+ # qualifies at each level.
748
+ def rect_path: (Component? root, Point point) -> ::Array[Component]
749
+
750
+ # _@param_ `chain` — the new hovered chain, root first.
751
+ def rehover: (::Array[Component] chain) -> void
752
+
753
+ # _@param_ `component`
754
+ #
755
+ # _@return_ — whether `component` is attached and it and every
756
+ # ancestor are shown.
757
+ def reachable?: (Component? component) -> bool
758
+
759
+ # The tracking level {Screen#run_event_loop} asked the terminal for, one of
760
+ # {Mouse::LEVELS}; `:hover` while no loop is running, so a spec may post
761
+ # anything. An ungrabbed move is dropped below `:hover` — under `:drag` the
762
+ # terminal reports one only while a button nobody claimed is held, and
763
+ # enter/exit that fire *sometimes* are worse than none.
764
+ attr_accessor level: Symbol?
765
+
766
+ # _@return_ — the component whose {Component#handle_mouse_down?}
767
+ # claimed the press currently held.
768
+ attr_reader grabbed: Component?
769
+ end
770
+ end
771
+
457
772
  # A point with `x` and `y` integer coordinates, both 0-based.
458
773
  #
459
774
  # @!attribute [r] x
@@ -476,12 +791,12 @@ module Tuile
476
791
  # restyles everything via one invalidate-everything pass. Book ch6 is the
477
792
  # concept in full (why accents-only, dark/light, live OS flips).
478
793
  #
479
- # The rendering helpers — {#active_bg}, {#active_border}, {#input_bg},
480
- # {#hint} — wrap a plain string in the token's SGR color (on the channel
481
- # appropriate for the token's role) and reset:
794
+ # The rendering helpers — {#active_bg}, {#active_border}, {#input_bg} — wrap
795
+ # a plain string in the token's SGR color (on the channel appropriate for the
796
+ # token's role) and reset:
482
797
  #
483
- # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
484
- # screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
798
+ # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
799
+ # screen.theme.active_border("┌────┐") # => "\e[32m┌────┐\e[0m"
485
800
  #
486
801
  # Content passes through verbatim (so it may carry other escapes). For
487
802
  # span-aware styling — a token applied to a {StyledString} without flattening
@@ -513,7 +828,7 @@ module Tuile
513
828
  #
514
829
  # For a color slot resolved *live* at paint — currently
515
830
  # {Component#bg_color=} — assign a {Ref} instead of reading + rebuilding
516
- # the token in {Component#on_theme_changed}; it tracks theme swaps on its
831
+ # the token in {Component#handle_theme_changed}; it tracks theme swaps on its
517
832
  # own. Baked content colors ({Component::Label} text and friends) can't:
518
833
  # they live in a frozen {StyledString} and still need the hook.
519
834
  #
@@ -533,12 +848,6 @@ module Tuile
533
848
  # {Component::TextArea} when *not* active — visibly a field, but
534
849
  # distinctly subtler than {#active_bg_color}.
535
850
  # @return [Color]
536
- # @!attribute [r] hint_color
537
- # Foreground of subdued *accent* text an app wants noticed — a
538
- # keyboard-shortcut caption, a {Component::PickerWindow} option. An accent,
539
- # not a grey: it pulls the eye, which is why a placeholder uses
540
- # {#placeholder_color} instead. See {#hint}.
541
- # @return [Color]
542
851
  # @!attribute [r] placeholder_color
543
852
  # Foreground of the hint a field paints into its own empty well
544
853
  # ({Component::HasPlaceholder}) — the one token tuned to be *barely*
@@ -583,8 +892,6 @@ module Tuile
583
892
  #
584
893
  # _@param_ `input_bg_color`
585
894
  #
586
- # _@param_ `hint_color`
587
- #
588
895
  # _@param_ `placeholder_color`
589
896
  #
590
897
  # _@param_ `error_color`
@@ -600,7 +907,6 @@ module Tuile
600
907
  active_bg_color: Color,
601
908
  active_border_color: Color,
602
909
  input_bg_color: Color,
603
- hint_color: Color,
604
910
  placeholder_color: Color,
605
911
  error_color: Color,
606
912
  error_bg_color: Color,
@@ -627,7 +933,14 @@ module Tuile
627
933
  def self.ref: (Symbol name) -> Ref
628
934
 
629
935
  # Renders `text` in the foreground color of the app-specific `token`
630
- # — the generic counterpart of {#hint} for {#custom} tokens.
936
+ # — the generic counterpart of {#active_border} for {#custom} tokens, and
937
+ # the route for an app's own status-line chrome:
938
+ #
939
+ # "q #{screen.theme.fg(:hint, "quit")}" # => "q \e[38;5;245mquit\e[0m"
940
+ #
941
+ # The color is baked into the returned String, so text built this way does
942
+ # *not* restyle on a {Screen#theme=} — rebuild it from
943
+ # {Component#handle_theme_changed} instead.
631
944
  #
632
945
  # _@param_ `token`
633
946
  #
@@ -669,17 +982,6 @@ module Tuile
669
982
  # _@return_ — ANSI-rendered text, ending with an SGR reset.
670
983
  def input_bg: (String text) -> String
671
984
 
672
- # Renders `text` in the {#hint_color} foreground, for status-bar hints,
673
- # e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
674
- # returned String, so strings built this way do *not* restyle when the
675
- # theme changes — rebuild them instead (the framework's own call sites
676
- # rebuild on every status-bar refresh).
677
- #
678
- # _@param_ `text`
679
- #
680
- # _@return_ — ANSI-rendered text, ending with an SGR reset.
681
- def hint: (String text) -> String
682
-
683
985
  # The single sanctioned place for verbatim SGR wrapping: `text` is not
684
986
  # parsed or validated, so callers may embed non-SGR escapes. Emits the
685
987
  # same bytes `StyledString.styled(text, ...).to_ansi` would for plain
@@ -708,12 +1010,6 @@ module Tuile
708
1010
  # distinctly subtler than {#active_bg_color}.
709
1011
  attr_reader input_bg_color: Color
710
1012
 
711
- # Foreground of subdued *accent* text an app wants noticed — a
712
- # keyboard-shortcut caption, a {Component::PickerWindow} option. An accent,
713
- # not a grey: it pulls the eye, which is why a placeholder uses
714
- # {#placeholder_color} instead. See {#hint}.
715
- attr_reader hint_color: Color
716
-
717
1013
  # Foreground of the hint a field paints into its own empty well
718
1014
  # ({Component::HasPlaceholder}) — the one token tuned to be *barely*
719
1015
  # visible, since a placeholder the user misses costs nothing.
@@ -750,7 +1046,7 @@ module Tuile
750
1046
  # A live reference to a theme token, resolved against the current theme at
751
1047
  # paint time rather than baked to a concrete {Color}. Assign one where a
752
1048
  # slot is resolved late — currently {Component#bg_color=} — and it follows
753
- # light/dark flips with no {Component#on_theme_changed} hook:
1049
+ # light/dark flips with no {Component#handle_theme_changed} hook:
754
1050
  #
755
1051
  # panel.bg_color = Tuile::Theme.ref(:panel_bg) # a #custom token
756
1052
  # dropdown.bg_color = Tuile::Theme.ref(:input_bg_color) # built-in chrome
@@ -1973,17 +2269,17 @@ module Tuile
1973
2269
  def add_popup: (Component::Overlay window) -> void
1974
2270
 
1975
2271
  # Runs the event loop on the calling thread, taking over stdin (raw mode,
1976
- # echo off): keys and mouse events are dispatched via {#handle_key} /
2272
+ # echo off): keys and mouse events are dispatched via {#handle_key?} /
1977
2273
  # {#handle_mouse}, and the loop repaints once per drained tick. Returns
1978
2274
  # when `q` or ESC is pressed unhandled. Restores terminal state on exit.
1979
2275
  #
1980
2276
  # For the duration this thread owns the UI ({#state} is `:running`);
1981
2277
  # ownership reverts to the creating thread once it returns.
1982
2278
  #
1983
- # _@param_ `capture_mouse` — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed {Component#handle_mouse}. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' `handle_mouse` is simply never invoked from the loop in that mode (the terminal stops sending the bytes).
2279
+ # _@param_ `capture_mouse` — how much mouse tracking to ask the terminal for — each level unlocking one tier of {Mouse}'s events, and costing what that tier reports: - `false` — none. The terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy. - `true` (default), `:clicks` — presses, releases and the wheel. - `:drag` — plus motion while a button is held, as {Component#handle_mouse_drag}. - `:hover` — plus motion with no button, as {Component#handle_mouse_move?} and the enter/exit hooks. ~84 reports a second (`R_mouse_reporting`), so ask for it only if something uses it.
1984
2280
  #
1985
2281
  # _@param_ `bracketed_paste` — when true (default), enables DEC private mode 2004 so pasted text arrives whole, as {Component#handle_paste}, instead of as one keystroke per character — which is the only way a pasted line break can be told from a typed Enter. When false, a paste streams in as keys again and a component that gives ENTER a meaning fires it once per pasted line. Turn it off only for a terminal that mishandles the mode.
1986
- def run_event_loop: (?capture_mouse: bool, ?bracketed_paste: bool) -> void
2282
+ def run_event_loop: (?capture_mouse: (bool | Symbol), ?bracketed_paste: bool) -> void
1987
2283
 
1988
2284
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
1989
2285
  # around. Scope is the topmost popup if one is open, otherwise {#content}
@@ -2008,9 +2304,9 @@ module Tuile
2008
2304
  #
2009
2305
  # - **Printable keys** — they'd hijack typing into a
2010
2306
  # {Component::TextField}. A scope-wide one-key binding belongs on the
2011
- # scope root's own `handle_key`, where a focused field consumes it first
2012
- # (see {ScreenPane#handle_key}).
2013
- # - **TAB / SHIFT_TAB** — {#handle_key} intercepts them for focus
2307
+ # scope root's own `handle_key?`, where a focused field consumes it first
2308
+ # (see {ScreenPane#handle_key?}).
2309
+ # - **TAB / SHIFT_TAB** — {#handle_key?} intercepts them for focus
2014
2310
  # navigation before the registry is consulted, so a binding would never
2015
2311
  # fire.
2016
2312
  # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
@@ -2063,7 +2359,7 @@ module Tuile
2063
2359
 
2064
2360
  # Tears the screen down and vacates the singleton slot, moving {#state} to
2065
2361
  # the terminal `:closed`. Unmounts the tree first, so every component gets
2066
- # its {Component#on_detached}. Idempotent.
2362
+ # its {Component#handle_detached}. Idempotent.
2067
2363
  def close: () -> void
2068
2364
 
2069
2365
  def self.close: () -> void
@@ -2111,13 +2407,31 @@ module Tuile
2111
2407
  # but only one focused.
2112
2408
  def cursor_position: () -> Point?
2113
2409
 
2410
+ # Routes one mouse event into the tree ({Mouse::Router}) — what the event
2411
+ # loop does with every report the terminal sends, and how a spec drives the
2412
+ # mouse:
2413
+ #
2414
+ # screen.handle_mouse(Mouse::DownEvent.new(:left, 5, 2))
2415
+ #
2416
+ # _@param_ `event`
2417
+ def handle_mouse: (Mouse::Event event) -> void
2418
+
2419
+ # _@return_ — the innermost component the pointer is over, as
2420
+ # of the last move. Always nil below `capture_mouse: :hover`, and frozen
2421
+ # at its last value while a press is grabbed.
2422
+ def hovered: () -> Component?
2423
+
2424
+ # _@return_ — the component holding the mouse grab — the one
2425
+ # whose {Component#handle_mouse_down?} claimed the press still held.
2426
+ def grabbed: () -> Component?
2427
+
2114
2428
  # Whether `component` is out of the user's reach because it or an ancestor
2115
2429
  # is {Component#visible? hidden}.
2116
2430
  #
2117
2431
  # Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
2118
2432
  # declined a `Component#paintable?`: it reads as a component-level concept
2119
2433
  # and is really this class's question. A *walk* needs no such predicate —
2120
- # it prunes at the hidden subtree's root ({Component#on_shown_tree}).
2434
+ # it prunes at the hidden subtree's root ({Component#walk_shown_tree}).
2121
2435
  #
2122
2436
  # _@param_ `component`
2123
2437
  def hidden?: (Component component) -> bool
@@ -2164,7 +2478,7 @@ module Tuile
2164
2478
  # thread as an {EventQueue::BackgroundColorEvent}.
2165
2479
  #
2166
2480
  # _@param_ `scheme` — `:dark` or `:light`.
2167
- def on_color_scheme: (Symbol scheme) -> void
2481
+ def handle_color_scheme: (Symbol scheme) -> void
2168
2482
 
2169
2483
  # The re-probe answered: adopt the color and restyle, since an app's
2170
2484
  # background-derived tints are now a scheme behind. Deliberately keeps
@@ -2173,7 +2487,7 @@ module Tuile
2173
2487
  # would otherwise lose the color it gave us at startup, permanently.
2174
2488
  #
2175
2489
  # _@param_ `color`
2176
- def on_background_color: (Color color) -> void
2490
+ def handle_background_color: (Color color) -> void
2177
2491
 
2178
2492
  # Walks the current modal scope in pre-order, collects tab stops, and
2179
2493
  # advances focus by one (wrapping). When the focused component isn't in
@@ -2215,18 +2529,13 @@ module Tuile
2215
2529
  # default `over_popups: false` fires only when no modal popup is open
2216
2530
  # (otherwise the modal popup receives the key normally). A non-modal
2217
2531
  # overlay doesn't suppress global shortcuts.
2218
- # 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
2532
+ # 3. {ScreenPane#handle_key?} — delivery to {#focused}, bubbling up the
2219
2533
  # focus chain to the scope root.
2220
2534
  #
2221
2535
  # _@param_ `key`
2222
2536
  #
2223
2537
  # _@return_ — true if the key was handled by some window.
2224
- def handle_key: (String key) -> bool
2225
-
2226
- # Finds target window and calls {Component::Window#handle_mouse}.
2227
- #
2228
- # _@param_ `event`
2229
- def handle_mouse: (MouseEvent event) -> void
2538
+ def handle_key?: (String key) -> bool
2230
2539
 
2231
2540
  # Delivers pasted text to {#focused} ({ScreenPane#handle_paste}).
2232
2541
  #
@@ -2234,12 +2543,11 @@ module Tuile
2234
2543
  # Tab traversal and the global-shortcut registry entirely, goes straight to
2235
2544
  # delivery, and does not bubble to ancestors the way a key does. Unhandled
2236
2545
  # text is dropped — there is no fallback that replays it as keys, which
2237
- # would put back the very ambiguity mode 2004 exists to remove.
2546
+ # would put back the very ambiguity mode 2004 exists to remove — and with no
2547
+ # alternative delivery, no verdict to carry either.
2238
2548
  #
2239
2549
  # _@param_ `text`
2240
- #
2241
- # _@return_ — true if the focused component consumed it.
2242
- def handle_paste: (String text) -> bool
2550
+ def handle_paste: (String text) -> void
2243
2551
 
2244
2552
  def event_loop: () -> void
2245
2553
 
@@ -2318,7 +2626,7 @@ module Tuile
2318
2626
  # Kept current across OS appearance flips, a frame behind: the flip
2319
2627
  # report carries light/dark only, so the screen re-probes and this
2320
2628
  # updates when the reply lands. A changed color then fires
2321
- # {Component#on_theme_changed} across the tree exactly as a theme swap
2629
+ # {Component#handle_theme_changed} across the tree exactly as a theme swap
2322
2630
  # does — a background-derived tint *is* a theme-derived color.
2323
2631
  attr_reader background_color: Color?
2324
2632
 
@@ -2348,17 +2656,17 @@ module Tuile
2348
2656
  #
2349
2657
  # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
2350
2658
  #
2351
- # **Edge-triggered**, like {Component#on_attached}: re-assigning the
2659
+ # **Edge-triggered**, like {Component#handle_attached}: re-assigning the
2352
2660
  # component that already has focus fires nothing, so a callback can be as
2353
2661
  # expensive as rebuilding a hint string without a `did it really change?`
2354
2662
  # guard of its own. That matters more than it looks — `ScreenPane#content=`
2355
2663
  # clears focus on every content swap, which on a level-triggered hook would
2356
2664
  # fire a nil→nil notification during assembly.
2357
2665
  #
2358
- # It runs *after* the active-flag cascade and `on_focus`, so the tree is
2666
+ # It runs *after* the active-flag cascade and `handle_focus`, so the tree is
2359
2667
  # settled. Two things a callback must tolerate: {#focused} being `nil`, and
2360
2668
  # firing during {#close} — teardown clears focus, exactly as it fires
2361
- # {Component#on_detached}. A raising callback propagates out of {#focused=}
2669
+ # {Component#handle_detached}. A raising callback propagates out of {#focused=}
2362
2670
  # and leaves focus assigned; keep it trivial, as with the attach hooks.
2363
2671
  attr_accessor on_focus_changed: Proc?
2364
2672
 
@@ -2377,7 +2685,7 @@ module Tuile
2377
2685
  # Finds a component in the tree, so a spec can drive the UI it built four
2378
2686
  # layers down:
2379
2687
  #
2380
- # Testing.get(Component::Button, caption: "Save").handle_key(Keys::ENTER)
2688
+ # Testing.get(Component::Button, caption: "Save").handle_key?(Keys::ENTER)
2381
2689
  # Testing.get(id: :name).value = "Zaphod"
2382
2690
  # Testing.find(Component::Checkbox, in: pane, count: 3)
2383
2691
  #
@@ -2393,7 +2701,7 @@ module Tuile
2393
2701
  #
2394
2702
  # **A hidden component is never found**: these simulate a user, and a spec
2395
2703
  # that drove a hidden {Component::Button} would pass against a form nobody
2396
- # can operate. Both walk {Component#on_shown_tree}; a failed lookup says how
2704
+ # can operate. Both walk {Component#walk_shown_tree}; a failed lookup says how
2397
2705
  # many hidden components *would* have matched, and {.dump} shows them.
2398
2706
  #
2399
2707
  # Testing.find(Component::TextField, count: 0) # the user can't reach it
@@ -2404,7 +2712,7 @@ module Tuile
2404
2712
  #
2405
2713
  # For *what a component shows*, assert on {Screen#buffer} instead — this
2406
2714
  # locates and drives, it does not replace that channel. See book ch8 for the
2407
- # worked usage and `DECISIONS.md` `D_component_lookup` for the design.
2715
+ # worked usage and `design/decisions.md` `D_component_lookup` for the design.
2408
2716
  module Testing
2409
2717
  # Every component in the searched tree matching the spec, in pre-order.
2410
2718
  #
@@ -2579,6 +2887,29 @@ module Tuile
2579
2887
  # isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
2580
2888
  # any stale invalidation entries are filtered out at drain time. Subclasses
2581
2889
  # can paint freely in {#repaint} without re-asserting attachment.
2890
+ #
2891
+ # == Handlers and listener slots
2892
+ #
2893
+ # Two families, told apart by the `=`:
2894
+ #
2895
+ # class Trimmed < Component::TextField
2896
+ # def handle_blur # handle_ — the override point
2897
+ # super
2898
+ # self.text = text.strip
2899
+ # end
2900
+ # end
2901
+ #
2902
+ # label.on_theme_changed = -> { … } # on_…= — the listener slot
2903
+ #
2904
+ # **What a handler returns is per hook**, declared in its own rdoc. Only the
2905
+ # ones a dispatcher routes answer at all — {#handle_key?},
2906
+ # {#handle_text_input_key?}, `MenuBar#handle_mnemonic?` — where `true` means "I
2907
+ # took this, stop bubbling". The rest, {#handle_paste} included, return `void`.
2908
+ #
2909
+ # An override calls `super`, even where the base body is empty: that is what
2910
+ # lets a hook grow an `on_foo=` slot without breaking you. The one carve-out is
2911
+ # {#handle_child_removed}, whose base does real work and whose overrides
2912
+ # replace it. `D_handler_naming` carries the argument.
2582
2913
  class Component
2583
2914
  extend Tuile::Final
2584
2915
  BG_STATES: ::Array[Symbol]
@@ -2618,7 +2949,7 @@ module Tuile
2618
2949
  # It flows **downward only**: no container consults it when dividing space,
2619
2950
  # so {#rect} still means exactly what the parent assigned (`D_extent`). Three
2620
2951
  # things read it, all of them this component or the framework painting it:
2621
- # {#clear_outside_extent} blanks the dead tail, {#handle_mouse} hit-tests
2952
+ # {#clear_outside_extent} blanks the dead tail, {Mouse::Router} hit-tests
2622
2953
  # against it so a click on that tail doesn't activate the widget, and a
2623
2954
  # dropdown anchors under it rather than under unused space.
2624
2955
  #
@@ -2629,7 +2960,7 @@ module Tuile
2629
2960
  def extent: () -> Size?
2630
2961
 
2631
2962
  # {#extent} placed at {#rect}'s top-left, for the consumers that need
2632
- # coordinates: `extent_rect.contains?(event.point)` in a {#handle_mouse}, and
2963
+ # coordinates: `extent_rect.contains?(point)` in {Mouse::Router}, and
2633
2964
  # the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
2634
2965
  # the whole {#rect}, so a generic caller never sees `nil`.
2635
2966
  def extent_rect: () -> Rect
@@ -2659,7 +2990,7 @@ module Tuile
2659
2990
  # showing it restores exactly the subtree that was showing before.
2660
2991
  # - **Focus never stays on what the user cannot see.** Hiding the subtree
2661
2992
  # holding focus repairs it exactly as removing that subtree would (see
2662
- # {#on_child_removed}), and does not hand it back on the way in.
2993
+ # {#handle_child_removed}), and does not hand it back on the way in.
2663
2994
  #
2664
2995
  # For "invisible but still occupying its space", use a
2665
2996
  # {Component::Slot} with no content (`D_slots`).
@@ -2688,6 +3019,9 @@ module Tuile
2688
3019
  # skipping `super`.** The clear then covers only what is outside it, so the
2689
3020
  # cells it is about to repaint are not blanked first — blanking them would
2690
3021
  # mark them dirty and make {Buffer#flush} re-emit them (`D_progress_bar`).
3022
+ # That saving is a *leaf*'s: a container's children paint its extent for it,
3023
+ # so a cell among them that none covers still gets blanked — an extent
3024
+ # narrows which cells are yours, never whether your gaps are wiped.
2691
3025
  #
2692
3026
  # **The children are re-invalidated whether or not they tile.** A container
2693
3027
  # that paints nothing of its own can only redraw its area *through* them, so
@@ -2709,48 +3043,108 @@ module Tuile
2709
3043
  # on the key alone and never gate on your own {#active?} state. See book ch5
2710
3044
  # for how a keystroke is routed to reach here.
2711
3045
  #
3046
+ # The `?` reads like `Set#add?`: calling it *delivers* the key and reports
3047
+ # whether it was taken, so it is never a "would you handle this?" probe.
3048
+ #
2712
3049
  # _@param_ `_key` — a key.
2713
3050
  #
2714
3051
  # _@return_ — true if the key was handled, false if not.
2715
- def handle_key: (String _key) -> bool
3052
+ def handle_key?: (String _key) -> bool
2716
3053
 
2717
3054
  # Called when text is pasted while this component is {Screen#focused};
2718
- # override to accept it (the default reports every paste unhandled, and
2719
- # unhandled text is dropped). Unlike a key, a paste does **not** bubble —
2720
- # only the focused component is offered it, so an ancestor never sees one
2721
- # its descendant declined. The text arrives whole and `\n`-normalized, so
2722
- # `text.lines.size` is the paste's line count and a single mutation can
2723
- # absorb it:
3055
+ # override to accept it. The default drops the text. It arrives whole and
3056
+ # `\n`-normalized, so `text.lines.size` is the paste's line count and a
3057
+ # single mutation can absorb it:
2724
3058
  #
2725
3059
  # def handle_paste(text)
2726
3060
  # self.caption = "[Pasted #{text.lines.size} lines]"
2727
- # true
2728
3061
  # end
2729
3062
  #
3063
+ # **No verdict, unlike {#handle_key?}**: a paste reaches the focused component
3064
+ # and stops, so one that declines has nowhere to hand it on to
3065
+ # (`D_bracketed_paste`).
3066
+ #
2730
3067
  # Reaching here means the terminal said "this came from the clipboard" —
2731
3068
  # {Component::AbstractStringField} inserts it at the caret, which is why a
2732
3069
  # subclass that rebinds ENTER to submit needs no paste handling of its own
2733
3070
  # to stop firing once per pasted line.
2734
3071
  #
2735
3072
  # _@param_ `_text` — the pasted text.
3073
+ def handle_paste: (String _text) -> void
3074
+
3075
+ # Called when a mouse button goes down over this component; answer `true` to
3076
+ # claim the press. The default claims nothing.
3077
+ #
3078
+ # def handle_mouse_down?(event)
3079
+ # return false unless event.button == :left
2736
3080
  #
2737
- # _@return_ — true if the paste was consumed.
2738
- def handle_paste: (String _text) -> bool
3081
+ # @on_click&.call
3082
+ # true
3083
+ # end
3084
+ #
3085
+ # {Mouse::Router} delivers it to the innermost component under the pointer
3086
+ # and bubbles it up the ancestors until one answers `true`; the claimant
3087
+ # then holds the *grab*, and the button's {#handle_mouse_drag} and
3088
+ # {#handle_mouse_up} go to it alone. The press has already moved focus by
3089
+ # the time it arrives, and it only arrives where {#extent_rect} contains the
3090
+ # point, so an override needs neither `super` nor a hit test of its own.
3091
+ #
3092
+ # Activate here, on the press: Tuile synthesizes no click, because a release
3093
+ # is losable over ssh and tmux (`D_mouse_dispatch`).
3094
+ #
3095
+ # _@param_ `_event`
3096
+ #
3097
+ # _@return_ — whether this component claimed the press.
3098
+ def handle_mouse_down?: (Mouse::DownEvent _event) -> bool
3099
+
3100
+ # Called when the wheel turns over this component; answer `true` to consume
3101
+ # the notch. Bubbles exactly as {#handle_mouse_down?} does, but grabs
3102
+ # nothing — so a scroller already at its limit answers `false` and its
3103
+ # ancestor scrolls instead.
3104
+ #
3105
+ # _@param_ `_event`
3106
+ #
3107
+ # _@return_ — whether this component consumed the notch.
3108
+ def handle_mouse_scroll?: (Mouse::ScrollEvent _event) -> bool
2739
3109
 
2740
- # Focuses this component when left-clicked (if {#focusable?}), then hands the
2741
- # event down to every child whose {#rect} contains the point — which is how a
2742
- # click descends the tiled tree to a leaf.
3110
+ # Called when the pointer moves over this component with nothing grabbed;
3111
+ # answer `true` to consume the move. Bubbles as {#handle_mouse_down?} does.
3112
+ # Arrives only under `run_event_loop(capture_mouse: :hover)`, at up to ~84
3113
+ # events a second, which is why the default passes it on untouched.
3114
+ #
3115
+ # _@param_ `_event`
2743
3116
  #
2744
- # A widget that resolves clicks *inside* its own rect — mapping a point to a
2745
- # row, or toggling an overlay — overrides this and does not call `super`.
2746
- # Such an override hit-tests {#extent_rect} rather than {#rect}, so a click
2747
- # on the tail it doesn't paint never activates it.
3117
+ # _@return_ — whether this component consumed the move.
3118
+ def handle_mouse_move?: (Mouse::MoveEvent _event) -> bool
3119
+
3120
+ # Called on the component that claimed a press when the button comes up,
3121
+ # ending the grab. For press feedback and for ending a drag — never for
3122
+ # activation: a release may never arrive, and any key or the next press
3123
+ # ends the grab without it.
2748
3124
  #
2749
- # The walk stops at a hidden child, so a hidden component is never reached
2750
- # and an override needs no `visible?` check of its own.
3125
+ # _@param_ `_event`
3126
+ def handle_mouse_up: (Mouse::UpEvent _event) -> void
3127
+
3128
+ # Called on the component that claimed a press whenever the pointer moves
3129
+ # while the button is held, wherever the pointer is. Needs
3130
+ # `capture_mouse: :drag` or `:hover`.
2751
3131
  #
2752
- # _@param_ `event`
2753
- def handle_mouse: (MouseEvent event) -> void
3132
+ # _@param_ `_event` — its point may lie outside {#rect}.
3133
+ def handle_mouse_drag: (Mouse::DragEvent _event) -> void
3134
+
3135
+ # Called when the pointer comes over this component or any of its
3136
+ # descendants — down the chain, root first, and after every
3137
+ # {#handle_mouse_exit} the same move fires. Needs `capture_mouse: :hover`,
3138
+ # and is suspended while a press is grabbed.
3139
+ #
3140
+ # **Never a commit point**: no terminal reports the pointer leaving the
3141
+ # window, so the matching {#handle_mouse_exit} may arrive late or not at
3142
+ # all. Anything done here must be cosmetic and survive that.
3143
+ def handle_mouse_enter: () -> void
3144
+
3145
+ # The other half of {#handle_mouse_enter}; also fires when this component is
3146
+ # detached or hidden while hovered, innermost first.
3147
+ def handle_mouse_exit: () -> void
2754
3148
 
2755
3149
  # _@return_ — true if the component is on the active chain — i.e. it
2756
3150
  # is the focused component or an ancestor of it. Set by {Screen#focused=}.
@@ -2787,30 +3181,30 @@ module Tuile
2787
3181
  def root: () -> Component
2788
3182
 
2789
3183
  # Calls block for this component and for every descendant component.
2790
- def on_tree: () ?{ (Component component) -> void } -> void
3184
+ def walk_tree: () ?{ (Component component) -> void } -> void
2791
3185
 
2792
- # {#on_tree}, pruned: a hidden subtree is skipped whole, this component
3186
+ # {#walk_tree}, pruned: a hidden subtree is skipped whole, this component
2793
3187
  # included when it is itself hidden (in which case nothing is yielded).
2794
3188
  #
2795
3189
  # stops = []
2796
- # scope.on_shown_tree { |c| stops << c if c.tab_stop? }
3190
+ # scope.walk_shown_tree { |c| stops << c if c.tab_stop? }
2797
3191
  #
2798
- # **Use this for anything asking "can the user reach it"**, {#on_tree} for
3192
+ # **Use this for anything asking "can the user reach it"**, {#walk_tree} for
2799
3193
  # what the framework does *to* a component regardless — lifecycle, theme
2800
3194
  # fan-out, invalidation — which a hidden component still gets. Writing the
2801
- # first as `on_tree` plus a `visible?` test is the trap: that is this walk
3195
+ # first as `walk_tree` plus a `visible?` test is the trap: that is this walk
2802
3196
  # with the ancestor case missing, so a field under a hidden panel is back
2803
3197
  # in the Tab cycle (`D_visibility`).
2804
- def on_shown_tree: () ?{ (Component component) -> void } -> void
3198
+ def walk_shown_tree: () ?{ (Component component) -> void } -> void
2805
3199
 
2806
3200
  # Called when the component receives focus — on this component alone, never
2807
- # on the ancestors that light up with it. {#on_blur} is the other half.
3201
+ # on the ancestors that light up with it. {#handle_blur} is the other half.
2808
3202
  #
2809
- # Unlike `on_blur` it is **not** edge-triggered: it fires on every
3203
+ # Unlike `handle_blur` it is **not** edge-triggered: it fires on every
2810
3204
  # {Screen#focused=}, re-assigning the component that already has focus
2811
3205
  # included, which is what lets a container forward focus into its content
2812
3206
  # from here.
2813
- def on_focus: () -> void
3207
+ def handle_focus: () -> void
2814
3208
 
2815
3209
  # Whether this component's tree is mounted on a UI, {ScreenPane} being the
2816
3210
  # root of every displayed tree.
@@ -2838,8 +3232,11 @@ module Tuile
2838
3232
  # its parent: an override may repair focus however it likes, but must not
2839
3233
  # assume the child is gone. Removal bookkeeping belongs in the remover.
2840
3234
  #
3235
+ # The one hook whose base body does real work, so an override *replaces* it
3236
+ # (as {Component::Slot} and {ScreenPane} do) instead of calling `super`.
3237
+ #
2841
3238
  # _@param_ `child` — the just-detached, or just-hidden, child.
2842
- def on_child_removed: (Component child) -> void
3239
+ def handle_child_removed: (Component child) -> void
2843
3240
 
2844
3241
  # Where the hardware terminal cursor should sit when this component is the
2845
3242
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
@@ -2885,7 +3282,7 @@ module Tuile
2885
3282
  # _@param_ `at` — index to insert at; appends when nil.
2886
3283
  def add_child: (Component child, ?at: Integer?) -> void
2887
3284
 
2888
- # Drops `child` and notifies {#on_child_removed}.
3285
+ # Drops `child` and notifies {#handle_child_removed}.
2889
3286
  # Final: one of the three mutators that write {#children} and the parent
2890
3287
  # pointer in the same call, which is what keeps them in agreement.
2891
3288
  #
@@ -2893,12 +3290,12 @@ module Tuile
2893
3290
  def remove_child: (Component child) -> void
2894
3291
 
2895
3292
  # Drops `child` *without* notifying — for a container swapping a named slot,
2896
- # which owes the {#on_child_removed} call once the new occupant is wired:
3293
+ # which owes the {#handle_child_removed} call once the new occupant is wired:
2897
3294
  #
2898
3295
  # detach_child(old)
2899
3296
  # @content = new
2900
3297
  # add_child(new, at: 0)
2901
- # on_child_removed(old) # focus repair cascades into the *new* content
3298
+ # handle_child_removed(old) # focus repair cascades into the *new* content
2902
3299
  #
2903
3300
  # The child leaves {#children} before its pointer is cleared, so nothing
2904
3301
  # observes a child whose parent has disowned it while still listing it.
@@ -2912,33 +3309,33 @@ module Tuile
2912
3309
  # i.e. when {#attached?} flips to true — the place to acquire whatever is
2913
3310
  # supposed to live for exactly as long as the component is on screen:
2914
3311
  #
2915
- # def on_attached
3312
+ # def handle_attached
2916
3313
  # @ticker = screen.event_queue.tick_fps(10) { advance }
2917
3314
  # end
2918
3315
  #
2919
- # def on_detached
3316
+ # def handle_detached
2920
3317
  # @ticker&.cancel
2921
3318
  # @ticker = nil
2922
3319
  # end
2923
3320
  #
2924
- # `on_attached` starts what `on_detached` stops; both must be cheap and
3321
+ # `handle_attached` starts what `handle_detached` stops; both must be cheap and
2925
3322
  # idempotent, since a component moved between parents is genuinely detached
2926
3323
  # in between and gets both, in that order. Whatever you acquire here you
2927
- # must release in {#on_detached} — nothing else will. Not a destructor:
2928
- # process teardown does *not* fire {#on_detached}.
3324
+ # must release in {#handle_detached} — nothing else will. Not a destructor:
3325
+ # process teardown does *not* fire {#handle_detached}.
2929
3326
  #
2930
3327
  # {#invalidate} needs no guard: {#attached?} is already true here (and
2931
- # already false in {#on_detached}, where it no-ops). Do not read {#rect} —
3328
+ # already false in {#handle_detached}, where it no-ops). Do not read {#rect} —
2932
3329
  # a parent assigns it *after* wiring, so it is still stale. Runs on the
2933
3330
  # thread that owns the UI.
2934
- def on_attached: () -> void
3331
+ def handle_attached: () -> void
2935
3332
 
2936
- # Mirror of {#on_attached}, called once the tree has been unmounted — see
3333
+ # Mirror of {#handle_attached}, called once the tree has been unmounted — see
2937
3334
  # there for the contract. Two things are still mid-flight when it runs, both
2938
3335
  # deliberate: {Screen#focused} may still point into this subtree (repair
2939
3336
  # happens after), and the ex-parent's own bookkeeping may not be finished.
2940
3337
  # So release resources here and don't inspect the tree around you.
2941
- def on_detached: () -> void
3338
+ def handle_detached: () -> void
2942
3339
 
2943
3340
  # Walks self-then-children calling one lifecycle hook, delivering at most one
2944
3341
  # call per component per transition however the hooks mutate the tree. Two
@@ -2950,22 +3347,25 @@ module Tuile
2950
3347
  # current attachedness rather than on `parent.equal?(self)`: a child pulled
2951
3348
  # out during a detach walk is *already* detached, so its own `parent=` saw
2952
3349
  # no transition and stayed silent — a parentage check would skip it too and
2953
- # it would never hear `on_detached` at all. The reverse case (pulled out
2954
- # during an *attach* walk) gets `on_detached` from its own `parent=` and no
2955
- # `on_attached`, which is why the hooks are required to be idempotent: an
2956
- # unpaired detach releases nothing, whereas firing `on_attached` at a
3350
+ # it would never hear `handle_detached` at all. The reverse case (pulled out
3351
+ # during an *attach* walk) gets `handle_detached` from its own `parent=` and no
3352
+ # `handle_attached`, which is why the hooks are required to be idempotent: an
3353
+ # unpaired detach releases nothing, whereas firing `handle_attached` at a
2957
3354
  # component that is no longer attached would start a ticker nothing stops.
2958
3355
  #
2959
- # _@param_ `attached` — true to fire {#on_attached}, false for {#on_detached}.
3356
+ # _@param_ `attached` — true to fire {#handle_attached}, false for {#handle_detached}.
2960
3357
  def fire_lifecycle: (bool attached) -> void
2961
3358
 
2962
3359
  # Called whenever the component width changes. Does nothing by default.
2963
- def on_width_changed: () -> void
3360
+ def handle_width_changed: () -> void
2964
3361
 
2965
3362
  # Called on the parent after a direct child's {#visible=} flipped, so a
2966
3363
  # container that divides space can re-divide it:
2967
3364
  #
2968
- # def on_child_visibility_changed(_child) = relayout
3365
+ # def handle_child_visibility_changed(_child)
3366
+ # super
3367
+ # relayout
3368
+ # end
2969
3369
  #
2970
3370
  # **A container with layout arithmetic owes this override**, or a hidden
2971
3371
  # child keeps its slot and its gap — the hole the flag exists to close.
@@ -2973,27 +3373,31 @@ module Tuile
2973
3373
  # arithmetic, and an app wanting the space back reads `visible?` there.
2974
3374
  #
2975
3375
  # Fires on the flip only, before the subtree is invalidated, never for a
2976
- # grandchild. {#visible=} repairs focus itself, so an override needs no
2977
- # `super`. Reached through `__send__`, so it may declare any visibility
2978
- # (`D_hook_visibility`).
3376
+ # grandchild. {#visible=} repairs focus itself, so an override has nothing
3377
+ # to inherit — it still calls `super`, per the class doc. Reached through
3378
+ # `__send__`, so it may declare any visibility (`D_hook_visibility`).
2979
3379
  #
2980
3380
  # _@param_ `_child` — the direct child whose flag changed.
2981
- def on_child_visibility_changed: (Component _child) -> void
3381
+ def handle_child_visibility_changed: (Component _child) -> void
2982
3382
 
2983
- # Mirror of {#on_focus}: the component just lost focus, to another component
3383
+ # Mirror of {#handle_focus}: the component just lost focus, to another component
2984
3384
  # or to nothing. The commit point a Tab-away still reaches — Tab is
2985
3385
  # unconditional, so {Component::TextField#on_enter} never fires for a user
2986
3386
  # who tabs out of a half-typed field:
2987
3387
  #
2988
3388
  # class TrimmedField < Component::TextField
2989
- # protected def on_blur = (self.text = text.strip)
3389
+ # protected def handle_blur
3390
+ # super
3391
+ # self.text = text.strip
3392
+ # false
3393
+ # end
2990
3394
  # end
2991
3395
  #
2992
3396
  # Edge-triggered, and fired on the blurred component alone — never on the
2993
3397
  # ancestors leaving the active chain with it, so a composed widget asking
2994
3398
  # "did focus leave me *and* my children" overrides {#active=} instead
2995
3399
  # ({Component::ComboBox} closes its dropdown from there). Focus that merely
2996
- # *passes through* does blur: a container forwarding focus from {#on_focus}
3400
+ # *passes through* does blur: a container forwarding focus from {#handle_focus}
2997
3401
  # is blurred by its own forward.
2998
3402
  #
2999
3403
  # A notification, not a veto — focus has already moved, and the active-flag
@@ -3006,11 +3410,47 @@ module Tuile
3006
3410
  # It fires wherever focus is *dropped*, not only where a user moved it, so
3007
3411
  # two paths reach it with the tree mid-flight: the popup-close repair blurs
3008
3412
  # an **already-detached** component, where {#invalidate} is the same silent
3009
- # no-op as in {#on_detached}, and {Screen#close} blurs on its way out. Keep
3413
+ # no-op as in {#handle_detached}, and {Screen#close} blurs on its way out. Keep
3010
3414
  # it cheap; a raise propagates out of {Screen#focused=}. Protected because
3011
3415
  # the framework calls it and an app never does — {Screen} reaches it with
3012
3416
  # `__send__`, so an override may declare any visibility (`D_hook_visibility`).
3013
- def on_blur: () -> void
3417
+ def handle_blur: () -> void
3418
+
3419
+ # Called on every attached component (pre-order, popups included) when
3420
+ # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
3421
+ # OS appearance flips. The hook exists for app *content* whose colors were
3422
+ # baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
3423
+ # styled with `theme[:accent]`); rebuild it here by re-running the code that
3424
+ # rendered it. See book ch6 for why built-in accents need no such handling.
3425
+ #
3426
+ # Runs on the UI thread with {Screen#theme} already updated, so mutating
3427
+ # content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
3428
+ # here. Subclasses overriding this must call `super` so an assigned
3429
+ # {#on_theme_changed=} listener keeps firing.
3430
+ #
3431
+ # Plumbing an app overrides and never calls, hence protected — and
3432
+ # {Screen}, not being a {Component}, fans it out through `__send__`, so an
3433
+ # override is free to declare any visibility (`D_hook_visibility`).
3434
+ #
3435
+ # _@return_ — is whatever the app's lambda happened to return.
3436
+ def handle_theme_changed: () -> void
3437
+
3438
+ # Called on every attached component (pre-order, popups included) when
3439
+ # {Screen#locale} changes — for state derived from the old conventions and
3440
+ # **pushed** somewhere, such as a date already rendered into a {Label}'s
3441
+ # text. Anything read at paint or parse time needs no override: the locale
3442
+ # change invalidates the whole tree.
3443
+ #
3444
+ # Runs on the UI thread with {Screen#locale} already updated. Subclasses
3445
+ # overriding it must call `super` so an assigned {#on_locale_changed=}
3446
+ # listener keeps firing.
3447
+ #
3448
+ # Plumbing an app overrides and never calls, hence protected — {Screen}
3449
+ # fans it out through `__send__`, so an override may declare any visibility
3450
+ # (`D_hook_visibility`).
3451
+ #
3452
+ # _@return_ — is whatever the app's lambda happened to return.
3453
+ def handle_locale_changed: () -> void
3014
3454
 
3015
3455
  # The formatting conventions to render and parse by ({Screen#locale}), or
3016
3456
  # {Locale::ISO} when there is no screen in the process — which a tree
@@ -3049,6 +3489,21 @@ module Tuile
3049
3489
  # it directly.
3050
3490
  def clear_outside_extent: () -> void
3051
3491
 
3492
+ # Blanks the {#extent} itself, for a *container* whose children don't cover
3493
+ # it — a {Component::Layout::Box}'s `spacing` column, the slack past the last
3494
+ # child, the span a child abandoned by going hidden or by a narrowing resize.
3495
+ #
3496
+ # In the *ambient* background, the same answer {#clear_outside_extent} gives
3497
+ # the dead tail: a gap between two children is not this widget's ink, so an
3498
+ # app's {#bg_color} tint covers it but a well of its own — a field's, a
3499
+ # validation error's — must not bleed into it.
3500
+ #
3501
+ # Called by the default {#repaint} for a container only; a leaf paints its
3502
+ # extent itself, and blanking that first is the re-emit `D_progress_bar`
3503
+ # bought back. Override it to decline when you paint your own ink into a
3504
+ # face cell no child covers.
3505
+ def clear_inside_extent: () -> void
3506
+
3052
3507
  # The background this component paints when the app has set no {#bg_color} —
3053
3508
  # `nil` by default, meaning "I have no surface of my own; whatever is behind
3054
3509
  # me shows through". A widget that paints an opaque surface overrides it, and
@@ -3065,7 +3520,7 @@ module Tuile
3065
3520
  # the cheap form and allocates nothing on the paint path.
3066
3521
  #
3067
3522
  # Read the theme here rather than in an ivar: this runs at paint time, so a
3068
- # {Screen#theme=} restyles the widget with no {#on_theme_changed} hook.
3523
+ # {Screen#theme=} restyles the widget with no {#handle_theme_changed} hook.
3069
3524
  def default_bg_color: () -> (Color | Theme::Ref | ::Hash[untyped, untyped])?
3070
3525
 
3071
3526
  # Final, and protected: it answers what the *framework* paints with, and an
@@ -3158,7 +3613,7 @@ module Tuile
3158
3613
  ) -> void
3159
3614
 
3160
3615
  # Hands focus out of the subtree just hidden, if it was in there, through
3161
- # the parent's {#on_child_removed} — see there for why hiding reuses the
3616
+ # the parent's {#handle_child_removed} — see there for why hiding reuses the
3162
3617
  # removal repair instead of growing a second one.
3163
3618
  #
3164
3619
  # The parent is necessarily showing (focus was inside it a moment ago, and
@@ -3256,43 +3711,25 @@ module Tuile
3256
3711
  # callers! May be empty.
3257
3712
  attr_reader children: ::Array[Component]
3258
3713
 
3259
- # Called on every attached component (pre-order, popups included) when
3260
- # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
3261
- # OS appearance flips. The hook exists for app *content* whose colors were
3262
- # baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
3263
- # styled with `theme[:accent]`); rebuild it here by re-running the code that
3264
- # rendered it. See book ch6 for why built-in accents need no such handling.
3714
+ # Optional zero-arg listener fired by the base {#handle_theme_changed} — the
3715
+ # composition-style alternative to overriding the method, for apps that
3716
+ # assemble stock components rather than subclass:
3265
3717
  #
3266
- # Runs on the UI thread with {Screen#theme} already updated, so mutating
3267
- # content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
3268
- # here. Subclasses overriding this must call `super` so an assigned
3269
- # {#on_theme_changed=} listener keeps firing.
3270
- #
3271
- # Plumbing an app overrides and never calls, hence protected — and
3272
- # {Screen}, not being a {Component}, fans it out through `__send__`, so an
3273
- # override is free to declare any visibility (`D_hook_visibility`).
3718
+ # label.on_theme_changed = -> { label.text = render_status_line }
3274
3719
  attr_accessor on_theme_changed: Proc?
3275
3720
 
3276
- # Called on every attached component (pre-order, popups included) when
3277
- # {Screen#locale} changes — for state derived from the old conventions and
3278
- # **pushed** somewhere, such as a date already rendered into a {Label}'s
3279
- # text. Anything read at paint or parse time needs no override: the locale
3280
- # change invalidates the whole tree.
3721
+ # Optional zero-arg listener fired by the base {#handle_locale_changed} — the
3722
+ # composition-style alternative to overriding the method, for an app that
3723
+ # rendered a date or a number into a stock component:
3281
3724
  #
3282
- # Runs on the UI thread with {Screen#locale} already updated. Subclasses
3283
- # overriding it must call `super` so an assigned {#on_locale_changed=}
3284
- # listener keeps firing.
3285
- #
3286
- # Plumbing an app overrides and never calls, hence protected — {Screen}
3287
- # fans it out through `__send__`, so an override may declare any visibility
3288
- # (`D_hook_visibility`).
3725
+ # label.on_locale_changed = -> { label.text = due_date.strftime(fmt) }
3289
3726
  attr_accessor on_locale_changed: Proc?
3290
3727
 
3291
3728
  # A scrollable list of typed items, one row each, with cursor support.
3292
3729
  #
3293
3730
  # list = Component::List.new
3294
3731
  # list.items = people
3295
- # list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.hint(" #{p.email}") }
3732
+ # list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
3296
3733
  # list.cursor = List::Cursor.new # a bare list has none
3297
3734
  # list.on_item_chosen = ->(index, person) { open(person) }
3298
3735
  #
@@ -3374,7 +3811,7 @@ module Tuile
3374
3811
  # _@param_ `key` — a key.
3375
3812
  #
3376
3813
  # _@return_ — true if the key was handled.
3377
- def handle_key: (String key) -> bool
3814
+ def handle_key?: (String key) -> bool
3378
3815
 
3379
3816
  # Moves the cursor to the next item whose text contains `query`
3380
3817
  # (case-insensitive substring match). Search wraps around the end of the
@@ -3414,8 +3851,17 @@ module Tuile
3414
3851
  # _@return_ — whether the cursor moved there.
3415
3852
  def select: (Integer index) -> bool
3416
3853
 
3854
+ # Moves the cursor to the pressed row and fires {#on_item_chosen}; what
3855
+ # each {Cursor} does with a press is its own.
3856
+ #
3857
+ # _@param_ `event`
3858
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
3859
+
3860
+ # Scrolls four rows a notch, and declines — so the notch bubbles to an
3861
+ # ancestor scroller — once this list is at that end of its items.
3862
+ #
3417
3863
  # _@param_ `event`
3418
- def handle_mouse: (MouseEvent event) -> void
3864
+ def handle_mouse_scroll?: (Mouse::ScrollEvent event) -> bool
3419
3865
 
3420
3866
  # Paints the visible items into {#rect}, rendering the ones not already
3421
3867
  # cached.
@@ -3436,7 +3882,7 @@ module Tuile
3436
3882
  # populated before the popup was opened), the auto-scroll update
3437
3883
  # was skipped because there was no viewport — re-run it now that there
3438
3884
  # is one, so the list snaps to the bottom on first paint.
3439
- def on_width_changed: () -> void
3885
+ def handle_width_changed: () -> void
3440
3886
 
3441
3887
  # Coerces and flattens a list of input entries into trimmed
3442
3888
  # {StyledString} lines. Each entry becomes a {StyledString} (String
@@ -3524,7 +3970,7 @@ module Tuile
3524
3970
  # negating the auto-scroll. Skipped when {#rect} is empty: without a
3525
3971
  # viewport the "items minus viewport" formula yields `@items.size`,
3526
3972
  # which would leave `scroll_top_row` past the last item once a real rect
3527
- # arrives. {#on_width_changed} re-runs this hook when the rect grows so
3973
+ # arrives. {#handle_width_changed} re-runs this hook when the rect grows so
3528
3974
  # the snap-to-bottom intent is preserved.
3529
3975
  #
3530
3976
  # Gated on {#following?}: once the user scrolls up off the bottom the
@@ -3652,16 +4098,16 @@ module Tuile
3652
4098
  # _@param_ `viewport_rows` — number of visible rows.
3653
4099
  #
3654
4100
  # _@return_ — true if the cursor moved.
3655
- def handle_key: (String key, Integer item_count, Integer viewport_rows) -> bool
4101
+ def handle_key?: (String key, Integer item_count, Integer viewport_rows) -> bool
3656
4102
 
3657
- # _@param_ `item_index` — the item the cursor is hovering over.
4103
+ # _@param_ `item_index` — the item pressed on.
3658
4104
  #
3659
4105
  # _@param_ `event` — the event.
3660
4106
  #
3661
4107
  # _@param_ `item_count` — number of items in the list.
3662
4108
  #
3663
- # _@return_ — true if the event was handled.
3664
- def handle_mouse: (Integer item_index, MouseEvent event, Integer item_count) -> bool
4109
+ # _@return_ — true if the cursor moved.
4110
+ def handle_mouse_down?: (Integer item_index, Mouse::DownEvent event, Integer item_count) -> bool
3665
4111
 
3666
4112
  # Moves the cursor to the new position. Public only because of testing.
3667
4113
  #
@@ -3701,14 +4147,14 @@ module Tuile
3701
4147
  # _@param_ `_item_count`
3702
4148
  #
3703
4149
  # _@param_ `_viewport_rows`
3704
- def handle_key: (String _key, Integer _item_count, Integer _viewport_rows) -> bool
4150
+ def handle_key?: (String _key, Integer _item_count, Integer _viewport_rows) -> bool
3705
4151
 
3706
4152
  # _@param_ `_item_index`
3707
4153
  #
3708
4154
  # _@param_ `_event`
3709
4155
  #
3710
4156
  # _@param_ `_item_count`
3711
- def handle_mouse: (Integer _item_index, MouseEvent _event, Integer _item_count) -> bool
4157
+ def handle_mouse_down?: (Integer _item_index, Mouse::DownEvent _event, Integer _item_count) -> bool
3712
4158
 
3713
4159
  # _@param_ `_item_count`
3714
4160
  def candidate_positions: (Integer _item_count) -> ::Array[Integer]
@@ -3736,7 +4182,7 @@ module Tuile
3736
4182
  # _@param_ `event`
3737
4183
  #
3738
4184
  # _@param_ `_item_count`
3739
- def handle_mouse: (Integer item_index, MouseEvent event, Integer _item_count) -> bool
4185
+ def handle_mouse_down?: (Integer item_index, Mouse::DownEvent event, Integer _item_count) -> bool
3740
4186
 
3741
4187
  # _@param_ `item_count`
3742
4188
  def candidate_positions: (Integer item_count) -> ::Array[Integer]
@@ -3778,7 +4224,7 @@ module Tuile
3778
4224
  # (what {Window} does with an absent footer); never detach it.
3779
4225
  #
3780
4226
  # Transparent to input: not {Component#focusable?},
3781
- # {Component#handle_mouse} descends through it, and a departing occupant's
4227
+ # the mouse routes straight through it, and a departing occupant's
3782
4228
  # focus repair is handed to the container.
3783
4229
  class Slot < Component
3784
4230
  include Tuile::Component::HasContent
@@ -3791,7 +4237,7 @@ module Tuile
3791
4237
  # nothing to bubble from.
3792
4238
  #
3793
4239
  # _@param_ `child` — the just-detached occupant.
3794
- def on_child_removed: (Component child) -> void
4240
+ def handle_child_removed: (Component child) -> void
3795
4241
 
3796
4242
  # _@param_ `content`
3797
4243
  def layout: (Component content) -> void
@@ -3799,7 +4245,7 @@ module Tuile
3799
4245
  # _@param_ `rect`
3800
4246
  def rect=: (Rect rect) -> void
3801
4247
 
3802
- def on_focus: () -> void
4248
+ def handle_focus: () -> void
3803
4249
  end
3804
4250
 
3805
4251
  # A one-row strip of captions with exactly one of them selected — the map of
@@ -3829,7 +4275,7 @@ module Tuile
3829
4275
  #
3830
4276
  # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
3831
4277
  # `items=`: a tab is identity plus its own state, so the set grows and
3832
- # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D_tabs`.
4278
+ # shrinks one tab at a time. See book ch7 and `design/decisions.md` `D_tabs`.
3833
4279
  #
3834
4280
  # == Sizing
3835
4281
  # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
@@ -3913,7 +4359,7 @@ module Tuile
3913
4359
  #
3914
4360
  # Both the focus highlight and the click hit test use it, so a click on the
3915
4361
  # blank tail — or on a lower row, when the rect is taller than one —
3916
- # selects nothing. It still *focuses*: {Component#handle_mouse}'s
4362
+ # selects nothing. It still *focuses*: {Mouse::Router}'s
3917
4363
  # click-to-focus is ungated by geometry.
3918
4364
  def extent: () -> Size
3919
4365
 
@@ -3922,13 +4368,13 @@ module Tuile
3922
4368
  # bubbles to an ancestor; an empty strip handles nothing at all.
3923
4369
  #
3924
4370
  # _@param_ `key`
3925
- def handle_key: (String key) -> bool
4371
+ def handle_key?: (String key) -> bool
3926
4372
 
3927
- # Selects the tab under a left click; `super` runs first, so a click
3928
- # anywhere in {#rect} still focuses.
4373
+ # Selects the tab under a left press; a press between tabs selects
4374
+ # nothing and still claims the strip.
3929
4375
  #
3930
4376
  # _@param_ `event`
3931
- def handle_mouse: (MouseEvent event) -> void
4377
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
3932
4378
 
3933
4379
  def repaint: () -> void
3934
4380
 
@@ -3938,7 +4384,7 @@ module Tuile
3938
4384
 
3939
4385
  # The rect's *width* is the only part of it the offset depends on, so this
3940
4386
  # hook is the whole geometry story; {Component#rect=} invalidates for us.
3941
- def on_width_changed: () -> void
4387
+ def handle_width_changed: () -> void
3942
4388
 
3943
4389
  # Scrolls the minimum needed to show the selected segment whole, and is the
3944
4390
  # sole writer of {#left_column}. Idempotent, so every mutation site can
@@ -4134,7 +4580,7 @@ module Tuile
4134
4580
  # that carries its own background keeps it.
4135
4581
  def repaint: () -> void
4136
4582
 
4137
- def on_width_changed: () -> void
4583
+ def handle_width_changed: () -> void
4138
4584
 
4139
4585
  # Recomputes {@rows} for the current text and rect width.
4140
4586
  # Each line is ellipsized to fit and padded with trailing spaces out to
@@ -4178,7 +4624,7 @@ module Tuile
4178
4624
  #
4179
4625
  # `q` and ESC close the popup — handled here, at the top of the popup's own
4180
4626
  # subtree, so the key only arrives after every component on the focus chain
4181
- # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
4627
+ # declined it (see {ScreenPane#handle_key?}). That's why typing `q` into a
4182
4628
  # nested {Component::TextField} doesn't dismiss the popup: the field consumes
4183
4629
  # it first.
4184
4630
  #
@@ -4224,7 +4670,7 @@ module Tuile
4224
4670
  # _@param_ `key`
4225
4671
  #
4226
4672
  # _@return_ — true if the key was handled.
4227
- def handle_key: (String key) -> bool
4673
+ def handle_key?: (String key) -> bool
4228
4674
 
4229
4675
  # The box this popup asks for, *as set* — a {Fraction} comes back
4230
4676
  # unresolved. {#rect} is the resolved answer. Named apart from `size`
@@ -4243,7 +4689,7 @@ module Tuile
4243
4689
  #
4244
4690
  # Buttons are tab stops — Tab and Shift+Tab will land on them as part of
4245
4691
  # the standard focus cycle. Click-to-focus also works via the inherited
4246
- # {Component#handle_mouse}.
4692
+ # {Component#handle_mouse_down?}.
4247
4693
  #
4248
4694
  # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
4249
4695
  # show `[ caption ]` — that natural width is `caption.display_width + 4`.
@@ -4260,13 +4706,13 @@ module Tuile
4260
4706
  def tab_stop?: () -> bool
4261
4707
 
4262
4708
  # _@param_ `key`
4263
- def handle_key: (String key) -> bool
4709
+ def handle_key?: (String key) -> bool
4264
4710
 
4265
4711
  # The cells the button actually paints: one row, `caption.display_width + 4`
4266
4712
  # columns, clipped to {#rect}. Both the focus highlight and the click hit
4267
4713
  # test use it, so a click on the blank tail of an over-wide rect — or on a
4268
4714
  # lower row, when the rect is taller than one — does not fire {#on_click}.
4269
- # It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
4715
+ # It still *focuses*: {Mouse::Router}'s click-to-focus is ungated
4270
4716
  # by geometry. Same rule as {Checkbox#extent}, which documents the two
4271
4717
  # traps behind it.
4272
4718
  def extent: () -> Size
@@ -4275,7 +4721,7 @@ module Tuile
4275
4721
  # a click anywhere in {#rect} still focuses.
4276
4722
  #
4277
4723
  # _@param_ `event`
4278
- def handle_mouse: (MouseEvent event) -> void
4724
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
4279
4725
 
4280
4726
  def repaint: () -> void
4281
4727
 
@@ -4324,7 +4770,7 @@ module Tuile
4324
4770
  # the popup. Layouts don't paint any visible chrome of their own
4325
4771
  # (the auto-cleared background is just blank space), so this has no
4326
4772
  # mouse-routing consequences — clicks on a gap area land back on the
4327
- # Layout itself and the on_focus cascade forwards to a tab stop.
4773
+ # Layout itself and the handle_focus cascade forwards to a tab stop.
4328
4774
  def focusable?: () -> bool
4329
4775
 
4330
4776
  # Adds a child component to this layout.
@@ -4335,7 +4781,7 @@ module Tuile
4335
4781
  # _@param_ `child`
4336
4782
  def remove: (Component child) -> void
4337
4783
 
4338
- def on_focus: () -> void
4784
+ def handle_focus: () -> void
4339
4785
 
4340
4786
  # How much space a child gets along one axis of a {Box}: exactly {#cells},
4341
4787
  # clamped to whatever is still unassigned.
@@ -4592,7 +5038,7 @@ module Tuile
4592
5038
  # {Component#visible=} buys over `remove` plus `add(…, at:)`.
4593
5039
  #
4594
5040
  # _@param_ `_child`
4595
- def on_child_visibility_changed: (Component _child) -> void
5041
+ def handle_child_visibility_changed: (Component _child) -> void
4596
5042
 
4597
5043
  # Recomputes and assigns every child's rect, giving each an empty one
4598
5044
  # when this layout's own rect — or {#inner_rect} — is empty.
@@ -4835,7 +5281,7 @@ module Tuile
4835
5281
  # Home/End are declined too, so they stay available app-wide.
4836
5282
  #
4837
5283
  # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
4838
- # the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
5284
+ # the feedback removed (`design/decisions.md` `D_select`). Which is also why labels
4839
5285
  # need no prefix-disambiguation.
4840
5286
  #
4841
5287
  # == Implementation details
@@ -4880,22 +5326,21 @@ module Tuile
4880
5326
  # is left unhandled so it bubbles to an ancestor.
4881
5327
  #
4882
5328
  # _@param_ `key`
4883
- def handle_key: (String key) -> bool
5329
+ def handle_key?: (String key) -> bool
4884
5330
 
4885
5331
  # The one row this Select paints — the full width, at the top of {#rect}.
4886
5332
  # A single-slot container ({Component::Window}, {Component::Popup}) hands
4887
5333
  # its content the whole inner rect, so a Select is routinely assigned more
4888
- # height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
4889
- # clicks in it, and the dropdown hangs under this rather than under the
5334
+ # height than it uses; {#repaint} clears that tail, a press in it never reaches
5335
+ # {#handle_mouse_down?}, and the dropdown hangs under this rather than under the
4890
5336
  # unused space.
4891
5337
  def extent: () -> Size
4892
5338
 
4893
- # Toggles the dropdown on a left click anywhere in {#extent} — a field's
4894
- # affordance is its whole row, as the well advertises; `super` runs first,
4895
- # so the click also focuses.
5339
+ # Toggles the dropdown on a left press anywhere in {#extent} — a field's
5340
+ # affordance is its whole row, as the well advertises.
4896
5341
  #
4897
5342
  # _@param_ `event`
4898
- def handle_mouse: (MouseEvent event) -> void
5343
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
4899
5344
 
4900
5345
  def repaint: () -> void
4901
5346
 
@@ -4975,7 +5420,7 @@ module Tuile
4975
5420
  # Input fields are focusable by default (overrides {Component#focusable?});
4976
5421
  # a read-only display field could override back to `false`. Only
4977
5422
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
4978
- # composing wrappers, so it stays per-class (`DECISIONS.md`
5423
+ # composing wrappers, so it stays per-class (`design/decisions.md`
4979
5424
  # `D_integer_field`).
4980
5425
  def focusable?: () -> bool
4981
5426
 
@@ -5131,7 +5576,7 @@ module Tuile
5131
5576
  # Adds `caption="…"` to {Component#inspect}, omitted while empty.
5132
5577
  def inspect_details: () -> ::Array[String]
5133
5578
 
5134
- def on_focus: () -> void
5579
+ def handle_focus: () -> void
5135
5580
 
5136
5581
  # _@return_ — optional chrome embedded into the bottom border
5137
5582
  # line, mirroring {#caption} on the top line. Empty by default; hidden
@@ -5160,7 +5605,7 @@ module Tuile
5160
5605
  # **{#focusable?} and {#modal?} move together — flip both or neither.** The
5161
5606
  # defaults here are inert (`false`, `false`): a bare overlay floats without
5162
5607
  # disturbing focus or key dispatch. {Component::Popup} flips both. What must
5163
- # not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
5608
+ # not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key?}
5164
5609
  # scopes delivery to the topmost modal popup or else the tiled content, so
5165
5610
  # such an overlay would hold focus outside the key scope, where delivery
5166
5611
  # reaches nobody and *every* keystroke goes dead until Tab recovers. A
@@ -5211,9 +5656,9 @@ module Tuile
5211
5656
  def visible=: (bool _value) -> void
5212
5657
 
5213
5658
  # Whether a left click outside this overlay closes it (default true). The
5214
- # pane does the closing — {ScreenPane#handle_mouse} snapshots the open
5215
- # overlays *before* routing the click and closes the dismissable ones
5216
- # *after*, so a widget that toggles its own overlay from a click on its
5659
+ # pane does the closing — {ScreenPane#dismissing_popups_outside} snapshots
5660
+ # the open overlays *before* the press is routed and closes the
5661
+ # dismissable ones *after*, so a widget that toggles its own overlay from a click on its
5217
5662
  # face (a {Component::Select}, a {Component::MenuBar} title) still toggles
5218
5663
  # correctly: the delivered click closes the overlay and the dismissal then
5219
5664
  # no-ops on it, rather than closing and reopening it. Only `:left`
@@ -5249,7 +5694,7 @@ module Tuile
5249
5694
  # overlay = Component::Overlay.new(content: label).open # construct and mount
5250
5695
  #
5251
5696
  # There is deliberately no class-level `Overlay.open` factory — see
5252
- # `DECISIONS.md` `D_popup_open`; returning `self` is what keeps the
5697
+ # `design/decisions.md` `D_popup_open`; returning `self` is what keeps the
5253
5698
  # one-liner above available without one.
5254
5699
  def open: () -> self
5255
5700
 
@@ -5267,7 +5712,7 @@ module Tuile
5267
5712
 
5268
5713
  # Fires {#on_close}. A subclass overriding this **must** call `super`, or
5269
5714
  # the overlay's driver never hears that it closed.
5270
- def on_detached: () -> void
5715
+ def handle_detached: () -> void
5271
5716
 
5272
5717
  # Content fills the overlay's full rect — an Overlay has no border to
5273
5718
  # subtract.
@@ -5275,7 +5720,7 @@ module Tuile
5275
5720
  # _@param_ `content`
5276
5721
  def layout: (Component content) -> void
5277
5722
 
5278
- def on_focus: () -> void
5723
+ def handle_focus: () -> void
5279
5724
 
5280
5725
  # _@return_ — see {#close_on_outside_click?}.
5281
5726
  attr_writer close_on_outside_click: bool
@@ -5298,12 +5743,12 @@ module Tuile
5298
5743
  # A callback taking no arguments, fired once this overlay has left the
5299
5744
  # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
5300
5745
  # an outside click, or teardown via {Screen#close}. That unconditionality
5301
- # is the point, so it hangs off {#on_detached} rather than {#close}; a
5746
+ # is the point, so it hangs off {#handle_detached} rather than {#close}; a
5302
5747
  # driver keeping its own record of open overlays reconciles it here and
5303
5748
  # cannot drift ({Component::MenuBar::Cascade} is the worked example).
5304
5749
  #
5305
5750
  # It fires *after* the overlay is detached, so {#open?} is already false
5306
- # and the usual {Component#on_detached} caveats apply: release state, don't
5751
+ # and the usual {Component#handle_detached} caveats apply: release state, don't
5307
5752
  # inspect the tree, keep it trivial (it may run while the pane is mid-way
5308
5753
  # through closing a batch of overlays, and a raise propagates).
5309
5754
  attr_accessor on_close: Proc?
@@ -5385,7 +5830,7 @@ module Tuile
5385
5830
  #
5386
5831
  # Both the focus highlight and the click hit test use it, so a click on the
5387
5832
  # blank tail — or on a lower row, when the rect is taller than one — does
5388
- # not toggle. It still *focuses*: {Component#handle_mouse}'s click-to-focus
5833
+ # not toggle. It still *focuses*: {Mouse::Router}'s click-to-focus
5389
5834
  # is ungated by geometry, and the tail is the field's own row.
5390
5835
  #
5391
5836
  # The extent ignores {Component#bg_color}: an inherited tint paints the dead
@@ -5397,13 +5842,13 @@ module Tuile
5397
5842
  # to an ancestor.
5398
5843
  #
5399
5844
  # _@param_ `key`
5400
- def handle_key: (String key) -> bool
5845
+ def handle_key?: (String key) -> bool
5401
5846
 
5402
- # Toggles on a left click within {#extent}; `super` runs first, so a click
5403
- # anywhere in {#rect} still focuses.
5847
+ # Toggles on a left press; a press on the dead tail past {#extent} focuses
5848
+ # the checkbox without reaching here.
5404
5849
  #
5405
5850
  # _@param_ `event`
5406
- def handle_mouse: (MouseEvent event) -> void
5851
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
5407
5852
 
5408
5853
  def repaint: () -> void
5409
5854
 
@@ -5444,7 +5889,7 @@ module Tuile
5444
5889
  # Input fields are focusable by default (overrides {Component#focusable?});
5445
5890
  # a read-only display field could override back to `false`. Only
5446
5891
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5447
- # composing wrappers, so it stays per-class (`DECISIONS.md`
5892
+ # composing wrappers, so it stays per-class (`design/decisions.md`
5448
5893
  # `D_integer_field`).
5449
5894
  def focusable?: () -> bool
5450
5895
 
@@ -5515,7 +5960,7 @@ module Tuile
5515
5960
  #
5516
5961
  # {Item} handles are minted by {#add_item} and nest via the *same* method, so
5517
5962
  # depth is unlimited. There is no removal, no reordering and no dynamic
5518
- # rebuilding: a menu is built once, at construction. See `DECISIONS.md`
5963
+ # rebuilding: a menu is built once, at construction. See `design/decisions.md`
5519
5964
  # `D_menu_bar`.
5520
5965
  #
5521
5966
  # == Sizing
@@ -5577,7 +6022,7 @@ module Tuile
5577
6022
  #
5578
6023
  # Both the highlight and the click hit test use it, so a click on the blank
5579
6024
  # tail — or on a lower row, when the rect is taller than one — opens
5580
- # nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
6025
+ # nothing. It still *focuses*: {Mouse::Router}'s click-to-focus is
5581
6026
  # ungated by geometry.
5582
6027
  def extent: () -> Size
5583
6028
 
@@ -5593,7 +6038,7 @@ module Tuile
5593
6038
  # Closes the cascade, so a bar removed from the tree can't strand its
5594
6039
  # panels on the pane — they are the {ScreenPane}'s children, not the bar's,
5595
6040
  # so nothing else would take them down.
5596
- def on_detached: () -> void
6041
+ def handle_detached: () -> void
5597
6042
 
5598
6043
  # Offers the key to the open cascade first, then to the strip's own
5599
6044
  # LEFT/RIGHT/Enter/Space/Down.
@@ -5603,14 +6048,14 @@ module Tuile
5603
6048
  # submenu — and both step to the sibling menu.
5604
6049
  #
5605
6050
  # _@param_ `key`
5606
- def handle_key: (String key) -> bool
6051
+ def handle_key?: (String key) -> bool
5607
6052
 
5608
- # Opens the menu under a left click, or closes it when it is already the
5609
- # open one; `super` runs first, so a click anywhere in {#rect} still
5610
- # focuses.
6053
+ # Opens the menu under a left press, or closes it when it is already the
6054
+ # open one; a press on the strip's padding claims the press and does
6055
+ # nothing else.
5611
6056
  #
5612
6057
  # _@param_ `event`
5613
- def handle_mouse: (MouseEvent event) -> void
6058
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
5614
6059
 
5615
6060
  def repaint: () -> void
5616
6061
 
@@ -5629,7 +6074,7 @@ module Tuile
5629
6074
  # The rect's *width* is the only part of it the offset depends on, so this
5630
6075
  # hook is the whole geometry story; {Component#rect=} invalidates for us,
5631
6076
  # and {#rect=} closes the cascade rather than re-anchoring it.
5632
- def on_width_changed: () -> void
6077
+ def handle_width_changed: () -> void
5633
6078
 
5634
6079
  # Scrolls the minimum needed to show the highlighted segment whole, and is
5635
6080
  # the sole writer of {#left_column}. Idempotent, so every mutation site can
@@ -5719,7 +6164,7 @@ module Tuile
5719
6164
  # _@param_ `key`
5720
6165
  #
5721
6166
  # _@return_ — whether a mnemonic claimed the key.
5722
- def handle_mnemonic: (String key) -> bool
6167
+ def handle_mnemonic?: (String key) -> bool
5723
6168
 
5724
6169
  # Moves the highlight along the strip, clamping at both ends. Consumes the
5725
6170
  # key even at an end, as {Tabs} does.
@@ -5841,18 +6286,18 @@ module Tuile
5841
6286
  # machinery of {MenuBar}; an app never names it.
5842
6287
  #
5843
6288
  # cascade.open_below(segment_rect, item) # Enter/Down on the strip
5844
- # return true if cascade.handle_key(key) # MenuBar#handle_key, first
6289
+ # return true if cascade.handle_key?(key) # MenuBar#handle_key?, first
5845
6290
  # cascade.close # focus lost, or rect changed
5846
6291
  #
5847
6292
  # A panel is a **non-modal overlay, not a child**, so it never takes focus:
5848
6293
  # focus stays on the {MenuBar} for the whole interaction and every key
5849
- # arrives via {MenuBar#handle_key}, which offers it here first. That is
6294
+ # arrives via {MenuBar#handle_key?}, which offers it here first. That is
5850
6295
  # {Component::Select}'s architecture extended to N levels, and it is why
5851
6296
  # nothing in the key-dispatch ladder changes.
5852
6297
  #
5853
6298
  # Widths are measured here, per level — the panel is as wide as the level's
5854
6299
  # widest label — because {ListDropdown} deliberately measures nothing
5855
- # itself (`DECISIONS.md` `D_select`).
6300
+ # itself (`design/decisions.md` `D_select`).
5856
6301
  #
5857
6302
  # == Implementation details
5858
6303
  # While open it consumes **everything** except the two keys that mean
@@ -5892,7 +6337,7 @@ module Tuile
5892
6337
  # _@return_ — `true` when consumed — almost always, while open.
5893
6338
  # `false` when closed, and for the two sideways keys {MenuBar} answers
5894
6339
  # (see the class docs).
5895
- def handle_key: (String key) -> bool
6340
+ def handle_key?: (String key) -> bool
5896
6341
 
5897
6342
  # Activates the deepest level's item bound to `key` — the drill-or-fire
5898
6343
  # the mnemonic shares with Enter. The highlight moves there *first*, so a
@@ -5903,7 +6348,7 @@ module Tuile
5903
6348
  #
5904
6349
  # _@return_ — whether an item on the deepest level claimed it. A
5905
6350
  # miss is never offered to a shallower level.
5906
- def handle_mnemonic: (String key) -> bool
6351
+ def handle_mnemonic?: (String key) -> bool
5907
6352
 
5908
6353
  # _@return_ — the deepest open panel.
5909
6354
  def deepest: () -> ListDropdown
@@ -5992,9 +6437,16 @@ module Tuile
5992
6437
  # The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
5993
6438
  # it for the theming knob.
5994
6439
  #
6440
+ # == The inner field is private machinery
6441
+ # It has no public accessor: its buffer is the *query*, so swapping the field
6442
+ # would break the filtering. What is worth reaching is re-exposed here
6443
+ # ({#placeholder}, {#cursor_position}); a **spec** reaches the field itself:
6444
+ #
6445
+ # field = Testing.get(Component::TextField, in: combo)
6446
+ # field.text = "ap" # type a query without a real loop
6447
+ #
5995
6448
  # UI-thread-confined, like every component (see {Screen}).
5996
6449
  class ComboBox < Component
5997
- include Tuile::Component::HasContent
5998
6450
  include Tuile::Component::HasValue
5999
6451
  include Tuile::Component::HasPlaceholder
6000
6452
 
@@ -6020,12 +6472,13 @@ module Tuile
6020
6472
  # _@param_ `text`
6021
6473
  def placeholder=: (String? text) -> void
6022
6474
 
6023
- # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
6024
- # field via {#layout}.
6475
+ # Resizes the field and re-anchors the dropdown if it is open.
6025
6476
  #
6026
6477
  # _@param_ `new_rect`
6027
6478
  def rect=: (Rect new_rect) -> void
6028
6479
 
6480
+ def handle_focus: () -> void
6481
+
6029
6482
  # Closes the dropdown and reverts an uncommitted query when the combo
6030
6483
  # leaves the focus chain — so tabbing away doesn't strand an open menu or
6031
6484
  # a half-typed filter. Safe against re-entrancy: focus never sits inside
@@ -6048,10 +6501,13 @@ module Tuile
6048
6501
  # _@param_ `key`
6049
6502
  #
6050
6503
  # _@return_ — true if consumed.
6051
- def handle_key: (String key) -> bool
6504
+ def handle_key?: (String key) -> bool
6052
6505
 
6506
+ # Toggles the dropdown on a left press on the ▾ cell — the only cell of
6507
+ # this component's own that is not the field's.
6508
+ #
6053
6509
  # _@param_ `event`
6054
- def handle_mouse: (MouseEvent event) -> void
6510
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
6055
6511
 
6056
6512
  def repaint: () -> void
6057
6513
 
@@ -6067,12 +6523,10 @@ module Tuile
6067
6523
  # hangs under this rather than under the unused space below it.
6068
6524
  def extent: () -> Size
6069
6525
 
6070
- # Field spans the row bar the last column, which the `▾` occupies
6071
- # ({HasContent} layout hook). One row, or none at all when the combo itself
6072
- # was given none — a starved parent must not hand out a rect it doesn't own.
6073
- #
6074
- # _@param_ `field`
6075
- def layout: (Component field) -> void
6526
+ # Declines the default's blank: `field` covers every column of the face but
6527
+ # the last, and this combo paints the `▾` into that one, so blanking would
6528
+ # only dirty a cell it is about to repaint (`D_progress_bar`).
6529
+ def clear_inside_extent: () -> void
6076
6530
 
6077
6531
  # Dismisses the dropdown and puts the current value's label back in the
6078
6532
  # field, undoing an uncommitted query.
@@ -6105,7 +6559,7 @@ module Tuile
6105
6559
  # Sets the field's text without triggering a refilter — for programmatic
6106
6560
  # value changes and query reverts, which must not spring the dropdown.
6107
6561
  # Every programmatic write to the field goes through here; a direct
6108
- # `content.text =` reaches the field's `on_change` and pops the dropdown
6562
+ # `field.text =` reaches the field's `on_change` and pops the dropdown
6109
6563
  # open on a {#value=} the user never asked to browse.
6110
6564
  # Parks the caret at the end: `text=` only *clamps* the caret, so a
6111
6565
  # shorter query replaced by a longer label would otherwise strand it
@@ -6153,7 +6607,7 @@ module Tuile
6153
6607
  # Input fields are focusable by default (overrides {Component#focusable?});
6154
6608
  # a read-only display field could override back to `false`. Only
6155
6609
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
6156
- # composing wrappers, so it stays per-class (`DECISIONS.md`
6610
+ # composing wrappers, so it stays per-class (`design/decisions.md`
6157
6611
  # `D_integer_field`).
6158
6612
  def focusable?: () -> bool
6159
6613
 
@@ -6183,14 +6637,15 @@ module Tuile
6183
6637
  # represent is invalid on the face too, even with no verdict written.
6184
6638
  def error_ink?: () -> bool
6185
6639
 
6186
- def on_focus: () -> void
6187
-
6188
6640
  # _@return_ — the candidate items.
6189
6641
  attr_accessor items: ::Array[untyped]
6190
6642
 
6191
6643
  # _@return_ — item -> shown label (a `String` or
6192
6644
  # {StyledString}); the field shows its `#to_s`, the list its styled form.
6193
6645
  attr_accessor item_label: (Proc | Method)
6646
+
6647
+ # _@return_ — the inner field, holding the query.
6648
+ attr_reader field: TextField
6194
6649
  end
6195
6650
 
6196
6651
  # The value seam every input component shares: a settable/gettable {#value}
@@ -6249,7 +6704,7 @@ module Tuile
6249
6704
  # Input fields are focusable by default (overrides {Component#focusable?});
6250
6705
  # a read-only display field could override back to `false`. Only
6251
6706
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
6252
- # composing wrappers, so it stays per-class (`DECISIONS.md`
6707
+ # composing wrappers, so it stays per-class (`design/decisions.md`
6253
6708
  # `D_integer_field`).
6254
6709
  def focusable?: () -> bool
6255
6710
 
@@ -6311,11 +6766,11 @@ module Tuile
6311
6766
  # designing around:
6312
6767
  #
6313
6768
  # - A hidden pane is invisible to *everything*: the Tab cycle, focus
6314
- # cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
6769
+ # cascades, repaint, the cursor, `walk_tree` walks. No gates anywhere.
6315
6770
  # - Its state survives, because state is ivars — scroll position, caret,
6316
6771
  # list cursor, text are all exactly as the user left them, and mutating a
6317
6772
  # hidden pane is safe (`invalidate` while detached is a silent no-op).
6318
- # - {Component#on_detached} / {Component#on_attached} fire on every switch,
6773
+ # - {Component#handle_detached} / {Component#handle_attached} fire on every switch,
6319
6774
  # so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
6320
6775
  # ticker — releases it while hidden and re-acquires it on return. A pane
6321
6776
  # that must keep something alive while hidden can't; that something
@@ -6325,7 +6780,7 @@ module Tuile
6325
6780
  # `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
6326
6781
  # traversal gives the strip-then-pane Tab order for free. The swap follows
6327
6782
  # the slot-swap recipe {Component#detach_child} documents — detach, rewire,
6328
- # `on_child_removed` last, so the focus repair sees the new occupant.
6783
+ # `handle_child_removed` last, so the focus repair sees the new occupant.
6329
6784
  #
6330
6785
  # Panes live in an identity-keyed `Tab => Component` map here rather than in
6331
6786
  # a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
@@ -6396,14 +6851,14 @@ module Tuile
6396
6851
 
6397
6852
  # Sends focus to the strip: a sheet is a container, and the strip is where
6398
6853
  # a tab switch is driven from. The pane is a Tab press away.
6399
- def on_focus: () -> void
6854
+ def handle_focus: () -> void
6400
6855
 
6401
6856
  # Lands focus on the strip rather than on `self` when the focused pane is
6402
6857
  # swapped out — a bare container can't use keys, and the user's last
6403
6858
  # action was a tab switch.
6404
6859
  #
6405
6860
  # _@param_ `child`
6406
- def on_child_removed: (Component child) -> void
6861
+ def handle_child_removed: (Component child) -> void
6407
6862
 
6408
6863
  # Makes the visible pane match `strip.selected`, swapping if it doesn't.
6409
6864
  # Idempotent and the sole writer of `@pane`: it derives everything from
@@ -6468,7 +6923,7 @@ module Tuile
6468
6923
  # class PromptArea < Component::TextArea
6469
6924
  # protected
6470
6925
  #
6471
- # def handle_text_input_key(key)
6926
+ # def handle_text_input_key?(key)
6472
6927
  # return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
6473
6928
  # return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
6474
6929
  #
@@ -6504,21 +6959,21 @@ module Tuile
6504
6959
  def cursor_position: () -> Point?
6505
6960
 
6506
6961
  # _@param_ `event`
6507
- def handle_mouse: (MouseEvent event) -> void
6962
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
6508
6963
 
6509
6964
  def repaint: () -> void
6510
6965
 
6511
- def on_text_mutated: () -> void
6966
+ def handle_text_mutated: () -> void
6512
6967
 
6513
- def on_caret_mutated: () -> void
6968
+ def handle_caret_mutated: () -> void
6514
6969
 
6515
6970
  # HOME/END and CTRL+U act on the caret's **row** — the wrapped one, not
6516
6971
  # the `\n` line — so CTRL+U kills back to exactly where HOME would go.
6517
6972
  #
6518
6973
  # _@param_ `key`
6519
- def handle_text_input_key: (String key) -> bool
6974
+ def handle_text_input_key?: (String key) -> bool
6520
6975
 
6521
- def on_width_changed: () -> void
6976
+ def handle_width_changed: () -> void
6522
6977
 
6523
6978
  # _@return_ — the current wrap of {#text} at {Rect#width}.
6524
6979
  def wrap: () -> WrappedText
@@ -6777,8 +7232,8 @@ module Tuile
6777
7232
  # splice a range in place. Turn on {#auto_scroll} to keep the latest content
6778
7233
  # in view.
6779
7234
  #
6780
- # Meant to be the content of a {Window} — focus indication and keyboard-hint
6781
- # surfacing rely on the surrounding window chrome.
7235
+ # Meant to be the content of a {Window} — focus indication relies on the
7236
+ # surrounding window chrome.
6782
7237
  class TextView < Component
6783
7238
  def initialize: () -> void
6784
7239
 
@@ -6913,10 +7368,13 @@ module Tuile
6913
7368
  # an unfocused view scrolls it (dispatch gates on focus, this doesn't).
6914
7369
  #
6915
7370
  # _@param_ `key`
6916
- def handle_key: (String key) -> bool
7371
+ def handle_key?: (String key) -> bool
6917
7372
 
7373
+ # Scrolls four rows a notch, and declines — so the notch bubbles to an
7374
+ # ancestor scroller — once this view is at that end of its text.
7375
+ #
6918
7376
  # _@param_ `event`
6919
- def handle_mouse: (MouseEvent event) -> void
7377
+ def handle_mouse_scroll?: (Mouse::ScrollEvent event) -> bool
6920
7378
 
6921
7379
  # Paints the text into {#rect}.
6922
7380
  #
@@ -6930,7 +7388,7 @@ module Tuile
6930
7388
  # Rewraps the text on width changes — {#wrap_width} is {#rect}`.width`
6931
7389
  # minus {#scrollbar_columns}, and the latter varies with the width too.
6932
7390
  # A {#scrollbar_visibility=} flip rewraps from its own setter instead.
6933
- def on_width_changed: () -> void
7391
+ def handle_width_changed: () -> void
6934
7392
 
6935
7393
  # Validates and unpacks a {#replace}-style range argument into
6936
7394
  # inclusive `[from, to]` line indices. An `Integer` `n` becomes
@@ -7292,7 +7750,7 @@ module Tuile
7292
7750
  # empty). Give it a single-row {#rect}:
7293
7751
  #
7294
7752
  # field = Component::DateField.new
7295
- # field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per change
7753
+ # field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per commit
7296
7754
  # field.value = Date.new(2026, 9, 4) # field shows "2026-09-04"
7297
7755
  # field.placeholder # => "yyyy-mm-dd"
7298
7756
  # field.clear # empties it; value => nil
@@ -7349,6 +7807,19 @@ module Tuile
7349
7807
  # `20`, `202` stay quiet, leaving the field (or pressing ENTER) reddens what
7350
7808
  # did not parse, and the next edit clears it again.
7351
7809
  #
7810
+ # == The value notice waits for the same gesture
7811
+ # A prefix of a date can also parse *cleanly*: typing `1.1.2024` into a
7812
+ # `%d.%m.%Y` field passes through `1.1.2`, a perfectly good 1st of January
7813
+ # in the year 2. So {HasValue#on_value_change} does not fire per keystroke,
7814
+ # but when the user leaves the field or presses ENTER — and a form
7815
+ # recalculating from it never sees that year 2.
7816
+ #
7817
+ # {#value} does *not* wait: it is a live parse of the buffer at every
7818
+ # moment, so a save gate reached without leaving the field reads the date
7819
+ # on screen. Nor does a change nobody had to type — a {#value=}, an Up/Down
7820
+ # step, a {#clear} and a reparse under new {#formats} all fire as they
7821
+ # happen.
7822
+ #
7352
7823
  # == Implementation details
7353
7824
  # - **The buffer is the single source of truth.** {#value} is a parse of it,
7354
7825
  # recomputed on read — so {#formats=} and {#calendar_start=} can change the
@@ -7456,6 +7927,11 @@ module Tuile
7456
7927
  # way, so input it could not parse starts painting the well.
7457
7928
  def commit: () -> void
7458
7929
 
7930
+ # `false`: a prefix of a date can parse cleanly (`1.1.2` for `1.1.2024`),
7931
+ # so the notice settles onto the commit gestures, exactly as the ink
7932
+ # does. The class docs carry the case.
7933
+ def notify_on_edit?: () -> bool
7934
+
7459
7935
  # Every prefix of a date is bad input, so the well is latched to the
7460
7936
  # commit gestures instead of painted per keystroke: `2`, `20`, `202` on
7461
7937
  # the way to `2026-09-04` never redden, and a date the field cannot parse
@@ -7465,11 +7941,11 @@ module Tuile
7465
7941
 
7466
7942
  # An edit is the user having another go, so the well goes quiet again
7467
7943
  # until the next commit gesture.
7468
- def on_editor_change: () -> void
7944
+ def handle_editor_change: () -> void
7469
7945
 
7470
7946
  # Re-derives the hint (which was *pushed* into the editor, so a repaint
7471
7947
  # alone would keep the old one) and rewrites a buffer that still parses.
7472
- def on_locale_changed: () -> void
7948
+ def handle_locale_changed: () -> void
7473
7949
 
7474
7950
  # _@param_ `flag`
7475
7951
  def settle: (bool flag) -> void
@@ -7571,7 +8047,7 @@ module Tuile
7571
8047
  # - an **index** counts characters into {#text} — {#caret},
7572
8048
  # {#max_text_length}, `text[i]`, every edit;
7573
8049
  # - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
7574
- # {MouseEvent}, and the private horizontal scroll offset `left_column`.
8050
+ # {Mouse::DownEvent}, and the private horizontal scroll offset `left_column`.
7575
8051
  #
7576
8052
  # They coincide only while every glyph is one column wide. A fullwidth CJK
7577
8053
  # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
@@ -7596,11 +8072,11 @@ module Tuile
7596
8072
 
7597
8073
  def cursor_position: () -> Point?
7598
8074
 
7599
- # Places the caret at the clicked column. A click on the right half of a
8075
+ # Places the caret at the pressed column. A press on the right half of a
7600
8076
  # wide glyph lands *after* it, as in any editor.
7601
8077
  #
7602
8078
  # _@param_ `event`
7603
- def handle_mouse: (MouseEvent event) -> void
8079
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
7604
8080
 
7605
8081
  def repaint: () -> void
7606
8082
 
@@ -7608,12 +8084,12 @@ module Tuile
7608
8084
  # "clear what I typed".
7609
8085
  #
7610
8086
  # _@param_ `key`
7611
- def handle_text_input_key: (String key) -> bool
8087
+ def handle_text_input_key?: (String key) -> bool
7612
8088
 
7613
8089
  # Keeps the paste's **first line** and drops the rest, then trims what's
7614
8090
  # left to what {#max_text_length} still allows:
7615
8091
  #
7616
- # f.handle_paste("name\nstreet\ncity") # => true
8092
+ # f.handle_paste("name\nstreet\ncity")
7617
8093
  # f.text # => "name"
7618
8094
  #
7619
8095
  # Overshooting the cap trims rather than rejects, which is what typing the
@@ -7623,11 +8099,11 @@ module Tuile
7623
8099
  # _@param_ `text`
7624
8100
  def preprocess_paste: (String text) -> String
7625
8101
 
7626
- def on_text_mutated: () -> void
8102
+ def handle_text_mutated: () -> void
7627
8103
 
7628
- def on_caret_mutated: () -> void
8104
+ def handle_caret_mutated: () -> void
7629
8105
 
7630
- def on_width_changed: () -> void
8106
+ def handle_width_changed: () -> void
7631
8107
 
7632
8108
  # What the field paints in place of {#text}: one display character per
7633
8109
  # {#text} character, in order. `column_at` measures `display_text[0, i]` as
@@ -7804,6 +8280,19 @@ module Tuile
7804
8280
  # the field (or pressing ENTER) reddens what did not parse, and the next
7805
8281
  # edit clears it again.
7806
8282
  #
8283
+ # == The value notice waits for the same gesture
8284
+ # A prefix of a time can also parse *cleanly*: typing `13:45` passes
8285
+ # through `13:4`, a perfectly good four minutes past one. So
8286
+ # {HasValue#on_value_change} does not fire per keystroke, but when the user
8287
+ # leaves the field or presses ENTER — and a form recalculating from it
8288
+ # never sees those intermediate readings.
8289
+ #
8290
+ # {#value} does *not* wait: it is a live parse of the buffer at every
8291
+ # moment, so a save gate reached without leaving the field reads the time
8292
+ # on screen. Nor does a change nobody had to type — a {#value=}, a
8293
+ # {#set_to}, an arrow-key step, a {#clear} and a reparse under a new
8294
+ # {#step} all fire as they happen.
8295
+ #
7807
8296
  # == Implementation details
7808
8297
  # - **The buffer is the single source of truth.** {#value} is a parse of it,
7809
8298
  # recomputed on read — so {#step=} and a {Screen#locale=} can change the
@@ -7927,13 +8416,18 @@ module Tuile
7927
8416
  #
7928
8417
  # _@return_ — `true` for the two page keys, else whatever `super`
7929
8418
  # returns.
7930
- def handle_key: (String key) -> bool
8419
+ def handle_key?: (String key) -> bool
7931
8420
 
7932
8421
  # Rewrites a buffer that parses in the primary format, leaving one that
7933
8422
  # does not exactly as the user typed it — and settles the field either
7934
8423
  # way, so input it could not parse starts painting the well.
7935
8424
  def commit: () -> void
7936
8425
 
8426
+ # `false`: a prefix of a time can parse cleanly (`13:4` for `13:45`), so
8427
+ # the notice settles onto the commit gestures, exactly as the ink does.
8428
+ # The class docs carry the case.
8429
+ def notify_on_edit?: () -> bool
8430
+
7937
8431
  # Every prefix of a time is bad input, so the well is latched to the
7938
8432
  # commit gestures instead of painted per keystroke: `1`, `13`, `13:` on
7939
8433
  # the way to `13:45` never redden, and a time the field cannot parse
@@ -7943,9 +8437,9 @@ module Tuile
7943
8437
 
7944
8438
  # An edit is the user having another go, so the well goes quiet again
7945
8439
  # until the next commit gesture.
7946
- def on_editor_change: () -> void
8440
+ def handle_editor_change: () -> void
7947
8441
 
7948
- def on_locale_changed: () -> void
8442
+ def handle_locale_changed: () -> void
7949
8443
 
7950
8444
  # Re-derives the hint (which was *pushed* into the editor, so a repaint
7951
8445
  # alone would keep the old one) and rewrites a buffer that still parses —
@@ -8128,7 +8622,7 @@ module Tuile
8128
8622
  # def bad_input_settled? = @settled # set on commit, cleared on an edit
8129
8623
  #
8130
8624
  # It gates the **ink only**: {#bad_input?} is a pull, and a save gate
8131
- # asking at a click must get the answer settled or not (`DECISIONS.md`
8625
+ # asking at a click must get the answer settled or not (`design/decisions.md`
8132
8626
  # `D_bad_input`).
8133
8627
  def bad_input_settled?: () -> bool
8134
8628
 
@@ -8253,7 +8747,7 @@ module Tuile
8253
8747
  # _@param_ `rect`
8254
8748
  def rect=: (Rect rect) -> void
8255
8749
 
8256
- def on_focus: () -> void
8750
+ def handle_focus: () -> void
8257
8751
 
8258
8752
  # _@return_ — the current content component.
8259
8753
  attr_accessor content: Component?
@@ -8370,7 +8864,7 @@ module Tuile
8370
8864
  # _@param_ `new_rect`
8371
8865
  def rect=: (Rect new_rect) -> void
8372
8866
 
8373
- def on_focus: () -> void
8867
+ def handle_focus: () -> void
8374
8868
 
8375
8869
  # _@return_ — the presented items.
8376
8870
  def items: () -> ::Array[untyped]
@@ -8393,7 +8887,7 @@ module Tuile
8393
8887
  # neither of us wants bubbles on to an ancestor.
8394
8888
  #
8395
8889
  # _@param_ `key`
8396
- def handle_key: (String key) -> bool
8890
+ def handle_key?: (String key) -> bool
8397
8891
 
8398
8892
  # Selects the item on row `index`; an index outside {#items} is ignored —
8399
8893
  # {List::Cursor::None}'s `-1` would otherwise select the *last* item.
@@ -8447,7 +8941,7 @@ module Tuile
8447
8941
  # Input fields are focusable by default (overrides {Component#focusable?});
8448
8942
  # a read-only display field could override back to `false`. Only
8449
8943
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
8450
- # composing wrappers, so it stays per-class (`DECISIONS.md`
8944
+ # composing wrappers, so it stays per-class (`design/decisions.md`
8451
8945
  # `D_integer_field`).
8452
8946
  def focusable?: () -> bool
8453
8947
 
@@ -8482,9 +8976,10 @@ module Tuile
8482
8976
 
8483
8977
  # The composed {List}: an app may *tune* it — its scrollbar, its cursor,
8484
8978
  # `show_cursor_when_inactive` — but never replace it, since this group's
8485
- # renderer and selection are wired into this one. Those knobs are {List}
8486
- # concepts rather than group concepts, which is why they are reached here
8487
- # instead of forwarded (`DECISIONS.md` `D_wrapping_field`).
8979
+ # renderer and selection are wired into this one (`design/decisions.md`
8980
+ # `D_has_content`). Those knobs are {List} concepts rather than group
8981
+ # concepts, which is why they are reached here instead of forwarded
8982
+ # (`D_wrapping_field`).
8488
8983
  attr_reader list: List
8489
8984
 
8490
8985
  # _@return_ — item -> row label (a `String`, {StyledString}, or
@@ -8518,20 +9013,20 @@ module Tuile
8518
9013
  # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
8519
9014
  # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
8520
9015
  # rows and is then ellipsized, and entries past the height cap wait unpainted.
8521
- # `DECISIONS.md` `D_notification` has why each of those is what it is.
9016
+ # `design/decisions.md` `D_notification` has why each of those is what it is.
8522
9017
  #
8523
9018
  # Three things it deliberately doesn't do:
8524
9019
  #
8525
9020
  # - **Take focus, or receive keys.** An {Overlay} sits off the
8526
- # key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
8527
- # all. A left click *on the box* dismisses ({#handle_mouse}); an app
9021
+ # key-dispatch scope ({ScreenPane#handle_key?}), so no key arrives here at
9022
+ # all. A left click *on the box* dismisses ({#handle_mouse_down?}); an app
8528
9023
  # wanting a key registers a global shortcut and calls {Overlay#close}. A
8529
9024
  # click *elsewhere* does not — this is the one overlay with
8530
9025
  # {Overlay#close_on_outside_click?} false, since a toast is timed and an
8531
9026
  # unrelated click is not about it.
8532
9027
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
8533
9028
  # the message is added — a toast lives seconds, so there is no
8534
- # {Component#on_theme_changed} rebuild.
9029
+ # {Component#handle_theme_changed} rebuild.
8535
9030
  # - **Take a size.** An {Overlay} has no declared box; the messages decide
8536
9031
  # this one's, in {#reposition}.
8537
9032
  class Notification < Tuile::Component::Overlay
@@ -8577,20 +9072,21 @@ module Tuile
8577
9072
  # from the width.
8578
9073
  def reposition: () -> void
8579
9074
 
8580
- # A left click dismisses the whole box, every message with it. Other buttons
8581
- # are consumed and inert — including the scroll wheel, which would otherwise
8582
- # nuke the box on a stray spin.
8583
- #
8584
- # Deliberately *replaces* rather than augments: neither `super` nor
8585
- # {HasContent#handle_mouse} may run, since both end at a
8586
- # `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
9075
+ # A left press dismisses the whole box, every message with it. Every other
9076
+ # press is claimed and inert, so nothing beneath the toast acts on it.
8587
9077
  #
8588
9078
  # _@param_ `event`
8589
- def handle_mouse: (MouseEvent event) -> void
9079
+ def handle_mouse_down?: (Mouse::DownEvent event) -> bool
9080
+
9081
+ # Claimed and inert: a stray wheel spin over the toast must neither nuke it
9082
+ # nor reach whatever it covers.
9083
+ #
9084
+ # _@param_ `_event`
9085
+ def handle_mouse_scroll?: (Mouse::ScrollEvent _event) -> bool
8590
9086
 
8591
- def on_attached: () -> void
9087
+ def handle_attached: () -> void
8592
9088
 
8593
- def on_detached: () -> void
9089
+ def handle_detached: () -> void
8594
9090
 
8595
9091
  # Retires the oldest message, closing the box when it was the last. Runs on
8596
9092
  # the event-loop thread, from the ticker.
@@ -8599,7 +9095,7 @@ module Tuile
8599
9095
  # Syncs the retirement clock from the invariant "something to retire, and on
8600
9096
  # screen" — the sole writer of `@ticker`. Four sites change whether it is
8601
9097
  # wanted (append, a retirement that empties the queue, {#close}, detach),
8602
- # which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
9098
+ # which is the 2×2 a start-in-{#handle_attached} / cancel-in-{#handle_detached} pair
8603
9099
  # gets half wrong. The early return is also what keeps an append from
8604
9100
  # *restarting* the clock and extending the oldest message's life.
8605
9101
  def sync_ticker: () -> void
@@ -8646,6 +9142,19 @@ module Tuile
8646
9142
  #
8647
9143
  # _@param_ `rows`
8648
9144
  def join_rows: (::Array[StyledString] rows) -> StyledString
9145
+
9146
+ # The message rows, minus the wheel — the one thing a plain
9147
+ # {Component::TextView} would add here. On a short terminal the queued
9148
+ # messages are taller than the box, and the box is unfocusable, so
9149
+ # scrolling them would be a capability the keyboard cannot reach
9150
+ # (`D_mouse`); they are meant to *wait* until the ticker retires the ones
9151
+ # above.
9152
+ class View < Tuile::Component::TextView
9153
+ # _@param_ `_event`
9154
+ #
9155
+ # _@return_ — true — claimed on the box's behalf, and inert.
9156
+ def handle_mouse_scroll?: (Mouse::ScrollEvent _event) -> bool
9157
+ end
8649
9158
  end
8650
9159
 
8651
9160
  # A one-row progress bar: a run of `█` growing left to right across {#rect},
@@ -8684,7 +9193,7 @@ module Tuile
8684
9193
  # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
8685
9194
  # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
8686
9195
  # the rendered length would vary with the fill level. Shipped anyway, per
8687
- # `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
9196
+ # `design/decisions.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
8688
9197
  # beats a third convention, and if that bet is ever reversed both swap
8689
9198
  # together.
8690
9199
  class ProgressBar < Component
@@ -8730,9 +9239,9 @@ module Tuile
8730
9239
  # _@param_ `flag` — coerced; truthiness decides.
8731
9240
  def indeterminate=: (bool flag) -> void
8732
9241
 
8733
- def on_attached: () -> void
9242
+ def handle_attached: () -> void
8734
9243
 
8735
- def on_detached: () -> void
9244
+ def handle_detached: () -> void
8736
9245
 
8737
9246
  # Paints the bar on the first row of {#rect} and blanks the rest.
8738
9247
  #
@@ -8811,7 +9320,7 @@ module Tuile
8811
9320
  #
8812
9321
  # == Implementation details
8813
9322
  # An includer overrides {#bad_input_message} and nothing else. Two rules
8814
- # bind that override, and `DECISIONS.md` `D_bad_input` has the why:
9323
+ # bind that override, and `design/decisions.md` `D_bad_input` has the why:
8815
9324
  #
8816
9325
  # - **Empty input is not bad input.** Return `nil` for an empty buffer even
8817
9326
  # though it parses to nothing, or every blank *optional* field blocks a
@@ -8859,7 +9368,7 @@ module Tuile
8859
9368
  # def bad_input_settled? = @settled # set on commit, cleared on an edit
8860
9369
  #
8861
9370
  # It gates the **ink only**: {#bad_input?} is a pull, and a save gate
8862
- # asking at a click must get the answer settled or not (`DECISIONS.md`
9371
+ # asking at a click must get the answer settled or not (`design/decisions.md`
8863
9372
  # `D_bad_input`).
8864
9373
  def bad_input_settled?: () -> bool
8865
9374
 
@@ -8966,7 +9475,7 @@ module Tuile
8966
9475
  # def bad_input_settled? = @settled # set on commit, cleared on an edit
8967
9476
  #
8968
9477
  # It gates the **ink only**: {#bad_input?} is a pull, and a save gate
8969
- # asking at a click must get the answer settled or not (`DECISIONS.md`
9478
+ # asking at a click must get the answer settled or not (`design/decisions.md`
8970
9479
  # `D_bad_input`).
8971
9480
  def bad_input_settled?: () -> bool
8972
9481
 
@@ -9132,7 +9641,7 @@ module Tuile
9132
9641
  #
9133
9642
  # _@param_ `rows` — how many rows there are to show — the content count, not the height; more than fits turns the scrollbar on. `0` collapses the dropdown to an empty rect (drivers close instead).
9134
9643
  #
9135
- # _@param_ `width` — the panel's width in columns, clamped to the screen. **Required, with no default:** `anchor.width` is the *parent's* width and would be meaningless here, so the caller measures (see `DECISIONS.md` `D_select` on why the width policy stays with the driver).
9644
+ # _@param_ `width` — the panel's width in columns, clamped to the screen. **Required, with no default:** `anchor.width` is the *parent's* width and would be meaningless here, so the caller measures (see `design/decisions.md` `D_select` on why the width policy stays with the driver).
9136
9645
  #
9137
9646
  # _@param_ `max_rows` — rows shown before the list scrolls.
9138
9647
  def anchor_beside: (
@@ -9234,18 +9743,33 @@ module Tuile
9234
9743
  end
9235
9744
 
9236
9745
  # A {Window} that lists options identified by single keyboard keys, asks
9237
- # the user to pick one, and fires a callback with the picked key.
9746
+ # the user to pick one, and fires a callback with the picked key. Each row
9747
+ # is `"<key> <caption>"`:
9748
+ #
9749
+ # PickerWindow.open("Sort by", [%w[n name], %w[s size]]) { sort_by(_1) }
9750
+ #
9751
+ # ┌Sort by───────┐
9752
+ # │n name │
9753
+ # │s size │
9754
+ # └──────────────┘
9238
9755
  #
9239
9756
  # Usable tiled (just add to a {Layout} and read picks via the block) or
9240
9757
  # as a popup via {.open}, which wraps it in a {Popup} that closes itself
9241
9758
  # after a pick. ESC / `q` close without firing the callback.
9759
+ #
9760
+ # Captions paint in the terminal's own foreground. To color them, hand in
9761
+ # {StyledString} captions — the picker styles nothing itself, so an app's
9762
+ # own token applies per option:
9763
+ #
9764
+ # PickerWindow.open("File", [["o", StyledString.plain("Open")],
9765
+ # ["d", theme.fg(:danger, "Delete")]]) { … }
9242
9766
  class PickerWindow < Tuile::Component::Window
9243
9767
  MAX_ITEMS: Integer
9244
9768
 
9245
9769
  # _@param_ `caption` — the window caption.
9246
9770
  #
9247
- # _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
9248
- def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
9771
+ # _@param_ `options` — pairs of keyboard key and option caption. A caption goes through {StyledString.parse}, so a plain String, an ANSI-coded one (what {Theme#fg} returns) and a {StyledString} are all accepted.
9772
+ def initialize: (String caption, ::Array[[String, StyledString]] options) ?{ (String key) -> void } -> void
9249
9773
 
9250
9774
  # Handles an option-key press. Reached by bubbling: the inner {List}
9251
9775
  # (the focused component) sees the key first and handles cursor/Enter
@@ -9253,7 +9777,7 @@ module Tuile
9253
9777
  # option's `key` picks that option.
9254
9778
  #
9255
9779
  # _@param_ `key`
9256
- def handle_key: (String key) -> bool
9780
+ def handle_key?: (String key) -> bool
9257
9781
 
9258
9782
  # Opens a picker as a popup. Picking an option fires `block`, then
9259
9783
  # closes the popup; ESC / `q` close without firing `block`.
@@ -9263,7 +9787,7 @@ module Tuile
9263
9787
  # _@param_ `options`
9264
9788
  #
9265
9789
  # _@return_ — the wrapping popup.
9266
- def self.open: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> Popup
9790
+ def self.open: (String caption, ::Array[[String, StyledString]] options) ?{ (String key) -> void } -> Popup
9267
9791
 
9268
9792
  # _@param_ `key`
9269
9793
  def select_option: (String key) -> void
@@ -9277,13 +9801,15 @@ module Tuile
9277
9801
  # @!attribute [r] key
9278
9802
  # @return [String] the keyboard key that picks this option.
9279
9803
  # @!attribute [r] caption
9280
- # @return [String] the option caption.
9804
+ # @return [StyledString] the option caption, coerced from whatever the
9805
+ # constructor was handed.
9281
9806
  class Option
9282
9807
  # _@return_ — the keyboard key that picks this option.
9283
9808
  attr_reader key: String
9284
9809
 
9285
- # _@return_ — the option caption.
9286
- attr_reader caption: String
9810
+ # _@return_ — the option caption, coerced from whatever the
9811
+ # constructor was handed.
9812
+ attr_reader caption: StyledString
9287
9813
  end
9288
9814
  end
9289
9815
 
@@ -9350,7 +9876,7 @@ module Tuile
9350
9876
  # _@param_ `new_rect`
9351
9877
  def rect=: (Rect new_rect) -> void
9352
9878
 
9353
- def on_focus: () -> void
9879
+ def handle_focus: () -> void
9354
9880
 
9355
9881
  # _@return_ — the presented items.
9356
9882
  def items: () -> ::Array[untyped]
@@ -9377,7 +9903,7 @@ module Tuile
9377
9903
  # neither of us wants bubbles on to an ancestor.
9378
9904
  #
9379
9905
  # _@param_ `key`
9380
- def handle_key: (String key) -> bool
9906
+ def handle_key?: (String key) -> bool
9381
9907
 
9382
9908
  # Flips membership of the item on row `index`; an index outside {#items} is
9383
9909
  # ignored — {List::Cursor::None}'s `-1` would otherwise toggle the *last*
@@ -9430,7 +9956,7 @@ module Tuile
9430
9956
  # Input fields are focusable by default (overrides {Component#focusable?});
9431
9957
  # a read-only display field could override back to `false`. Only
9432
9958
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
9433
- # composing wrappers, so it stays per-class (`DECISIONS.md`
9959
+ # composing wrappers, so it stays per-class (`design/decisions.md`
9434
9960
  # `D_integer_field`).
9435
9961
  def focusable?: () -> bool
9436
9962
 
@@ -9465,9 +9991,10 @@ module Tuile
9465
9991
 
9466
9992
  # The composed {List}: an app may *tune* it — its scrollbar, its cursor,
9467
9993
  # `show_cursor_when_inactive` — but never replace it, since this group's
9468
- # renderer and selection are wired into this one. Those knobs are {List}
9469
- # concepts rather than group concepts, which is why they are reached here
9470
- # instead of forwarded (`DECISIONS.md` `D_wrapping_field`).
9994
+ # renderer and selection are wired into this one (`design/decisions.md`
9995
+ # `D_has_content`). Those knobs are {List} concepts rather than group
9996
+ # concepts, which is why they are reached here instead of forwarded
9997
+ # (`D_wrapping_field`).
9471
9998
  attr_reader list: List
9472
9999
 
9473
10000
  # _@return_ — item -> row label (a `String`, {StyledString}, or
@@ -9517,7 +10044,7 @@ module Tuile
9517
10044
  # There is deliberately no content slot: the body is prose ({#message=}
9518
10045
  # takes a component for the rare rich body, but the dialog then cannot
9519
10046
  # measure it). A dialog collecting *input* is not a confirm dialog — build a
9520
- # `Popup.new(content: your_layout)`. See `DECISIONS.md` `D_confirm_window`
10047
+ # `Popup.new(content: your_layout)`. See `design/decisions.md` `D_confirm_window`
9521
10048
  # for the API rationale.
9522
10049
  class ConfirmWindow < Tuile::Component::Window
9523
10050
  BODY_SCROLL_KEYS: ::Array[String]
@@ -9584,8 +10111,10 @@ module Tuile
9584
10111
  def measured_size: (?Size reference) -> Size
9585
10112
 
9586
10113
  # Focus lands on the first button rather than cascading into the message
9587
- # body, which sits before the button row in the tree.
9588
- def on_focus: () -> void
10114
+ # body, which sits before the button row in the tree. `super` is reached
10115
+ # only when there is no button to take it: {HasContent#handle_focus} *is*
10116
+ # the cascade this override exists to skip.
10117
+ def handle_focus: () -> void
9589
10118
 
9590
10119
  # Handles the dialog-wide keys: Left/Right move between the buttons,
9591
10120
  # {BODY_SCROLL_KEYS} are hand-fed to the message body, and a mnemonic
@@ -9596,7 +10125,7 @@ module Tuile
9596
10125
  # _@param_ `key`
9597
10126
  #
9598
10127
  # _@return_ — true if the key was handled.
9599
- def handle_key: (String key) -> bool
10128
+ def handle_key?: (String key) -> bool
9600
10129
 
9601
10130
  # Opens an acknowledgement dialog: a message and one button, whose press
9602
10131
  # is the dismissal. The button exists for discoverability — ESC and `q`
@@ -9751,7 +10280,7 @@ module Tuile
9751
10280
  # nothing — where red *text* is invisible on the empty field that is the
9752
10281
  # required-field case, and invisible again on content carrying colors of its
9753
10282
  # own. It takes two tokens rather than one because a focused invalid field
9754
- # still has to look focused (`DECISIONS.md` `D_has_validation`).
10283
+ # still has to look focused (`design/decisions.md` `D_has_validation`).
9755
10284
  #
9756
10285
  # The well reaches the whole widget with nothing forwarding it: a composed
9757
10286
  # field's inner face is marked {Component::BG_INHERIT} and a group's {List}
@@ -9774,7 +10303,7 @@ module Tuile
9774
10303
  #
9775
10304
  # Unlike `bad_input?`, this fact is *discrete* — asserted at a click or a
9776
10305
  # binder pass, not recomputed per keystroke — which is why it carries a
9777
- # change notice where `bad_input?` deliberately doesn't (`DECISIONS.md`
10306
+ # change notice where `bad_input?` deliberately doesn't (`design/decisions.md`
9778
10307
  # `D_bad_input`, `D_has_validation`).
9779
10308
  module HasValidation
9780
10309
  # _@return_ — why the field is invalid, or `nil` when it
@@ -9864,6 +10393,217 @@ module Tuile
9864
10393
  attr_accessor revealed: (bool | Object)
9865
10394
  end
9866
10395
 
10396
+ # A one-row field pairing a {DateField} and a {TimeField} behind a single
10397
+ # `DateTime`. Give it a single-row {#rect}, 16 columns or wider:
10398
+ #
10399
+ # [2026-09-14] [13:45]
10400
+ # ↑ the blank column is {Layout::Box#spacing}, not a component
10401
+ #
10402
+ # f = Component::DateTimeField.new
10403
+ # f.on_value_change = ->(dt) { puts dt.inspect } # DateTime or nil, per commit
10404
+ # f.value = DateTime.new(2026, 9, 14, 13, 45) # "2026-09-14" / "13:45"
10405
+ # f.clear # empties both halves
10406
+ #
10407
+ # Neither half is labelled: each paints the hint derived from its own format
10408
+ # (`yyyy-mm-dd`, `hh:mm`), which names it while it is empty — the moment
10409
+ # naming matters. The *caption* ("Starts at") belongs to the layout around
10410
+ # the field, as it does for every field (`design/decisions.md`
10411
+ # `D_caption_ownership`).
10412
+ #
10413
+ # == Tune the halves; don't replace them
10414
+ # They are exposed read-only, so everything they configure is reached
10415
+ # directly rather than forwarded through a second set of names:
10416
+ #
10417
+ # f.date_field.formats = "%d.%m.%Y" # ambiguous if it were `f.formats=`
10418
+ # f.date_field.calendar_start = Date::ITALY
10419
+ # f.time_field.step = 900 # Up/Down walk a quarter hour
10420
+ #
10421
+ # Two of their knobs are **claimed** by this field and must not be
10422
+ # reassigned: each half's {HasValue#on_value_change} (that is how the
10423
+ # composite hears them) and each half's {Component#bg_color} (see the well
10424
+ # rule below).
10425
+ #
10426
+ # == The value is a `DateTime` at +00:00
10427
+ # Both halves feed it with no adapter, and the offset is a placeholder
10428
+ # rather than a zone — {TimeField}'s epoch cost, taken the same way: a value
10429
+ # that is visibly wrong where an instant was meant beats one that is subtly
10430
+ # wrong. Combine it with a zone at your own boundary (`f.value&.to_time`).
10431
+ #
10432
+ # Lenient in, strict out, so an input carrying more than the halves can hold
10433
+ # does not round-trip:
10434
+ #
10435
+ # f.value = Time.now # takes today's date and the wall clock
10436
+ # f.value == DateTime.now # => false — the zone went, and the seconds with it
10437
+ #
10438
+ # == Three states, and only one of them is this field's own fault
10439
+ # {HasValue#value} is non-nil **iff both halves parse**, so a half going bad
10440
+ # nils the whole value ({HasBadInput}: a field holds bad input *or* a value,
10441
+ # never both). Who reddens follows from whether the fault is attributable:
10442
+ #
10443
+ # date half time half value bad_input? red
10444
+ # 2026-09-14 13:45 DateTime no nobody
10445
+ # (empty) (empty) nil no — empty is not bad input nobody
10446
+ # 2026-99-99 13:45 nil "not a valid date" the date half
10447
+ # 2026-09-14 (empty) nil "needs both a date and a time" this field
10448
+ #
10449
+ # A half's bad input is the half's to paint, on its own latch, and this
10450
+ # field paints nothing. Half-filled is nobody else's, so this field reddens
10451
+ # whole — but **only while it is not active**: it judges you when you leave
10452
+ # and goes quiet when you come back to fix it. A validator's verdict
10453
+ # ({HasValidation#error_message=}) is by definition not attributable either,
10454
+ # and reddens whole with no latch at all.
10455
+ #
10456
+ # The one cost: **ENTER does not redden this field**, where it reddens a
10457
+ # half. A save gate on ENTER over a date with no time still reads
10458
+ # {HasBadInput#bad_input?} true and gets the message; only the ink waits for
10459
+ # the blur.
10460
+ #
10461
+ # == Implementation details
10462
+ # - **The halves keep their own wells, and this field's ink is *synced* onto
10463
+ # them.** `error_bg_color` sits at the top of the background chain, so a
10464
+ # child answering {Component#default_bg_color} — every field does — never
10465
+ # inherits an ancestor's error level, so marking only this field would
10466
+ # leave the halves untouched and reach no cell at all. So the halves are
10467
+ # marked {Component::BG_INHERIT} exactly while this field inks, and `nil`
10468
+ # otherwise. A guilty half's *own* error well still beats the mark, which
10469
+ # is what keeps the ink rule free of arithmetic.
10470
+ # - **The spacing column is nobody's surface** — {Component#clear_inside_extent}
10471
+ # blanks it in the ambient background, so the two wells read as two fields
10472
+ # rather than one long one and each half keeps its own focus highlight.
10473
+ # - **A half announces from its own `value=` and its Up/Down step** — the
10474
+ # other half of {AbstractWrappingField#notify_on_edit?}'s contract — so
10475
+ # writing a value into both halves would announce a half-assembled
10476
+ # `DateTime`. Suppressed while applying, and announced once from this
10477
+ # field's own diff.
10478
+ # - **Nothing else is wired.** Focus forwards through {Layout#handle_focus},
10479
+ # the mouse routes down through {Mouse::Router}, each half commits
10480
+ # on its own blur (Tab between them canonicalizes the date and leaves this
10481
+ # field active), and ENTER commits inside the half and keeps bubbling to
10482
+ # the scope's default button.
10483
+ #
10484
+ # UI-thread-confined, like every component (see {Screen}).
10485
+ class DateTimeField < Tuile::Component::Layout::Horizontal
10486
+ include Tuile::Component::HasValue
10487
+ include Tuile::Component::HasBadInput
10488
+ HALF_FILLED_MESSAGE: String
10489
+ CIVIL_PARTS: ::Array[Symbol]
10490
+ DATE_WEIGHT: Integer
10491
+ TIME_WEIGHT: Integer
10492
+ DEFAULT_PLACEMENT: ::Hash[Symbol, Object]
10493
+ ALIGNMENTS: ::Array[Symbol]
10494
+
10495
+ def initialize: () -> void
10496
+
10497
+ # _@return_ — the two halves assembled, on the calendar
10498
+ # {DateField#calendar_start} parsed the date in; `nil` unless both parse.
10499
+ def value: () -> DateTime?
10500
+
10501
+ # Writes the date into one half and the time of day into the other, firing
10502
+ # {HasValue#on_value_change} once if the value actually changed.
10503
+ #
10504
+ # _@param_ `new_value` — anything carrying both a civil date and a time of day; `nil` empties both halves.
10505
+ def value=: ((DateTime | Time)? new_value) -> void
10506
+
10507
+ # `nil`, not a pair of nils: a field with no parseable date *and* time is
10508
+ # empty.
10509
+ def empty_value: () -> void
10510
+
10511
+ # Empties the *input* of both halves, not just the value — either may be
10512
+ # holding glyphs no parse could use ({HasBadInput}).
10513
+ def clear: () -> void
10514
+
10515
+ # The guilty half's own report, the date's first when both are bad; else
10516
+ # the one fault no half can wear, a half-filled pair.
10517
+ def bad_input_message: () -> String?
10518
+
10519
+ # Sets the verdict and syncs the halves' wells onto it.
10520
+ #
10521
+ # _@param_ `new_message`
10522
+ def error_message=: ((String | StyledString)? new_message) -> void
10523
+
10524
+ # Syncs the halves' wells on both focus edges — this field inks its
10525
+ # half-filled fault only once you have left it.
10526
+ #
10527
+ # _@param_ `flag`
10528
+ def active=: (bool flag) -> void
10529
+
10530
+ # _@return_ — the full width, one row — so a taller rect gets the
10531
+ # ambient background rather than this field's well ({Component#extent}).
10532
+ def extent: () -> Size
10533
+
10534
+ # The ink rule in the class doc, as an expression.
10535
+ #
10536
+ # No latch ivar, deliberately: every input here is a fact something
10537
+ # announces, which is what lets the well sync have a complete call list. A
10538
+ # half's `bad_input?` moves with every keystroke and announces nothing at
10539
+ # all by design, so a latch of this field's own could not follow it.
10540
+ def bad_input_settled?: () -> bool
10541
+
10542
+ # _@return_ — whether a half is holding input its own value cannot
10543
+ # represent, and so wears the error itself.
10544
+ def attributable?: () -> bool
10545
+
10546
+ # One idempotent sync over one condition, this field the sole writer of
10547
+ # its halves' {Component#bg_color} — the shape a hook-owned resource takes.
10548
+ # Called from the three places {HasValidation#error_ink?} can change: a
10549
+ # verdict, a focus edge, and a half's announcement. Leave one out and this
10550
+ # field stops inking while its halves stay marked, i.e. both halves flat
10551
+ # with their wells gone.
10552
+ def sync_half_wells: () -> void
10553
+
10554
+ def handle_half_change: () -> void
10555
+
10556
+ # Runs `block` with the halves' notices suppressed, so a value written
10557
+ # into both is announced once rather than half-assembled.
10558
+ def applying: () -> void
10559
+
10560
+ def fire_if_changed: () -> void
10561
+
10562
+ # _@return_ — true iff the field is holding input its value cannot
10563
+ # represent.
10564
+ def bad_input?: () -> bool
10565
+
10566
+ # Widens {HasValidation#error_ink?}: bad input paints the invalid well
10567
+ # too, with no verdict written — once {#bad_input_settled?} says the
10568
+ # report may be shown.
10569
+ def error_ink?: () -> bool
10570
+
10571
+ # _@return_ — why the field is invalid, or `nil` when it
10572
+ # is not; `nil` until something sets it.
10573
+ def error_message: () -> StyledString?
10574
+
10575
+ # The invalid well, picked up by everything this component paints —
10576
+ # including the inner face of a composed field and the {List} of a group,
10577
+ # neither of which forwards anything: both declare no background of their
10578
+ # own, so the ordinary chain walks up to this (overrides
10579
+ # {Component#error_bg_color}).
10580
+ def error_bg_color: () -> Color?
10581
+
10582
+ # Adds `error_message=…` to {Component#inspect}, omitted while valid — so
10583
+ # a {Testing.get} failure dump says which field is already flagged.
10584
+ def inspect_details: () -> ::Array[String]
10585
+
10586
+ # Empty of *value*: a field whose parse is partial reports `true` while the
10587
+ # user is looking at glyphs it could not use, so ask
10588
+ # {HasBadInput#bad_input?} first.
10589
+ #
10590
+ # _@return_ — true iff {#value} equals {#empty_value}.
10591
+ def empty?: () -> bool
10592
+
10593
+ # Input fields are focusable by default (overrides {Component#focusable?});
10594
+ # a read-only display field could override back to `false`. Only
10595
+ # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
10596
+ # composing wrappers, so it stays per-class (`design/decisions.md`
10597
+ # `D_integer_field`).
10598
+ def focusable?: () -> bool
10599
+
10600
+ # _@return_ — the left half; tune it, never replace it.
10601
+ attr_reader date_field: DateField
10602
+
10603
+ # _@return_ — the right half; tune it, never replace it.
10604
+ attr_reader time_field: TimeField
10605
+ end
10606
+
9867
10607
  # The hint a field paints in its own cells while it holds nothing — a format
9868
10608
  # the user could not otherwise guess:
9869
10609
  #
@@ -9883,7 +10623,7 @@ module Tuile
9883
10623
  # Include it in a field whose *input shape* is unguessable from an empty
9884
10624
  # well. Not in {Select}, the near miss: a blank face plus `▾` already reads
9885
10625
  # as "nothing picked", so an absent enum *value* needs no hint the way an
9886
- # unguessable input *format* does (`DECISIONS.md` `D_select`).
10626
+ # unguessable input *format* does (`design/decisions.md` `D_select`).
9887
10627
  #
9888
10628
  # == Implementation details
9889
10629
  # The ink is {Theme#placeholder_color}, calibrated to be *barely* visible —
@@ -10008,7 +10748,7 @@ module Tuile
10008
10748
  # def bad_input_settled? = @settled # set on commit, cleared on an edit
10009
10749
  #
10010
10750
  # It gates the **ink only**: {#bad_input?} is a pull, and a save gate
10011
- # asking at a click must get the answer settled or not (`DECISIONS.md`
10751
+ # asking at a click must get the answer settled or not (`design/decisions.md`
10012
10752
  # `D_bad_input`).
10013
10753
  def bad_input_settled?: () -> bool
10014
10754
 
@@ -10075,7 +10815,7 @@ module Tuile
10075
10815
  # f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
10076
10816
  # f.caret = 1 # into the middle of the e-acute …
10077
10817
  # f.caret # => 2, its end — where the caret already drew
10078
- # f.handle_key(Keys::BACKSPACE)
10818
+ # f.handle_key?(Keys::BACKSPACE)
10079
10819
  # f.text # => "x": the whole glyph went, not its accent
10080
10820
  #
10081
10821
  # Insertion stays character-native, so `String#insert` merges a typed
@@ -10085,11 +10825,11 @@ module Tuile
10085
10825
  # Subclasses implement the layout-specific pieces ({#cursor_position},
10086
10826
  # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
10087
10827
  # printable insertion) by overriding the protected
10088
- # {#handle_text_input_key} hook — `super` falls through to the common
10828
+ # {#handle_text_input_key?} hook — `super` falls through to the common
10089
10829
  # navigation handling.
10090
10830
  #
10091
10831
  # == Customizing a field is subclassing it, and there are two seams
10092
- # To change what **keys** do, override {#handle_text_input_key}; to
10832
+ # To change what **keys** do, override {#handle_text_input_key?}; to
10093
10833
  # constrain what the buffer may **hold**, override {#insert_text}, which
10094
10834
  # every insertion runs through — typed, pasted, or the ENTER newline:
10095
10835
  #
@@ -10097,7 +10837,7 @@ module Tuile
10097
10837
  # protected
10098
10838
  #
10099
10839
  # # ENTER submits instead of falling through to the parent.
10100
- # def handle_text_input_key(key)
10840
+ # def handle_text_input_key?(key)
10101
10841
  # return super unless key == Keys::ENTER
10102
10842
  #
10103
10843
  # submit(text)
@@ -10127,7 +10867,7 @@ module Tuile
10127
10867
  # which insertion does *not* pass through.
10128
10868
  # - {#preprocess_paste} — sanitizer for {#handle_paste}, run before the
10129
10869
  # clipboard reaches {#insert_text} ({TextField} keeps its first line).
10130
- # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
10870
+ # - {#handle_text_mutated} / {#handle_caret_mutated} — post-mutation side
10131
10871
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
10132
10872
  # keep the caret visible).
10133
10873
  class AbstractStringField < Component
@@ -10149,23 +10889,20 @@ module Tuile
10149
10889
 
10150
10890
  def tab_stop?: () -> bool
10151
10891
 
10152
- # Handles a key, by delegating to the {#handle_text_input_key} hook a
10153
- # subclass overrides. Dispatch ({ScreenPane#handle_key}) only routes keys
10892
+ # Handles a key, by delegating to the {#handle_text_input_key?} hook a
10893
+ # subclass overrides. Dispatch ({ScreenPane#handle_key?}) only routes keys
10154
10894
  # here when this input is on the focus chain, so there is no {#active?}
10155
10895
  # gate.
10156
10896
  #
10157
10897
  # _@param_ `key`
10158
- def handle_key: (String key) -> bool
10898
+ def handle_key?: (String key) -> bool
10159
10899
 
10160
10900
  # Inserts pasted text at the caret as **one** mutation, so {#on_change}
10161
10901
  # fires once for the whole paste rather than once per character.
10162
10902
  # {#preprocess_paste} filters it first.
10163
10903
  #
10164
10904
  # _@param_ `text`
10165
- #
10166
- # _@return_ — always true — a field consumes every paste: an empty
10167
- # one, and one its {#insert_text} rejects wholesale.
10168
- def handle_paste: (String text) -> bool
10905
+ def handle_paste: (String text) -> void
10169
10906
 
10170
10907
  # Input filter for {#handle_paste}, the paste-side counterpart of
10171
10908
  # {#preprocess_text}. Strips the C0 control characters a text buffer
@@ -10241,14 +10978,14 @@ module Tuile
10241
10978
  # Hook called after {#text} has been mutated, before invalidation /
10242
10979
  # {#on_change}. Default no-op. Subclasses use this to invalidate caches
10243
10980
  # ({TextArea}'s wrap cache) and update derived state.
10244
- def on_text_mutated: () -> void
10981
+ def handle_text_mutated: () -> void
10245
10982
 
10246
10983
  # Hook called after {#caret} has been mutated, before invalidation.
10247
10984
  # Default no-op. Subclasses use this to keep the caret visible
10248
10985
  # ({TextArea}'s vertical scroll).
10249
- def on_caret_mutated: () -> void
10986
+ def handle_caret_mutated: () -> void
10250
10987
 
10251
- # Dispatch hook for {#handle_key}. Handles ESC and the editing keys that
10988
+ # Dispatch hook for {#handle_key?}. Handles ESC and the editing keys that
10252
10989
  # have identical semantics in single-line and multi-line inputs:
10253
10990
  # LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
10254
10991
  # moves), CTRL+LEFT/CTRL+RIGHT for word jumps, and CTRL+W, which deletes
@@ -10260,7 +10997,7 @@ module Tuile
10260
10997
  # _@param_ `key`
10261
10998
  #
10262
10999
  # _@return_ — true if the key was handled.
10263
- def handle_text_input_key: (String key) -> bool
11000
+ def handle_text_input_key?: (String key) -> bool
10264
11001
 
10265
11002
  # Removes the whole grapheme cluster before the caret — one press, one
10266
11003
  # glyph, whatever it is built from (a ZWJ emoji family and a three-jamo
@@ -10328,7 +11065,7 @@ module Tuile
10328
11065
  # Input fields are focusable by default (overrides {Component#focusable?});
10329
11066
  # a read-only display field could override back to `false`. Only
10330
11067
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
10331
- # composing wrappers, so it stays per-class (`DECISIONS.md`
11068
+ # composing wrappers, so it stays per-class (`design/decisions.md`
10332
11069
  # `D_integer_field`).
10333
11070
  def focusable?: () -> bool
10334
11071
 
@@ -10444,6 +11181,12 @@ module Tuile
10444
11181
  # default button still sees it; only an {#on_enter} of this field's own
10445
11182
  # consumes it, which is {TextField#on_enter}'s existing contract.
10446
11183
  #
11184
+ # == When the value notice fires
11185
+ # Per edit by default. A field whose grammar is not prefix-closed sets
11186
+ # {#notify_on_edit?} to `false` and lets the notice settle onto those same
11187
+ # two gestures, so a form is never handed a half-typed date that happens to
11188
+ # parse ({DateField}, {TimeField}).
11189
+ #
10447
11190
  # == Implementation details
10448
11191
  # - **{HasValue#value} and {#value=} raise until overridden.** The inherited
10449
11192
  # pair stores into `@value` and never touches the editor, so a subclass
@@ -10454,7 +11197,7 @@ module Tuile
10454
11197
  # - **The editor's `on_change` and `on_enter` slots are claimed** — for that
10455
11198
  # guard, and to commit before an app's ENTER handler runs. A slot cannot
10456
11199
  # be shared, so a subclass reacting to buffer edits overrides
10457
- # {#on_editor_change} (every edit), {#value=} or {#commit} rather than
11200
+ # {#handle_editor_change} (every edit), {#value=} or {#commit} rather than
10458
11201
  # reassigning either.
10459
11202
  # - **Not for a field whose editor is a *filter*.** This base assumes the
10460
11203
  # buffer is a rendering of the value, so an edit may change the value.
@@ -10500,7 +11243,7 @@ module Tuile
10500
11243
  #
10501
11244
  # _@return_ — whatever `super` returns — committing never consumes
10502
11245
  # the key.
10503
- def handle_key: (String key) -> bool
11246
+ def handle_key?: (String key) -> bool
10504
11247
 
10505
11248
  # _@return_ — the editor's caret — the hardware cursor is
10506
11249
  # delegated to it.
@@ -10513,7 +11256,7 @@ module Tuile
10513
11256
  # _@param_ `flag`
10514
11257
  def active=: (bool flag) -> void
10515
11258
 
10516
- def on_focus: () -> void
11259
+ def handle_focus: () -> void
10517
11260
 
10518
11261
  # _@param_ `new_rect`
10519
11262
  def rect=: (Rect new_rect) -> void
@@ -10523,13 +11266,27 @@ module Tuile
10523
11266
  # field rewrites its buffer from.
10524
11267
  def commit: () -> void
10525
11268
 
11269
+ # Whether an edit of the buffer fires {HasValue#on_value_change} as it
11270
+ # happens. `true` here, which is right wherever every buffer state is a
11271
+ # value the user might mean: an {IntegerField} passing through `4` on the
11272
+ # way to `42` really does hold 4 for that keystroke. A field whose
11273
+ # grammar is **not prefix-closed** answers `false` and lets the notice
11274
+ # settle onto the commit gestures instead ({DateField}, `D_date_field`).
11275
+ #
11276
+ # Only the *push* settles: {HasValue#value} stays a live parse of the
11277
+ # buffer either way. And overriding this is half the job — {#commit} is
11278
+ # covered here, but the field must fire from its own `value=` too, or a
11279
+ # programmatic write and an Up/Down step go unannounced until the next
11280
+ # commit.
11281
+ def notify_on_edit?: () -> bool
11282
+
10526
11283
  # Called whenever the editor's buffer changes, however the characters
10527
11284
  # arrived — a typed key, a paste, or a {#value=} of this field's own. It
10528
11285
  # is named for the *editor*, not for the user, because those last two are
10529
11286
  # not input. No-op by default; override it to drop state that describes
10530
11287
  # the *previous* buffer, as a field latching whether its input has settled
10531
11288
  # must ({HasBadInput}).
10532
- def on_editor_change: () -> void
11289
+ def handle_editor_change: () -> void
10533
11290
 
10534
11291
  # Places the editor across the whole rect; override to reserve cells for a
10535
11292
  # face of your own.
@@ -10542,6 +11299,11 @@ module Tuile
10542
11299
  # widget) and {Component#bg_color} set here reaches the cells it paints.
10543
11300
  def default_bg_color: () -> Color
10544
11301
 
11302
+ # Every commit gesture runs through here, so a field holding its notice
11303
+ # ({#notify_on_edit?}) announces from one place rather than three; the
11304
+ # diff guard makes the call free for a field that fired on the way in.
11305
+ def commit_and_notify: () -> void
11306
+
10545
11307
  # Re-emits {HasValue#on_value_change}, but only when {#value} differs from
10546
11308
  # the last one fired — so a buffer edit that leaves the value alone
10547
11309
  # (`"7"`→`"07"`) stays silent.
@@ -10564,7 +11326,7 @@ module Tuile
10564
11326
  # Input fields are focusable by default (overrides {Component#focusable?});
10565
11327
  # a read-only display field could override back to `false`. Only
10566
11328
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
10567
- # composing wrappers, so it stays per-class (`DECISIONS.md`
11329
+ # composing wrappers, so it stays per-class (`design/decisions.md`
10568
11330
  # `D_integer_field`).
10569
11331
  def focusable?: () -> bool
10570
11332
 
@@ -10781,7 +11543,7 @@ module Tuile
10781
11543
  def running?: () -> bool
10782
11544
 
10783
11545
  # _@return_ — true if this thread is the one running {#run_loop}.
10784
- def on_loop_thread?: () -> bool
11546
+ def in_loop_thread?: () -> bool
10785
11547
 
10786
11548
  # Stops ongoing {#run_loop}. The stop may not be immediate: {#run_loop} may
10787
11549
  # process a bunch of events before terminating.
@@ -10815,7 +11577,7 @@ module Tuile
10815
11577
  #
10816
11578
  # {Screen#event_loop} routes it to {Component#handle_paste} down the focus
10817
11579
  # chain. It never enters the key ladder: no Tab traversal, no global
10818
- # shortcut, no {Component#handle_key}.
11580
+ # shortcut, no {Component#handle_key?}.
10819
11581
  #
10820
11582
  # @!attribute [r] text
10821
11583
  # @return [String] the pasted text, `\n`-normalized by
@@ -11012,13 +11774,83 @@ module Tuile
11012
11774
  # Screen.instance.background_color = Color.rgb(30, 30, 46)
11013
11775
  #
11014
11776
  # Takes the same path a real reply does — a changed color fires
11015
- # {Component#on_theme_changed} across the tree and invalidates it.
11777
+ # {Component#handle_theme_changed} across the tree and invalidates it.
11016
11778
  # There is no such writer on {Screen}: the value is a report from the
11017
11779
  # terminal, not a setting.
11018
11780
  #
11019
11781
  # _@param_ `color`
11020
11782
  def background_color=: (Color color) -> void
11021
11783
 
11784
+ # Plays a whole click at a screen cell — the press, then the release that
11785
+ # ends its grab:
11786
+ #
11787
+ # screen.click(save_button.rect.left, save_button.rect.top)
11788
+ #
11789
+ # Routed exactly as the terminal's own report would be ({Mouse::Router}), so
11790
+ # it focuses, dismisses popups and bubbles.
11791
+ #
11792
+ # _@param_ `x` — 0-based column.
11793
+ #
11794
+ # _@param_ `y` — 0-based row.
11795
+ #
11796
+ # _@param_ `button` — `:left`, `:middle` or `:right`.
11797
+ def click: (Integer x, Integer y, ?button: Symbol) -> void
11798
+
11799
+ # Half a {#click}, for a spec about the grab — what is claimed, what the
11800
+ # drag does, what the release lands on.
11801
+ #
11802
+ # _@param_ `x` — 0-based column.
11803
+ #
11804
+ # _@param_ `y` — 0-based row.
11805
+ #
11806
+ # _@param_ `button` — `:left`, `:middle` or `:right`.
11807
+ def press: (Integer x, Integer y, ?button: Symbol) -> void
11808
+
11809
+ # The other half of {#press}.
11810
+ #
11811
+ # _@param_ `x` — 0-based column.
11812
+ #
11813
+ # _@param_ `y` — 0-based row.
11814
+ def release: (Integer x, Integer y) -> void
11815
+
11816
+ # One wheel notch over a cell.
11817
+ #
11818
+ # _@param_ `direction` — `:up`, `:down`, `:left` or `:right`.
11819
+ #
11820
+ # _@param_ `x` — 0-based column.
11821
+ #
11822
+ # _@param_ `y` — 0-based row.
11823
+ def scroll: (Symbol direction, Integer x, Integer y) -> void
11824
+
11825
+ # Moves the pointer, firing the enter/exit hooks the new position implies —
11826
+ # or, while a press is grabbed, one {Component#handle_mouse_drag}.
11827
+ #
11828
+ # _@param_ `x` — 0-based column.
11829
+ #
11830
+ # _@param_ `y` — 0-based row.
11831
+ #
11832
+ # _@param_ `button` — the button held while moving, if any.
11833
+ def move: (Integer x, Integer y, ?button: Symbol?) -> void
11834
+
11835
+ # Plays a whole drag: the press at the first point, one move per point
11836
+ # after it, and the release at the last.
11837
+ #
11838
+ # screen.drag([2, 1], [3, 2], [4, 3]) # three reports, a diagonal
11839
+ # screen.drag(canvas.rect.top_left, [9, 9]) # the coarsest drag there is
11840
+ #
11841
+ # **Reports only the points given**, as the wire does: at ~84 reports a
11842
+ # second (`R_mouse_reporting`) a quick drag genuinely skips cells, so
11843
+ # interpolating them would let a spec assert a continuity no terminal
11844
+ # delivers. A one-point drag is a {#click} — use that.
11845
+ #
11846
+ # _@param_ `points` — two or more positions, as {Point}s or `[x, y]` pairs.
11847
+ #
11848
+ # _@param_ `button` — `:left`, `:middle` or `:right`.
11849
+ def drag: (*::Array[(Point | [Integer, Integer])] points, ?button: Symbol) -> void
11850
+
11851
+ # _@param_ `point`
11852
+ def coerce_point: ((Point | [Integer, Integer]) point) -> Point
11853
+
11022
11854
  # No terminal probing in tests: skip {TerminalBackground.detect}
11023
11855
  # (which would write an OSC 11 query to the test runner's TTY and
11024
11856
  # steal its input) and pin the deterministic default. The color is nil
@@ -11046,59 +11878,6 @@ module Tuile
11046
11878
  attr_reader prints: ::Array[String]
11047
11879
  end
11048
11880
 
11049
- # A mouse event.
11050
- #
11051
- # @!attribute [r] button
11052
- # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
11053
- # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
11054
- # @!attribute [r] x
11055
- # @return [Integer] x coordinate, 0-based.
11056
- # @!attribute [r] y
11057
- # @return [Integer] y coordinate, 0-based.
11058
- class MouseEvent
11059
- # _@return_ — the event's position.
11060
- def point: () -> Point
11061
-
11062
- # Checks whether given key is a mouse event key. Returns true on the X10
11063
- # `\e[M` prefix regardless of length — {.parse} is the place that
11064
- # validates the full 6-byte shape and raises on malformed input.
11065
- #
11066
- # _@param_ `key` — key read via {Keys.getkey}
11067
- #
11068
- # _@return_ — true if it is a mouse event
11069
- def self.mouse_event?: (String key) -> bool
11070
-
11071
- # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
11072
- #
11073
- # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
11074
- # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
11075
- # the upstream key-reader: a shorter prefix means the tail was lost on
11076
- # the way in, and a longer one means we over-consumed into the next
11077
- # escape sequence. We refuse to silently truncate either case because
11078
- # the trailing `\e` of an over-read corrupts the *next* getkey, and the
11079
- # corruption then surfaces as garbled keystrokes in focused inputs
11080
- # rather than as a parser failure pointing at the actual cause.
11081
- #
11082
- # _@param_ `key` — key read via {Keys.getkey}
11083
- #
11084
- # _@return_ — `nil` if `key` is not a mouse event
11085
- def self.parse: (String key) -> MouseEvent?
11086
-
11087
- def self.start_tracking: () -> String
11088
-
11089
- def self.stop_tracking: () -> String
11090
-
11091
- # _@return_ — one of `:left`, `:middle`, `:right`, `:scroll_up`,
11092
- # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
11093
- attr_reader button: Symbol?
11094
-
11095
- # _@return_ — x coordinate, 0-based.
11096
- attr_reader x: Integer
11097
-
11098
- # _@return_ — y coordinate, 0-based.
11099
- attr_reader y: Integer
11100
- end
11101
-
11102
11881
  # The structural root of the {Screen}'s component tree.
11103
11882
  #
11104
11883
  # {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
@@ -11106,7 +11885,7 @@ module Tuile
11106
11885
  # {#content} and the {#popups} stack. Putting them under a single Component
11107
11886
  # parent gives focus traversal a real root, makes {Component#attached?} a
11108
11887
  # one-liner, and lets popup-focus repair fall out of the standard
11109
- # {Component#on_child_removed} hook.
11888
+ # {Component#handle_child_removed} hook.
11110
11889
  #
11111
11890
  # The pane owns no chrome of its own — no status bar, no reserved row.
11112
11891
  # {#content} gets the full pane rect, and an app that wants a status line
@@ -11146,7 +11925,7 @@ module Tuile
11146
11925
  # _@param_ `window`
11147
11926
  def remove_popup: (Component window) -> void
11148
11927
 
11149
- # Unmounts everything: each child is detached — firing {Component#on_detached}
11928
+ # Unmounts everything: each child is detached — firing {Component#handle_detached}
11150
11929
  # down its subtree — and every slot is emptied. Terminal; the pane isn't
11151
11930
  # reusable afterwards, and {Screen#close} is its only caller.
11152
11931
  #
@@ -11185,7 +11964,7 @@ module Tuile
11185
11964
  def repaint: () -> void
11186
11965
 
11187
11966
  # Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
11188
- # the first component whose `handle_key` returns true wins.
11967
+ # the first component whose `handle_key?` returns true wins.
11189
11968
  #
11190
11969
  # Bubbling stops at the *scope* root: the topmost *modal* popup when one is
11191
11970
  # open, else the tiled {#content}. Focus that is nil or sits outside the
@@ -11203,36 +11982,34 @@ module Tuile
11203
11982
  # _@param_ `key`
11204
11983
  #
11205
11984
  # _@return_ — true if the key was handled.
11206
- def handle_key: (String key) -> bool
11985
+ def handle_key?: (String key) -> bool
11207
11986
 
11208
11987
  # Delivers pasted text to {Screen#focused} — and to nobody else.
11209
11988
  #
11210
- # Scoped exactly like {#handle_key} (focus that is nil or sits outside the
11989
+ # Scoped exactly like {#handle_key?} (focus that is nil or sits outside the
11211
11990
  # modal scope receives nothing, which is what keeps a popup modal) but
11212
11991
  # **not bubbled**: an ancestor is never offered a paste its descendant
11213
11992
  # declined, and unhandled text is dropped. Why keys bubble and pastes
11214
11993
  # don't: `D_bracketed_paste`.
11215
11994
  #
11216
11995
  # _@param_ `text`
11217
- #
11218
- # _@return_ — true if the focused component consumed it.
11219
- def handle_paste: (String text) -> bool
11996
+ def handle_paste: (String text) -> void
11220
11997
 
11221
- # Mouse events check popups in reverse stacking order (topmost first), and
11222
- # fall through to content only when no popup is hit *and* no modal popup is
11223
- # open. This preserves modal click-blocking — an open modal eats clicks
11224
- # even outside its rect — while a non-modal overlay blocks nothing: clicks
11225
- # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
11226
- # content beneath.
11998
+ # Where {Mouse::Router} starts its walk for a pointer at `point`: the
11999
+ # topmost popup containing it, else {#content} — unless a modal popup is
12000
+ # open, which eats the event even outside its rect. A non-modal overlay
12001
+ # blocks nothing: a point outside it reaches the content beneath.
11227
12002
  #
11228
- # A left click also *dismisses* the open popups it landed outside of that
11229
- # asked for it ({Component::Overlay#close_on_outside_click?}). That is a
11230
- # second thing happening on a click, but not a second dispatch: the click is
11231
- # still delivered exactly once, down one chain, and a dismissed popup is
12003
+ # _@param_ `point`
12004
+ def mouse_root_at: (Point point) -> Component?
12005
+
12006
+ # Runs the press delivery in the block, then *dismisses* the open popups a
12007
+ # left press landed outside of that asked for it
12008
+ # ({Component::Overlay#close_on_outside_click?}). A dismissed popup is
11232
12009
  # closed rather than told.
11233
12010
  #
11234
12011
  # "Outside" is measured against the {Component::Overlay#owner} chain, not
11235
- # against one rect and not against stacking order: the popup the click hit
12012
+ # against one rect and not against stacking order: the popup the press hit
11236
12013
  # is kept, and so is every popup that one *belongs to*, transitively. That
11237
12014
  # is what stops a dialog being dismissed by a click on a dropdown its own
11238
12015
  # field opened, and a menu cascade being dismissed by a click on one of its
@@ -11242,10 +12019,10 @@ module Tuile
11242
12019
  #
11243
12020
  # Two halves of the ordering are load-bearing, and both are specced:
11244
12021
  #
11245
- # - **Snapshot before routing.** A popup the delivered click *opens* must
12022
+ # - **Snapshot before the block.** A popup the delivered press *opens* must
11246
12023
  # not be in the set (it would immediately dismiss itself — every
11247
12024
  # {Component::Select} would be unopenable by mouse).
11248
- # - **Close after routing.** A widget toggling its own overlay from a click
12025
+ # - **Close after the block.** A widget toggling its own overlay from a press
11249
12026
  # on its face closes it during delivery, and {Component::Popup#close} is
11250
12027
  # idempotent, so the dismissal no-ops. Close *first* and the widget sees
11251
12028
  # a shut overlay and reopens it — a Select's dropdown could then never be
@@ -11254,10 +12031,12 @@ module Tuile
11254
12031
  # The snapshot is a fresh array for a third reason: a handler may close
11255
12032
  # further popups, and `@popups` must not be mutated mid-iteration.
11256
12033
  #
11257
- # _@param_ `event`
11258
- def handle_mouse: (MouseEvent event) -> void
12034
+ # _@param_ `point`
12035
+ #
12036
+ # _@param_ `left` — whether the press was the left button; no other button dismisses.
12037
+ def dismissing_popups_outside: (Point point, left: bool) -> void
11259
12038
 
11260
- # Focus repair when a child detaches. Default {Component#on_child_removed}
12039
+ # Focus repair when a child detaches. Default {Component#handle_child_removed}
11261
12040
  # would refocus to `self` (the pane), which isn't a useful focus target.
11262
12041
  # Instead, route focus to the first interactable widget in the now-topmost
11263
12042
  # modal popup; falling back to the focus snapshotted when this popup was opened
@@ -11270,7 +12049,7 @@ module Tuile
11270
12049
  # `q`/ESC still has somewhere to dispatch from.
11271
12050
  #
11272
12051
  # _@param_ `child`
11273
- def on_child_removed: (Component child) -> void
12052
+ def handle_child_removed: (Component child) -> void
11274
12053
 
11275
12054
  # The overlays a click counts as landing *inside*: the one it hit, plus
11276
12055
  # every overlay that one belongs to, up the {Component::Overlay#owner}
@@ -11283,6 +12062,11 @@ module Tuile
11283
12062
  # _@param_ `hit` — the overlay the click landed in, if any.
11284
12063
  def kept_by: (Component::Overlay? hit) -> ::Array[Component::Overlay]
11285
12064
 
12065
+ # _@param_ `point`
12066
+ #
12067
+ # _@return_ — the topmost popup containing `point`.
12068
+ def popup_at: (Point point) -> Component::Overlay?
12069
+
11286
12070
  # _@param_ `component`
11287
12071
  #
11288
12072
  # _@return_ — `component` itself when it is an
@@ -11814,7 +12598,7 @@ module Tuile
11814
12598
  def running?: () -> bool
11815
12599
 
11816
12600
  # _@return_ — always true.
11817
- def on_loop_thread?: () -> bool
12601
+ def in_loop_thread?: () -> bool
11818
12602
 
11819
12603
  def stop: () -> void
11820
12604
 
@@ -12034,7 +12818,7 @@ module Tuile
12034
12818
  # track is a solid column carrying no information, so `row_count <= height`
12035
12819
  # paints track at every row — while {#handle_height} / {#handle_start} /
12036
12820
  # {#handle_end} still report the covering handle. Ink only: the caller's bar
12037
- # keeps its column and its content width (`DECISIONS.md` `D_scrollbar_ink`).
12821
+ # keeps its column and its content width (`design/decisions.md` `D_scrollbar_ink`).
12038
12822
  class VerticalScrollBar
12039
12823
  # One cell, exactly: a painter concatenates the glyph onto a row already
12040
12824
  # padded to fill the rest of its rect, so a two-column one pushes every