tuile 0.5.0 → 0.6.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.
@@ -59,9 +59,8 @@ module Tuile
59
59
  def repaint
60
60
  return if rect.empty?
61
61
 
62
- bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
63
62
  padded = @text + (" " * (rect.width - @text.length))
64
- screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
63
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), background(padded)
65
64
  end
66
65
 
67
66
  protected
@@ -88,21 +88,6 @@ module Tuile
88
88
  invalidate
89
89
  end
90
90
 
91
- # 256-color SGR for the focused-button highlight (matches what
92
- # `Rainbow(...).bg(:darkslategray)` emits, which is what
93
- # {Component::Button#repaint} uses for its focused state).
94
- # @return [String]
95
- ACTIVE_BG_SGR = "\e[48;5;59m"
96
- # 256-color SGR for the unfocused field's "well": index 238 sits in
97
- # the grayscale ramp (~#444444), bright enough to stand out against
98
- # non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
99
- # backgrounds sit in the #1d–#2d range), and still distinctly darker
100
- # than the active highlight at index 59 (~#5f5f5f). Rainbow's
101
- # RGB-to-256 mapping snaps everything dark to palette index 16
102
- # (terminal black), so we emit the escape directly to reach the ramp.
103
- # @return [String]
104
- INACTIVE_BG_SGR = "\e[48;5;238m"
105
-
106
91
  # Handles a key. Returns false when the component is inactive. Otherwise
107
92
  # first runs the {Component#handle_key} shortcut search via `super`, then
108
93
  # delegates to {#handle_text_input_key}.
@@ -117,6 +102,16 @@ module Tuile
117
102
 
118
103
  protected
119
104
 
105
+ # Renders `text` on the field's background well, looked up from the
106
+ # current {Screen#theme} at paint time: {Theme#active_bg} when this
107
+ # input is on the active (focus) chain, {Theme#input_bg} otherwise —
108
+ # visibly a field either way, distinctly highlighted when active.
109
+ # @param text [String]
110
+ # @return [String] ANSI-rendered text.
111
+ def background(text)
112
+ active? ? screen.theme.active_bg(text) : screen.theme.input_bg(text)
113
+ end
114
+
120
115
  # Input filter for {#text=}. Subclasses override to truncate or reject
121
116
  # invalid input. Default coerces to String.
122
117
  # @param new_text [String]
@@ -50,7 +50,6 @@ module Tuile
50
50
  @physical_lines = []
51
51
  @hard_line_wrap_counts = []
52
52
  @text = StyledString::EMPTY
53
- @content_size = Size::ZERO
54
53
  @blank_line = StyledString::EMPTY
55
54
  @top_line = 0
56
55
  @auto_scroll = false
@@ -110,10 +109,10 @@ module Tuile
110
109
  @regions = [Region.send(:new, self, @hard_lines.size)]
111
110
  return if content_unchanged
112
111
 
113
- @content_size = compute_content_size
114
112
  rewrap
115
113
  update_top_line_if_auto_scroll
116
114
  invalidate
115
+ self.content_size = compute_content_size
117
116
  end
118
117
 
119
118
  # Creates a new empty {Region} at the spatial tail of the document
@@ -180,9 +179,9 @@ module Tuile
180
179
 
181
180
  tail_region.send(:line_count=, tail_region.line_count + added)
182
181
  @text = nil
183
- @content_size = compute_content_size
184
182
  update_top_line_if_auto_scroll
185
183
  invalidate
184
+ self.content_size = compute_content_size
186
185
  end
187
186
 
188
187
  # Verbatim append, returning `self` for chainability (`view << a << b`).
@@ -254,10 +253,10 @@ module Tuile
254
253
  end
255
254
 
256
255
  @text = nil
257
- @content_size = compute_content_size
258
256
  @top_line = top_line_max if @top_line > top_line_max
259
257
  update_top_line_if_auto_scroll
260
258
  invalidate
259
+ self.content_size = compute_content_size
261
260
  end
262
261
 
263
262
  # Replaces a contiguous range of hard lines with the parsed content
@@ -315,10 +314,10 @@ module Tuile
315
314
  splice_hard_lines(from, length, new_hard_lines)
316
315
  update_region_counts(from, length, new_hard_lines.size)
317
316
  @text = nil
318
- @content_size = compute_content_size
319
317
  @top_line = top_line_max if @top_line > top_line_max
320
318
  update_top_line_if_auto_scroll
321
319
  invalidate
320
+ self.content_size = compute_content_size
322
321
  end
323
322
 
324
323
  # Inserts `str` at hard-line index `at`. Equivalent to
@@ -529,10 +528,10 @@ module Tuile
529
528
  splice_hard_lines(start, old_count, new_lines)
530
529
  region.send(:line_count=, new_lines.size)
531
530
  @text = nil
532
- @content_size = compute_content_size
533
531
  @top_line = top_line_max if @top_line > top_line_max
534
532
  update_top_line_if_auto_scroll
535
533
  invalidate
534
+ self.content_size = compute_content_size
536
535
  end
537
536
 
538
537
  # Region-scoped {#replace}. Validates `range` against
@@ -555,10 +554,10 @@ module Tuile
555
554
  splice_hard_lines(abs_from, length, new_hard_lines)
556
555
  region.send(:line_count=, region.line_count - length + new_hard_lines.size)
557
556
  @text = nil
558
- @content_size = compute_content_size
559
557
  @top_line = top_line_max if @top_line > top_line_max
560
558
  update_top_line_if_auto_scroll
561
559
  invalidate
560
+ self.content_size = compute_content_size
562
561
  end
563
562
 
564
563
  # Verbatim append into `region`.
@@ -595,10 +594,10 @@ module Tuile
595
594
  region.send(:line_count=, region.line_count + rest.size)
596
595
  end
597
596
  @text = nil
598
- @content_size = compute_content_size
599
597
  @top_line = top_line_max if @top_line > top_line_max
600
598
  update_top_line_if_auto_scroll
601
599
  invalidate
600
+ self.content_size = compute_content_size
602
601
  end
603
602
 
604
603
  # Drops the last `n` hard lines from `region`'s tail via
@@ -617,10 +616,10 @@ module Tuile
617
616
  splice_hard_lines(drop_from, to_drop, [])
618
617
  region.send(:line_count=, region.line_count - to_drop)
619
618
  @text = nil
620
- @content_size = compute_content_size
621
619
  @top_line = top_line_max if @top_line > top_line_max
622
620
  update_top_line_if_auto_scroll
623
621
  invalidate
622
+ self.content_size = compute_content_size
624
623
  end
625
624
 
626
625
  # Drops `region` from {@regions}: its hard lines are removed via
@@ -643,10 +642,10 @@ module Tuile
643
642
  return unless had_lines
644
643
 
645
644
  @text = nil
646
- @content_size = compute_content_size
647
645
  @top_line = top_line_max if @top_line > top_line_max
648
646
  update_top_line_if_auto_scroll
649
647
  invalidate
648
+ self.content_size = compute_content_size
650
649
  end
651
650
 
652
651
  # Adjusts region line counts after a {@hard_lines} splice that
@@ -19,9 +19,12 @@ module Tuile
19
19
  super()
20
20
  @border_right = 1
21
21
  @caption = caption
22
+ @content = nil
22
23
  # Optional bottom-row chrome that overlays the bottom border (e.g. a
23
24
  # search field).
24
25
  @footer = nil
26
+ @footer_sizing = Sizing::FILL
27
+ update_content_size
25
28
  end
26
29
 
27
30
  def focusable? = true
@@ -30,8 +33,25 @@ module Tuile
30
33
  # row.
31
34
  attr_reader :footer
32
35
 
33
- # Sets the bottom-row chrome slot. The footer overlays the bottom border at
34
- # full inner width and is positioned automatically; pass `nil` to remove.
36
+ # @return [Sizing] how the footer's width is computed from the window's
37
+ # inner width; defaults to {Sizing::FILL} (the footer spans the full
38
+ # inner width). The footer's height is always 1 (the border row).
39
+ attr_reader :footer_sizing
40
+
41
+ # Sets the footer width policy and re-lays-out the footer.
42
+ # @param sizing [Sizing]
43
+ def footer_sizing=(sizing)
44
+ raise TypeError, "expected Sizing, got #{sizing.inspect}" unless sizing.is_a?(Sizing)
45
+ return if @footer_sizing == sizing
46
+
47
+ @footer_sizing = sizing
48
+ layout_footer
49
+ invalidate # repaint border cells the footer may have just vacated
50
+ end
51
+
52
+ # Sets the bottom-row chrome slot. The footer overlays the bottom border
53
+ # row and is positioned automatically — its width is governed by
54
+ # {#footer_sizing}; pass `nil` to remove.
35
55
  #
36
56
  # Symmetric to {#content=}: validates the new component, swaps parent
37
57
  # pointers, invalidates the old/new components and the window border, and
@@ -110,20 +130,36 @@ module Tuile
110
130
  def caption=(new_caption)
111
131
  @caption = new_caption
112
132
  invalidate
133
+ update_content_size
113
134
  end
114
135
 
115
- # @return [Size] the size needed to fit the window's content, footer
116
- # (width only footer overlays the bottom border), and caption,
117
- # plus the 2-character border. Returns {Size}`.new(2, 2)` when the
118
- # window has no content, footer, or caption.
119
- def content_size
120
- inner_w = [
121
- content&.content_size&.width || 0,
122
- @footer&.content_size&.width || 0,
123
- frame_caption.length
124
- ].max
125
- inner_h = content&.content_size&.height || 0
126
- Size.new(inner_w + 2, inner_h + 2)
136
+ # Sets the new content. Also recomputes the window's natural size.
137
+ # @param new_content [Component, nil]
138
+ def content=(new_content)
139
+ super
140
+ update_content_size
141
+ end
142
+
143
+ # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
144
+ # size changes, and folds a content resize into the window's own
145
+ # natural size (whose change then bubbles to the window's parent — e.g.
146
+ # a {Popup} re-self-sizes). The footer deliberately does *not*
147
+ # participate in the window's {#content_size}: it is decoration
148
+ # overlaying the border, and must not drive the window's size — if it
149
+ # doesn't fit, it is clipped to the inner width.
150
+ # @param child [Component]
151
+ # @return [void]
152
+ def on_child_content_size_changed(child)
153
+ if child.equal?(@footer)
154
+ old_rect = @footer.rect
155
+ layout_footer
156
+ # Repaint on any footer geometry change: a shrinking footer vacates
157
+ # border cells that must be re-dashed (a growing one merely
158
+ # overdraws, but distinguishing isn't worth the code).
159
+ invalidate if @footer.rect != old_rect
160
+ else
161
+ update_content_size
162
+ end
127
163
  end
128
164
 
129
165
  # Fully repaints the window: both frame and contents.
@@ -150,6 +186,7 @@ module Tuile
150
186
  super
151
187
  # The shortcut key is shown in the caption — repaint.
152
188
  invalidate
189
+ update_content_size
153
190
  end
154
191
 
155
192
  protected
@@ -166,7 +203,7 @@ module Tuile
166
203
  return if rect.empty?
167
204
 
168
205
  frame = build_frame(frame_caption)
169
- frame = Rainbow(frame).green if active?
206
+ frame = screen.theme.active_border(frame) if active?
170
207
  screen.print frame
171
208
  end
172
209
 
@@ -206,11 +243,28 @@ module Tuile
206
243
 
207
244
  private
208
245
 
246
+ # Recomputes the window's natural size: content's natural size (or the
247
+ # caption, whichever is wider) plus the 2-character border. The footer
248
+ # is deliberately excluded — see {#on_child_content_size_changed}. A
249
+ # window with no content or caption sizes to `Size.new(2, 2)` (bare
250
+ # border).
251
+ # @return [void]
252
+ def update_content_size
253
+ inner_w = [content&.content_size&.width || 0, frame_caption.length].max
254
+ inner_h = content&.content_size&.height || 0
255
+ self.content_size = Size.new(inner_w + 2, inner_h + 2)
256
+ end
257
+
258
+ # Positions the footer over the bottom border row, with its width
259
+ # resolved by {#footer_sizing} against the inner width. A
260
+ # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
261
+ # rect — i.e. it is invisible, as if never assigned.
209
262
  # @return [void]
210
263
  def layout_footer
211
264
  return if @footer.nil? || rect.empty?
212
265
 
213
- width = [rect.width - 2, 0].max
266
+ available = [rect.width - 2, 0].max
267
+ width = @footer_sizing.resolve(available, @footer.content_size.width)
214
268
  @footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
215
269
  end
216
270
  end
@@ -12,6 +12,8 @@ module Tuile
12
12
  def initialize
13
13
  @rect = Rect.new(0, 0, 0, 0)
14
14
  @active = false
15
+ @content_size = Size::ZERO
16
+ @on_theme_changed = nil
15
17
  end
16
18
 
17
19
  # @return [Rect] the rectangle the component occupies on screen.
@@ -195,6 +197,38 @@ module Tuile
195
197
  # @return [void]
196
198
  def on_focus; end
197
199
 
200
+ # Optional zero-arg listener fired by the base {#on_theme_changed} — the
201
+ # composition-style alternative to overriding the method, for apps that
202
+ # assemble stock components rather than subclass:
203
+ #
204
+ # label.on_theme_changed = -> { label.text = render_status_line }
205
+ #
206
+ # @return [Proc, nil]
207
+ attr_writer :on_theme_changed
208
+
209
+ # Called on every attached component (pre-order, popups included) when
210
+ # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
211
+ # assignment and on OS appearance flips.
212
+ #
213
+ # Built-in components read {Screen#theme} at paint time, so their accents
214
+ # restyle automatically; this hook exists for *content* whose colors the
215
+ # app baked in from the old theme — a {Label#text} / {List#lines} /
216
+ # {TextView#text} {StyledString} styled with `theme[:accent]` and the
217
+ # like. Only the app knows which of its colors were theme-derived (as
218
+ # opposed to inherent to the data, e.g. log-level colors), so it rebuilds
219
+ # them here, re-running the same code that rendered them initially.
220
+ #
221
+ # Runs on the UI thread; {Screen#theme} already returns the new theme.
222
+ # Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
223
+ # event-loop tick. Do not assign {Screen#theme=} from inside the hook.
224
+ #
225
+ # Subclasses overriding this should call `super` so an assigned
226
+ # {#on_theme_changed=} listener keeps firing.
227
+ # @return [void]
228
+ def on_theme_changed
229
+ @on_theme_changed&.call
230
+ end
231
+
198
232
  # @return [Boolean] true if this component's tree is currently mounted on
199
233
  # the {Screen}, i.e. its root is the {ScreenPane}.
200
234
  def attached? = root == screen.pane
@@ -225,12 +259,27 @@ module Tuile
225
259
 
226
260
  # The {Size} big enough to show the entire component contents without
227
261
  # scrolling. Plain components have no intrinsic content and report
228
- # {Size::ZERO}; container/decorative components (e.g. {Label}, {List},
229
- # {Layout}, {Window}) override this to fold in their content's natural
230
- # extent. Used by callers like {Component::Popup} to auto-size to
231
- # whatever content was assigned, regardless of its concrete type.
262
+ # {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
263
+ # {TextView}, {Window}) maintain it eagerly via {#content_size=} from
264
+ # their mutators, so reads are O(1). Used by callers like
265
+ # {Component::Popup} to auto-size to whatever content was assigned,
266
+ # regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
232
267
  # @return [Size]
233
- def content_size = Size::ZERO
268
+ attr_reader :content_size
269
+
270
+ # Called by a child component whose {#content_size} just changed (fired
271
+ # from the child's {#content_size=}). Does nothing by default — a plain
272
+ # container is not size-coupled to its children. Containers that derive
273
+ # their own natural size or child layout from a child's natural size
274
+ # override this (e.g. {Component::Window} re-lays-out a
275
+ # {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
276
+ # {Component::Popup} re-self-sizes). If the receiver's own
277
+ # {#content_size} changes as a consequence, its {#content_size=} notifies
278
+ # *its* parent in turn — so the event bubbles exactly as far as geometry
279
+ # keeps changing, and stops where it doesn't.
280
+ # @param child [Component] the resized direct child.
281
+ # @return [void]
282
+ def on_child_content_size_changed(child); end
234
283
 
235
284
  # Where the hardware terminal cursor should sit when this component is the
236
285
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
@@ -253,6 +302,26 @@ module Tuile
253
302
  # @return [void]
254
303
  def on_width_changed; end
255
304
 
305
+ # Memoizes the component's natural size and notifies {#parent} via
306
+ # {#on_child_content_size_changed} when the value actually changed.
307
+ # Subclasses call this from their content mutators (`text=`, `add_lines`,
308
+ # `caption=`, …) instead of caching ad-hoc.
309
+ #
310
+ # Call this as the *last* step of a mutator: the parent hook may
311
+ # reentrantly reposition this component (assign {#rect} — e.g. {Window}
312
+ # re-laying-out a wrap-content footer, or {Popup} re-self-sizing), which
313
+ # triggers {#on_width_changed} and {#repaint}-related recomputation, so
314
+ # all internal state must already be consistent.
315
+ # @param new_size [Size]
316
+ # @return [void]
317
+ def content_size=(new_size)
318
+ raise TypeError, "expected Size, got #{new_size.inspect}" unless new_size.is_a?(Size)
319
+ return if @content_size == new_size
320
+
321
+ @content_size = new_size
322
+ parent&.on_child_content_size_changed(self)
323
+ end
324
+
256
325
  # Invalidates the component: {Screen} records this component as
257
326
  # needs-repaint and once all events are processed, will call {#repaint}.
258
327
  #
@@ -184,6 +184,31 @@ module Tuile
184
184
  def size = Size.new(width, height)
185
185
  end
186
186
 
187
+ # The terminal's color scheme changed — the user flipped the OS between
188
+ # light and dark appearance. Terminals supporting mode 2031 (kitty,
189
+ # foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
190
+ # (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
191
+ # the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
192
+ # it into this event and {Screen#event_loop} follows by assigning the
193
+ # matching {Theme}.
194
+ #
195
+ # @!attribute [r] scheme
196
+ # @return [Symbol] `:light` or `:dark`.
197
+ class ColorSchemeEvent < Data.define(:scheme)
198
+ # The DSR-style color-scheme report: `\e[?997;1n` dark, `\e[?997;2n`
199
+ # light.
200
+ # @return [Regexp]
201
+ REPORT = /\A\e\[\?997;([12])n\z/
202
+
203
+ # @param key [String] key read via {Keys.getkey}.
204
+ # @return [ColorSchemeEvent, nil] nil when `key` is not a
205
+ # color-scheme report.
206
+ def self.parse(key)
207
+ match = REPORT.match(key)
208
+ match && new(match[1] == "2" ? :light : :dark)
209
+ end
210
+ end
211
+
187
212
  # Emitted once when the queue is cleared, all messages are processed and the
188
213
  # event loop will block waiting for more messages. Perfect time for
189
214
  # repainting windows.
@@ -278,8 +303,7 @@ module Tuile
278
303
  @key_thread = Thread.new do
279
304
  loop do
280
305
  key = Keys.getkey
281
- event = MouseEvent.parse(key)
282
- event = KeyEvent.new(key) if event.nil?
306
+ event = MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
283
307
  post event
284
308
  end
285
309
  rescue StandardError => e
@@ -54,5 +54,13 @@ module Tuile
54
54
  def invalidated_clear
55
55
  @invalidated.clear
56
56
  end
57
+
58
+ private
59
+
60
+ # No terminal probing in tests: skip {TerminalBackground.detect}
61
+ # (which would write an OSC 11 query to the test runner's TTY and
62
+ # steal its input) and pin the deterministic default.
63
+ # @return [Symbol]
64
+ def detect_scheme = :dark
57
65
  end
58
66
  end
data/lib/tuile/keys.rb CHANGED
@@ -160,6 +160,16 @@ module Tuile
160
160
  char += $stdin.read(6 - char.bytesize)
161
161
  end
162
162
 
163
+ # Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
164
+ # can outgrow the 5-byte gulp above — the mode-2031 color-scheme
165
+ # notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
166
+ # bytes after the `\e`. Drain to the final byte with blocking 1-byte
167
+ # reads so the tail doesn't surface as phantom keypresses. Keyboard
168
+ # sequences never start with `\e[?`, so this can't eat a regular key.
169
+ if char.start_with?("\e[?")
170
+ char += $stdin.read(1) until char.match?(/[\x40-\x7e]\z/)
171
+ end
172
+
163
173
  char
164
174
  end
165
175
  end
data/lib/tuile/screen.rb CHANGED
@@ -35,6 +35,9 @@ module Tuile
35
35
  @repainting = Set.new
36
36
  # Until the event loop is run, we pretend we're in the UI thread.
37
37
  @pretend_ui_lock = true
38
+ @scheme = detect_scheme
39
+ @theme_def = ThemeDef.default
40
+ @theme = @theme_def.for(@scheme)
38
41
  # Structural root of the component tree: holds tiled content, popup
39
42
  # stack and status bar.
40
43
  @pane = ScreenPane.new
@@ -93,6 +96,66 @@ module Tuile
93
96
  # @return [Size] current screen size.
94
97
  attr_reader :size
95
98
 
99
+ # The color {Theme} built-in components read at paint time: the member
100
+ # of {#theme_def} matching the terminal background detected at
101
+ # construction (see {TerminalBackground.detect}; inconclusive means
102
+ # dark). While the event loop runs, terminals supporting mode 2031
103
+ # push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
104
+ # screen re-picks from {#theme_def}.
105
+ # @return [Theme]
106
+ attr_reader :theme
107
+
108
+ # The app's {ThemeDef} — the dark/light {Theme} pair the screen picks
109
+ # {#theme} from, at startup and on every OS appearance flip. Starts as
110
+ # {ThemeDef.default} ({ThemeDef::DEFAULT} unless reassigned — tests
111
+ # do, see {ThemeDef.default=}). Assigning a custom definition is the
112
+ # durable way to theme an app: unlike a bare {#theme=}, it survives
113
+ # the user toggling the OS appearance.
114
+ # @return [ThemeDef]
115
+ attr_reader :theme_def
116
+
117
+ # Replaces the theme definition and immediately applies the member
118
+ # matching the current color scheme (via {#theme=}, so the whole UI
119
+ # restyles — or nothing repaints if that member equals the current
120
+ # theme).
121
+ # @param theme_def [ThemeDef]
122
+ # @return [void]
123
+ def theme_def=(theme_def)
124
+ raise TypeError, "expected ThemeDef, got #{theme_def.inspect}" unless theme_def.is_a?(ThemeDef)
125
+
126
+ check_locked
127
+ @theme_def = theme_def
128
+ self.theme = @theme_def.for(@scheme)
129
+ end
130
+
131
+ # Replaces the theme and restyles the whole UI: fires
132
+ # {Component#on_theme_changed} across the attached tree (so the app can
133
+ # rebuild styled content whose colors were derived from the old theme),
134
+ # refreshes the status bar and invalidates every attached component so
135
+ # the next repaint uses the new colors. No-op when `new_theme` equals
136
+ # the current theme.
137
+ #
138
+ # This is a transient override: the next OS appearance flip re-picks
139
+ # from {#theme_def} and replaces it. To theme an app durably, assign
140
+ # {#theme_def=} instead.
141
+ #
142
+ # Note status-bar hints supplied by the host as preformatted strings
143
+ # (see {#register_global_shortcut}) have their colors baked in and are
144
+ # not restyled by this.
145
+ # @param new_theme [Theme]
146
+ # @return [void]
147
+ def theme=(new_theme)
148
+ raise TypeError, "expected Theme, got #{new_theme.inspect}" unless new_theme.is_a?(Theme)
149
+
150
+ check_locked
151
+ return if @theme == new_theme
152
+
153
+ @theme = new_theme
154
+ @pane&.on_tree(&:on_theme_changed)
155
+ refresh_status_bar
156
+ needs_full_repaint
157
+ end
158
+
96
159
  # @return [Array<Component>] currently active popup components (forwarded
97
160
  # to {ScreenPane}). The array must not be modified!
98
161
  def popups = @pane.popups
@@ -172,7 +235,7 @@ module Tuile
172
235
  top_popup = @pane.popups.last
173
236
  globals = global_shortcut_hints(popup_open: !top_popup.nil?)
174
237
  @pane.status_bar.text = if top_popup.nil?
175
- ["q #{Rainbow("quit").cadetblue}", *globals,
238
+ ["q #{@theme.hint("quit")}", *globals,
176
239
  active_window&.keyboard_hint].compact.reject(&:empty?).join(" ")
177
240
  else
178
241
  [*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ")
@@ -224,10 +287,15 @@ module Tuile
224
287
  @pretend_ui_lock = false
225
288
  $stdin.echo = false
226
289
  print MouseEvent.start_tracking if capture_mouse
290
+ # Follow OS light/dark flips live: terminals supporting mode 2031
291
+ # push color-scheme reports that the key thread turns into
292
+ # {EventQueue::ColorSchemeEvent}s.
293
+ print TerminalBackground::NOTIFY_ON
227
294
  $stdin.raw do
228
295
  event_loop
229
296
  end
230
297
  ensure
298
+ print TerminalBackground::NOTIFY_OFF
231
299
  print MouseEvent.stop_tracking if capture_mouse
232
300
  print TTY::Cursor.show
233
301
  $stdin.echo = true
@@ -272,7 +340,7 @@ module Tuile
272
340
  #
273
341
  # screen.register_global_shortcut(Keys::CTRL_L,
274
342
  # over_popups: true,
275
- # hint: "^L #{Rainbow("log").cadetblue}") do
343
+ # hint: "^L #{screen.theme.hint("log")}") do
276
344
  # log_popup.open
277
345
  # end
278
346
  #
@@ -283,8 +351,9 @@ module Tuile
283
351
  # (default), the shortcut is suppressed while any popup is open and
284
352
  # the popup gets the key instead.
285
353
  # @param hint [String, nil] preformatted status-bar hint (e.g.
286
- # `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut
287
- # is silent in the status bar.
354
+ # `"^L #{screen.theme.hint("log")}"`). When nil (default) the shortcut
355
+ # is silent in the status bar. The colors are baked into the string,
356
+ # so a later {#theme=} does not restyle it — re-register if needed.
288
357
  # @yield invoked with no arguments when `key` is pressed.
289
358
  # @return [void]
290
359
  def register_global_shortcut(key, over_popups: false, hint: nil, &block)
@@ -476,6 +545,27 @@ module Tuile
476
545
 
477
546
  private
478
547
 
548
+ # Startup color scheme: `:light` when {TerminalBackground.detect}
549
+ # reports a light terminal background, `:dark` otherwise (including
550
+ # when detection is inconclusive). Runs in the constructor — the
551
+ # OSC 11 reply arrives on stdin, which is only safe to read before
552
+ # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
553
+ # to pin `:dark`, keeping specs deterministic and off the test
554
+ # runner's TTY.
555
+ # @return [Symbol] `:dark` or `:light`.
556
+ def detect_scheme
557
+ TerminalBackground.detect == :light ? :light : :dark
558
+ end
559
+
560
+ # An OS appearance flip arrived (mode-2031 report): remember the
561
+ # scheme and apply the matching member of {#theme_def}.
562
+ # @param scheme [Symbol] `:dark` or `:light`.
563
+ # @return [void]
564
+ def on_color_scheme(scheme)
565
+ @scheme = scheme
566
+ self.theme = @theme_def.for(@scheme)
567
+ end
568
+
479
569
  # Walks the current modal scope in pre-order, collects tab stops, and
480
570
  # advances focus by one (wrapping). When the focused component isn't in
481
571
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -596,6 +686,8 @@ module Tuile
596
686
  when EventQueue::TTYSizeEvent
597
687
  @size = event.size
598
688
  layout
689
+ when EventQueue::ColorSchemeEvent
690
+ on_color_scheme(event.scheme)
599
691
  when EventQueue::EmptyQueueEvent
600
692
  repaint
601
693
  when Proc