tuile 0.5.0 → 0.7.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.
@@ -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.
@@ -188,13 +190,45 @@ module Tuile
188
190
  # @return [void]
189
191
  def on_tree(&block)
190
192
  block.call(self)
191
- children.each { it.on_tree(&block) }
193
+ children.each { _1.on_tree(&block) }
192
194
  end
193
195
 
194
196
  # Called when the component receives focus.
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
@@ -156,9 +156,15 @@ module Tuile
156
156
  # sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
157
157
  # with a blocking read so the parser downstream sees a complete event
158
158
  # instead of leaking tail bytes as keypresses.
159
- if char.start_with?("\e[M") && char.bytesize < 6
160
- char += $stdin.read(6 - char.bytesize)
161
- end
159
+ char += $stdin.read(6 - char.bytesize) if char.start_with?("\e[M") && char.bytesize < 6
160
+
161
+ # Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
162
+ # can outgrow the 5-byte gulp above — the mode-2031 color-scheme
163
+ # notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
164
+ # bytes after the `\e`. Drain to the final byte with blocking 1-byte
165
+ # reads so the tail doesn't surface as phantom keypresses. Keyboard
166
+ # sequences never start with `\e[?`, so this can't eat a regular key.
167
+ char += $stdin.read(1) while char.start_with?("\e[?") && !char.match?(/[\x40-\x7e]\z/)
162
168
 
163
169
  char
164
170
  end
data/lib/tuile/rect.rb CHANGED
@@ -49,6 +49,18 @@ module Tuile
49
49
  point.x >= left && point.x < left + width && point.y >= top && point.y < top + height
50
50
  end
51
51
 
52
+ # @param other [Rect] another rectangle.
53
+ # @return [Boolean] true if `other` lies entirely within this rectangle.
54
+ # Uses the same half-open edges as {#contains?} (right/bottom exclusive).
55
+ # An {#empty? empty} `other` covers no cells, so it is trivially contained.
56
+ def contains_rect?(other)
57
+ return true if other.empty?
58
+
59
+ other.left >= left && other.top >= top &&
60
+ other.left + other.width <= left + width &&
61
+ other.top + other.height <= top + height
62
+ end
63
+
52
64
  # @return [Size]
53
65
  def size = Size.new(width, height)
54
66
 
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
@@ -143,7 +206,7 @@ module Tuile
143
206
  check_locked
144
207
  if focused.nil?
145
208
  @focused = nil
146
- @pane.on_tree { it.active = false }
209
+ @pane.on_tree { _1.active = false }
147
210
  else
148
211
  raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
149
212
 
@@ -154,7 +217,7 @@ module Tuile
154
217
  active << cursor
155
218
  cursor = cursor.parent
156
219
  end
157
- @pane.on_tree { it.active = active.include?(it) }
220
+ @pane.on_tree { _1.active = active.include?(_1) }
158
221
  @focused.on_focus
159
222
  end
160
223
  refresh_status_bar
@@ -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)
@@ -301,9 +370,7 @@ module Tuile
301
370
  raise ArgumentError,
302
371
  "#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
303
372
  end
304
- unless hint.nil? || hint.is_a?(String)
305
- raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}"
306
- end
373
+ raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)
307
374
 
308
375
  @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
309
376
  refresh_status_bar
@@ -322,7 +389,7 @@ module Tuile
322
389
  def active_window
323
390
  check_locked
324
391
  result = nil
325
- @pane.content&.on_tree { result = it if it.is_a?(Component::Window) && it.active? }
392
+ @pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
326
393
  result
327
394
  end
328
395
 
@@ -335,10 +402,25 @@ module Tuile
335
402
  # @return [void]
336
403
  def remove_popup(window)
337
404
  check_locked
405
+ return unless @pane.has_popup?(window)
406
+
338
407
  @pane.remove_popup(window)
339
408
  needs_full_repaint
340
409
  end
341
410
 
411
+ # Invalidates the entire attached tree, forcing every component to repaint
412
+ # on the next cycle. Needed whenever something overdraws the scene without
413
+ # clipping and then exposes what was underneath — a closing popup
414
+ # ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
415
+ # longer covers the cells it previously painted ({Component::Popup#rect=}).
416
+ # The popup-only fast path in {#repaint} can't clear those vacated cells on
417
+ # its own, so we accept the cost of a full repaint.
418
+ # @api private
419
+ # @return [void]
420
+ def needs_full_repaint
421
+ @pane&.on_tree { invalidate _1 }
422
+ end
423
+
342
424
  # Internal — use {Component::Popup#open?} instead.
343
425
  # @api private
344
426
  # @param window [Component::Popup]
@@ -426,8 +508,8 @@ module Tuile
426
508
  # grandchild (depth 3) sorts after a popup's content (depth 2) and
427
509
  # overdraws it.
428
510
  popup_tree = Set.new
429
- popups.each { |p| p.on_tree { popup_tree << it } }
430
- tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(it) }
511
+ popups.each { |p| p.on_tree { popup_tree << _1 } }
512
+ tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(_1) }
431
513
 
432
514
  # Within the tiled tree, paint parents before children.
433
515
  tiled.sort_by!(&:depth)
@@ -476,6 +558,27 @@ module Tuile
476
558
 
477
559
  private
478
560
 
561
+ # Startup color scheme: `:light` when {TerminalBackground.detect}
562
+ # reports a light terminal background, `:dark` otherwise (including
563
+ # when detection is inconclusive). Runs in the constructor — the
564
+ # OSC 11 reply arrives on stdin, which is only safe to read before
565
+ # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
566
+ # to pin `:dark`, keeping specs deterministic and off the test
567
+ # runner's TTY.
568
+ # @return [Symbol] `:dark` or `:light`.
569
+ def detect_scheme
570
+ TerminalBackground.detect == :light ? :light : :dark
571
+ end
572
+
573
+ # An OS appearance flip arrived (mode-2031 report): remember the
574
+ # scheme and apply the matching member of {#theme_def}.
575
+ # @param scheme [Symbol] `:dark` or `:light`.
576
+ # @return [void]
577
+ def on_color_scheme(scheme)
578
+ @scheme = scheme
579
+ self.theme = @theme_def.for(@scheme)
580
+ end
581
+
479
582
  # Walks the current modal scope in pre-order, collects tab stops, and
480
583
  # advances focus by one (wrapping). When the focused component isn't in
481
584
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -510,7 +613,7 @@ module Tuile
510
613
  # @return [Array<Component>]
511
614
  def collect_subtree(component)
512
615
  result = []
513
- component.on_tree { result << it }
616
+ component.on_tree { result << _1 }
514
617
  result
515
618
  end
516
619
 
@@ -536,13 +639,6 @@ module Tuile
536
639
  repaint
537
640
  end
538
641
 
539
- # Called after a popup is closed. Since a popup can cover any window,
540
- # top-level component or other popups, we need to redraw everything.
541
- # @return [void]
542
- def needs_full_repaint
543
- @pane&.on_tree { invalidate it }
544
- end
545
-
546
642
  # A key has been pressed on the keyboard. Handle it, or forward to active
547
643
  # window.
548
644
  #
@@ -596,6 +692,8 @@ module Tuile
596
692
  when EventQueue::TTYSizeEvent
597
693
  @size = event.size
598
694
  layout
695
+ when EventQueue::ColorSchemeEvent
696
+ on_color_scheme(event.scheme)
599
697
  when EventQueue::EmptyQueueEvent
600
698
  repaint
601
699
  when Proc
@@ -140,7 +140,7 @@ module Tuile
140
140
  # @param event [MouseEvent]
141
141
  # @return [void]
142
142
  def handle_mouse(event)
143
- clicked = @popups.reverse_each.find { it.rect.contains?(event.point) }
143
+ clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
144
144
  clicked = @content if clicked.nil? && @popups.empty?
145
145
  clicked&.handle_mouse(event)
146
146
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A sizing policy for a slot whose position is managed by a parent
5
+ # component (e.g. {Component::Window#footer}). Resolves one dimension at a
6
+ # time via {#resolve}, so the same value works for widths and heights.
7
+ #
8
+ # Three policies exist:
9
+ #
10
+ # - {FILL} — take everything the slot offers;
11
+ # - {WRAP_CONTENT} — take the component's natural extent (its
12
+ # {Component#content_size}), clamped to the slot;
13
+ # - {.fixed} — take exactly the given number of cells, clamped to the slot.
14
+ #
15
+ # Note that {WRAP_CONTENT} only makes sense for components that report a
16
+ # natural {Component#content_size} ({Component::Label}, {Component::Button},
17
+ # {Component::List}, …). Input components ({Component::TextField} et al.)
18
+ # report {Size::ZERO}, so a wrap-content slot collapses to zero width —
19
+ # i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
20
+ #
21
+ # @!attribute [r] mode
22
+ # @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
23
+ # @!attribute [r] amount
24
+ # @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
25
+ class Sizing < Data.define(:mode, :amount)
26
+ # @param amount [Integer] the number of cells to occupy; 0 or greater.
27
+ # @return [Sizing] a fixed-size policy.
28
+ def self.fixed(amount)
29
+ raise TypeError, "expected Integer, got #{amount.inspect}" unless amount.is_a?(Integer)
30
+ raise ArgumentError, "amount must not be negative, got #{amount}" if amount.negative?
31
+
32
+ new(mode: :fixed, amount: amount)
33
+ end
34
+
35
+ # Resolves one dimension of a slot.
36
+ # @param available [Integer] cells the slot offers; 0 or greater.
37
+ # @param content [Integer] the component's natural extent on this axis
38
+ # (one dimension of its {Component#content_size}).
39
+ # @return [Integer] the resolved extent, always in `0..available`.
40
+ def resolve(available, content)
41
+ case mode
42
+ when :fill then available
43
+ when :fixed then amount.clamp(0, available)
44
+ when :wrap_content then content.clamp(0, available)
45
+ else raise ArgumentError, "unknown mode #{mode.inspect}"
46
+ end
47
+ end
48
+
49
+ # Occupy everything the slot offers.
50
+ # @return [Sizing]
51
+ FILL = new(mode: :fill, amount: nil)
52
+
53
+ # Occupy the component's natural {Component#content_size}, clamped to the
54
+ # slot. Components reporting {Size::ZERO} collapse to invisibility — see
55
+ # the class doc.
56
+ # @return [Sizing]
57
+ WRAP_CONTENT = new(mode: :wrap_content, amount: nil)
58
+ end
59
+ end