tuile 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
data/lib/tuile/screen.rb CHANGED
@@ -75,13 +75,19 @@ module Tuile
75
75
  @color_scheme = background.scheme
76
76
  @background_color = background.color
77
77
  @color_depth = detect_color_depth
78
+ @locale = detect_locale
78
79
  @theme_def = ThemeDef.default
79
80
  @theme = @theme_def.for(@color_scheme)
80
81
  # Structural root of the component tree: holds tiled content and the
81
- # popup stack.
82
+ # popup stack. Sized here rather than waiting for the first {#layout},
83
+ # for the same reason {#size} is seeded from {EventQueue::TTYSizeEvent}:
84
+ # an empty pane rect is an *ancestor* empty rect, and {#repaint}'s drain
85
+ # filter would take the whole tree with it.
82
86
  @pane = ScreenPane.new
87
+ @pane.rect = Rect.new(0, 0, @size.width, @size.height)
88
+ @mouse_router = Mouse::Router.new(self)
83
89
  @on_error = ->(e) { raise e }
84
- # App-level keyboard shortcuts dispatched by {#handle_key} before keys
90
+ # App-level keyboard shortcuts dispatched by {#handle_key?} before keys
85
91
  # reach the pane. See {#register_global_shortcut}.
86
92
  @global_shortcuts = {}
87
93
  # The back buffer components paint into. {#repaint} flushes its diff to
@@ -102,8 +108,8 @@ module Tuile
102
108
  # {Component::TextArea}'s newline, a caret move, a deletion. `ENTER` is the
103
109
  # trap worth naming: it is unprintable, so nothing else stops it, and
104
110
  # "bind Enter to submit" is the obvious wrong way to build a default
105
- # button. The right way is a `handle_key` on the form itself, where a
106
- # 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?}.
107
113
  #
108
114
  # Deliberately *not* reserved: `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN`. They
109
115
  # move within a widget rather than mutate its value, and binding them
@@ -163,6 +169,12 @@ module Tuile
163
169
  @@instance
164
170
  end
165
171
 
172
+ # Whether {.instance} would answer rather than raise — for code that must
173
+ # work with no screen in the process at all, which a detached component
174
+ # tree legitimately is (`Component#locale` is the one caller in the gem).
175
+ # @return [Boolean]
176
+ def self.instance? = !@@instance.nil?
177
+
166
178
  # @return [Component, nil] tiled content (forwarded to {ScreenPane}).
167
179
  def content = @pane.content
168
180
 
@@ -212,7 +224,7 @@ module Tuile
212
224
  # Kept current across OS appearance flips, a frame behind: the flip
213
225
  # report carries light/dark only, so the screen re-probes and this
214
226
  # updates when the reply lands. A changed color then fires
215
- # {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
216
228
  # does — a background-derived tint *is* a theme-derived color.
217
229
  # @return [Color, nil]
218
230
  attr_reader :background_color
@@ -232,7 +244,7 @@ module Tuile
232
244
  end
233
245
 
234
246
  # Replaces the theme and restyles the whole UI: fires
235
- # {Component#on_theme_changed} across the attached tree and invalidates
247
+ # {Component#handle_theme_changed} across the attached tree and invalidates
236
248
  # every attached component. No-op when `new_theme` equals the current theme.
237
249
  # This is a *transient* override — the next OS appearance flip re-picks from
238
250
  # {#theme_def}; assign {#theme_def=} for durable theming.
@@ -245,9 +257,44 @@ module Tuile
245
257
  return if @theme == new_theme
246
258
 
247
259
  @theme = new_theme
248
- # `__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
249
261
  # subclass may narrow it further (`D_hook_visibility`).
250
- @pane&.on_tree { _1.__send__(:on_theme_changed) }
262
+ @pane&.walk_tree { _1.__send__(:handle_theme_changed) }
263
+ needs_full_repaint
264
+ end
265
+
266
+ # The formatting conventions this session renders and parses by — date
267
+ # formats, the calendar, month and weekday names, the decimal separator.
268
+ # Detected once at construction from {Locale.system}; {Locale::ISO} when
269
+ # the environment says nothing.
270
+ #
271
+ # Read it at paint or parse time and never cache it in an ivar, for the
272
+ # same reason {#theme} says so: {#locale=} can replace it mid-session.
273
+ # @return [Locale]
274
+ attr_reader :locale
275
+
276
+ # Replaces the locale: fires {Component#handle_locale_changed} across the
277
+ # attached tree and invalidates every attached component. No-op when
278
+ # `new_locale` equals the current one.
279
+ #
280
+ # screen.locale = Locale::ISO.with(date_formats: ["%d.%m.%Y"])
281
+ #
282
+ # Note this can invalidate what a user has half-typed: a field's buffer
283
+ # reparses under the new grammar, and one that no longer parses reads as
284
+ # bad input.
285
+ # @param new_locale [Locale]
286
+ # @return [void]
287
+ # @raise [TypeError] unless `new_locale` is a {Locale}.
288
+ def locale=(new_locale)
289
+ raise TypeError, "expected Locale, got #{new_locale.inspect}" unless new_locale.is_a?(Locale)
290
+
291
+ check_locked
292
+ return if @locale == new_locale
293
+
294
+ @locale = new_locale
295
+ # `__send__` for the same reason `theme=` uses it: the hook is protected
296
+ # (`D_hook_visibility`).
297
+ @pane&.walk_tree { _1.__send__(:handle_locale_changed) }
251
298
  needs_full_repaint
252
299
  end
253
300
 
@@ -281,7 +328,7 @@ module Tuile
281
328
  # @return [void]
282
329
  def check_locked
283
330
  raise Tuile::Error, "Screen is closed: no UI mutation is possible after Screen#close" if @closed
284
- 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)
285
332
 
286
333
  # `submit` is the wrong remedy with no loop running — nothing would drain
287
334
  # the queue, so the block silently never fires.
@@ -319,6 +366,15 @@ module Tuile
319
366
  # Sets the focused {Component}. Focused component receives keyboard events.
320
367
  # All focusable components live under {#pane}, so this is a single uniform
321
368
  # path (no separate popup-vs-content branches).
369
+ #
370
+ # A hidden target — or one under a hidden ancestor — raises, exactly as a
371
+ # detached one does: focusing it would park the hardware cursor inside
372
+ # whatever is painted over it and feed it every keystroke.
373
+ #
374
+ # Once the pointer and the active flags are settled, three notices fire in
375
+ # order: {Component#handle_blur} on what lost focus, {Component#handle_focus} on
376
+ # what took it, then {#on_focus_changed}. The outer two are edge-triggered
377
+ # and `handle_focus` is not — see there.
322
378
  # @param focused [Component, nil] the new component to be focused.
323
379
  def focused=(focused)
324
380
  unless focused.nil? || focused.is_a?(Component)
@@ -329,9 +385,10 @@ module Tuile
329
385
  previous = @focused
330
386
  if focused.nil?
331
387
  @focused = nil
332
- @pane.on_tree { _1.active = false }
388
+ @pane.walk_tree { _1.active = false }
333
389
  else
334
390
  raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
391
+ raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor" if hidden?(focused)
335
392
 
336
393
  @focused = focused
337
394
  active = Set[focused]
@@ -340,10 +397,9 @@ module Tuile
340
397
  active << cursor
341
398
  cursor = cursor.parent
342
399
  end
343
- @pane.on_tree { _1.active = active.include?(_1) }
344
- @focused.on_focus
400
+ @pane.walk_tree { _1.active = active.include?(_1) }
345
401
  end
346
- @on_focus_changed&.call unless @focused.equal?(previous)
402
+ fire_focus_hooks(previous, focused)
347
403
  end
348
404
 
349
405
  # Called after the focused component *changes* — including to and from
@@ -357,17 +413,17 @@ module Tuile
357
413
  #
358
414
  # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
359
415
  #
360
- # **Edge-triggered**, like {Component#on_attached}: re-assigning the
416
+ # **Edge-triggered**, like {Component#handle_attached}: re-assigning the
361
417
  # component that already has focus fires nothing, so a callback can be as
362
418
  # expensive as rebuilding a hint string without a `did it really change?`
363
419
  # guard of its own. That matters more than it looks — `ScreenPane#content=`
364
420
  # clears focus on every content swap, which on a level-triggered hook would
365
421
  # fire a nil→nil notification during assembly.
366
422
  #
367
- # 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
368
424
  # settled. Two things a callback must tolerate: {#focused} being `nil`, and
369
425
  # firing during {#close} — teardown clears focus, exactly as it fires
370
- # {Component#on_detached}. A raising callback propagates out of {#focused=}
426
+ # {Component#handle_detached}. A raising callback propagates out of {#focused=}
371
427
  # and leaves focus assigned; keep it trivial, as with the attach hooks.
372
428
  # @return [Proc, nil]
373
429
  attr_accessor :on_focus_changed
@@ -385,20 +441,25 @@ module Tuile
385
441
  end
386
442
 
387
443
  # Runs the event loop on the calling thread, taking over stdin (raw mode,
388
- # echo off): keys and mouse events are dispatched via {#handle_key} /
444
+ # echo off): keys and mouse events are dispatched via {#handle_key?} /
389
445
  # {#handle_mouse}, and the loop repaints once per drained tick. Returns
390
446
  # when `q` or ESC is pressed unhandled. Restores terminal state on exit.
391
447
  #
392
448
  # For the duration this thread owns the UI ({#state} is `:running`);
393
449
  # ownership reverts to the creating thread once it returns.
394
450
  #
395
- # @param capture_mouse [Boolean] when true (default), enables xterm mouse
396
- # tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed
397
- # {Component#handle_mouse}. When false, no tracking escape sequence is
398
- # written: the terminal keeps its native click handling, which is what
399
- # you want if the app benefits more from select-to-copy than from
400
- # click-to-focus. Components' `handle_mouse` is simply never invoked
401
- # 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.
402
463
  # @param bracketed_paste [Boolean] when true (default), enables DEC private
403
464
  # mode 2004 so pasted text arrives whole, as {Component#handle_paste},
404
465
  # instead of as one keystroke per character — which is the only way a
@@ -407,16 +468,20 @@ module Tuile
407
468
  # fires it once per pasted line. Turn it off only for a terminal that
408
469
  # mishandles the mode.
409
470
  # @raise [Tuile::Error] if the screen is already {#close}d.
471
+ # @raise [ArgumentError] on an unknown `capture_mouse:` level.
410
472
  # @return [void]
411
473
  def run_event_loop(capture_mouse: true, bracketed_paste: true)
412
474
  raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed
413
475
 
476
+ level = Mouse.level(capture_mouse)
477
+
414
478
  # The guard above stays outside the begin: teardown for a setup that never
415
479
  # happened restores echo on a non-TTY stdin, and the ENOTTY masks the
416
480
  # real error.
417
481
  begin
418
482
  $stdin.echo = false
419
- print MouseEvent.start_tracking if capture_mouse
483
+ @mouse_router.level = level
484
+ print Mouse.start_tracking(level) if level
420
485
  print Keys::BRACKETED_PASTE_ON if bracketed_paste
421
486
  # Follow OS light/dark flips live: terminals supporting mode 2031
422
487
  # push color-scheme reports that the key thread turns into
@@ -428,7 +493,9 @@ module Tuile
428
493
  ensure
429
494
  print TerminalBackground::NOTIFY_OFF
430
495
  print Keys::BRACKETED_PASTE_OFF if bracketed_paste
431
- 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
432
499
  print TTY::Cursor.show
433
500
  $stdin.echo = true
434
501
  end
@@ -455,9 +522,9 @@ module Tuile
455
522
  #
456
523
  # - **Printable keys** — they'd hijack typing into a
457
524
  # {Component::TextField}. A scope-wide one-key binding belongs on the
458
- # scope root's own `handle_key`, where a focused field consumes it first
459
- # (see {ScreenPane#handle_key}).
460
- # - **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
461
528
  # navigation before the registry is consulted, so a binding would never
462
529
  # fire.
463
530
  # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
@@ -480,7 +547,7 @@ module Tuile
480
547
  if Keys.printable?(key)
481
548
  raise ArgumentError,
482
549
  "global shortcut key must be unprintable; got #{key.inspect}. " \
483
- "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 " \
484
551
  "(your content layout, or the popup) — a focused text field then " \
485
552
  "consumes the key first, so typing isn't hijacked."
486
553
  end
@@ -492,7 +559,7 @@ module Tuile
492
559
  raise ArgumentError,
493
560
  "#{key.inspect} is reserved: every editable widget needs it, and this registry " \
494
561
  "sits above the component tree with nothing to suppress it. For a default " \
495
- "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 " \
496
563
  "TextArea/TextField gets first refusal there."
497
564
  end
498
565
  @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups)
@@ -531,7 +598,7 @@ module Tuile
531
598
  # @api private
532
599
  # @return [void]
533
600
  def needs_full_repaint
534
- @pane&.on_tree { invalidate _1 }
601
+ @pane&.walk_tree { invalidate _1 }
535
602
  end
536
603
 
537
604
  # Internal — use {Component::Overlay#open?} instead.
@@ -552,7 +619,7 @@ module Tuile
552
619
 
553
620
  # Tears the screen down and vacates the singleton slot, moving {#state} to
554
621
  # the terminal `:closed`. Unmounts the tree first, so every component gets
555
- # its {Component#on_detached}. Idempotent.
622
+ # its {Component#handle_detached}. Idempotent.
556
623
  # @raise [Tuile::Error] if an event loop is still running — stop it with
557
624
  # `event_queue.stop` and let {#run_event_loop} return first, since closing
558
625
  # under a live loop drops the pane it is still painting — or if the caller
@@ -567,11 +634,12 @@ module Tuile
567
634
  begin
568
635
  @pane.detach_all
569
636
  ensure
570
- # 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
571
638
  # framework guards — but teardown still has to finish, or one such bug
572
639
  # leaves a half-closed screen behind and every later example fails with it.
573
640
  clear
574
641
  @pane = nil
642
+ @mouse_router = nil
575
643
  @closed = true
576
644
  @@instance = nil # rubocop:disable Style/ClassVars
577
645
  end
@@ -627,6 +695,9 @@ module Tuile
627
695
  # @return [void]
628
696
  def repaint
629
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
630
701
  # This simple TUI framework doesn't support window clipping since tiled
631
702
  # windows are not expected to overlap. If there rarely is a popup, we
632
703
  # just repaint all windows in correct order — sure they will paint over
@@ -636,12 +707,31 @@ module Tuile
636
707
 
637
708
  did_paint = false
638
709
  until @invalidated.empty?
639
- # Defensive filter: a component can become detached between enqueue
640
- # and drain (popup close, sibling removed mid-event-handling, focus
641
- # repair). Detached components have no place on the screen and must
642
- # never paint, even though Component#invalidate already gates them
643
- # out — this catches the case where attachment changed since.
644
- @invalidated.delete_if { |c| !c.attached? }
710
+ # Defensive filter, two ways a queued component has no place on the
711
+ # screen by the time we drain it. It became *detached* since (popup
712
+ # close, sibling removed mid-event-handling, focus repair) — which
713
+ # Component#invalidate gates at enqueue, so this catches only a change
714
+ # since. Or it sits under an *empty rect*, its own or any ancestor's,
715
+ # which is how a subtree is collapsed.
716
+ #
717
+ # The self half is belt-and-braces — every #repaint opens with the same
718
+ # `return if rect.empty?`, and that stays: it is each component's own
719
+ # contract, local to the method an app subclass calls `super` on. The
720
+ # ancestor half is the one that does work no component can do for
721
+ # itself, so a container that forgets to zero its children leaves them
722
+ # inert rather than painting them at stale coordinates
723
+ # (`D_empty_ancestor`).
724
+ #
725
+ # {Component#visible?} rides the same walk, one more term in the same
726
+ # AND — a different question (geometry says *where*, the flag says
727
+ # *whether*) with the same answer for this frame (`D_visibility`).
728
+ @invalidated.delete_if do |c|
729
+ next true unless c.attached?
730
+
731
+ blocked = c
732
+ blocked = blocked.parent while blocked && !blocked.rect.empty? && blocked.visible?
733
+ !blocked.nil?
734
+ end
645
735
  break if @invalidated.empty?
646
736
 
647
737
  did_paint = true
@@ -651,14 +741,14 @@ module Tuile
651
741
  # than a depth sort. The pane's pre-order traversal already orders the
652
742
  # tiled layer (the content subtree) parent-before-child; the popups are
653
743
  # the top layer and must paint last, so we collect the tiled layer first
654
- # 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.
655
745
  popup_members = Set.new
656
- popups.each { |p| p.on_tree { popup_members << _1 } }
746
+ popups.each { |p| p.walk_tree { popup_members << _1 } }
657
747
 
658
748
  # Tiled layer: invalidated non-popup components, in tree order.
659
749
  repaint = []
660
750
  tiled_invalidated = false
661
- @pane.on_tree do |c|
751
+ @pane.walk_tree do |c|
662
752
  next if popup_members.include?(c)
663
753
  next unless @invalidated.include?(c)
664
754
 
@@ -679,8 +769,8 @@ module Tuile
679
769
  below_repainted = tiled_invalidated
680
770
  popups.each do |p|
681
771
  layer_invalidated = false
682
- p.on_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
683
- 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) }
684
774
  below_repainted ||= layer_invalidated
685
775
  end
686
776
 
@@ -704,8 +794,62 @@ module Tuile
704
794
  # @return [Point, nil]
705
795
  def cursor_position = @focused&.cursor_position
706
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
+
707
819
  private
708
820
 
821
+ # Whether `component` is out of the user's reach because it or an ancestor
822
+ # is {Component#visible? hidden}.
823
+ #
824
+ # Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
825
+ # declined a `Component#paintable?`: it reads as a component-level concept
826
+ # and is really this class's question. A *walk* needs no such predicate —
827
+ # it prunes at the hidden subtree's root ({Component#walk_shown_tree}).
828
+ # @param component [Component]
829
+ # @return [Boolean]
830
+ def hidden?(component)
831
+ cursor = component
832
+ cursor = cursor.parent while cursor&.visible?
833
+ !cursor.nil?
834
+ end
835
+
836
+ # The tail of {#focused=}: blur, then focus, then the app notice.
837
+ #
838
+ # A hook may reassign {#focused}; that nested call has already run this whole
839
+ # sequence for the target it chose, so this one stops rather than announcing
840
+ # a focus that no longer holds.
841
+ # @param previous [Component, nil] what held focus before the assignment.
842
+ # @param focused [Component, nil] what the assignment asked for.
843
+ # @return [void]
844
+ def fire_focus_hooks(previous, focused)
845
+ unless focused.equal?(previous)
846
+ previous&.__send__(:handle_blur)
847
+ return unless @focused.equal?(focused)
848
+ end
849
+ @focused&.__send__(:handle_focus)
850
+ @on_focus_changed&.call unless @focused.equal?(previous)
851
+ end
852
+
709
853
  # The startup background probe, seeding {#theme} and
710
854
  # {#background_color}. Inconclusive detection means `:dark` with no
711
855
  # color. Runs in the constructor — the OSC 11 reply arrives on stdin,
@@ -724,6 +868,14 @@ module Tuile
724
868
  # @return [Symbol]
725
869
  def detect_color_depth = ColorDepth.detect
726
870
 
871
+ # The startup locale probe, seeding {#locale}. Shells out (see
872
+ # {Locale.system}), so unlike {#detect_background} it touches no terminal
873
+ # stream and has no timing constraint. {FakeScreen} overrides it to pin
874
+ # {Locale::ISO}, keeping specs off whatever `LC_TIME` the test runner
875
+ # happens to carry — and out of a subprocess per example.
876
+ # @return [Locale]
877
+ def detect_locale = Locale.system
878
+
727
879
  # An OS appearance flip arrived (mode-2031 report): remember the
728
880
  # scheme, apply the matching member of {#theme_def}, and re-probe for
729
881
  # the new background RGB, which the report does not carry.
@@ -734,7 +886,7 @@ module Tuile
734
886
  # thread as an {EventQueue::BackgroundColorEvent}.
735
887
  # @param scheme [Symbol] `:dark` or `:light`.
736
888
  # @return [void]
737
- def on_color_scheme(scheme)
889
+ def handle_color_scheme(scheme)
738
890
  @color_scheme = scheme
739
891
  self.theme = @theme_def.for(@color_scheme)
740
892
  print TerminalBackground::QUERY
@@ -747,11 +899,11 @@ module Tuile
747
899
  # would otherwise lose the color it gave us at startup, permanently.
748
900
  # @param color [Color]
749
901
  # @return [void]
750
- def on_background_color(color)
902
+ def handle_background_color(color)
751
903
  return if @background_color == color
752
904
 
753
905
  @background_color = color
754
- @pane&.on_tree { _1.__send__(:on_theme_changed) }
906
+ @pane&.walk_tree { _1.__send__(:handle_theme_changed) }
755
907
  needs_full_repaint
756
908
  end
757
909
 
@@ -768,7 +920,7 @@ module Tuile
768
920
  return false if scope.nil?
769
921
 
770
922
  stops = []
771
- scope.on_tree { |c| stops << c if c.tab_stop? }
923
+ scope.walk_shown_tree { |c| stops << c if c.tab_stop? }
772
924
  return false if stops.empty?
773
925
 
774
926
  idx = @focused.nil? ? nil : stops.index(@focused)
@@ -826,11 +978,14 @@ module Tuile
826
978
  # default `over_popups: false` fires only when no modal popup is open
827
979
  # (otherwise the modal popup receives the key normally). A non-modal
828
980
  # overlay doesn't suppress global shortcuts.
829
- # 3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
981
+ # 3. {ScreenPane#handle_key?} — delivery to {#focused}, bubbling up the
830
982
  # focus chain to the scope root.
831
983
  # @param key [String]
832
984
  # @return [Boolean] true if the key was handled by some window.
833
- 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
834
989
  case key
835
990
  when Keys::TAB
836
991
  focus_next
@@ -844,25 +999,21 @@ module Tuile
844
999
  shortcut.block.call
845
1000
  true
846
1001
  else
847
- @pane.handle_key(key)
1002
+ @pane.handle_key?(key)
848
1003
  end
849
1004
  end
850
1005
  end
851
1006
 
852
- # Finds target window and calls {Component::Window#handle_mouse}.
853
- # @param event [MouseEvent]
854
- # @return [void]
855
- def handle_mouse(event) = @pane.handle_mouse(event)
856
-
857
- # Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
1007
+ # Delivers pasted text to {#focused} ({ScreenPane#handle_paste}).
858
1008
  #
859
- # Deliberately *not* the key ladder: a paste is not a keystroke, so it
860
- # skips Tab traversal and the global-shortcut registry entirely and goes
861
- # straight to delivery. Unhandled text is dropped — there is no fallback
862
- # that replays it as keys, which would put back the very ambiguity mode
863
- # 2004 exists to remove.
1009
+ # Deliberately *not* the key ladder: a paste is not a keystroke, so it skips
1010
+ # Tab traversal and the global-shortcut registry entirely, goes straight to
1011
+ # delivery, and does not bubble to ancestors the way a key does. Unhandled
1012
+ # text is dropped — there is no fallback that replays it as keys, which
1013
+ # would put back the very ambiguity mode 2004 exists to remove — and with no
1014
+ # alternative delivery, no verdict to carry either.
864
1015
  # @param text [String]
865
- # @return [Boolean] true if some component consumed it.
1016
+ # @return [void]
866
1017
  def handle_paste(text) = @pane.handle_paste(text)
867
1018
 
868
1019
  # @return [void]
@@ -871,19 +1022,19 @@ module Tuile
871
1022
  case event
872
1023
  when EventQueue::KeyEvent
873
1024
  key = event.key
874
- handled = handle_key(key)
1025
+ handled = handle_key?(key)
875
1026
  @event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
876
1027
  when EventQueue::PasteEvent
877
1028
  handle_paste(event.text)
878
- when MouseEvent
1029
+ when Mouse::Event
879
1030
  handle_mouse(event)
880
1031
  when EventQueue::TTYSizeEvent
881
1032
  @size = event.size
882
1033
  layout
883
1034
  when EventQueue::ColorSchemeEvent
884
- on_color_scheme(event.scheme)
1035
+ handle_color_scheme(event.scheme)
885
1036
  when EventQueue::BackgroundColorEvent
886
- on_background_color(event.color)
1037
+ handle_background_color(event.color)
887
1038
  when EventQueue::EmptyQueueEvent
888
1039
  repaint
889
1040
  when Proc