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/lib/tuile/screen.rb CHANGED
@@ -85,8 +85,9 @@ module Tuile
85
85
  # filter would take the whole tree with it.
86
86
  @pane = ScreenPane.new
87
87
  @pane.rect = Rect.new(0, 0, @size.width, @size.height)
88
+ @mouse_router = Mouse::Router.new(self)
88
89
  @on_error = ->(e) { raise e }
89
- # App-level keyboard shortcuts dispatched by {#handle_key} before keys
90
+ # App-level keyboard shortcuts dispatched by {#handle_key?} before keys
90
91
  # reach the pane. See {#register_global_shortcut}.
91
92
  @global_shortcuts = {}
92
93
  # The back buffer components paint into. {#repaint} flushes its diff to
@@ -107,8 +108,8 @@ module Tuile
107
108
  # {Component::TextArea}'s newline, a caret move, a deletion. `ENTER` is the
108
109
  # trap worth naming: it is unprintable, so nothing else stops it, and
109
110
  # "bind Enter to submit" is the obvious wrong way to build a default
110
- # button. The right way is a `handle_key` on the form itself, where a
111
- # focused field still gets first refusal — see {ScreenPane#handle_key}.
111
+ # button. The right way is a `handle_key?` on the form itself, where a
112
+ # focused field still gets first refusal — see {ScreenPane#handle_key?}.
112
113
  #
113
114
  # Deliberately *not* reserved: `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN`. They
114
115
  # move within a widget rather than mutate its value, and binding them
@@ -223,7 +224,7 @@ module Tuile
223
224
  # Kept current across OS appearance flips, a frame behind: the flip
224
225
  # report carries light/dark only, so the screen re-probes and this
225
226
  # updates when the reply lands. A changed color then fires
226
- # {Component#on_theme_changed} across the tree exactly as a theme swap
227
+ # {Component#handle_theme_changed} across the tree exactly as a theme swap
227
228
  # does — a background-derived tint *is* a theme-derived color.
228
229
  # @return [Color, nil]
229
230
  attr_reader :background_color
@@ -243,7 +244,7 @@ module Tuile
243
244
  end
244
245
 
245
246
  # Replaces the theme and restyles the whole UI: fires
246
- # {Component#on_theme_changed} across the attached tree and invalidates
247
+ # {Component#handle_theme_changed} across the attached tree and invalidates
247
248
  # every attached component. No-op when `new_theme` equals the current theme.
248
249
  # This is a *transient* override — the next OS appearance flip re-picks from
249
250
  # {#theme_def}; assign {#theme_def=} for durable theming.
@@ -256,9 +257,9 @@ module Tuile
256
257
  return if @theme == new_theme
257
258
 
258
259
  @theme = new_theme
259
- # `__send__`, not `&:on_theme_changed`: the hook is protected, and an app
260
+ # `__send__`, not `&:handle_theme_changed`: the hook is protected, and an app
260
261
  # subclass may narrow it further (`D_hook_visibility`).
261
- @pane&.on_tree { _1.__send__(:on_theme_changed) }
262
+ @pane&.walk_tree { _1.__send__(:handle_theme_changed) }
262
263
  needs_full_repaint
263
264
  end
264
265
 
@@ -272,7 +273,7 @@ module Tuile
272
273
  # @return [Locale]
273
274
  attr_reader :locale
274
275
 
275
- # Replaces the locale: fires {Component#on_locale_changed} across the
276
+ # Replaces the locale: fires {Component#handle_locale_changed} across the
276
277
  # attached tree and invalidates every attached component. No-op when
277
278
  # `new_locale` equals the current one.
278
279
  #
@@ -293,7 +294,7 @@ module Tuile
293
294
  @locale = new_locale
294
295
  # `__send__` for the same reason `theme=` uses it: the hook is protected
295
296
  # (`D_hook_visibility`).
296
- @pane&.on_tree { _1.__send__(:on_locale_changed) }
297
+ @pane&.walk_tree { _1.__send__(:handle_locale_changed) }
297
298
  needs_full_repaint
298
299
  end
299
300
 
@@ -327,7 +328,7 @@ module Tuile
327
328
  # @return [void]
328
329
  def check_locked
329
330
  raise Tuile::Error, "Screen is closed: no UI mutation is possible after Screen#close" if @closed
330
- return if @event_queue.running? ? @event_queue.on_loop_thread? : Thread.current.equal?(@ui_thread)
331
+ return if @event_queue.running? ? @event_queue.in_loop_thread? : Thread.current.equal?(@ui_thread)
331
332
 
332
333
  # `submit` is the wrong remedy with no loop running — nothing would drain
333
334
  # the queue, so the block silently never fires.
@@ -371,9 +372,9 @@ module Tuile
371
372
  # whatever is painted over it and feed it every keystroke.
372
373
  #
373
374
  # Once the pointer and the active flags are settled, three notices fire in
374
- # order: {Component#on_blur} on what lost focus, {Component#on_focus} on
375
+ # order: {Component#handle_blur} on what lost focus, {Component#handle_focus} on
375
376
  # what took it, then {#on_focus_changed}. The outer two are edge-triggered
376
- # and `on_focus` is not — see there.
377
+ # and `handle_focus` is not — see there.
377
378
  # @param focused [Component, nil] the new component to be focused.
378
379
  def focused=(focused)
379
380
  unless focused.nil? || focused.is_a?(Component)
@@ -384,7 +385,7 @@ module Tuile
384
385
  previous = @focused
385
386
  if focused.nil?
386
387
  @focused = nil
387
- @pane.on_tree { _1.active = false }
388
+ @pane.walk_tree { _1.active = false }
388
389
  else
389
390
  raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
390
391
  raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor" if hidden?(focused)
@@ -396,7 +397,7 @@ module Tuile
396
397
  active << cursor
397
398
  cursor = cursor.parent
398
399
  end
399
- @pane.on_tree { _1.active = active.include?(_1) }
400
+ @pane.walk_tree { _1.active = active.include?(_1) }
400
401
  end
401
402
  fire_focus_hooks(previous, focused)
402
403
  end
@@ -412,17 +413,17 @@ module Tuile
412
413
  #
413
414
  # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
414
415
  #
415
- # **Edge-triggered**, like {Component#on_attached}: re-assigning the
416
+ # **Edge-triggered**, like {Component#handle_attached}: re-assigning the
416
417
  # component that already has focus fires nothing, so a callback can be as
417
418
  # expensive as rebuilding a hint string without a `did it really change?`
418
419
  # guard of its own. That matters more than it looks — `ScreenPane#content=`
419
420
  # clears focus on every content swap, which on a level-triggered hook would
420
421
  # fire a nil→nil notification during assembly.
421
422
  #
422
- # It runs *after* the active-flag cascade and `on_focus`, so the tree is
423
+ # It runs *after* the active-flag cascade and `handle_focus`, so the tree is
423
424
  # settled. Two things a callback must tolerate: {#focused} being `nil`, and
424
425
  # firing during {#close} — teardown clears focus, exactly as it fires
425
- # {Component#on_detached}. A raising callback propagates out of {#focused=}
426
+ # {Component#handle_detached}. A raising callback propagates out of {#focused=}
426
427
  # and leaves focus assigned; keep it trivial, as with the attach hooks.
427
428
  # @return [Proc, nil]
428
429
  attr_accessor :on_focus_changed
@@ -440,20 +441,25 @@ module Tuile
440
441
  end
441
442
 
442
443
  # Runs the event loop on the calling thread, taking over stdin (raw mode,
443
- # echo off): keys and mouse events are dispatched via {#handle_key} /
444
+ # echo off): keys and mouse events are dispatched via {#handle_key?} /
444
445
  # {#handle_mouse}, and the loop repaints once per drained tick. Returns
445
446
  # when `q` or ESC is pressed unhandled. Restores terminal state on exit.
446
447
  #
447
448
  # For the duration this thread owns the UI ({#state} is `:running`);
448
449
  # ownership reverts to the creating thread once it returns.
449
450
  #
450
- # @param capture_mouse [Boolean] when true (default), enables xterm mouse
451
- # tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed
452
- # {Component#handle_mouse}. When false, no tracking escape sequence is
453
- # written: the terminal keeps its native click handling, which is what
454
- # you want if the app benefits more from select-to-copy than from
455
- # click-to-focus. Components' `handle_mouse` is simply never invoked
456
- # from the loop in that mode (the terminal stops sending the bytes).
451
+ # @param capture_mouse [Boolean, Symbol] how much mouse tracking to ask the
452
+ # terminal for — each level unlocking one tier of {Mouse}'s events, and
453
+ # costing what that tier reports:
454
+ #
455
+ # - `false` — none. The terminal keeps its native click handling, which is
456
+ # what you want if the app benefits more from select-to-copy.
457
+ # - `true` (default), `:clicks` — presses, releases and the wheel.
458
+ # - `:drag` — plus motion while a button is held, as
459
+ # {Component#handle_mouse_drag}.
460
+ # - `:hover` — plus motion with no button, as
461
+ # {Component#handle_mouse_move?} and the enter/exit hooks. ~84 reports a
462
+ # second (`R_mouse_reporting`), so ask for it only if something uses it.
457
463
  # @param bracketed_paste [Boolean] when true (default), enables DEC private
458
464
  # mode 2004 so pasted text arrives whole, as {Component#handle_paste},
459
465
  # instead of as one keystroke per character — which is the only way a
@@ -462,16 +468,20 @@ module Tuile
462
468
  # fires it once per pasted line. Turn it off only for a terminal that
463
469
  # mishandles the mode.
464
470
  # @raise [Tuile::Error] if the screen is already {#close}d.
471
+ # @raise [ArgumentError] on an unknown `capture_mouse:` level.
465
472
  # @return [void]
466
473
  def run_event_loop(capture_mouse: true, bracketed_paste: true)
467
474
  raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
468
475
 
476
+ level = Mouse.level(capture_mouse)
477
+
469
478
  # The guard above stays outside the begin: teardown for a setup that never
470
479
  # happened restores echo on a non-TTY stdin, and the ENOTTY masks the
471
480
  # real error.
472
481
  begin
473
482
  $stdin.echo = false
474
- print MouseEvent.start_tracking if capture_mouse
483
+ @mouse_router.level = level
484
+ print Mouse.start_tracking(level) if level
475
485
  print Keys::BRACKETED_PASTE_ON if bracketed_paste
476
486
  # Follow OS light/dark flips live: terminals supporting mode 2031
477
487
  # push color-scheme reports that the key thread turns into
@@ -483,7 +493,9 @@ module Tuile
483
493
  ensure
484
494
  print TerminalBackground::NOTIFY_OFF
485
495
  print Keys::BRACKETED_PASTE_OFF if bracketed_paste
486
- print MouseEvent.stop_tracking if capture_mouse
496
+ print Mouse.stop_tracking(level) if level
497
+ # Back to delivering everything a spec posts once no loop owns the wire.
498
+ @mouse_router.level = :hover
487
499
  print TTY::Cursor.show
488
500
  $stdin.echo = true
489
501
  end
@@ -510,9 +522,9 @@ module Tuile
510
522
  #
511
523
  # - **Printable keys** — they'd hijack typing into a
512
524
  # {Component::TextField}. A scope-wide one-key binding belongs on the
513
- # scope root's own `handle_key`, where a focused field consumes it first
514
- # (see {ScreenPane#handle_key}).
515
- # - **TAB / SHIFT_TAB** — {#handle_key} intercepts them for focus
525
+ # scope root's own `handle_key?`, where a focused field consumes it first
526
+ # (see {ScreenPane#handle_key?}).
527
+ # - **TAB / SHIFT_TAB** — {#handle_key?} intercepts them for focus
516
528
  # navigation before the registry is consulted, so a binding would never
517
529
  # fire.
518
530
  # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
@@ -535,7 +547,7 @@ module Tuile
535
547
  if Keys.printable?(key)
536
548
  raise ArgumentError,
537
549
  "global shortcut key must be unprintable; got #{key.inspect}. " \
538
- "For a one-key binding, override handle_key on the scope root " \
550
+ "For a one-key binding, override handle_key? on the scope root " \
539
551
  "(your content layout, or the popup) — a focused text field then " \
540
552
  "consumes the key first, so typing isn't hijacked."
541
553
  end
@@ -547,7 +559,7 @@ module Tuile
547
559
  raise ArgumentError,
548
560
  "#{key.inspect} is reserved: every editable widget needs it, and this registry " \
549
561
  "sits above the component tree with nothing to suppress it. For a default " \
550
- "button, handle ENTER in the form's own handle_key instead — a focused " \
562
+ "button, handle ENTER in the form's own handle_key? instead — a focused " \
551
563
  "TextArea/TextField gets first refusal there."
552
564
  end
553
565
  @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
@@ -586,7 +598,7 @@ module Tuile
586
598
  # @api private
587
599
  # @return [void]
588
600
  def needs_full_repaint
589
- @pane&.on_tree { invalidate _1 }
601
+ @pane&.walk_tree { invalidate _1 }
590
602
  end
591
603
 
592
604
  # Internal — use {Component::Overlay#open?} instead.
@@ -607,7 +619,7 @@ module Tuile
607
619
 
608
620
  # Tears the screen down and vacates the singleton slot, moving {#state} to
609
621
  # the terminal `:closed`. Unmounts the tree first, so every component gets
610
- # its {Component#on_detached}. Idempotent.
622
+ # its {Component#handle_detached}. Idempotent.
611
623
  # @raise [Tuile::Error] if an event loop is still running — stop it with
612
624
  # `event_queue.stop` and let {#run_event_loop} return first, since closing
613
625
  # under a live loop drops the pane it is still painting — or if the caller
@@ -622,11 +634,12 @@ module Tuile
622
634
  begin
623
635
  @pane.detach_all
624
636
  ensure
625
- # A raising on_detached propagates — it's a bug to fix, not something the
637
+ # A raising handle_detached propagates — it's a bug to fix, not something the
626
638
  # framework guards — but teardown still has to finish, or one such bug
627
639
  # leaves a half-closed screen behind and every later example fails with it.
628
640
  clear
629
641
  @pane = nil
642
+ @mouse_router = nil
630
643
  @closed = true
631
644
  @@instance = nil # rubocop:disable Style/ClassVars
632
645
  end
@@ -682,6 +695,9 @@ module Tuile
682
695
  # @return [void]
683
696
  def repaint
684
697
  check_locked
698
+ # The one site that runs after every mutation, so a component hidden,
699
+ # detached or reparented while hovered gets its exit exactly once.
700
+ @mouse_router.sync_hover
685
701
  # This simple TUI framework doesn't support window clipping since tiled
686
702
  # windows are not expected to overlap. If there rarely is a popup, we
687
703
  # just repaint all windows in correct order — sure they will paint over
@@ -725,14 +741,14 @@ module Tuile
725
741
  # than a depth sort. The pane's pre-order traversal already orders the
726
742
  # tiled layer (the content subtree) parent-before-child; the popups are
727
743
  # the top layer and must paint last, so we collect the tiled layer first
728
- # and append popups rather than taking a single pane.on_tree walk.
744
+ # and append popups rather than taking a single pane.walk_tree walk.
729
745
  popup_members = Set.new
730
- popups.each { |p| p.on_tree { popup_members << _1 } }
746
+ popups.each { |p| p.walk_tree { popup_members << _1 } }
731
747
 
732
748
  # Tiled layer: invalidated non-popup components, in tree order.
733
749
  repaint = []
734
750
  tiled_invalidated = false
735
- @pane.on_tree do |c|
751
+ @pane.walk_tree do |c|
736
752
  next if popup_members.include?(c)
737
753
  next unless @invalidated.include?(c)
738
754
 
@@ -753,8 +769,8 @@ module Tuile
753
769
  below_repainted = tiled_invalidated
754
770
  popups.each do |p|
755
771
  layer_invalidated = false
756
- p.on_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
757
- p.on_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
772
+ p.walk_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
773
+ p.walk_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
758
774
  below_repainted ||= layer_invalidated
759
775
  end
760
776
 
@@ -778,6 +794,28 @@ module Tuile
778
794
  # @return [Point, nil]
779
795
  def cursor_position = @focused&.cursor_position
780
796
 
797
+ # Routes one mouse event into the tree ({Mouse::Router}) — what the event
798
+ # loop does with every report the terminal sends, and how a spec drives the
799
+ # mouse:
800
+ #
801
+ # screen.handle_mouse(Mouse::DownEvent.new(:left, 5, 2))
802
+ #
803
+ # @param event [Mouse::Event]
804
+ # @return [void]
805
+ def handle_mouse(event)
806
+ check_locked
807
+ @mouse_router.dispatch(event)
808
+ end
809
+
810
+ # @return [Component, nil] the innermost component the pointer is over, as
811
+ # of the last move. Always nil below `capture_mouse: :hover`, and frozen
812
+ # at its last value while a press is grabbed.
813
+ def hovered = @mouse_router&.hovered
814
+
815
+ # @return [Component, nil] the component holding the mouse grab — the one
816
+ # whose {Component#handle_mouse_down?} claimed the press still held.
817
+ def grabbed = @mouse_router&.grabbed
818
+
781
819
  private
782
820
 
783
821
  # Whether `component` is out of the user's reach because it or an ancestor
@@ -786,7 +824,7 @@ module Tuile
786
824
  # Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
787
825
  # declined a `Component#paintable?`: it reads as a component-level concept
788
826
  # and is really this class's question. A *walk* needs no such predicate —
789
- # it prunes at the hidden subtree's root ({Component#on_shown_tree}).
827
+ # it prunes at the hidden subtree's root ({Component#walk_shown_tree}).
790
828
  # @param component [Component]
791
829
  # @return [Boolean]
792
830
  def hidden?(component)
@@ -805,10 +843,10 @@ module Tuile
805
843
  # @return [void]
806
844
  def fire_focus_hooks(previous, focused)
807
845
  unless focused.equal?(previous)
808
- previous&.__send__(:on_blur)
846
+ previous&.__send__(:handle_blur)
809
847
  return unless @focused.equal?(focused)
810
848
  end
811
- @focused&.__send__(:on_focus)
849
+ @focused&.__send__(:handle_focus)
812
850
  @on_focus_changed&.call unless @focused.equal?(previous)
813
851
  end
814
852
 
@@ -848,7 +886,7 @@ module Tuile
848
886
  # thread as an {EventQueue::BackgroundColorEvent}.
849
887
  # @param scheme [Symbol] `:dark` or `:light`.
850
888
  # @return [void]
851
- def on_color_scheme(scheme)
889
+ def handle_color_scheme(scheme)
852
890
  @color_scheme = scheme
853
891
  self.theme = @theme_def.for(@color_scheme)
854
892
  print TerminalBackground::QUERY
@@ -861,11 +899,11 @@ module Tuile
861
899
  # would otherwise lose the color it gave us at startup, permanently.
862
900
  # @param color [Color]
863
901
  # @return [void]
864
- def on_background_color(color)
902
+ def handle_background_color(color)
865
903
  return if @background_color == color
866
904
 
867
905
  @background_color = color
868
- @pane&.on_tree { _1.__send__(:on_theme_changed) }
906
+ @pane&.walk_tree { _1.__send__(:handle_theme_changed) }
869
907
  needs_full_repaint
870
908
  end
871
909
 
@@ -882,7 +920,7 @@ module Tuile
882
920
  return false if scope.nil?
883
921
 
884
922
  stops = []
885
- scope.on_shown_tree { |c| stops << c if c.tab_stop? }
923
+ scope.walk_shown_tree { |c| stops << c if c.tab_stop? }
886
924
  return false if stops.empty?
887
925
 
888
926
  idx = @focused.nil? ? nil : stops.index(@focused)
@@ -940,11 +978,14 @@ module Tuile
940
978
  # default `over_popups: false` fires only when no modal popup is open
941
979
  # (otherwise the modal popup receives the key normally). A non-modal
942
980
  # overlay doesn't suppress global shortcuts.
943
- # 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
981
+ # 3. {ScreenPane#handle_key?} — delivery to {#focused}, bubbling up the
944
982
  # focus chain to the scope root.
945
983
  # @param key [String]
946
984
  # @return [Boolean] true if the key was handled by some window.
947
- def handle_key(key)
985
+ def handle_key?(key)
986
+ # A key is one of the grab's releases: a terminal loses a release over ssh
987
+ # and tmux, and a stuck grab would swallow every later drag.
988
+ @mouse_router.release_grab
948
989
  case key
949
990
  when Keys::TAB
950
991
  focus_next
@@ -958,25 +999,21 @@ module Tuile
958
999
  shortcut.block.call
959
1000
  true
960
1001
  else
961
- @pane.handle_key(key)
1002
+ @pane.handle_key?(key)
962
1003
  end
963
1004
  end
964
1005
  end
965
1006
 
966
- # Finds target window and calls {Component::Window#handle_mouse}.
967
- # @param event [MouseEvent]
968
- # @return [void]
969
- def handle_mouse(event) = @pane.handle_mouse(event)
970
-
971
1007
  # Delivers pasted text to {#focused} ({ScreenPane#handle_paste}).
972
1008
  #
973
1009
  # Deliberately *not* the key ladder: a paste is not a keystroke, so it skips
974
1010
  # Tab traversal and the global-shortcut registry entirely, goes straight to
975
1011
  # delivery, and does not bubble to ancestors the way a key does. Unhandled
976
1012
  # text is dropped — there is no fallback that replays it as keys, which
977
- # would put back the very ambiguity mode 2004 exists to remove.
1013
+ # would put back the very ambiguity mode 2004 exists to remove — and with no
1014
+ # alternative delivery, no verdict to carry either.
978
1015
  # @param text [String]
979
- # @return [Boolean] true if the focused component consumed it.
1016
+ # @return [void]
980
1017
  def handle_paste(text) = @pane.handle_paste(text)
981
1018
 
982
1019
  # @return [void]
@@ -985,19 +1022,19 @@ module Tuile
985
1022
  case event
986
1023
  when EventQueue::KeyEvent
987
1024
  key = event.key
988
- handled = handle_key(key)
1025
+ handled = handle_key?(key)
989
1026
  @event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
990
1027
  when EventQueue::PasteEvent
991
1028
  handle_paste(event.text)
992
- when MouseEvent
1029
+ when Mouse::Event
993
1030
  handle_mouse(event)
994
1031
  when EventQueue::TTYSizeEvent
995
1032
  @size = event.size
996
1033
  layout
997
1034
  when EventQueue::ColorSchemeEvent
998
- on_color_scheme(event.scheme)
1035
+ handle_color_scheme(event.scheme)
999
1036
  when EventQueue::BackgroundColorEvent
1000
- on_background_color(event.color)
1037
+ handle_background_color(event.color)
1001
1038
  when EventQueue::EmptyQueueEvent
1002
1039
  repaint
1003
1040
  when Proc
@@ -8,7 +8,7 @@ module Tuile
8
8
  # {#content} and the {#popups} stack. Putting them under a single Component
9
9
  # parent gives focus traversal a real root, makes {Component#attached?} a
10
10
  # one-liner, and lets popup-focus repair fall out of the standard
11
- # {Component#on_child_removed} hook.
11
+ # {Component#handle_child_removed} hook.
12
12
  #
13
13
  # The pane owns no chrome of its own — no status bar, no reserved row.
14
14
  # {#content} gets the full pane rect, and an app that wants a status line
@@ -79,7 +79,7 @@ module Tuile
79
79
  window.center
80
80
  screen.focused = window
81
81
  end
82
- window.on_tree { |c| screen.invalidate(c) }
82
+ window.walk_tree { |c| screen.invalidate(c) }
83
83
  end
84
84
 
85
85
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
@@ -102,7 +102,7 @@ module Tuile
102
102
  @removing_popup_prior = nil
103
103
  end
104
104
 
105
- # Unmounts everything: each child is detached — firing {Component#on_detached}
105
+ # Unmounts everything: each child is detached — firing {Component#handle_detached}
106
106
  # down its subtree — and every slot is emptied. Terminal; the pane isn't
107
107
  # reusable afterwards, and {Screen#close} is its only caller.
108
108
  #
@@ -113,7 +113,7 @@ module Tuile
113
113
  # components, which is the desync the tree API exists to prevent.
114
114
  # @return [void]
115
115
  def detach_all
116
- screen.focused = nil # …so the focus repair in on_child_removed has nothing to do
116
+ screen.focused = nil # …so the focus repair in handle_child_removed has nothing to do
117
117
  children.dup.each { detach_child(_1) }
118
118
  @content = nil
119
119
  @popups.clear
@@ -157,7 +157,7 @@ module Tuile
157
157
  def repaint; end
158
158
 
159
159
  # Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
160
- # the first component whose `handle_key` returns true wins.
160
+ # the first component whose `handle_key?` returns true wins.
161
161
  #
162
162
  # Bubbling stops at the *scope* root: the topmost *modal* popup when one is
163
163
  # open, else the tiled {#content}. Focus that is nil or sits outside the
@@ -173,7 +173,7 @@ module Tuile
173
173
  # hijacked).
174
174
  # @param key [String]
175
175
  # @return [Boolean] true if the key was handled.
176
- def handle_key(key)
176
+ def handle_key?(key)
177
177
  scope = modal_popup || @content
178
178
  return false if scope.nil?
179
179
 
@@ -182,38 +182,39 @@ module Tuile
182
182
 
183
183
  # Delivers pasted text to {Screen#focused} — and to nobody else.
184
184
  #
185
- # Scoped exactly like {#handle_key} (focus that is nil or sits outside the
185
+ # Scoped exactly like {#handle_key?} (focus that is nil or sits outside the
186
186
  # modal scope receives nothing, which is what keeps a popup modal) but
187
187
  # **not bubbled**: an ancestor is never offered a paste its descendant
188
188
  # declined, and unhandled text is dropped. Why keys bubble and pastes
189
189
  # don't: `D_bracketed_paste`.
190
190
  # @param text [String]
191
- # @return [Boolean] true if the focused component consumed it.
191
+ # @return [void]
192
192
  def handle_paste(text)
193
193
  scope = modal_popup || @content
194
- return false if scope.nil?
194
+ return if scope.nil?
195
195
 
196
196
  chain = focus_chain(scope)
197
- return false if chain.nil?
197
+ return if chain.nil?
198
198
 
199
199
  chain.first.handle_paste(text)
200
200
  end
201
201
 
202
- # Mouse events check popups in reverse stacking order (topmost first), and
203
- # fall through to content only when no popup is hit *and* no modal popup is
204
- # open. This preserves modal click-blocking — an open modal eats clicks
205
- # even outside its rect — while a non-modal overlay blocks nothing: clicks
206
- # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
207
- # content beneath.
208
- #
209
- # A left click also *dismisses* the open popups it landed outside of that
210
- # asked for it ({Component::Overlay#close_on_outside_click?}). That is a
211
- # second thing happening on a click, but not a second dispatch: the click is
212
- # still delivered exactly once, down one chain, and a dismissed popup is
202
+ # Where {Mouse::Router} starts its walk for a pointer at `point`: the
203
+ # topmost popup containing it, else {#content} — unless a modal popup is
204
+ # open, which eats the event even outside its rect. A non-modal overlay
205
+ # blocks nothing: a point outside it reaches the content beneath.
206
+ # @api private
207
+ # @param point [Point]
208
+ # @return [Component, nil]
209
+ def mouse_root_at(point) = popup_at(point) || (@content if modal_popup.nil?)
210
+
211
+ # Runs the press delivery in the block, then *dismisses* the open popups a
212
+ # left press landed outside of that asked for it
213
+ # ({Component::Overlay#close_on_outside_click?}). A dismissed popup is
213
214
  # closed rather than told.
214
215
  #
215
216
  # "Outside" is measured against the {Component::Overlay#owner} chain, not
216
- # against one rect and not against stacking order: the popup the click hit
217
+ # against one rect and not against stacking order: the popup the press hit
217
218
  # is kept, and so is every popup that one *belongs to*, transitively. That
218
219
  # is what stops a dialog being dismissed by a click on a dropdown its own
219
220
  # field opened, and a menu cascade being dismissed by a click on one of its
@@ -223,10 +224,10 @@ module Tuile
223
224
  #
224
225
  # Two halves of the ordering are load-bearing, and both are specced:
225
226
  #
226
- # - **Snapshot before routing.** A popup the delivered click *opens* must
227
+ # - **Snapshot before the block.** A popup the delivered press *opens* must
227
228
  # not be in the set (it would immediately dismiss itself — every
228
229
  # {Component::Select} would be unopenable by mouse).
229
- # - **Close after routing.** A widget toggling its own overlay from a click
230
+ # - **Close after the block.** A widget toggling its own overlay from a press
230
231
  # on its face closes it during delivery, and {Component::Popup#close} is
231
232
  # idempotent, so the dismissal no-ops. Close *first* and the widget sees
232
233
  # a shut overlay and reopens it — a Select's dropdown could then never be
@@ -234,19 +235,19 @@ module Tuile
234
235
  #
235
236
  # The snapshot is a fresh array for a third reason: a handler may close
236
237
  # further popups, and `@popups` must not be mutated mid-iteration.
237
- # @param event [MouseEvent]
238
+ # @api private
239
+ # @param point [Point]
240
+ # @param left [Boolean] whether the press was the left button; no other
241
+ # button dismisses.
242
+ # @yield the press delivery.
238
243
  # @return [void]
239
- def handle_mouse(event)
240
- hit = @popups.reverse_each.find { _1.rect.contains?(event.point) }
241
- dismissable = event.button == :left ? @popups - kept_by(hit) : []
242
-
243
- clicked = hit || (@content if modal_popup.nil?)
244
- clicked&.handle_mouse(event)
245
-
244
+ def dismissing_popups_outside(point, left:)
245
+ dismissable = left ? @popups - kept_by(popup_at(point)) : []
246
+ yield
246
247
  dismissable.each { _1.close if _1.close_on_outside_click? }
247
248
  end
248
249
 
249
- # Focus repair when a child detaches. Default {Component#on_child_removed}
250
+ # Focus repair when a child detaches. Default {Component#handle_child_removed}
250
251
  # would refocus to `self` (the pane), which isn't a useful focus target.
251
252
  # Instead, route focus to the first interactable widget in the now-topmost
252
253
  # modal popup; falling back to the focus snapshotted when this popup was opened
@@ -259,7 +260,7 @@ module Tuile
259
260
  # `q`/ESC still has somewhere to dispatch from.
260
261
  # @param child [Component]
261
262
  # @return [void]
262
- def on_child_removed(child)
263
+ def handle_child_removed(child)
263
264
  return unless attached?
264
265
 
265
266
  f = screen.focused
@@ -301,6 +302,10 @@ module Tuile
301
302
  kept
302
303
  end
303
304
 
305
+ # @param point [Point]
306
+ # @return [Component::Overlay, nil] the topmost popup containing `point`.
307
+ def popup_at(point) = @popups.reverse_each.find { _1.rect.contains?(point) }
308
+
304
309
  # @param component [Component, nil]
305
310
  # @return [Component::Overlay, nil] `component` itself when it is an
306
311
  # overlay, else the nearest overlay above it, else nil.
@@ -321,7 +326,7 @@ module Tuile
321
326
  chain = focus_chain(scope)
322
327
  return false if chain.nil?
323
328
 
324
- chain.each { |c| return true if c.handle_key(key) }
329
+ chain.each { |c| return true if c.handle_key?(key) }
325
330
  false
326
331
  end
327
332
 
@@ -350,7 +355,7 @@ module Tuile
350
355
  def first_tab_stop_or_root(root)
351
356
  return nil if root.nil? || !root.visible?
352
357
 
353
- root.on_shown_tree { |c| return c if c.tab_stop? }
358
+ root.walk_shown_tree { |c| return c if c.tab_stop? }
354
359
  root
355
360
  end
356
361
  end
@@ -410,11 +410,11 @@ module Tuile
410
410
  #
411
411
  # `:rgi` credits width 2 only to [RGI](https://www.unicode.org/reports/tr51/)
412
412
  # sequences — the ones vendors actually ship a single glyph for — and *sums*
413
- # the parts of anything else. That is the one setting never wrong in the
414
- # dangerous direction: under-measuring lets a glyph overrun its cell, which
415
- # shifts the rest of the row, desyncs the cursor and escapes the component's
416
- # rect, while over-measuring leaves a blank column. `D_cluster_width` has the
417
- # per-setting reasoning.
413
+ # the parts of anything else, erring only in the direction that cannot escape
414
+ # a component: {Buffer#flush} emits a dirty run contiguously, so a mis-measure
415
+ # of either sign shifts the rest of that run, but only an under-measure shifts
416
+ # it right, into a neighbour whose cells are clean and so never repainted.
417
+ # `D_cluster_width` has the per-setting reasoning.
418
418
  # @return [Symbol]
419
419
  EMOJI_WIDTH = :rgi
420
420