tuile 0.13.0 → 0.15.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
data/lib/tuile/screen.rb CHANGED
@@ -16,13 +16,13 @@ module Tuile
16
16
  # out its own children), the modal/overlay {#popups} stack (opened via
17
17
  # {Component::Popup#open}, drawn on top of the content). Popups are *not*
18
18
  # sized from their content — each carries its own top-down
19
- # {Component::Popup#size} — and they deliberately overdraw the content
19
+ # {Component::Popup#declared_size} — and they deliberately overdraw the content
20
20
  # without clipping.
21
21
  #
22
22
  # Tuile draws no chrome of its own: there is no status bar and no reserved
23
23
  # row, so {#content} gets the whole terminal. An app that wants a status line
24
24
  # builds one into its own layout and drives it from {#on_focus_changed=}
25
- # (`D-status-bar`).
25
+ # (`D_status_bar`).
26
26
  #
27
27
  # ## Repaint model
28
28
  #
@@ -71,12 +71,20 @@ module Tuile
71
71
  # during :idle, at both ends of the screen's life. See {#check_locked}.
72
72
  @ui_thread = Thread.current
73
73
  @closed = false
74
- @color_scheme = detect_scheme
74
+ background = detect_background
75
+ @color_scheme = background.scheme
76
+ @background_color = background.color
77
+ @color_depth = detect_color_depth
78
+ @locale = detect_locale
75
79
  @theme_def = ThemeDef.default
76
80
  @theme = @theme_def.for(@color_scheme)
77
81
  # Structural root of the component tree: holds tiled content and the
78
- # 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.
79
86
  @pane = ScreenPane.new
87
+ @pane.rect = Rect.new(0, 0, @size.width, @size.height)
80
88
  @on_error = ->(e) { raise e }
81
89
  # App-level keyboard shortcuts dispatched by {#handle_key} before keys
82
90
  # reach the pane. See {#register_global_shortcut}.
@@ -84,7 +92,7 @@ module Tuile
84
92
  # The back buffer components paint into. {#repaint} flushes its diff to
85
93
  # the terminal, so only changed cells are emitted (flicker-free on any
86
94
  # terminal). Sized to the current viewport; {#layout} resizes it.
87
- @buffer = Buffer.new(@size)
95
+ @buffer = Buffer.new(@size, color_depth: @color_depth)
88
96
  end
89
97
 
90
98
  # Entry in the global shortcut registry: the block to run, and whether it
@@ -118,6 +126,17 @@ module Tuile
118
126
  # @return [Symbol] `:light` or `:dark`
119
127
  attr_reader :color_scheme
120
128
 
129
+ # How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
130
+ # at construction. {Buffer#flush} degrades every color it emits to this,
131
+ # so an app may compute an RGB tint — say from {#background_color} — and
132
+ # paint with it, whatever the terminal turns out to understand.
133
+ #
134
+ # Deliberately read-only: detection runs once and the answer can't change
135
+ # mid-session. Override a terminal that reports itself wrong through
136
+ # {ColorDepth::OVERRIDE_ENV} instead.
137
+ # @return [Symbol]
138
+ attr_reader :color_depth
139
+
121
140
  # @return [Buffer] the back buffer components paint into
122
141
  # ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
123
142
  attr_reader :buffer
@@ -149,6 +168,12 @@ module Tuile
149
168
  @@instance
150
169
  end
151
170
 
171
+ # Whether {.instance} would answer rather than raise — for code that must
172
+ # work with no screen in the process at all, which a detached component
173
+ # tree legitimately is (`Component#locale` is the one caller in the gem).
174
+ # @return [Boolean]
175
+ def self.instance? = !@@instance.nil?
176
+
152
177
  # @return [Component, nil] tiled content (forwarded to {ScreenPane}).
153
178
  def content = @pane.content
154
179
 
@@ -183,6 +208,26 @@ module Tuile
183
208
  # @return [ThemeDef]
184
209
  attr_reader :theme_def
185
210
 
211
+ # The terminal's own background, as it reported it — for deriving a
212
+ # color *from* the background rather than picking one against it. A pane
213
+ # tinted a few percent off it sits right on any terminal, where a fixed
214
+ # near-neutral only sits right near the one it was tuned on:
215
+ #
216
+ # bg = screen.background_color
217
+ # sidebar.bg_color =
218
+ # bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
219
+ #
220
+ # **Nil is the normal case, not an edge** — a terminal answering only
221
+ # `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
222
+ #
223
+ # Kept current across OS appearance flips, a frame behind: the flip
224
+ # report carries light/dark only, so the screen re-probes and this
225
+ # updates when the reply lands. A changed color then fires
226
+ # {Component#on_theme_changed} across the tree exactly as a theme swap
227
+ # does — a background-derived tint *is* a theme-derived color.
228
+ # @return [Color, nil]
229
+ attr_reader :background_color
230
+
186
231
  # Replaces the theme definition and immediately applies the member
187
232
  # matching the current color scheme (via {#theme=}, so the whole UI
188
233
  # restyles — or nothing repaints if that member equals the current
@@ -211,12 +256,50 @@ module Tuile
211
256
  return if @theme == new_theme
212
257
 
213
258
  @theme = new_theme
214
- @pane&.on_tree(&:on_theme_changed)
259
+ # `__send__`, not `&:on_theme_changed`: the hook is protected, and an app
260
+ # subclass may narrow it further (`D_hook_visibility`).
261
+ @pane&.on_tree { _1.__send__(:on_theme_changed) }
262
+ needs_full_repaint
263
+ end
264
+
265
+ # The formatting conventions this session renders and parses by — date
266
+ # formats, the calendar, month and weekday names, the decimal separator.
267
+ # Detected once at construction from {Locale.system}; {Locale::ISO} when
268
+ # the environment says nothing.
269
+ #
270
+ # Read it at paint or parse time and never cache it in an ivar, for the
271
+ # same reason {#theme} says so: {#locale=} can replace it mid-session.
272
+ # @return [Locale]
273
+ attr_reader :locale
274
+
275
+ # Replaces the locale: fires {Component#on_locale_changed} across the
276
+ # attached tree and invalidates every attached component. No-op when
277
+ # `new_locale` equals the current one.
278
+ #
279
+ # screen.locale = Locale::ISO.with(date_formats: ["%d.%m.%Y"])
280
+ #
281
+ # Note this can invalidate what a user has half-typed: a field's buffer
282
+ # reparses under the new grammar, and one that no longer parses reads as
283
+ # bad input.
284
+ # @param new_locale [Locale]
285
+ # @return [void]
286
+ # @raise [TypeError] unless `new_locale` is a {Locale}.
287
+ def locale=(new_locale)
288
+ raise TypeError, "expected Locale, got #{new_locale.inspect}" unless new_locale.is_a?(Locale)
289
+
290
+ check_locked
291
+ return if @locale == new_locale
292
+
293
+ @locale = new_locale
294
+ # `__send__` for the same reason `theme=` uses it: the hook is protected
295
+ # (`D_hook_visibility`).
296
+ @pane&.on_tree { _1.__send__(:on_locale_changed) }
215
297
  needs_full_repaint
216
298
  end
217
299
 
218
- # @return [Array<Component>] currently active popup components (forwarded
219
- # to {ScreenPane}). The array must not be modified!
300
+ # @return [Array<Component::Overlay>] the open overlay stack — both
301
+ # {Component::Popup} modals and bare {Component::Overlay}s — in stacking
302
+ # order (forwarded to {ScreenPane}). The array must not be modified!
220
303
  def popups = @pane.popups
221
304
 
222
305
  # @return [EventQueue] the event queue.
@@ -282,6 +365,15 @@ module Tuile
282
365
  # Sets the focused {Component}. Focused component receives keyboard events.
283
366
  # All focusable components live under {#pane}, so this is a single uniform
284
367
  # path (no separate popup-vs-content branches).
368
+ #
369
+ # A hidden target — or one under a hidden ancestor — raises, exactly as a
370
+ # detached one does: focusing it would park the hardware cursor inside
371
+ # whatever is painted over it and feed it every keystroke.
372
+ #
373
+ # 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
+ # what took it, then {#on_focus_changed}. The outer two are edge-triggered
376
+ # and `on_focus` is not — see there.
285
377
  # @param focused [Component, nil] the new component to be focused.
286
378
  def focused=(focused)
287
379
  unless focused.nil? || focused.is_a?(Component)
@@ -295,6 +387,7 @@ module Tuile
295
387
  @pane.on_tree { _1.active = false }
296
388
  else
297
389
  raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
390
+ raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor" if hidden?(focused)
298
391
 
299
392
  @focused = focused
300
393
  active = Set[focused]
@@ -304,9 +397,8 @@ module Tuile
304
397
  cursor = cursor.parent
305
398
  end
306
399
  @pane.on_tree { _1.active = active.include?(_1) }
307
- @focused.on_focus
308
400
  end
309
- @on_focus_changed&.call unless @focused.equal?(previous)
401
+ fire_focus_hooks(previous, focused)
310
402
  end
311
403
 
312
404
  # Called after the focused component *changes* — including to and from
@@ -316,7 +408,7 @@ module Tuile
316
408
  #
317
409
  # This is the hook an app drives its own status line from. Tuile owns no
318
410
  # status bar and reserves no row: build a {Component::Label} into your own
319
- # layout and fill it here (`D-status-bar`).
411
+ # layout and fill it here (`D_status_bar`).
320
412
  #
321
413
  # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
322
414
  #
@@ -335,10 +427,10 @@ module Tuile
335
427
  # @return [Proc, nil]
336
428
  attr_accessor :on_focus_changed
337
429
 
338
- # Internal — use {Component::Popup#open} instead. Adds the popup to
339
- # {#pane}, centers and focuses it.
430
+ # Internal — use {Component::Overlay#open} instead. Adds the overlay to
431
+ # {#pane}; a {Component::Popup} is additionally centered and focused.
340
432
  # @api private
341
- # @param window [Component::Popup]
433
+ # @param window [Component::Overlay] any overlay, modal or not.
342
434
  # @return [void]
343
435
  def add_popup(window)
344
436
  check_locked
@@ -469,12 +561,12 @@ module Tuile
469
561
  @global_shortcuts.delete(key)
470
562
  end
471
563
 
472
- # Internal — use {Component::Popup#close} instead. Removes the popup
564
+ # Internal — use {Component::Overlay#close} instead. Removes the overlay
473
565
  # from {#pane}, repairs focus, and repaints the scene.
474
566
  #
475
- # Does nothing if the window is not open on this screen.
567
+ # Does nothing if the overlay is not open on this screen.
476
568
  # @api private
477
- # @param window [Component::Popup]
569
+ # @param window [Component::Overlay] any overlay, modal or not.
478
570
  # @return [void]
479
571
  def remove_popup(window)
480
572
  check_locked
@@ -497,10 +589,10 @@ module Tuile
497
589
  @pane&.on_tree { invalidate _1 }
498
590
  end
499
591
 
500
- # Internal — use {Component::Popup#open?} instead.
592
+ # Internal — use {Component::Overlay#open?} instead.
501
593
  # @api private
502
- # @param window [Component::Popup]
503
- # @return [Boolean] true if this popup is currently mounted.
594
+ # @param window [Component::Overlay] any overlay, modal or not.
595
+ # @return [Boolean] true if this overlay is currently mounted.
504
596
  def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
505
597
  check_locked
506
598
  @pane.has_popup?(window)
@@ -546,15 +638,20 @@ module Tuile
546
638
  end
547
639
 
548
640
  # Writes terminal-housekeeping escapes straight to stdout: {#clear},
549
- # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
550
- # on teardown. Component painting does *not* go through here anymore — it
551
- # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
552
- # overrides this (and {#emit}) to capture into `@prints` instead of the
553
- # test runner's stdout.
641
+ # mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
642
+ # background re-probe, cursor-show on teardown. Component painting does
643
+ # *not* go through here anymore — it writes into {#buffer}, which
644
+ # {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
645
+ # {#emit}) to capture into `@prints` instead of the test runner's stdout.
646
+ #
647
+ # Flushed, like {#emit}: none of these escapes ends in a newline, and a
648
+ # buffered stdout would hold a *query* — one whose reply the app is
649
+ # waiting on — until the next frame went out.
554
650
  # @param args [String] stuff to print.
555
651
  # @return [void]
556
652
  def print(*args)
557
653
  Kernel.print(*args)
654
+ $stdout.flush
558
655
  end
559
656
 
560
657
  # Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
@@ -594,12 +691,31 @@ module Tuile
594
691
 
595
692
  did_paint = false
596
693
  until @invalidated.empty?
597
- # Defensive filter: a component can become detached between enqueue
598
- # and drain (popup close, sibling removed mid-event-handling, focus
599
- # repair). Detached components have no place on the screen and must
600
- # never paint, even though Component#invalidate already gates them
601
- # out — this catches the case where attachment changed since.
602
- @invalidated.delete_if { |c| !c.attached? }
694
+ # Defensive filter, two ways a queued component has no place on the
695
+ # screen by the time we drain it. It became *detached* since (popup
696
+ # close, sibling removed mid-event-handling, focus repair) — which
697
+ # Component#invalidate gates at enqueue, so this catches only a change
698
+ # since. Or it sits under an *empty rect*, its own or any ancestor's,
699
+ # which is how a subtree is collapsed.
700
+ #
701
+ # The self half is belt-and-braces — every #repaint opens with the same
702
+ # `return if rect.empty?`, and that stays: it is each component's own
703
+ # contract, local to the method an app subclass calls `super` on. The
704
+ # ancestor half is the one that does work no component can do for
705
+ # itself, so a container that forgets to zero its children leaves them
706
+ # inert rather than painting them at stale coordinates
707
+ # (`D_empty_ancestor`).
708
+ #
709
+ # {Component#visible?} rides the same walk, one more term in the same
710
+ # AND — a different question (geometry says *where*, the flag says
711
+ # *whether*) with the same answer for this frame (`D_visibility`).
712
+ @invalidated.delete_if do |c|
713
+ next true unless c.attached?
714
+
715
+ blocked = c
716
+ blocked = blocked.parent while blocked && !blocked.rect.empty? && blocked.visible?
717
+ !blocked.nil?
718
+ end
603
719
  break if @invalidated.empty?
604
720
 
605
721
  did_paint = true
@@ -624,12 +740,22 @@ module Tuile
624
740
  tiled_invalidated = true
625
741
  end
626
742
 
627
- # Popups on top: the whole stack when a tiled repaint may have clobbered
628
- # cells they share in the buffer, else just the invalidated popup
629
- # components. Overdraw into the buffer is free (only net-visible cell
630
- # changes reach the terminal), so reasserting the stack is cheap.
743
+ # Popups on top: a layer repaints whole whenever anything *beneath* it —
744
+ # the tiled tree, or a lower popup — repaints, else just its invalidated
745
+ # members. Layer-by-layer rather than one tiled_invalidated bool because
746
+ # the drain loops: a lower popup's repaint cascade re-invalidates
747
+ # children into the *next* iteration (its gap-clearing Layout wipes and
748
+ # re-queues a button, say), and that iteration has no tiled repaint —
749
+ # only the full re-assert of every layer above keeps the stacking order
750
+ # true across iterations (screen_spec pins it with two overlapping
751
+ # popups). Overdraw into the buffer is free (only net-visible cell
752
+ # changes reach the terminal), so reasserting layers is cheap.
753
+ below_repainted = tiled_invalidated
631
754
  popups.each do |p|
632
- p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) }
755
+ 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) }
758
+ below_repainted ||= layer_invalidated
633
759
  end
634
760
 
635
761
  @repainting = repaint.to_set
@@ -654,25 +780,93 @@ module Tuile
654
780
 
655
781
  private
656
782
 
657
- # Startup color scheme: `:light` when {TerminalBackground.detect}
658
- # reports a light terminal background, `:dark` otherwise (including
659
- # when detection is inconclusive). Runs in the constructor — the
660
- # OSC 11 reply arrives on stdin, which is only safe to read before
661
- # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
662
- # to pin `:dark`, keeping specs deterministic and off the test
663
- # runner's TTY.
664
- # @return [Symbol] `:dark` or `:light`.
665
- def detect_scheme
666
- TerminalBackground.detect == :light ? :light : :dark
783
+ # Whether `component` is out of the user's reach because it or an ancestor
784
+ # is {Component#visible? hidden}.
785
+ #
786
+ # Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
787
+ # declined a `Component#paintable?`: it reads as a component-level concept
788
+ # 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}).
790
+ # @param component [Component]
791
+ # @return [Boolean]
792
+ def hidden?(component)
793
+ cursor = component
794
+ cursor = cursor.parent while cursor&.visible?
795
+ !cursor.nil?
796
+ end
797
+
798
+ # The tail of {#focused=}: blur, then focus, then the app notice.
799
+ #
800
+ # A hook may reassign {#focused}; that nested call has already run this whole
801
+ # sequence for the target it chose, so this one stops rather than announcing
802
+ # a focus that no longer holds.
803
+ # @param previous [Component, nil] what held focus before the assignment.
804
+ # @param focused [Component, nil] what the assignment asked for.
805
+ # @return [void]
806
+ def fire_focus_hooks(previous, focused)
807
+ unless focused.equal?(previous)
808
+ previous&.__send__(:on_blur)
809
+ return unless @focused.equal?(focused)
810
+ end
811
+ @focused&.__send__(:on_focus)
812
+ @on_focus_changed&.call unless @focused.equal?(previous)
813
+ end
814
+
815
+ # The startup background probe, seeding {#theme} and
816
+ # {#background_color}. Inconclusive detection means `:dark` with no
817
+ # color. Runs in the constructor — the OSC 11 reply arrives on stdin,
818
+ # which is only safe to read before {EventQueue#start_key_thread} owns
819
+ # it. {FakeScreen} overrides this to pin the result, keeping specs
820
+ # deterministic and off the test runner's TTY.
821
+ # @return [TerminalBackground::Result]
822
+ def detect_background
823
+ TerminalBackground.detect || TerminalBackground::Result.new(scheme: :dark, color: nil)
667
824
  end
668
825
 
826
+ # The startup color-depth probe, seeding {#color_depth}. Reads the
827
+ # environment only, so unlike {#detect_background} it has no timing
828
+ # constraint. {FakeScreen} overrides it to pin the result, keeping specs
829
+ # off whatever `COLORTERM` the test runner happens to carry.
830
+ # @return [Symbol]
831
+ def detect_color_depth = ColorDepth.detect
832
+
833
+ # The startup locale probe, seeding {#locale}. Shells out (see
834
+ # {Locale.system}), so unlike {#detect_background} it touches no terminal
835
+ # stream and has no timing constraint. {FakeScreen} overrides it to pin
836
+ # {Locale::ISO}, keeping specs off whatever `LC_TIME` the test runner
837
+ # happens to carry — and out of a subprocess per example.
838
+ # @return [Locale]
839
+ def detect_locale = Locale.system
840
+
669
841
  # An OS appearance flip arrived (mode-2031 report): remember the
670
- # scheme and apply the matching member of {#theme_def}.
842
+ # scheme, apply the matching member of {#theme_def}, and re-probe for
843
+ # the new background RGB, which the report does not carry.
844
+ #
845
+ # The query goes out from *this* thread — the event-loop thread, which
846
+ # also owns {#emit} — so its bytes can never land inside a frame's
847
+ # synchronized-output batch. The reply comes back through the key
848
+ # thread as an {EventQueue::BackgroundColorEvent}.
671
849
  # @param scheme [Symbol] `:dark` or `:light`.
672
850
  # @return [void]
673
851
  def on_color_scheme(scheme)
674
852
  @color_scheme = scheme
675
853
  self.theme = @theme_def.for(@color_scheme)
854
+ print TerminalBackground::QUERY
855
+ end
856
+
857
+ # The re-probe answered: adopt the color and restyle, since an app's
858
+ # background-derived tints are now a scheme behind. Deliberately keeps
859
+ # the previous color until the reply lands rather than blanking it on
860
+ # the flip — a terminal that reports mode-2031 flips but not OSC 11
861
+ # would otherwise lose the color it gave us at startup, permanently.
862
+ # @param color [Color]
863
+ # @return [void]
864
+ def on_background_color(color)
865
+ return if @background_color == color
866
+
867
+ @background_color = color
868
+ @pane&.on_tree { _1.__send__(:on_theme_changed) }
869
+ needs_full_repaint
676
870
  end
677
871
 
678
872
  # Walks the current modal scope in pre-order, collects tab stops, and
@@ -688,7 +882,7 @@ module Tuile
688
882
  return false if scope.nil?
689
883
 
690
884
  stops = []
691
- scope.on_tree { |c| stops << c if c.tab_stop? }
885
+ scope.on_shown_tree { |c| stops << c if c.tab_stop? }
692
886
  return false if stops.empty?
693
887
 
694
888
  idx = @focused.nil? ? nil : stops.index(@focused)
@@ -774,15 +968,15 @@ module Tuile
774
968
  # @return [void]
775
969
  def handle_mouse(event) = @pane.handle_mouse(event)
776
970
 
777
- # Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
971
+ # Delivers pasted text to {#focused} ({ScreenPane#handle_paste}).
778
972
  #
779
- # Deliberately *not* the key ladder: a paste is not a keystroke, so it
780
- # skips Tab traversal and the global-shortcut registry entirely and goes
781
- # straight to delivery. Unhandled text is dropped — there is no fallback
782
- # that replays it as keys, which would put back the very ambiguity mode
783
- # 2004 exists to remove.
973
+ # Deliberately *not* the key ladder: a paste is not a keystroke, so it skips
974
+ # Tab traversal and the global-shortcut registry entirely, goes straight to
975
+ # delivery, and does not bubble to ancestors the way a key does. Unhandled
976
+ # 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.
784
978
  # @param text [String]
785
- # @return [Boolean] true if some component consumed it.
979
+ # @return [Boolean] true if the focused component consumed it.
786
980
  def handle_paste(text) = @pane.handle_paste(text)
787
981
 
788
982
  # @return [void]
@@ -802,6 +996,8 @@ module Tuile
802
996
  layout
803
997
  when EventQueue::ColorSchemeEvent
804
998
  on_color_scheme(event.scheme)
999
+ when EventQueue::BackgroundColorEvent
1000
+ on_background_color(event.color)
805
1001
  when EventQueue::EmptyQueueEvent
806
1002
  repaint
807
1003
  when Proc
@@ -13,7 +13,7 @@ module Tuile
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
15
15
  # builds one into its own layout and drives it from
16
- # {Screen#on_focus_changed=} (`D-status-bar`).
16
+ # {Screen#on_focus_changed=} (`D_status_bar`).
17
17
  #
18
18
  # The pane is not a {Component::Layout}: popups deliberately overlap content
19
19
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
@@ -31,9 +31,10 @@ module Tuile
31
31
 
32
32
  # @return [Component, nil] the tiled content component.
33
33
  attr_reader :content
34
- # @return [Array<Component>] overlay popups in stacking order; last is
35
- # topmost. Holds both modal popups and non-modal overlays
36
- # ({Component::Popup#modal?}). The array must not be mutated by callers.
34
+ # @return [Array<Component::Overlay>] the open overlays in stacking order;
35
+ # last is topmost. Holds both {Component::Popup} modals and bare
36
+ # {Component::Overlay}s ({Component::Overlay#modal?}). The array must not
37
+ # be mutated by callers.
37
38
  attr_reader :popups
38
39
 
39
40
  def focusable? = false
@@ -54,21 +55,21 @@ module Tuile
54
55
  layout
55
56
  end
56
57
 
57
- # Adds a popup and invalidates it for repaint. A modal popup is centered
58
- # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
59
- # left wherever the caller positions it and does *not* take focus, so the
60
- # component that was focused keeps the cursor and keeps receiving keys —
61
- # the overlay floats above the content, driven from app code.
58
+ # Adds an overlay and invalidates it for repaint. A {Component::Popup} is
59
+ # centered and grabs focus; a bare {Component::Overlay} is left wherever
60
+ # the caller positioned it and does *not* take focus, so the component that
61
+ # was focused keeps the cursor and keeps receiving keys — the overlay
62
+ # floats above the content, driven from app code.
62
63
  #
63
- # The *whole subtree* is invalidated, not just the popup wrapper (which
64
+ # The *whole subtree* is invalidated, not just the overlay wrapper (which
64
65
  # paints nothing on its own): a reopened popup may land on cells that the
65
66
  # tiled content has since overpainted, and if its rect is unchanged from
66
67
  # last time its content components won't re-invalidate themselves — so
67
- # without this the popup's contents would stay blank on reopen.
68
- # @param window [Component::Popup]
68
+ # without this the overlay's contents would stay blank on reopen.
69
+ # @param window [Component::Overlay] any overlay, modal or not.
69
70
  # @return [void]
70
71
  def add_popup(window)
71
- raise TypeError, "expected Popup, got #{window.inspect}" unless window.is_a? Component::Popup
72
+ raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay
72
73
  raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
73
74
 
74
75
  @popup_prior_focus[window] = screen.focused
@@ -123,11 +124,11 @@ module Tuile
123
124
  # @return [Boolean] true if this pane currently hosts the popup.
124
125
  def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
125
126
 
126
- # @return [Component::Popup, nil] the topmost *modal* popup, or nil when
127
- # only non-modal overlays (or no popups) are open. This is the "modal
127
+ # @return [Component::Popup, nil] the topmost modal overlay, or nil when
128
+ # only bare {Component::Overlay}s (or none) are open. This is the "modal
128
129
  # owner": the popup that scopes key dispatch, blocks mouse clicks, and
129
- # confines Tab cycling. Non-modal overlays are excluded — they float above
130
- # the content without capturing input.
130
+ # confines Tab cycling. Bare overlays are excluded — they float above the
131
+ # content without capturing input.
131
132
  def modal_popup = @popups.reverse_each.find(&:modal?)
132
133
 
133
134
  # Re-lays out children whenever the pane's own rect changes.
@@ -139,7 +140,7 @@ module Tuile
139
140
  end
140
141
 
141
142
  # Gives {#content} the whole pane rect — the pane reserves nothing for
142
- # itself. Each popup re-resolves its {Component::Popup#size} against the new
143
+ # itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
143
144
  # screen via {Component::Popup#reposition} — so a {Fraction} size tracks
144
145
  # resize — repositioning itself (modal popups recenter; non-modal overlays
145
146
  # keep the top-left their owner assigned).
@@ -179,11 +180,15 @@ module Tuile
179
180
  bubble_key(key, scope)
180
181
  end
181
182
 
182
- # Delivers pasted text along the same focus chain {#handle_key} bubbles
183
- # along, and with the same scoping — first {Component#handle_paste}
184
- # returning true wins.
183
+ # Delivers pasted text to {Screen#focused} — and to nobody else.
184
+ #
185
+ # Scoped exactly like {#handle_key} (focus that is nil or sits outside the
186
+ # modal scope receives nothing, which is what keeps a popup modal) but
187
+ # **not bubbled**: an ancestor is never offered a paste its descendant
188
+ # declined, and unhandled text is dropped. Why keys bubble and pastes
189
+ # don't: `D_bracketed_paste`.
185
190
  # @param text [String]
186
- # @return [Boolean] true if the text was consumed.
191
+ # @return [Boolean] true if the focused component consumed it.
187
192
  def handle_paste(text)
188
193
  scope = modal_popup || @content
189
194
  return false if scope.nil?
@@ -191,8 +196,7 @@ module Tuile
191
196
  chain = focus_chain(scope)
192
197
  return false if chain.nil?
193
198
 
194
- chain.each { |c| return true if c.handle_paste(text) }
195
- false
199
+ chain.first.handle_paste(text)
196
200
  end
197
201
 
198
202
  # Mouse events check popups in reverse stacking order (topmost first), and
@@ -203,12 +207,12 @@ module Tuile
203
207
  # content beneath.
204
208
  #
205
209
  # A left click also *dismisses* the open popups it landed outside of that
206
- # asked for it ({Component::Popup#close_on_outside_click?}). That is a
210
+ # asked for it ({Component::Overlay#close_on_outside_click?}). That is a
207
211
  # second thing happening on a click, but not a second dispatch: the click is
208
212
  # still delivered exactly once, down one chain, and a dismissed popup is
209
213
  # closed rather than told.
210
214
  #
211
- # "Outside" is measured against the {Component::Popup#owner} chain, not
215
+ # "Outside" is measured against the {Component::Overlay#owner} chain, not
212
216
  # against one rect and not against stacking order: the popup the click hit
213
217
  # is kept, and so is every popup that one *belongs to*, transitively. That
214
218
  # is what stops a dialog being dismissed by a click on a dropdown its own
@@ -278,29 +282,30 @@ module Tuile
278
282
 
279
283
  private
280
284
 
281
- # The popups a click counts as landing *inside*: the one it hit, plus every
282
- # popup that one belongs to, up the {Component::Popup#owner} chain. An owner
283
- # is any component, so it is resolved to the popup enclosing it (a popup
284
- # resolves to itself) — which keeps the relationship a live tree question
285
- # rather than one frozen when the overlay opened. The `include?` guard makes
286
- # a mis-wired cycle terminate instead of hanging the UI thread.
287
- # @param hit [Component::Popup, nil] the popup the click landed in, if any.
288
- # @return [Array<Component::Popup>]
285
+ # The overlays a click counts as landing *inside*: the one it hit, plus
286
+ # every overlay that one belongs to, up the {Component::Overlay#owner}
287
+ # chain. An owner is any component, so it is resolved to the overlay
288
+ # enclosing it (an overlay resolves to itself) — which keeps the
289
+ # relationship a live tree question rather than one frozen when the overlay
290
+ # opened. The `include?` guard makes a mis-wired cycle terminate instead of
291
+ # hanging the UI thread.
292
+ # @param hit [Component::Overlay, nil] the overlay the click landed in, if any.
293
+ # @return [Array<Component::Overlay>]
289
294
  def kept_by(hit)
290
295
  kept = []
291
- popup = hit
292
- while popup && !kept.include?(popup)
293
- kept << popup
294
- popup = enclosing_popup(popup.owner)
296
+ overlay = hit
297
+ while overlay && !kept.include?(overlay)
298
+ kept << overlay
299
+ overlay = enclosing_popup(overlay.owner)
295
300
  end
296
301
  kept
297
302
  end
298
303
 
299
304
  # @param component [Component, nil]
300
- # @return [Component::Popup, nil] `component` itself when it is a popup,
301
- # else the nearest popup above it, else nil.
305
+ # @return [Component::Overlay, nil] `component` itself when it is an
306
+ # overlay, else the nearest overlay above it, else nil.
302
307
  def enclosing_popup(component)
303
- component = component.parent until component.nil? || component.is_a?(Component::Popup)
308
+ component = component.parent until component.nil? || component.is_a?(Component::Overlay)
304
309
  component
305
310
  end
306
311
 
@@ -338,13 +343,14 @@ module Tuile
338
343
 
339
344
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
340
345
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
341
- # if `root` is `nil`.
346
+ # if `root` is `nil`, or if `root` is itself hidden — there is nothing in
347
+ # there to focus, so the caller falls through to its next candidate.
342
348
  # @param root [Component, nil]
343
349
  # @return [Component, nil]
344
350
  def first_tab_stop_or_root(root)
345
- return nil if root.nil?
351
+ return nil if root.nil? || !root.visible?
346
352
 
347
- root.on_tree { |c| return c if c.tab_stop? }
353
+ root.on_shown_tree { |c| return c if c.tab_stop? }
348
354
  root
349
355
  end
350
356
  end