tuile 0.13.0 → 0.14.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
@@ -9,7 +9,43 @@ module Tuile
9
9
  # any stale invalidation entries are filtered out at drain time. Subclasses
10
10
  # can paint freely in {#repaint} without re-asserting attachment.
11
11
  class Component
12
+ # The methods that *define* the tree, and so may never be overridden —
13
+ # {#attached?} walks the parent chain while every subtree walk uses
14
+ # {#children}, and an override that makes those two disagree leaves a
15
+ # component attached but never painted, with nothing raising (`D_final_tree`).
16
+ # Enforced by {.verify_final!}; reparent through {#add_child} /
17
+ # {#remove_child} / {#detach_child}, and hold a {Slot} for a swappable region.
18
+ # @return [Array<Symbol>]
19
+ FINAL_METHODS = %i[children parent parent= add_child remove_child detach_child].freeze
20
+
21
+ @final_verified = {}
22
+
23
+ # Raises unless `klass` inherits every {FINAL_METHODS} entry from
24
+ # {Component}; memoized, so it costs one hash lookup per construction.
25
+ #
26
+ # Comparing each *resolved* method's `owner` is what makes this catch all
27
+ # four routes in — `def`, `define_method`, an included module, a `prepend`.
28
+ # A `method_added` hook would fire earlier but see only the first two.
29
+ # @param klass [Class] the class being instantiated.
30
+ # @raise [Error] if any final method has been overridden.
31
+ # @return [void]
32
+ def self.verify_final!(klass)
33
+ return if @final_verified.key?(klass)
34
+
35
+ overridden = FINAL_METHODS.reject { klass.instance_method(_1).owner == Component }
36
+ unless overridden.empty?
37
+ raise Error, "#{klass} overrides #{overridden.join(", ")}, which are final on Component. " \
38
+ "The tree is the framework's single source of truth — #attached? walks the " \
39
+ "parent chain while subtree walks use #children, so an override desyncs them " \
40
+ "silently. Reparent through add_child / remove_child / detach_child; for a " \
41
+ "swappable region, hold a Component::Slot."
42
+ end
43
+
44
+ @final_verified[klass] = true
45
+ end
46
+
12
47
  def initialize
48
+ Component.verify_final!(self.class)
13
49
  @rect = Rect.new(0, 0, 0, 0)
14
50
  @active = false
15
51
  @on_theme_changed = nil
@@ -20,6 +56,65 @@ module Tuile
20
56
  # @return [Rect] the rectangle the component occupies on screen.
21
57
  attr_reader :rect
22
58
 
59
+ # The three readers below report the geometry a parent *assigned*, as
60
+ # shorthand for the matching {#rect} field. They are reports, not requests:
61
+ # no container consults them when dividing space, and there is deliberately
62
+ # no writer — layout is top-down (`DECISIONS.md` `D_box_layouts`), so a
63
+ # component says how big it *is*, never how big it wants to be.
64
+
65
+ # @return [Size] `rect.size`.
66
+ def size = rect.size
67
+
68
+ # @return [Integer] `rect.width`.
69
+ def width = rect.width
70
+
71
+ # @return [Integer] `rect.height`.
72
+ def height = rect.height
73
+
74
+ # The size of the region this component paints, or `nil` (the default) to
75
+ # declare nothing — in which case the whole {#rect} is treated as fair game
76
+ # and the default {#repaint} blanks all of it. Override it when you paint
77
+ # less: a one-row {Component::Checkbox} handed a tall column, or a
78
+ # {Component::Select} used as a {Component::Popup}'s content and assigned the
79
+ # whole inner box.
80
+ #
81
+ # It always sits at {#rect}'s top-left — which is why this is a {Size} and
82
+ # not a {Rect}: an offset extent is not merely unsupported, it is
83
+ # unrepresentable. Use {#extent_rect} where coordinates are wanted.
84
+ #
85
+ # **`nil` is not the same as `rect.size`.** `nil` says "I have not declared
86
+ # what I paint, so clear everything before I do", which is what a
87
+ # {Component::Label} with short text needs. A declared extent — even one that
88
+ # happens to equal the rect, as a one-row {Component::Select} in a one-row
89
+ # rect does — says "I paint this in full, don't blank it", which is what
90
+ # keeps the default {#repaint} from dirtying cells it is about to redraw
91
+ # (`D_progress_bar`). The base cannot tell those apart from the value alone;
92
+ # that is what the `nil` carries.
93
+ #
94
+ # It flows **downward only**: no container consults it when dividing space,
95
+ # so {#rect} still means exactly what the parent assigned (`D_extent`). Three
96
+ # things read it, all of them this component or the framework painting it:
97
+ # {#clear_outside_extent} blanks the dead tail, {#handle_mouse} hit-tests
98
+ # against it so a click on that tail doesn't activate the widget, and a
99
+ # dropdown anchors under it rather than under unused space.
100
+ #
101
+ # **An override promises to paint the extent in full**, so `super` in
102
+ # {#repaint} blanks only what is outside it. The arithmetic is each widget's
103
+ # own — caption width, painted strip, one row — and must not vary with
104
+ # {#bg_color} (`D_boolean_fields`).
105
+ # @return [Size, nil]
106
+ def extent = nil
107
+
108
+ # {#extent} placed at {#rect}'s top-left, for the consumers that need
109
+ # coordinates: `extent_rect.contains?(event.point)` in a {#handle_mouse}, and
110
+ # the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
111
+ # the whole {#rect}, so a generic caller never sees `nil`.
112
+ # @return [Rect]
113
+ def extent_rect
114
+ e = extent
115
+ e.nil? ? rect : Rect.new(rect.left, rect.top, e.width, e.height)
116
+ end
117
+
23
118
  # Sets new position of the component. This is the absolute component
24
119
  # positioning on screen, not a relative positioning relative to component's
25
120
  # {#parent}.
@@ -104,6 +199,11 @@ module Tuile
104
199
  # paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
105
200
  # row-by-row paint). Never draw outside {#rect}. Only called when attached.
106
201
  #
202
+ # **A widget that paints less than its rect declares an {#extent} rather than
203
+ # skipping `super`.** The clear then covers only what is outside it, so the
204
+ # cells it is about to repaint are not blanked first — blanking them would
205
+ # mark them dirty and make {Buffer#flush} re-emit them (`D_progress_bar`).
206
+ #
107
207
  # **The children are re-invalidated whether or not they tile.** A container
108
208
  # that paints nothing of its own can only redraw its area *through* them, so
109
209
  # a tiling container that skipped this would be a dead end in the cascade: an
@@ -116,7 +216,7 @@ module Tuile
116
216
  def repaint
117
217
  return if rect.empty?
118
218
 
119
- clear_background unless children.any? && children_tile_rect?
219
+ clear_outside_extent unless children.any? && children_tile_rect?
120
220
  children.each { |c| screen.invalidate(c) }
121
221
  end
122
222
 
@@ -152,12 +252,21 @@ module Tuile
152
252
  false
153
253
  end
154
254
 
155
- # Handles mouse event. Default implementation focuses this component when
156
- # clicked (if {#focusable?}).
255
+ # Focuses this component when left-clicked (if {#focusable?}), then hands the
256
+ # event down to every child whose {#rect} contains the point — which is how a
257
+ # click descends the tiled tree to a leaf.
258
+ #
259
+ # A widget that resolves clicks *inside* its own rect — mapping a point to a
260
+ # row, or toggling an overlay — overrides this and does not call `super`.
261
+ # Such an override hit-tests {#extent_rect} rather than {#rect}, so a click
262
+ # on the tail it doesn't paint never activates it.
157
263
  # @param event [MouseEvent]
158
264
  # @return [void]
159
265
  def handle_mouse(event)
160
266
  screen.focused = self unless event.button != :left || active? || !focusable?
267
+ # Snapshot: a handler may add or remove siblings (a click that swaps a
268
+ # slot's occupant), and `each` over a mutating array skips an entry.
269
+ children.dup.each { |c| c.handle_mouse(event) if c.rect.contains?(event.point) }
161
270
  end
162
271
 
163
272
  # @return [Boolean] true if the component is on the active chain — i.e. it
@@ -238,22 +347,6 @@ module Tuile
238
347
  # @return [Proc, nil]
239
348
  attr_writer :on_theme_changed
240
349
 
241
- # Called on every attached component (pre-order, popups included) when
242
- # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
243
- # OS appearance flips. The hook exists for app *content* whose colors were
244
- # baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
245
- # styled with `theme[:accent]`); rebuild it here by re-running the code that
246
- # rendered it. See book ch6 for why built-in accents need no such handling.
247
- #
248
- # Runs on the UI thread with {Screen#theme} already updated, so mutating
249
- # content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
250
- # here. Subclasses overriding this must call `super` so an assigned
251
- # {#on_theme_changed=} listener keeps firing.
252
- # @return [void]
253
- def on_theme_changed
254
- @on_theme_changed&.call
255
- end
256
-
257
350
  # Whether this component's tree is mounted on a UI, {ScreenPane} being the
258
351
  # root of every displayed tree.
259
352
  #
@@ -427,6 +520,26 @@ module Tuile
427
520
  # @return [void]
428
521
  def on_width_changed; end
429
522
 
523
+ # Called on every attached component (pre-order, popups included) when
524
+ # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
525
+ # OS appearance flips. The hook exists for app *content* whose colors were
526
+ # baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
527
+ # styled with `theme[:accent]`); rebuild it here by re-running the code that
528
+ # rendered it. See book ch6 for why built-in accents need no such handling.
529
+ #
530
+ # Runs on the UI thread with {Screen#theme} already updated, so mutating
531
+ # content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
532
+ # here. Subclasses overriding this must call `super` so an assigned
533
+ # {#on_theme_changed=} listener keeps firing.
534
+ #
535
+ # Plumbing an app overrides and never calls, hence protected — and
536
+ # {Screen}, not being a {Component}, fans it out through `__send__`, so an
537
+ # override is free to declare any visibility (`D_hook_visibility`).
538
+ # @return [void]
539
+ def on_theme_changed
540
+ @on_theme_changed&.call
541
+ end
542
+
430
543
  # Invalidates the component: {Screen} records this component as
431
544
  # needs-repaint and once all events are processed, will call {#repaint}.
432
545
  #
@@ -455,6 +568,23 @@ module Tuile
455
568
  total >= rect.width * rect.height
456
569
  end
457
570
 
571
+ # Blanks the part of {#rect} outside {#extent} — the dead tail a widget that
572
+ # paints less than it was given must not leave stale. Up to two regions,
573
+ # since a narrowed extent leaves an L: the columns right of it, and the rows
574
+ # below it. A `nil` extent declares nothing, so the whole rect is blanked.
575
+ # Called by the default {#repaint}; a self-painter that skips `super` calls
576
+ # it directly.
577
+ # @return [void]
578
+ def clear_outside_extent
579
+ e = extent
580
+ return clear_background if e.nil? # nothing declared: all of it is fair game
581
+
582
+ right = Rect.new(rect.left + e.width, rect.top, rect.width - e.width, e.height)
583
+ below = Rect.new(rect.left, rect.top + e.height, rect.width, rect.height - e.height)
584
+ clear_background(right) unless right.empty?
585
+ clear_background(below) unless below.empty?
586
+ end
587
+
458
588
  # Clears the background: fills every cell with a blank in the
459
589
  # {#effective_bg_color} (the terminal default when none is inherited).
460
590
  #
@@ -247,6 +247,25 @@ module Tuile
247
247
  end
248
248
  end
249
249
 
250
+ # The terminal reported its background color — an OSC 11 reply, read
251
+ # back as a whole "key" by {Keys.getkey}.
252
+ #
253
+ # Only ever an answer to a {TerminalBackground::QUERY} someone wrote,
254
+ # and only the color: a {ColorSchemeEvent} is what prompts the re-probe
255
+ # and what carries the light/dark half. See {Screen#background_color}.
256
+ #
257
+ # @!attribute [r] color
258
+ # @return [Color] the reported background, 24-bit RGB.
259
+ class BackgroundColorEvent < Data.define(:color)
260
+ # @param key [String] key read via {Keys.getkey}.
261
+ # @return [BackgroundColorEvent, nil] nil when `key` is not an OSC 11
262
+ # background reply.
263
+ def self.parse(key)
264
+ result = TerminalBackground.parse(key)
265
+ result&.color && new(result.color)
266
+ end
267
+ end
268
+
250
269
  # Emitted once when the queue is cleared, all messages are processed and the
251
270
  # event loop will block waiting for more messages. Perfect time for
252
271
  # repainting windows.
@@ -344,7 +363,8 @@ module Tuile
344
363
  event = if key == Keys::PASTE_START
345
364
  PasteEvent.new(Keys.read_paste)
346
365
  else
347
- MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
366
+ MouseEvent.parse(key) || ColorSchemeEvent.parse(key) ||
367
+ BackgroundColorEvent.parse(key) || KeyEvent.new(key)
348
368
  end
349
369
  post event
350
370
  end
@@ -81,12 +81,36 @@ module Tuile
81
81
  @invalidated.clear
82
82
  end
83
83
 
84
+ # Plays the terminal answering the OSC 11 re-probe, so a spec can
85
+ # exercise app code that derives colors from {#background_color}:
86
+ #
87
+ # Screen.instance.background_color = Color.rgb(30, 30, 46)
88
+ #
89
+ # Takes the same path a real reply does — a changed color fires
90
+ # {Component#on_theme_changed} across the tree and invalidates it.
91
+ # There is no such writer on {Screen}: the value is a report from the
92
+ # terminal, not a setting.
93
+ # @param color [Color]
94
+ # @return [void]
95
+ def background_color=(color)
96
+ on_background_color(color)
97
+ end
98
+
84
99
  private
85
100
 
86
101
  # No terminal probing in tests: skip {TerminalBackground.detect}
87
102
  # (which would write an OSC 11 query to the test runner's TTY and
88
- # steal its input) and pin the deterministic default.
103
+ # steal its input) and pin the deterministic default. The color is nil
104
+ # — the case every app must handle anyway — until a spec assigns one
105
+ # through {#background_color=}.
106
+ # @return [TerminalBackground::Result]
107
+ def detect_background = TerminalBackground::Result.new(scheme: :dark, color: nil)
108
+
109
+ # Pins the depth rather than reading the test runner's environment, so a
110
+ # spec asserting flushed bytes gets the same answer on a truecolor
111
+ # terminal, under `TERM=dumb` in CI, and inside tmux. A spec exercising
112
+ # degradation builds its own {Buffer} with the depth it wants.
89
113
  # @return [Symbol]
90
- def detect_scheme = :dark
114
+ def detect_color_depth = :truecolor
91
115
  end
92
116
  end
data/lib/tuile/keys.rb CHANGED
@@ -184,6 +184,13 @@ module Tuile
184
184
  # sequences never start with `\e[?`, so this can't eat a regular key.
185
185
  char += $stdin.read(1) while char.start_with?("\e[?") && !char.match?(/[\x40-\x7e]\z/)
186
186
 
187
+ # OSC replies (the ~22-byte background report, {TerminalBackground})
188
+ # outgrow the gulp too, and end in BEL or ST rather than at a fixed
189
+ # length. Byte-at-a-time is required, not merely tidy: ST *is* `\e\\`,
190
+ # so a gulping read would swallow it plus the keys typed behind it.
191
+ # Keyboard sequences never start with `\e]`, so this eats no real key.
192
+ char += $stdin.read(1) while char.start_with?("\e]") && !char.end_with?("\a", "\e\\")
193
+
187
194
  char
188
195
  end
189
196
 
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,7 +71,10 @@ 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
75
78
  @theme_def = ThemeDef.default
76
79
  @theme = @theme_def.for(@color_scheme)
77
80
  # Structural root of the component tree: holds tiled content and the
@@ -84,7 +87,7 @@ module Tuile
84
87
  # The back buffer components paint into. {#repaint} flushes its diff to
85
88
  # the terminal, so only changed cells are emitted (flicker-free on any
86
89
  # terminal). Sized to the current viewport; {#layout} resizes it.
87
- @buffer = Buffer.new(@size)
90
+ @buffer = Buffer.new(@size, color_depth: @color_depth)
88
91
  end
89
92
 
90
93
  # Entry in the global shortcut registry: the block to run, and whether it
@@ -118,6 +121,17 @@ module Tuile
118
121
  # @return [Symbol] `:light` or `:dark`
119
122
  attr_reader :color_scheme
120
123
 
124
+ # How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
125
+ # at construction. {Buffer#flush} degrades every color it emits to this,
126
+ # so an app may compute an RGB tint — say from {#background_color} — and
127
+ # paint with it, whatever the terminal turns out to understand.
128
+ #
129
+ # Deliberately read-only: detection runs once and the answer can't change
130
+ # mid-session. Override a terminal that reports itself wrong through
131
+ # {ColorDepth::OVERRIDE_ENV} instead.
132
+ # @return [Symbol]
133
+ attr_reader :color_depth
134
+
121
135
  # @return [Buffer] the back buffer components paint into
122
136
  # ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
123
137
  attr_reader :buffer
@@ -183,6 +197,26 @@ module Tuile
183
197
  # @return [ThemeDef]
184
198
  attr_reader :theme_def
185
199
 
200
+ # The terminal's own background, as it reported it — for deriving a
201
+ # color *from* the background rather than picking one against it. A pane
202
+ # tinted a few percent off it sits right on any terminal, where a fixed
203
+ # near-neutral only sits right near the one it was tuned on:
204
+ #
205
+ # bg = screen.background_color
206
+ # sidebar.bg_color =
207
+ # bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
208
+ #
209
+ # **Nil is the normal case, not an edge** — a terminal answering only
210
+ # `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
211
+ #
212
+ # Kept current across OS appearance flips, a frame behind: the flip
213
+ # report carries light/dark only, so the screen re-probes and this
214
+ # updates when the reply lands. A changed color then fires
215
+ # {Component#on_theme_changed} across the tree exactly as a theme swap
216
+ # does — a background-derived tint *is* a theme-derived color.
217
+ # @return [Color, nil]
218
+ attr_reader :background_color
219
+
186
220
  # Replaces the theme definition and immediately applies the member
187
221
  # matching the current color scheme (via {#theme=}, so the whole UI
188
222
  # restyles — or nothing repaints if that member equals the current
@@ -211,12 +245,15 @@ module Tuile
211
245
  return if @theme == new_theme
212
246
 
213
247
  @theme = new_theme
214
- @pane&.on_tree(&:on_theme_changed)
248
+ # `__send__`, not `&:on_theme_changed`: the hook is protected, and an app
249
+ # subclass may narrow it further (`D_hook_visibility`).
250
+ @pane&.on_tree { _1.__send__(:on_theme_changed) }
215
251
  needs_full_repaint
216
252
  end
217
253
 
218
- # @return [Array<Component>] currently active popup components (forwarded
219
- # to {ScreenPane}). The array must not be modified!
254
+ # @return [Array<Component::Overlay>] the open overlay stack — both
255
+ # {Component::Popup} modals and bare {Component::Overlay}s — in stacking
256
+ # order (forwarded to {ScreenPane}). The array must not be modified!
220
257
  def popups = @pane.popups
221
258
 
222
259
  # @return [EventQueue] the event queue.
@@ -316,7 +353,7 @@ module Tuile
316
353
  #
317
354
  # This is the hook an app drives its own status line from. Tuile owns no
318
355
  # status bar and reserves no row: build a {Component::Label} into your own
319
- # layout and fill it here (`D-status-bar`).
356
+ # layout and fill it here (`D_status_bar`).
320
357
  #
321
358
  # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
322
359
  #
@@ -335,10 +372,10 @@ module Tuile
335
372
  # @return [Proc, nil]
336
373
  attr_accessor :on_focus_changed
337
374
 
338
- # Internal — use {Component::Popup#open} instead. Adds the popup to
339
- # {#pane}, centers and focuses it.
375
+ # Internal — use {Component::Overlay#open} instead. Adds the overlay to
376
+ # {#pane}; a {Component::Popup} is additionally centered and focused.
340
377
  # @api private
341
- # @param window [Component::Popup]
378
+ # @param window [Component::Overlay] any overlay, modal or not.
342
379
  # @return [void]
343
380
  def add_popup(window)
344
381
  check_locked
@@ -469,12 +506,12 @@ module Tuile
469
506
  @global_shortcuts.delete(key)
470
507
  end
471
508
 
472
- # Internal — use {Component::Popup#close} instead. Removes the popup
509
+ # Internal — use {Component::Overlay#close} instead. Removes the overlay
473
510
  # from {#pane}, repairs focus, and repaints the scene.
474
511
  #
475
- # Does nothing if the window is not open on this screen.
512
+ # Does nothing if the overlay is not open on this screen.
476
513
  # @api private
477
- # @param window [Component::Popup]
514
+ # @param window [Component::Overlay] any overlay, modal or not.
478
515
  # @return [void]
479
516
  def remove_popup(window)
480
517
  check_locked
@@ -497,10 +534,10 @@ module Tuile
497
534
  @pane&.on_tree { invalidate _1 }
498
535
  end
499
536
 
500
- # Internal — use {Component::Popup#open?} instead.
537
+ # Internal — use {Component::Overlay#open?} instead.
501
538
  # @api private
502
- # @param window [Component::Popup]
503
- # @return [Boolean] true if this popup is currently mounted.
539
+ # @param window [Component::Overlay] any overlay, modal or not.
540
+ # @return [Boolean] true if this overlay is currently mounted.
504
541
  def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
505
542
  check_locked
506
543
  @pane.has_popup?(window)
@@ -546,15 +583,20 @@ module Tuile
546
583
  end
547
584
 
548
585
  # 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.
586
+ # mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
587
+ # background re-probe, cursor-show on teardown. Component painting does
588
+ # *not* go through here anymore — it writes into {#buffer}, which
589
+ # {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
590
+ # {#emit}) to capture into `@prints` instead of the test runner's stdout.
591
+ #
592
+ # Flushed, like {#emit}: none of these escapes ends in a newline, and a
593
+ # buffered stdout would hold a *query* — one whose reply the app is
594
+ # waiting on — until the next frame went out.
554
595
  # @param args [String] stuff to print.
555
596
  # @return [void]
556
597
  def print(*args)
557
598
  Kernel.print(*args)
599
+ $stdout.flush
558
600
  end
559
601
 
560
602
  # Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
@@ -624,12 +666,22 @@ module Tuile
624
666
  tiled_invalidated = true
625
667
  end
626
668
 
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.
669
+ # Popups on top: a layer repaints whole whenever anything *beneath* it —
670
+ # the tiled tree, or a lower popup — repaints, else just its invalidated
671
+ # members. Layer-by-layer rather than one tiled_invalidated bool because
672
+ # the drain loops: a lower popup's repaint cascade re-invalidates
673
+ # children into the *next* iteration (its gap-clearing Layout wipes and
674
+ # re-queues a button, say), and that iteration has no tiled repaint —
675
+ # only the full re-assert of every layer above keeps the stacking order
676
+ # true across iterations (screen_spec pins it with two overlapping
677
+ # popups). Overdraw into the buffer is free (only net-visible cell
678
+ # changes reach the terminal), so reasserting layers is cheap.
679
+ below_repainted = tiled_invalidated
631
680
  popups.each do |p|
632
- p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) }
681
+ 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) }
684
+ below_repainted ||= layer_invalidated
633
685
  end
634
686
 
635
687
  @repainting = repaint.to_set
@@ -654,25 +706,53 @@ module Tuile
654
706
 
655
707
  private
656
708
 
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
709
+ # The startup background probe, seeding {#theme} and
710
+ # {#background_color}. Inconclusive detection means `:dark` with no
711
+ # color. Runs in the constructor — the OSC 11 reply arrives on stdin,
712
+ # which is only safe to read before {EventQueue#start_key_thread} owns
713
+ # it. {FakeScreen} overrides this to pin the result, keeping specs
714
+ # deterministic and off the test runner's TTY.
715
+ # @return [TerminalBackground::Result]
716
+ def detect_background
717
+ TerminalBackground.detect || TerminalBackground::Result.new(scheme: :dark, color: nil)
667
718
  end
668
719
 
720
+ # The startup color-depth probe, seeding {#color_depth}. Reads the
721
+ # environment only, so unlike {#detect_background} it has no timing
722
+ # constraint. {FakeScreen} overrides it to pin the result, keeping specs
723
+ # off whatever `COLORTERM` the test runner happens to carry.
724
+ # @return [Symbol]
725
+ def detect_color_depth = ColorDepth.detect
726
+
669
727
  # An OS appearance flip arrived (mode-2031 report): remember the
670
- # scheme and apply the matching member of {#theme_def}.
728
+ # scheme, apply the matching member of {#theme_def}, and re-probe for
729
+ # the new background RGB, which the report does not carry.
730
+ #
731
+ # The query goes out from *this* thread — the event-loop thread, which
732
+ # also owns {#emit} — so its bytes can never land inside a frame's
733
+ # synchronized-output batch. The reply comes back through the key
734
+ # thread as an {EventQueue::BackgroundColorEvent}.
671
735
  # @param scheme [Symbol] `:dark` or `:light`.
672
736
  # @return [void]
673
737
  def on_color_scheme(scheme)
674
738
  @color_scheme = scheme
675
739
  self.theme = @theme_def.for(@color_scheme)
740
+ print TerminalBackground::QUERY
741
+ end
742
+
743
+ # The re-probe answered: adopt the color and restyle, since an app's
744
+ # background-derived tints are now a scheme behind. Deliberately keeps
745
+ # the previous color until the reply lands rather than blanking it on
746
+ # the flip — a terminal that reports mode-2031 flips but not OSC 11
747
+ # would otherwise lose the color it gave us at startup, permanently.
748
+ # @param color [Color]
749
+ # @return [void]
750
+ def on_background_color(color)
751
+ return if @background_color == color
752
+
753
+ @background_color = color
754
+ @pane&.on_tree { _1.__send__(:on_theme_changed) }
755
+ needs_full_repaint
676
756
  end
677
757
 
678
758
  # Walks the current modal scope in pre-order, collects tab stops, and
@@ -802,6 +882,8 @@ module Tuile
802
882
  layout
803
883
  when EventQueue::ColorSchemeEvent
804
884
  on_color_scheme(event.scheme)
885
+ when EventQueue::BackgroundColorEvent
886
+ on_background_color(event.color)
805
887
  when EventQueue::EmptyQueueEvent
806
888
  repaint
807
889
  when Proc