tuile 0.4.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.
data/sig/tuile.rbs CHANGED
@@ -17,9 +17,10 @@ module Tuile
17
17
  end
18
18
 
19
19
  # ANSI escape sequence constants. Tuile emits colors and text attributes
20
- # via Rainbow, which produces **SGR** sequences ("Select Graphic
21
- # Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m` bold,
22
- # `\e[0m` reset).
20
+ # via {StyledString} / {Color}, which produce **SGR** sequences ("Select
21
+ # Graphic Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m`
22
+ # bold, `\e[0m` reset). Host apps may also use Rainbow, which emits the
23
+ # same form.
23
24
  module Ansi
24
25
  RESET: String
25
26
  end
@@ -188,6 +189,113 @@ module Tuile
188
189
  attr_reader height: Integer
189
190
  end
190
191
 
192
+ # An immutable terminal color. Accepts the three forms ANSI/SGR understands:
193
+ #
194
+ # - a Symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright named colors
195
+ # (SGR 30..37 / 90..97 for fg, 40..47 / 100..107 for bg)
196
+ # - an Integer 0..255 — the 256-color palette (SGR 38;5;N / 48;5;N)
197
+ # - an Array of three Integers 0..255 — 24-bit RGB (SGR 38;2;R;G;B / 48;2;R;G;B)
198
+ #
199
+ # A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
200
+ # …) so callers can reach for `Color::RED` instead of building one each time.
201
+ # The 256-color palette gets the same treatment via {PALETTE_NAMES}:
202
+ # `Color::CADET_BLUE`, `Color::DODGER_BLUE1`, `Color::GREY37`, … — the
203
+ # standard xterm chart names for indices 16..255, each an exact palette cell.
204
+ # {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
205
+ # an existing {Color} (returned as-is), so APIs that accept colors typically
206
+ # take `[Color, nil]` and pass through {.coerce}.
207
+ #
208
+ # ```ruby
209
+ # Color.new(:red) # named
210
+ # Color.new(42) # 256-color palette
211
+ # Color.new([255, 100, 0]) # RGB
212
+ # Color::RED # constant
213
+ # Color.palette(42) # 256-color palette, explicit
214
+ # Color.rgb(255, 100, 0) # 24-bit RGB, explicit
215
+ # Color.hex("#ff6400") # 24-bit RGB from a CSS-style hex string
216
+ # Color.coerce(:red) # accepts raw forms, returns Color
217
+ # Color.coerce(nil) # nil → nil
218
+ # ```
219
+ #
220
+ # Which entry point to use is a deliberate policy split. High-traffic
221
+ # call sites ({StyledString} and friends) stay lenient and {.coerce} raw
222
+ # forms — you don't want factory ceremony on every styled span.
223
+ # Declaration sites ({Theme}, defined once per app) are strict and take
224
+ # only {Color} instances, where `Color.palette(130)` documents itself in
225
+ # a way the bare `130` (palette index? RGB channel?) does not.
226
+ #
227
+ # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
228
+ # raw numeric codes so callers (notably {StyledString}) can combine them with
229
+ # other SGR attributes in a single sequence.
230
+ class Color
231
+ COLOR_SYMBOLS: ::Array[Symbol]
232
+ PALETTE_NAMES: ::Hash[Symbol, Integer]
233
+
234
+ # Coerces the input to a {Color}. `nil` passes through unchanged (callers
235
+ # use `nil` for the terminal default); an existing {Color} is returned
236
+ # as-is; otherwise the value is fed to {.new}.
237
+ #
238
+ # _@param_ `value`
239
+ def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
240
+
241
+ # A color from the 256-color palette (SGR 38;5;N / 48;5;N). Same as
242
+ # `Color.new(index)`, but the name says what the bare integer is.
243
+ #
244
+ # _@param_ `index` — palette index, 0..255.
245
+ def self.palette: (Integer index) -> Color
246
+
247
+ # A 24-bit RGB color (SGR 38;2;R;G;B / 48;2;R;G;B). Same as
248
+ # `Color.new([r, g, b])`, but with the channels spelled out.
249
+ #
250
+ # _@param_ `red` — 0..255.
251
+ #
252
+ # _@param_ `green` — 0..255.
253
+ #
254
+ # _@param_ `blue` — 0..255.
255
+ def self.rgb: (Integer red, Integer green, Integer blue) -> Color
256
+
257
+ # A 24-bit RGB color from a CSS-style hex string — for when the value
258
+ # comes from a hex source (a designer's palette, a CSS variable). The
259
+ # leading `#` is optional, digits are case-insensitive, and the CSS
260
+ # 3-digit shorthand expands as in CSS (`"#345"` → `"#334455"`).
261
+ # 4/8-digit alpha forms are rejected: SGR has no alpha channel, and
262
+ # silently dropping it would lie about the rendered color.
263
+ #
264
+ # _@param_ `string` — e.g. `"#333333"`, `"5F9EA0"`, `"#333"`.
265
+ #
266
+ # _@return_ — same value form as {.rgb} — `Color.hex("#333") ==
267
+ # Color.rgb(51, 51, 51)`.
268
+ def self.hex: (String string) -> Color
269
+
270
+ # _@param_ `value` — see class-level docs for the three accepted forms.
271
+ def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
272
+
273
+ # SGR parameter codes for emitting this color as either a foreground
274
+ # (`target: :fg`) or background (`target: :bg`). Returned as an array so
275
+ # callers can splice them into a multi-attribute SGR (e.g. bold + color).
276
+ #
277
+ # _@param_ `target` — `:fg` or `:bg`.
278
+ def sgr_codes: (?Symbol target) -> ::Array[Integer]
279
+
280
+ # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
281
+ # `print`-style direct emission; for composing with other attributes use
282
+ # {#sgr_codes} instead.
283
+ #
284
+ # _@param_ `target` — `:fg` or `:bg`.
285
+ def to_ansi: (?Symbol target) -> String
286
+
287
+ # _@param_ `other`
288
+ def ==: (Object other) -> bool
289
+
290
+ def hash: () -> Integer
291
+
292
+ def inspect: () -> String
293
+
294
+ # The underlying raw representation — a Symbol, Integer, or frozen
295
+ # Array<Integer>.
296
+ attr_reader value: (Symbol | Integer | ::Array[Integer])
297
+ end
298
+
191
299
  # A point with `x` and `y` integer coordinates, both 0-based.
192
300
  #
193
301
  # @!attribute [r] x
@@ -204,6 +312,213 @@ module Tuile
204
312
  attr_reader y: Integer
205
313
  end
206
314
 
315
+ # A set of semantic colors the built-in components read when painting.
316
+ # The current theme lives at {Screen#theme}; components must look it up
317
+ # at paint time (inside `repaint`) rather than caching values, so that
318
+ # assigning {Screen#theme=} restyles everything via a single
319
+ # invalidate-everything pass.
320
+ #
321
+ # The primary API is the rendering helpers — {#active_bg},
322
+ # {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
323
+ # the token's SGR color (on the channel appropriate for the token's
324
+ # role) and reset:
325
+ #
326
+ # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
327
+ # screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
328
+ #
329
+ # The helpers pass content through verbatim, so input may carry other
330
+ # escape sequences (e.g. {Component::Window} feeds its border string,
331
+ # cursor moves included). For span-aware styling — applying a token to a
332
+ # {StyledString} while preserving per-span colors — use the `*_color`
333
+ # readers instead (e.g. {Component::List} highlights its cursor row via
334
+ # `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
335
+ # helper; structured text → `*_color` reader + {StyledString}.
336
+ #
337
+ # Two built-in themes are provided: {DARK} (the default; the colors Tuile
338
+ # has always used) and {LIGHT} (counterparts legible on light terminal
339
+ # backgrounds). A custom theme is one `with` away:
340
+ #
341
+ # screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
342
+ #
343
+ # Tokens deliberately cover only the *accents* Tuile paints. Everything
344
+ # else inherits the terminal's own default foreground/background, which
345
+ # already matches the user's terminal theme perfectly — that's why there
346
+ # is no global `bg`/`fg` token.
347
+ #
348
+ # Every token is a {Color} — and must be passed as one. Unlike the
349
+ # lenient {Color.coerce} call sites elsewhere in the framework, a theme
350
+ # is declared once per app, so it takes only {Color} instances: at a
351
+ # declaration site `Color.palette(130)` documents itself in a way the
352
+ # bare `130` does not (palette index? RGB channel?) — and the named
353
+ # palette constants (`Color::DARK_ORANGE3` *is* 130; see
354
+ # {Color::PALETTE_NAMES}) go one step further.
355
+ #
356
+ # ## App-specific tokens
357
+ #
358
+ # Beyond the built-in tokens, an app can carry its own colors in
359
+ # {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
360
+ # {#[]} (fail-fast: a typo raises `KeyError`) and render with the
361
+ # generic {#fg} / {#bg} helpers:
362
+ #
363
+ # theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
364
+ # theme[:accent] # => Color, e.g. for StyledString#with_fg
365
+ # theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
366
+ #
367
+ # Apps wanting semantic readers can subclass — `Data#with` preserves the
368
+ # subclass, so an `AppTheme` stays an `AppTheme` through `with`:
369
+ #
370
+ # class AppTheme < Tuile::Theme
371
+ # def accent(text) = fg(:accent, text)
372
+ # end
373
+ #
374
+ # Pair the dark and light variants in a {ThemeDef} and hand it to
375
+ # {Screen#theme_def=} so OS appearance flips pick the right one.
376
+ #
377
+ # @!attribute [r] active_bg_color
378
+ # Background highlight of the component the user is interacting with:
379
+ # the {Component::List} cursor row, the focused {Component::TextField} /
380
+ # {Component::TextArea} well, the focused {Component::Button}. "Active"
381
+ # matches the {Component#active?} focus-chain flag — this is the
382
+ # focus/selection highlight in conventional UI terms.
383
+ # @return [Color]
384
+ # @!attribute [r] active_border_color
385
+ # Foreground of a {Component::Window} border when the window is on the
386
+ # active (focus) chain.
387
+ # @return [Color]
388
+ # @!attribute [r] input_bg_color
389
+ # Resting background "well" of {Component::TextField} /
390
+ # {Component::TextArea} when *not* active — visibly a field, but
391
+ # distinctly subtler than {#active_bg_color}.
392
+ # @return [Color]
393
+ # @!attribute [r] hint_color
394
+ # Foreground of keyboard-shortcut captions in status-bar hints (the
395
+ # "quit" in "q quit") — see {#hint}.
396
+ # @return [Color]
397
+ # @!attribute [r] custom
398
+ # App-specific color tokens; empty in the built-in themes. Frozen —
399
+ # build a changed theme via `with(custom: ...)`. Prefer {#[]} for
400
+ # lookups (it fail-fasts on typos); read this directly to enumerate
401
+ # the tokens.
402
+ # @return [Hash{Symbol => Color}]
403
+ class Theme
404
+ DARK: Theme
405
+ LIGHT: Theme
406
+
407
+ # _@param_ `active_bg_color`
408
+ #
409
+ # _@param_ `active_border_color`
410
+ #
411
+ # _@param_ `input_bg_color`
412
+ #
413
+ # _@param_ `hint_color`
414
+ #
415
+ # _@param_ `custom` — app-specific tokens, see {#custom}.
416
+ def initialize: (
417
+ active_bg_color: Color,
418
+ active_border_color: Color,
419
+ input_bg_color: Color,
420
+ hint_color: Color,
421
+ ?custom: ::Hash[Symbol, Color]
422
+ ) -> void
423
+
424
+ # Looks up an app-specific token from {#custom}.
425
+ #
426
+ # _@param_ `token`
427
+ def []: (Symbol token) -> Color
428
+
429
+ # Renders `text` in the foreground color of the app-specific `token`
430
+ # — the generic counterpart of {#hint} for {#custom} tokens.
431
+ #
432
+ # _@param_ `token`
433
+ #
434
+ # _@param_ `text`
435
+ #
436
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
437
+ def fg: (Symbol token, String text) -> String
438
+
439
+ # Renders `text` on the background color of the app-specific `token`
440
+ # — the generic counterpart of {#active_bg} for {#custom} tokens.
441
+ #
442
+ # _@param_ `token`
443
+ #
444
+ # _@param_ `text`
445
+ #
446
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
447
+ def bg: (Symbol token, String text) -> String
448
+
449
+ # Renders `text` on the {#active_bg_color} background.
450
+ #
451
+ # _@param_ `text`
452
+ #
453
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
454
+ def active_bg: (String text) -> String
455
+
456
+ # Renders `text` in the {#active_border_color} foreground. Content
457
+ # passes through verbatim, so it may embed non-SGR escapes (cursor
458
+ # moves in a border string).
459
+ #
460
+ # _@param_ `text`
461
+ #
462
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
463
+ def active_border: (String text) -> String
464
+
465
+ # Renders `text` on the {#input_bg_color} background.
466
+ #
467
+ # _@param_ `text`
468
+ #
469
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
470
+ def input_bg: (String text) -> String
471
+
472
+ # Renders `text` in the {#hint_color} foreground, for status-bar hints,
473
+ # e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
474
+ # returned String, so strings built this way do *not* restyle when the
475
+ # theme changes — rebuild them instead (the framework's own call sites
476
+ # rebuild on every status-bar refresh).
477
+ #
478
+ # _@param_ `text`
479
+ #
480
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
481
+ def hint: (String text) -> String
482
+
483
+ # The single sanctioned place for verbatim SGR wrapping: `text` is not
484
+ # parsed or validated, so callers may embed non-SGR escapes. Emits the
485
+ # same bytes `StyledString.styled(text, ...).to_ansi` would for plain
486
+ # text.
487
+ #
488
+ # _@param_ `text`
489
+ #
490
+ # _@param_ `color`
491
+ #
492
+ # _@param_ `target` — `:fg` or `:bg`.
493
+ def wrap: (String text, Color color, Symbol target) -> String
494
+
495
+ # Background highlight of the component the user is interacting with:
496
+ # the {Component::List} cursor row, the focused {Component::TextField} /
497
+ # {Component::TextArea} well, the focused {Component::Button}. "Active"
498
+ # matches the {Component#active?} focus-chain flag — this is the
499
+ # focus/selection highlight in conventional UI terms.
500
+ attr_reader active_bg_color: Color
501
+
502
+ # Foreground of a {Component::Window} border when the window is on the
503
+ # active (focus) chain.
504
+ attr_reader active_border_color: Color
505
+
506
+ # Resting background "well" of {Component::TextField} /
507
+ # {Component::TextArea} when *not* active — visibly a field, but
508
+ # distinctly subtler than {#active_bg_color}.
509
+ attr_reader input_bg_color: Color
510
+
511
+ # Foreground of keyboard-shortcut captions in status-bar hints (the
512
+ # "quit" in "q quit") — see {#hint}.
513
+ attr_reader hint_color: Color
514
+
515
+ # App-specific color tokens; empty in the built-in themes. Frozen —
516
+ # build a changed theme via `with(custom: ...)`. Prefer {#[]} for
517
+ # lookups (it fail-fasts on typos); read this directly to enumerate
518
+ # the tokens.
519
+ attr_reader custom: ::Hash[Symbol, Color]
520
+ end
521
+
207
522
  # The TTY screen. There is exactly one screen per app.
208
523
  #
209
524
  # A screen runs the event loop; call {#run_event_loop} to do that.
@@ -322,7 +637,7 @@ module Tuile
322
637
  #
323
638
  # screen.register_global_shortcut(Keys::CTRL_L,
324
639
  # over_popups: true,
325
- # hint: "^L #{Rainbow("log").cadetblue}") do
640
+ # hint: "^L #{screen.theme.hint("log")}") do
326
641
  # log_popup.open
327
642
  # end
328
643
  #
@@ -330,7 +645,7 @@ module Tuile
330
645
  #
331
646
  # _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup's own key handling). When false (default), the shortcut is suppressed while any popup is open and the popup gets the key instead.
332
647
  #
333
- # _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut is silent in the status bar.
648
+ # _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{screen.theme.hint("log")}"`). When nil (default) the shortcut is silent in the status bar. The colors are baked into the string, so a later {#theme=} does not restyle it — re-register if needed.
334
649
  def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
335
650
 
336
651
  # Removes a shortcut previously installed by {#register_global_shortcut}.
@@ -398,6 +713,23 @@ module Tuile
398
713
  # but only one focused.
399
714
  def cursor_position: () -> Point?
400
715
 
716
+ # Startup color scheme: `:light` when {TerminalBackground.detect}
717
+ # reports a light terminal background, `:dark` otherwise (including
718
+ # when detection is inconclusive). Runs in the constructor — the
719
+ # OSC 11 reply arrives on stdin, which is only safe to read before
720
+ # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
721
+ # to pin `:dark`, keeping specs deterministic and off the test
722
+ # runner's TTY.
723
+ #
724
+ # _@return_ — `:dark` or `:light`.
725
+ def detect_scheme: () -> Symbol
726
+
727
+ # An OS appearance flip arrived (mode-2031 report): remember the
728
+ # scheme and apply the matching member of {#theme_def}.
729
+ #
730
+ # _@param_ `scheme` — `:dark` or `:light`.
731
+ def on_color_scheme: (Symbol scheme) -> void
732
+
401
733
  # Walks the current modal scope in pre-order, collects tab stops, and
402
734
  # advances focus by one (wrapping). When the focused component isn't in
403
735
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -481,6 +813,22 @@ module Tuile
481
813
  # _@return_ — current screen size.
482
814
  attr_reader size: Size
483
815
 
816
+ # The color {Theme} built-in components read at paint time: the member
817
+ # of {#theme_def} matching the terminal background detected at
818
+ # construction (see {TerminalBackground.detect}; inconclusive means
819
+ # dark). While the event loop runs, terminals supporting mode 2031
820
+ # push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
821
+ # screen re-picks from {#theme_def}.
822
+ attr_accessor theme: Theme
823
+
824
+ # The app's {ThemeDef} — the dark/light {Theme} pair the screen picks
825
+ # {#theme} from, at startup and on every OS appearance flip. Starts as
826
+ # {ThemeDef.default} ({ThemeDef::DEFAULT} unless reassigned — tests
827
+ # do, see {ThemeDef.default=}). Assigning a custom definition is the
828
+ # durable way to theme an app: unlike a bare {#theme=}, it survives
829
+ # the user toggling the OS appearance.
830
+ attr_accessor theme_def: ThemeDef
831
+
484
832
  # _@return_ — the event queue.
485
833
  attr_reader event_queue: EventQueue
486
834
 
@@ -502,6 +850,52 @@ module Tuile
502
850
  end
503
851
  end
504
852
 
853
+ # A sizing policy for a slot whose position is managed by a parent
854
+ # component (e.g. {Component::Window#footer}). Resolves one dimension at a
855
+ # time via {#resolve}, so the same value works for widths and heights.
856
+ #
857
+ # Three policies exist:
858
+ #
859
+ # - {FILL} — take everything the slot offers;
860
+ # - {WRAP_CONTENT} — take the component's natural extent (its
861
+ # {Component#content_size}), clamped to the slot;
862
+ # - {.fixed} — take exactly the given number of cells, clamped to the slot.
863
+ #
864
+ # Note that {WRAP_CONTENT} only makes sense for components that report a
865
+ # natural {Component#content_size} ({Component::Label}, {Component::Button},
866
+ # {Component::List}, …). Input components ({Component::TextField} et al.)
867
+ # report {Size::ZERO}, so a wrap-content slot collapses to zero width —
868
+ # i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
869
+ #
870
+ # @!attribute [r] mode
871
+ # @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
872
+ # @!attribute [r] amount
873
+ # @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
874
+ class Sizing
875
+ FILL: Sizing
876
+ WRAP_CONTENT: Sizing
877
+
878
+ # _@param_ `amount` — the number of cells to occupy; 0 or greater.
879
+ #
880
+ # _@return_ — a fixed-size policy.
881
+ def self.fixed: (Integer amount) -> Sizing
882
+
883
+ # Resolves one dimension of a slot.
884
+ #
885
+ # _@param_ `available` — cells the slot offers; 0 or greater.
886
+ #
887
+ # _@param_ `content` — the component's natural extent on this axis (one dimension of its {Component#content_size}).
888
+ #
889
+ # _@return_ — the resolved extent, always in `0..available`.
890
+ def resolve: (Integer available, Integer content) -> Integer
891
+
892
+ # _@return_ — `:fill`, `:wrap_content` or `:fixed`.
893
+ attr_reader mode: Symbol
894
+
895
+ # _@return_ — the cell count for `:fixed`; `nil` otherwise.
896
+ attr_reader amount: Integer?
897
+ end
898
+
505
899
  # A UI component which is positioned on the screen and draws characters into
506
900
  # its bounding rectangle (in {#repaint}).
507
901
  #
@@ -636,13 +1030,19 @@ module Tuile
636
1030
  # _@param_ `child` — the just-detached child.
637
1031
  def on_child_removed: (Component child) -> void
638
1032
 
639
- # The {Size} big enough to show the entire component contents without
640
- # scrolling. Plain components have no intrinsic content and report
641
- # {Size::ZERO}; container/decorative components (e.g. {Label}, {List},
642
- # {Layout}, {Window}) override this to fold in their content's natural
643
- # extent. Used by callers like {Component::Popup} to auto-size to
644
- # whatever content was assigned, regardless of its concrete type.
645
- def content_size: () -> Size
1033
+ # Called by a child component whose {#content_size} just changed (fired
1034
+ # from the child's {#content_size=}). Does nothing by default a plain
1035
+ # container is not size-coupled to its children. Containers that derive
1036
+ # their own natural size or child layout from a child's natural size
1037
+ # override this (e.g. {Component::Window} re-lays-out a
1038
+ # {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
1039
+ # {Component::Popup} re-self-sizes). If the receiver's own
1040
+ # {#content_size} changes as a consequence, its {#content_size=} notifies
1041
+ # *its* parent in turn — so the event bubbles exactly as far as geometry
1042
+ # keeps changing, and stops where it doesn't.
1043
+ #
1044
+ # _@param_ `child` — the resized direct child.
1045
+ def on_child_content_size_changed: (Component child) -> void
646
1046
 
647
1047
  # Where the hardware terminal cursor should sit when this component is the
648
1048
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
@@ -695,6 +1095,35 @@ module Tuile
695
1095
  # no parent.
696
1096
  attr_accessor parent: Component?
697
1097
 
1098
+ # Called on every attached component (pre-order, popups included) when
1099
+ # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
1100
+ # assignment and on OS appearance flips.
1101
+ #
1102
+ # Built-in components read {Screen#theme} at paint time, so their accents
1103
+ # restyle automatically; this hook exists for *content* whose colors the
1104
+ # app baked in from the old theme — a {Label#text} / {List#lines} /
1105
+ # {TextView#text} {StyledString} styled with `theme[:accent]` and the
1106
+ # like. Only the app knows which of its colors were theme-derived (as
1107
+ # opposed to inherent to the data, e.g. log-level colors), so it rebuilds
1108
+ # them here, re-running the same code that rendered them initially.
1109
+ #
1110
+ # Runs on the UI thread; {Screen#theme} already returns the new theme.
1111
+ # Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
1112
+ # event-loop tick. Do not assign {Screen#theme=} from inside the hook.
1113
+ #
1114
+ # Subclasses overriding this should call `super` so an assigned
1115
+ # {#on_theme_changed=} listener keeps firing.
1116
+ attr_accessor on_theme_changed: Proc?
1117
+
1118
+ # The {Size} big enough to show the entire component contents without
1119
+ # scrolling. Plain components have no intrinsic content and report
1120
+ # {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
1121
+ # {TextView}, {Window}) maintain it eagerly via {#content_size=} from
1122
+ # their mutators, so reads are O(1). Used by callers like
1123
+ # {Component::Popup} to auto-size to whatever content was assigned,
1124
+ # regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
1125
+ attr_accessor content_size: Size
1126
+
698
1127
  # A scrollable list of items with cursor support.
699
1128
  #
700
1129
  # Items are modeled as {StyledString}s and painted directly into the
@@ -707,11 +1136,9 @@ module Tuile
707
1136
  #
708
1137
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
709
1138
  # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
710
- # list automatically. The cursor highlight overlays a dark background
711
- # while preserving each span's foreground color.
1139
+ # list automatically. The cursor highlight overlays
1140
+ # {Theme#active_bg_color} while preserving each span's foreground color.
712
1141
  class List < Component
713
- CURSOR_BG: Integer
714
-
715
1142
  def initialize: () -> void
716
1143
 
717
1144
  # Sets new lines. Each entry is coerced into a {StyledString} (a
@@ -750,8 +1177,6 @@ module Tuile
750
1177
  # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
751
1178
  def add_lines: (::Array[untyped] lines) -> void
752
1179
 
753
- def content_size: () -> Size
754
-
755
1180
  def focusable?: () -> bool
756
1181
 
757
1182
  def tab_stop?: () -> bool
@@ -803,6 +1228,19 @@ module Tuile
803
1228
  # is one, so the list snaps to the bottom on first paint.
804
1229
  def on_width_changed: () -> void
805
1230
 
1231
+ # Natural size from scratch: longest line's display width plus the two
1232
+ # single-space gutters {#pad_to_row} adds, × line count. An empty list
1233
+ # is {Size::ZERO} (no gutters for no content).
1234
+ def compute_content_size: () -> Size
1235
+
1236
+ # Incremental {#content_size} update for appends: folds just the
1237
+ # appended lines into the running maximum, keeping {#add_lines}
1238
+ # O(appended) instead of re-scanning the whole list (LogWindow appends
1239
+ # a line per log statement).
1240
+ #
1241
+ # _@param_ `appended` — the just-appended lines (already concatenated onto {@lines}).
1242
+ def grow_content_size: (::Array[StyledString] appended) -> void
1243
+
806
1244
  # Coerces and flattens a list of input entries into trimmed
807
1245
  # {StyledString} lines. Each entry becomes a {StyledString} (String
808
1246
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -1090,11 +1528,6 @@ module Tuile
1090
1528
  class Label < Component
1091
1529
  def initialize: () -> void
1092
1530
 
1093
- # _@return_ — longest hard-line's display width × number of hard
1094
- # lines. Reported on the *unclipped* text — sizing is intrinsic to
1095
- # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
1096
- def content_size: () -> Size
1097
-
1098
1531
  # Paints the text into {#rect}.
1099
1532
  #
1100
1533
  # Skips the {Component#repaint} default's auto-clear: every row is
@@ -1105,13 +1538,22 @@ module Tuile
1105
1538
 
1106
1539
  def on_width_changed: () -> void
1107
1540
 
1541
+ # Natural size: longest hard-line's display width × number of hard
1542
+ # lines. Computed on the *unclipped* text — sizing is intrinsic to the
1543
+ # content, not the viewport. Empty text yields {Size::ZERO}.
1544
+ def compute_content_size: () -> Size
1545
+
1108
1546
  # Recomputes {@clipped_lines} for the current text and rect width.
1109
1547
  # Each line is ellipsized to fit, padded with trailing spaces out to
1110
1548
  # the full width, and pre-rendered to ANSI so {#repaint} is just a
1111
1549
  # lookup + screen.print per row. {@blank_line} covers rows past the
1112
- # last text line.
1550
+ # last text line. When {#bg} is set, every produced line (and the
1551
+ # blank row) has the bg applied uniformly.
1113
1552
  def update_clipped_lines: () -> void
1114
1553
 
1554
+ # _@param_ `line`
1555
+ def apply_bg: (StyledString line) -> StyledString
1556
+
1115
1557
  # _@param_ `line`
1116
1558
  #
1117
1559
  # _@param_ `width`
@@ -1120,6 +1562,11 @@ module Tuile
1120
1562
  # _@return_ — the current text. Defaults to an empty
1121
1563
  # {StyledString}.
1122
1564
  attr_accessor text: (StyledString | String)?
1565
+
1566
+ # _@return_ — background color applied uniformly across every
1567
+ # painted row (including padding past the text). `nil` (default)
1568
+ # leaves whatever bg the text's own styling carries.
1569
+ attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
1123
1570
  end
1124
1571
 
1125
1572
  # A modal overlay that wraps any {Component} as its content. Popup itself
@@ -1182,6 +1629,14 @@ module Tuile
1182
1629
  # _@param_ `new_content`
1183
1630
  def content=: (Component? new_content) -> void
1184
1631
 
1632
+ # Re-sizes (and recenters, when open) whenever the wrapped content's
1633
+ # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
1634
+ # `add_line`, or a nested {Window} whose own content grew (the window
1635
+ # recomputes its {Component#content_size} and the change bubbles here).
1636
+ #
1637
+ # _@param_ `_child`
1638
+ def on_child_content_size_changed: (Component _child) -> void
1639
+
1185
1640
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
1186
1641
  def keyboard_hint: () -> String
1187
1642
 
@@ -1229,10 +1684,6 @@ module Tuile
1229
1684
 
1230
1685
  def tab_stop?: () -> bool
1231
1686
 
1232
- # _@return_ — natural width is `caption.length + 4` to fit
1233
- # `[ caption ]`; height is 1.
1234
- def content_size: () -> Size
1235
-
1236
1687
  # _@param_ `key`
1237
1688
  def handle_key: (String key) -> bool
1238
1689
 
@@ -1241,6 +1692,9 @@ module Tuile
1241
1692
 
1242
1693
  def repaint: () -> void
1243
1694
 
1695
+ # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
1696
+ def natural_size: () -> Size
1697
+
1244
1698
  # _@return_ — the button's label.
1245
1699
  attr_accessor caption: String
1246
1700
 
@@ -1335,11 +1789,21 @@ module Tuile
1335
1789
  # _@param_ `value`
1336
1790
  def scrollbar=: (bool value) -> void
1337
1791
 
1338
- # _@return_ the size needed to fit the window's content, footer
1339
- # (width only — footer overlays the bottom border), and caption,
1340
- # plus the 2-character border. Returns {Size}`.new(2, 2)` when the
1341
- # window has no content, footer, or caption.
1342
- def content_size: () -> Size
1792
+ # Sets the new content. Also recomputes the window's natural size.
1793
+ #
1794
+ # _@param_ `new_content`
1795
+ def content=: (Component? new_content) -> void
1796
+
1797
+ # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
1798
+ # size changes, and folds a content resize into the window's own
1799
+ # natural size (whose change then bubbles to the window's parent — e.g.
1800
+ # a {Popup} re-self-sizes). The footer deliberately does *not*
1801
+ # participate in the window's {#content_size}: it is decoration
1802
+ # overlaying the border, and must not drive the window's size — if it
1803
+ # doesn't fit, it is clipped to the inner width.
1804
+ #
1805
+ # _@param_ `child`
1806
+ def on_child_content_size_changed: (Component child) -> void
1343
1807
 
1344
1808
  # Fully repaints the window: both frame and contents.
1345
1809
  #
@@ -1373,6 +1837,17 @@ module Tuile
1373
1837
  # _@param_ `caption`
1374
1838
  def build_frame: (String caption) -> String
1375
1839
 
1840
+ # Recomputes the window's natural size: content's natural size (or the
1841
+ # caption, whichever is wider) plus the 2-character border. The footer
1842
+ # is deliberately excluded — see {#on_child_content_size_changed}. A
1843
+ # window with no content or caption sizes to `Size.new(2, 2)` (bare
1844
+ # border).
1845
+ def update_content_size: () -> void
1846
+
1847
+ # Positions the footer over the bottom border row, with its width
1848
+ # resolved by {#footer_sizing} against the inner width. A
1849
+ # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
1850
+ # rect — i.e. it is invisible, as if never assigned.
1376
1851
  def layout_footer: () -> void
1377
1852
 
1378
1853
  def on_focus: () -> void
@@ -1381,6 +1856,11 @@ module Tuile
1381
1856
  # row.
1382
1857
  attr_accessor footer: Component?
1383
1858
 
1859
+ # _@return_ — how the footer's width is computed from the window's
1860
+ # inner width; defaults to {Sizing::FILL} (the footer spans the full
1861
+ # inner width). The footer's height is always 1 (the border row).
1862
+ attr_accessor footer_sizing: Sizing
1863
+
1384
1864
  # _@return_ — the current caption, empty by default.
1385
1865
  attr_accessor caption: String
1386
1866
  end
@@ -1402,9 +1882,6 @@ module Tuile
1402
1882
  # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
1403
1883
  # callback may opt out of that by consuming Enter instead.
1404
1884
  class TextArea < Tuile::Component::TextInput
1405
- ACTIVE_BG_SGR: String
1406
- INACTIVE_BG_SGR: String
1407
-
1408
1885
  def initialize: () -> void
1409
1886
 
1410
1887
  def cursor_position: () -> Point?
@@ -1513,9 +1990,27 @@ module Tuile
1513
1990
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
1514
1991
  # empty {StyledString}.
1515
1992
  #
1993
+ # Detaches every existing {Region} (including the original default)
1994
+ # and installs a fresh internal default region that owns all the new
1995
+ # hard lines. Any handle the caller was holding becomes detached and
1996
+ # raises on use — see {Region#attached?}. The no-op short-circuit
1997
+ # (matching value, same {StyledString}) preserves existing regions.
1998
+ #
1516
1999
  # _@param_ `value`
1517
2000
  def text=: ((String | StyledString)? value) -> void
1518
2001
 
2002
+ # Creates a new empty {Region} at the spatial tail of the document
2003
+ # and returns its handle. Subsequent {#append} / {#<<} / {#add_line}
2004
+ # calls route through this new region (since it is now the spatial
2005
+ # tail). Earlier regions keep their content and their handles stay
2006
+ # valid; their {Region#range} shifts as later regions grow.
2007
+ #
2008
+ # Apps streaming logically-distinct sections (e.g. an LLM's "thinking"
2009
+ # vs. "assistant" output) create one region per section, hold the
2010
+ # handles, and call `region.append` / `region.text=` directly when
2011
+ # they need to grow or rewrite an earlier section.
2012
+ def create_region: () -> Region
2013
+
1519
2014
  # _@return_ — true iff {#text} is empty (no hard lines).
1520
2015
  def empty?: () -> bool
1521
2016
 
@@ -1568,6 +2063,56 @@ module Tuile
1568
2063
  # _@param_ `n` — number of hard lines to drop; must be >= 0.
1569
2064
  def remove_last_n_lines: (Integer n) -> void
1570
2065
 
2066
+ # Replaces a contiguous range of hard lines with the parsed content
2067
+ # of `str`. The replacement is parsed exactly like {#text=} and
2068
+ # {#append}: a {String} is run through {StyledString.parse} (so
2069
+ # embedded ANSI is honored), a {StyledString} is used as-is, `nil`
2070
+ # behaves like an empty replacement (the range is deleted). Embedded
2071
+ # `"\n"` in the replacement produces multiple hard lines, so a single
2072
+ # `replace` can grow or shrink the buffer.
2073
+ #
2074
+ # `range` selects which hard lines to swap out:
2075
+ #
2076
+ # - an `Integer` `n` is shorthand for `n..n` (replace one existing
2077
+ # line — `n` must be in `[0, hard-line count)`);
2078
+ # - a non-empty `Range` of hard-line indices replaces those lines;
2079
+ # - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
2080
+ # `hard_lines.size...hard_lines.size`) is *insertion* at that
2081
+ # position — no lines are removed. {#insert} is a thin alias for
2082
+ # this case.
2083
+ #
2084
+ # Endpoints must be non-negative integers; `begin` may equal
2085
+ # `hard-line count` (insertion at the end), `end` may not exceed
2086
+ # `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
2087
+ # are not accepted.
2088
+ #
2089
+ # Cost is roughly `O(from + length + new content)`: the splice
2090
+ # updates only the affected slice of the physical-row buffer, using
2091
+ # the per-hard-line wrap-count cache to locate the starting offset
2092
+ # without re-wrapping preceding lines. Lines outside the splice are
2093
+ # never re-wrapped. {#top_line} is clamped if the new line count
2094
+ # puts it past the end; {#auto_scroll} pins it to the bottom as
2095
+ # usual. The call is a no-op (no invalidation) when the parsed
2096
+ # replacement equals the covered range (vacuously true for an empty
2097
+ # range plus empty replacement, so `replace(n...n, "")` is a cheap
2098
+ # no-op).
2099
+ #
2100
+ # _@param_ `range` — hard-line indices to replace.
2101
+ #
2102
+ # _@param_ `str` — replacement content.
2103
+ def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
2104
+
2105
+ # Inserts `str` at hard-line index `at`. Equivalent to
2106
+ # `replace(at...at, str)` — a no-removal splice that grows the buffer
2107
+ # by the parsed line count. `at == hard-line count` is allowed and
2108
+ # appends at the end; for that case {#append} / {#add_line} are
2109
+ # usually more idiomatic.
2110
+ #
2111
+ # _@param_ `at` — 0-based hard-line index in `[0, hard-line count]`.
2112
+ #
2113
+ # _@param_ `str` — content to insert.
2114
+ def insert: (Integer at, (String | StyledString)? str) -> void
2115
+
1571
2116
  # Clears the text. Equivalent to `text = ""`.
1572
2117
  def clear: () -> void
1573
2118
 
@@ -1593,41 +2138,164 @@ module Tuile
1593
2138
  # this hook.
1594
2139
  def on_width_changed: () -> void
1595
2140
 
2141
+ # Validates and unpacks a {#replace}-style range argument into
2142
+ # inclusive `[from, to]` line indices. An `Integer` `n` becomes
2143
+ # `[n, n]` (which must point at an existing line — `Integer` is
2144
+ # never insertion sugar). A `Range` is normalized for
2145
+ # `exclude_end?`; `to == from - 1` is a valid empty range
2146
+ # (insertion at `from`), and `from` may equal `size` for
2147
+ # end-insertion. Shared by {#replace} and {Region#replace};
2148
+ # `size` is the buffer or region line count, and `what` is the
2149
+ # entity name woven into error messages.
2150
+ #
2151
+ # _@param_ `range`
2152
+ #
2153
+ # _@param_ `size`
2154
+ #
2155
+ # _@param_ `what`
2156
+ def normalize_replace_range: ((::Range[untyped] | Integer) range, ?Integer size, ?String what) -> [Integer, Integer]
2157
+
2158
+ # Hard-line index where `region` begins in {@hard_lines} — derived
2159
+ # by summing the line counts of all regions that precede it.
2160
+ #
2161
+ # _@param_ `region`
2162
+ def region_start_index: (Region region) -> Integer
2163
+
2164
+ # Joined {StyledString} of the hard lines that `region` owns. Mirrors
2165
+ # {#text} but scoped to one region.
2166
+ #
2167
+ # _@param_ `region`
2168
+ def text_for_region: (Region region) -> StyledString
2169
+
2170
+ # Replaces all of `region`'s hard lines with the parsed content of
2171
+ # `value`. Symmetric with {#text=}, scoped to one region. Empty/nil
2172
+ # content empties the region (no visible blank line). Works on
2173
+ # already-empty regions (insertion at the region's position).
2174
+ #
2175
+ # _@param_ `region`
2176
+ #
2177
+ # _@param_ `value`
2178
+ def set_region_text: (Region region, (String | StyledString)? value) -> void
2179
+
2180
+ # Region-scoped {#replace}. Validates `range` against
2181
+ # `region.line_count`, translates region-relative indices to
2182
+ # absolute buffer indices, splices, and updates the region's count.
2183
+ #
2184
+ # _@param_ `region`
2185
+ #
2186
+ # _@param_ `range`
2187
+ #
2188
+ # _@param_ `str`
2189
+ def replace_in_region: (Region region, (::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
2190
+
2191
+ # Verbatim append into `region`.
2192
+ #
2193
+ # _@param_ `region`
2194
+ #
2195
+ # _@param_ `str`
2196
+ def append_to_region: (Region region, (String | StyledString)? str) -> void
2197
+
2198
+ # Drops the last `n` hard lines from `region`'s tail via
2199
+ # {#splice_hard_lines}. `n` is clamped to the region's current
2200
+ # line count; callers guarantee `n > 0` and the region is
2201
+ # non-empty (the {Region#remove_last_n_lines} guard handles the
2202
+ # no-op cases).
2203
+ #
2204
+ # _@param_ `region`
2205
+ #
2206
+ # _@param_ `n`
2207
+ def remove_last_n_from_region: (Region region, Integer n) -> void
2208
+
2209
+ # Drops `region` from {@regions}: its hard lines are removed via
2210
+ # {#splice_hard_lines}, the handle is detached, and the always-one
2211
+ # default is restored if the removal would have left zero regions.
2212
+ # Skips the rewrap / invalidate work when the region was empty
2213
+ # (the buffer didn't change), but always detaches.
2214
+ #
2215
+ # _@param_ `region`
2216
+ def remove_region: (Region region) -> void
2217
+
2218
+ # Adjusts region line counts after a {@hard_lines} splice that
2219
+ # removed `removed_count` lines at index `from` and inserted
2220
+ # `added_count` in their place. Two passes:
2221
+ #
2222
+ # 1. Subtract each region's overlap with the removed range (uses
2223
+ # the original counts to compute positions). Remember the first
2224
+ # region that lost lines — that's the natural home for the
2225
+ # replacement content.
2226
+ # 2. Credit `added_count` to that region. For pure insertions (no
2227
+ # removal), there's no "first overlapping region" to pick from;
2228
+ # walk regions and credit the latest one starting at `from` (the
2229
+ # boundary tiebreaker matches the spatial-tail-routing of
2230
+ # {#append}). Past-the-end inserts fall back to the tail region.
2231
+ #
2232
+ # _@param_ `from`
2233
+ #
2234
+ # _@param_ `removed_count`
2235
+ #
2236
+ # _@param_ `added_count`
2237
+ def update_region_counts: (Integer from, Integer removed_count, Integer added_count) -> void
2238
+
1596
2239
  # _@return_ — number of visible lines.
1597
2240
  def viewport_lines: () -> Integer
1598
2241
 
1599
2242
  # _@return_ — the max value of {#top_line} for scroll-key clamping.
1600
2243
  def top_line_max: () -> Integer
1601
2244
 
1602
- # Recomputes {@physical_lines} for the current text and wrap width,
1603
- # pre-padding every line to `wrap_width` so {#paintable_line} is just
1604
- # a lookup + optional scrollbar-char append at paint time (and the
1605
- # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
1606
- # memoization, so re-painting on scroll is near-free). Clamps
1607
- # {@top_line} if the new line count puts it out of range.
2245
+ # Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
2246
+ # from {@hard_lines}. Called when wrap width changes (which
2247
+ # invalidates every cached row count) and from {#text=} (which
2248
+ # replaces the whole logical model). Mid-buffer mutators splice
2249
+ # incrementally via {#splice_hard_lines} and do *not* go through
2250
+ # here. Clamps {@top_line} if the new line count puts it out of
2251
+ # range.
1608
2252
  def rewrap: () -> void
1609
2253
 
1610
- # Wraps `hard_line` at `width` and appends the padded physical lines
1611
- # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
1612
- # and degenerate `width <= 0` both emit a single {@blank_line} row,
1613
- # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
1614
- # would have produced for those cases.
2254
+ # Wraps `hard_line` at `width` and returns the padded physical rows
2255
+ # alongside the row count. Empty hard lines (e.g. from a `"\n\n"`
2256
+ # run) and degenerate `width <= 0` both emit a single {@blank_line}
2257
+ # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
2258
+ # would have produced.
1615
2259
  #
1616
- # _@param_ `hard_line` — one hard-broken line (no embedded `"\n"`).
2260
+ # _@param_ `hard_line`
1617
2261
  #
1618
2262
  # _@param_ `width`
1619
- def append_physical_lines: (StyledString hard_line, Integer width) -> void
2263
+ def wrap_hard_line: (StyledString hard_line, Integer width) -> [::Array[StyledString], Integer]
1620
2264
 
1621
- # Pops from {@physical_lines} the rows that `hard_line` previously
1622
- # contributed (the inverse of {#append_physical_lines} for the same
1623
- # input). Used by {#append} when extending the last hard line: its
1624
- # old wrapped rows are dropped, then the extended hard line is
1625
- # re-wrapped and appended.
2265
+ # Appends `hard_line` to the tail of {@hard_lines}, updating the
2266
+ # wrap-count cache and {@physical_lines} in lockstep.
1626
2267
  #
1627
2268
  # _@param_ `hard_line`
1628
2269
  #
1629
2270
  # _@param_ `width`
1630
- def drop_physical_rows_for: (StyledString hard_line, Integer width) -> void
2271
+ def push_hard_line: (StyledString hard_line, Integer width) -> void
2272
+
2273
+ # Pops the last hard line, the corresponding cache entry, and the
2274
+ # physical rows that hard line contributed. Returns the popped
2275
+ # hard line.
2276
+ def pop_hard_line: () -> StyledString
2277
+
2278
+ # Splices `new_hard_lines` into the buffer in place of the `count`
2279
+ # hard lines starting at index `from`. Updates {@hard_lines},
2280
+ # {@hard_line_wrap_counts}, and {@physical_lines} consistently.
2281
+ # The starting physical-row offset is computed in O(`from`) integer
2282
+ # adds via the cache — no wraps of preceding hard lines. Wraps are
2283
+ # done only for the new content, so total cost is
2284
+ # `O(from + count + new_hard_lines.sum(&:display_width))`.
2285
+ #
2286
+ # _@param_ `from`
2287
+ #
2288
+ # _@param_ `count` — number of existing hard lines to remove.
2289
+ #
2290
+ # _@param_ `new_hard_lines`
2291
+ def splice_hard_lines: (Integer from, Integer count, ::Array[StyledString] new_hard_lines) -> void
2292
+
2293
+ # _@param_ `idx`
2294
+ #
2295
+ # _@return_ — the {@physical_lines} index where the hard line
2296
+ # at {@hard_lines}`[idx]` starts. O(`idx`) integer adds via the
2297
+ # wrap-count cache.
2298
+ def phys_offset_at: (Integer idx) -> Integer
1631
2299
 
1632
2300
  # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
1633
2301
  # default-styled `"\n"` between hard lines. Called from the {#text}
@@ -1691,6 +2359,136 @@ module Tuile
1691
2359
  # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
1692
2360
  # {#append}, so reads are O(1).
1693
2361
  attr_reader content_size: Size
2362
+
2363
+ # A logical section of a {TextView}'s text — a contiguous run of
2364
+ # hard lines the app wants to address as a unit (e.g. an LLM's
2365
+ # "thinking" output vs. its assistant message). The view always
2366
+ # has at least one region, an internal default that owns whatever
2367
+ # hard lines aren't claimed by an app-created region.
2368
+ #
2369
+ # Apps don't construct regions directly; call {TextView#create_region}
2370
+ # to get one. The handle stays valid as long as the region is
2371
+ # attached — i.e. until {TextView#text=} (or {TextView#clear}) wipes
2372
+ # the slate and installs a fresh internal default. Detached regions
2373
+ # raise {RuntimeError} on every mutator and reader.
2374
+ #
2375
+ # A region's position is derived from its sibling order and counts,
2376
+ # so growing or shrinking an earlier region implicitly shifts the
2377
+ # ranges of all later regions. Empty regions occupy zero rows but
2378
+ # still hold a position in the sequence; `region.text = ""` collapses
2379
+ # a region's visible footprint without detaching it. Pre-creating
2380
+ # empty placeholder regions is supported and is the natural pattern
2381
+ # for "I'll fill this in later" layouts.
2382
+ class Region
2383
+ # _@param_ `view` — the owning view (never `nil` at construction).
2384
+ #
2385
+ # _@param_ `line_count` — number of hard lines this region owns.
2386
+ def initialize: (TextView view, ?Integer line_count) -> void
2387
+
2388
+ # _@return_ — `true` while the region is owned by its
2389
+ # {TextView}. Becomes `false` permanently once detached
2390
+ # (typically by {TextView#text=} / {TextView#clear}).
2391
+ def attached?: () -> bool
2392
+
2393
+ # _@return_ — true iff the region owns zero hard lines.
2394
+ # Empty regions render nothing — they still hold a position in
2395
+ # the sequence, so subsequent mutations route to them as usual.
2396
+ def empty?: () -> bool
2397
+
2398
+ # _@return_ — the joined content of just this region's
2399
+ # hard lines. Empty regions return {StyledString::EMPTY}.
2400
+ def text: () -> StyledString
2401
+
2402
+ # Replaces all of this region's hard lines with the parsed content
2403
+ # of `value`. Accepts the same inputs as {TextView#text=}; empty
2404
+ # or `nil` content collapses the region to zero hard lines.
2405
+ #
2406
+ # _@param_ `value`
2407
+ def text=: ((String | StyledString)? value) -> void
2408
+
2409
+ # Verbatim append into this region's tail. Same semantics as
2410
+ # {TextView#append} but scoped to the region: embedded `"\n"`
2411
+ # creates new hard lines within the region, no-leading-newline
2412
+ # input extends the region's last hard line. Empty / `nil` input
2413
+ # is a no-op (but still raises when detached). When the region is
2414
+ # the spatial tail of the view, this uses the incremental
2415
+ # {TextView#append} path; mid-document regions splice the affected
2416
+ # slice of the physical-row buffer (lines outside the region are
2417
+ # not re-wrapped).
2418
+ #
2419
+ # _@param_ `str`
2420
+ def append: ((String | StyledString)? str) -> void
2421
+
2422
+ # _@return_ — the hard-line indices this region currently
2423
+ # occupies — `start...(start + line_count)`. Empty regions
2424
+ # return a degenerate exclusive range at their position (e.g.
2425
+ # `5...5`). The result is computed on each call and so always
2426
+ # reflects sibling mutations.
2427
+ def range: () -> ::Range[untyped]
2428
+
2429
+ # Removes this region from its view. The region's hard lines (if
2430
+ # any) are deleted from the buffer — subsequent regions' ranges
2431
+ # shift up by `line_count` — and the handle detaches permanently.
2432
+ # The view keeps its always-≥1-region invariant: if this was the
2433
+ # only remaining region, a fresh internal default is installed
2434
+ # (the app doesn't get a handle to it; call
2435
+ # {TextView#create_region} again to start tracking).
2436
+ #
2437
+ # Idempotent: calling `remove` on an already-detached region is a
2438
+ # silent no-op (unlike the other mutators, which raise). This
2439
+ # lets cleanup paths blindly call `remove` without first checking
2440
+ # {#attached?}.
2441
+ def remove: () -> void
2442
+
2443
+ # Appends `str` as a new entry in this region: starts a fresh
2444
+ # hard line first (when the region is non-empty), then appends
2445
+ # `str`. Scoped equivalent of {TextView#add_line}. On an empty
2446
+ # region behaves like {#append}.
2447
+ #
2448
+ # _@param_ `str`
2449
+ def add_line: ((String | StyledString)? str) -> void
2450
+
2451
+ # Replaces a contiguous range of this region's hard lines with the
2452
+ # parsed content of `str`. Region-scoped counterpart of
2453
+ # {TextView#replace}: indices are 0-based **within the region**
2454
+ # (so `replace(0, "x")` rewrites the region's first line, not
2455
+ # the buffer's). Same range conventions apply — `Integer`,
2456
+ # inclusive/exclusive `Range`, empty range as insertion at
2457
+ # `begin`, and `begin == line_count` for end-insertion.
2458
+ #
2459
+ # _@param_ `range` — region-relative hard-line indices.
2460
+ #
2461
+ # _@param_ `str` — replacement content.
2462
+ def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
2463
+
2464
+ # Inserts `str` at region-relative hard-line index `at`.
2465
+ # Equivalent to `replace(at...at, str)`. Region-scoped counterpart
2466
+ # of {TextView#insert}; `at == line_count` is allowed and appends
2467
+ # at the region's tail.
2468
+ #
2469
+ # _@param_ `at` — region-relative index in `[0, line_count]`.
2470
+ #
2471
+ # _@param_ `str`
2472
+ def insert: (Integer at, (String | StyledString)? str) -> void
2473
+
2474
+ # Drops the last `n` hard lines from this region's tail.
2475
+ # Subsequent regions' ranges shift up by the number actually
2476
+ # dropped. `n` is clamped to {#line_count}, so passing a large
2477
+ # `n` empties the region — the handle stays attached (use
2478
+ # {#remove} when the goal is to drop the region itself).
2479
+ # `n == 0` and an already-empty region are no-ops.
2480
+ #
2481
+ # _@param_ `n`
2482
+ def remove_last_n_lines: (Integer n) -> void
2483
+
2484
+ def detach!: () -> void
2485
+
2486
+ def check_attached: () -> void
2487
+
2488
+ # _@return_ — number of hard lines this region owns. Safe to
2489
+ # read on a detached region (no error raised).
2490
+ attr_accessor line_count: (Integer | untyped)
2491
+ end
1694
2492
  end
1695
2493
 
1696
2494
  # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
@@ -1741,9 +2539,6 @@ module Tuile
1741
2539
  # positioned by {Screen} after each repaint cycle when this component is
1742
2540
  # focused; see {Component#cursor_position}.
1743
2541
  class TextField < Tuile::Component::TextInput
1744
- ACTIVE_BG_SGR: String
1745
- INACTIVE_BG_SGR: String
1746
-
1747
2542
  def initialize: () -> void
1748
2543
 
1749
2544
  def cursor_position: () -> Point?
@@ -1818,9 +2613,6 @@ module Tuile
1818
2613
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
1819
2614
  # keep the caret visible).
1820
2615
  class TextInput < Component
1821
- ACTIVE_BG_SGR: String
1822
- INACTIVE_BG_SGR: String
1823
-
1824
2616
  def initialize: () -> void
1825
2617
 
1826
2618
  # _@return_ — true iff {#text} is the empty string.
@@ -1837,6 +2629,16 @@ module Tuile
1837
2629
  # _@param_ `key`
1838
2630
  def handle_key: (String key) -> bool
1839
2631
 
2632
+ # Renders `text` on the field's background well, looked up from the
2633
+ # current {Screen#theme} at paint time: {Theme#active_bg} when this
2634
+ # input is on the active (focus) chain, {Theme#input_bg} otherwise —
2635
+ # visibly a field either way, distinctly highlighted when active.
2636
+ #
2637
+ # _@param_ `text`
2638
+ #
2639
+ # _@return_ — ANSI-rendered text.
2640
+ def background: (String text) -> String
2641
+
1840
2642
  # Input filter for {#text=}. Subclasses override to truncate or reject
1841
2643
  # invalid input. Default coerces to String.
1842
2644
  #
@@ -2004,6 +2806,71 @@ module Tuile
2004
2806
  end
2005
2807
  end
2006
2808
 
2809
+ # An app's theme definition: the {Theme} pair covering both terminal
2810
+ # appearances. {Screen} keeps one at {Screen#theme_def} (defaulting to
2811
+ # {DEFAULT}) and picks the member matching the detected background at
2812
+ # startup and on every OS appearance flip (mode 2031) — so a custom
2813
+ # definition survives the user toggling light/dark, where a bare
2814
+ # {Screen#theme=} assignment would be replaced.
2815
+ #
2816
+ # APP_THEME = Tuile::ThemeDef.new(
2817
+ # dark: Tuile::Theme::DARK.with(custom: { accent: Color::DARK_ORANGE }),
2818
+ # light: Tuile::Theme::LIGHT.with(custom: { accent: Color::DARK_ORANGE3 })
2819
+ # )
2820
+ # screen.theme_def = APP_THEME
2821
+ #
2822
+ # Both members must declare the same {Theme#custom} key set. Without
2823
+ # that, a token present only in one member would raise `KeyError` at
2824
+ # the unpredictable moment the user flips OS appearance; checking here
2825
+ # turns it into an immediate construction-time failure.
2826
+ #
2827
+ # @!attribute [r] dark
2828
+ # The theme applied on dark terminal backgrounds.
2829
+ # @return [Theme]
2830
+ # @!attribute [r] light
2831
+ # The theme applied on light terminal backgrounds.
2832
+ # @return [Theme]
2833
+ class ThemeDef
2834
+ DEFAULT: ThemeDef
2835
+
2836
+ # _@param_ `dark`
2837
+ #
2838
+ # _@param_ `light`
2839
+ def initialize: (dark: Theme, light: Theme) -> void
2840
+
2841
+ # The member for the given color scheme. Anything other than `:light`
2842
+ # selects {#dark}, matching {TerminalBackground.detect}'s
2843
+ # inconclusive-means-dark policy.
2844
+ #
2845
+ # _@param_ `scheme` — `:dark` or `:light`.
2846
+ def for: (Symbol scheme) -> Theme
2847
+
2848
+ # The definition newly-constructed {Screen}s start from (see
2849
+ # {Screen#theme_def}); initially {DEFAULT}. Reassigning affects
2850
+ # future screens only — an already-constructed screen keeps its
2851
+ # definition until {Screen#theme_def=}.
2852
+ #
2853
+ # Intended for test suites: production apps assign
2854
+ # {Screen#theme_def=} once at startup, but component specs build a
2855
+ # fresh {FakeScreen} per example, and a component reading a custom
2856
+ # token (`theme[:accent]`) would `KeyError` against the built-in
2857
+ # default. Point this at the app's definition once and every
2858
+ # {Screen.fake} carries it:
2859
+ #
2860
+ # Tuile::ThemeDef.default = APP_THEME # spec_helper.rb, once
2861
+ # before { Screen.fake } # theme[:accent] resolves
2862
+ def self.default: () -> ThemeDef
2863
+
2864
+ # _@param_ `theme_def`
2865
+ def self.default=: (ThemeDef value) -> ThemeDef
2866
+
2867
+ # The theme applied on dark terminal backgrounds.
2868
+ attr_reader dark: Theme
2869
+
2870
+ # The theme applied on light terminal backgrounds.
2871
+ attr_reader light: Theme
2872
+ end
2873
+
2007
2874
  # An event queue. The idea is that all UI-related updates run from the thread
2008
2875
  # which runs the event queue only; this removes any need for locking and/or
2009
2876
  # need for thread-safety mechanisms.
@@ -2032,13 +2899,39 @@ module Tuile
2032
2899
  # Awaits until the event queue is empty (all events have been processed).
2033
2900
  def await_empty: () -> void
2034
2901
 
2902
+ # Schedules `block` to fire on the event-loop thread roughly `fps` times
2903
+ # per second, passing a 0-based monotonically increasing tick counter. Use
2904
+ # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
2905
+ # periodic UI refresh from a background task.
2906
+ #
2907
+ # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
2908
+ # stop it.
2909
+ #
2910
+ # **Errors:** if `block` raises, the {Ticker} cancels itself and the
2911
+ # exception flows through the normal event-loop error path — i.e.
2912
+ # {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
2913
+ # broken block from spamming `on_error` at the tick rate.
2914
+ #
2915
+ # Tickers reuse `concurrent-ruby`'s shared timer thread
2916
+ # ({Concurrent}.global_timer_set) — adding more tickers does not add more
2917
+ # threads, just more work on the shared scheduler.
2918
+ #
2919
+ # _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`fps: 0.5` ⇒ one tick every two seconds).
2920
+ def tick: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
2921
+
2035
2922
  # Runs the event loop and blocks. Must be run from at most one thread at the
2036
2923
  # same time. Blocks until some thread calls {#stop}. Calls block for all
2037
- # events submitted via {#post}; the block is always called from the thread
2038
- # running this function.
2924
+ # events; the block is always called from the thread running this function.
2039
2925
  #
2040
- # Any exception raised by block is re-thrown, causing this function to
2041
- # terminate.
2926
+ # Any exception raised by the block is re-thrown, causing this function to
2927
+ # terminate. Wrap the block body in `rescue` if you want to handle errors
2928
+ # without tearing down the loop — see {Screen#event_loop} for an example.
2929
+ #
2930
+ # **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
2931
+ # the consumer is responsible for invoking it (typically `event.call`).
2932
+ # Yielding rather than dispatching inline means a raise inside the
2933
+ # submitted block flows through the consumer's `rescue` like any other
2934
+ # event-handler error, instead of bypassing it.
2042
2935
  def run_loop: () ?{ (Object event) -> void } -> void
2043
2936
 
2044
2937
  # _@return_ — true if this thread is running inside an event queue.
@@ -2103,12 +2996,65 @@ module Tuile
2103
2996
  attr_reader height: Integer
2104
2997
  end
2105
2998
 
2999
+ # The terminal's color scheme changed — the user flipped the OS between
3000
+ # light and dark appearance. Terminals supporting mode 2031 (kitty,
3001
+ # foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
3002
+ # (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
3003
+ # the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
3004
+ # it into this event and {Screen#event_loop} follows by assigning the
3005
+ # matching {Theme}.
3006
+ #
3007
+ # @!attribute [r] scheme
3008
+ # @return [Symbol] `:light` or `:dark`.
3009
+ class ColorSchemeEvent
3010
+ REPORT: Regexp
3011
+
3012
+ # _@param_ `key` — key read via {Keys.getkey}.
3013
+ #
3014
+ # _@return_ — nil when `key` is not a
3015
+ # color-scheme report.
3016
+ def self.parse: (String key) -> ColorSchemeEvent?
3017
+
3018
+ # _@return_ — `:light` or `:dark`.
3019
+ attr_reader scheme: Symbol
3020
+ end
3021
+
2106
3022
  # Emitted once when the queue is cleared, all messages are processed and the
2107
3023
  # event loop will block waiting for more messages. Perfect time for
2108
3024
  # repainting windows.
2109
3025
  class EmptyQueueEvent
2110
3026
  include Singleton
2111
3027
  end
3028
+
3029
+ # Handle returned by {EventQueue#tick}. Cancel a running ticker via
3030
+ # {#cancel}.
3031
+ #
3032
+ # Internally wraps a `Concurrent::TimerTask` whose firing posts a single
3033
+ # submit-block to the owning {EventQueue}; the user's block therefore
3034
+ # always runs on the event-loop thread and may freely mutate UI. If the
3035
+ # user block raises, the Ticker auto-cancels and the exception is
3036
+ # re-raised so it flows through the loop's normal error handling
3037
+ # ({Screen#on_error} for the default Tuile setup).
3038
+ class Ticker
3039
+ # _@param_ `event_queue` — queue to dispatch tick calls onto.
3040
+ #
3041
+ # _@param_ `fps` — firings per second (positive).
3042
+ #
3043
+ # _@param_ `block` — called as `block.call(tick_count)` on each fire.
3044
+ def initialize: (EventQueue event_queue, Numeric fps, Proc block) -> void
3045
+
3046
+ # _@return_ — true once {#cancel} has been called.
3047
+ def cancelled?: () -> bool
3048
+
3049
+ # Stops the ticker. Idempotent and safe to call from any thread,
3050
+ # including from inside the tick block. Any tick already queued on the
3051
+ # event loop at the moment of cancellation is dropped before the user
3052
+ # block runs.
3053
+ def cancel: () -> void
3054
+
3055
+ # Runs on the event-loop thread.
3056
+ def fire: () -> void
3057
+ end
2112
3058
  end
2113
3059
 
2114
3060
  # Testing only — a screen which doesn't paint anything and pretends that the
@@ -2147,6 +3093,11 @@ module Tuile
2147
3093
 
2148
3094
  def invalidated_clear: () -> void
2149
3095
 
3096
+ # No terminal probing in tests: skip {TerminalBackground.detect}
3097
+ # (which would write an OSC 11 query to the test runner's TTY and
3098
+ # steal its input) and pin the deterministic default.
3099
+ def detect_scheme: () -> Symbol
3100
+
2150
3101
  # _@return_ — whatever {#print} printed so far.
2151
3102
  attr_reader prints: ::Array[String]
2152
3103
  end
@@ -2456,16 +3407,16 @@ module Tuile
2456
3407
  # `underline`). Useful for row-level highlights — the new bg overlays
2457
3408
  # without dropping foreground colors the original styling carried.
2458
3409
  #
2459
- # _@param_ `bg` — background color, in any of the forms accepted by {Style.new}. `nil` clears bg back to the terminal default.
2460
- def with_bg: ((Symbol | Integer | ::Array[Integer])? bg) -> StyledString
3410
+ # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
3411
+ def with_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
2461
3412
 
2462
3413
  # Returns a new {StyledString} with `fg` applied to every span, preserving
2463
3414
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
2464
3415
  # `underline`). The new fg overlays without dropping background colors or
2465
3416
  # text attributes the original styling carried.
2466
3417
  #
2467
- # _@param_ `fg` — foreground color, in any of the forms accepted by {Style.new}. `nil` clears fg back to the terminal default.
2468
- def with_fg: ((Symbol | Integer | ::Array[Integer])? fg) -> StyledString
3418
+ # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
3419
+ def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
2469
3420
 
2470
3421
  def inspect: () -> String
2471
3422
 
@@ -2481,10 +3432,11 @@ module Tuile
2481
3432
 
2482
3433
  # _@param_ `color`
2483
3434
  #
2484
- # _@param_ `base` — base SGR code — 30 for fg, 40 for bg.
3435
+ # _@param_ `target` — `:fg` or `:bg`.
2485
3436
  #
2486
- # _@param_ `ext` extended-color SGR code 38 for fg, 48 for bg.
2487
- def color_codes: ((Symbol | Integer | ::Array[Integer])? color, base: Integer, ext: Integer) -> ::Array[Integer]
3437
+ # _@return_ — SGR codes; `[39]` / `[49]` for the "default" reset
3438
+ # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
3439
+ def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
2488
3440
 
2489
3441
  # _@param_ `start_or_range`
2490
3442
  #
@@ -2537,18 +3489,16 @@ module Tuile
2537
3489
  class ParseError < Tuile::Error
2538
3490
  end
2539
3491
 
2540
- # A frozen value type describing the visual style of a {Span}.
2541
- #
2542
- # `fg` and `bg` accept:
2543
- # - `nil` the terminal default (SGR 39 / 49)
2544
- # - a symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright ANSI colors
2545
- # - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
2546
- # - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
3492
+ # A frozen value type describing the visual style of a {Span}. Colors are
3493
+ # stored as {Color} instances (or `nil` for the terminal default); inputs
3494
+ # to {.new} and {#merge} are coerced via {Color.coerce}, so the four
3495
+ # accepted color forms — `nil`, Symbol, Integer 0..255, RGB Array work
3496
+ # transparently.
2547
3497
  #
2548
3498
  # @!attribute [r] fg
2549
- # @return [Symbol, Integer, Array<Integer>, nil]
3499
+ # @return [Color, nil]
2550
3500
  # @!attribute [r] bg
2551
- # @return [Symbol, Integer, Array<Integer>, nil]
3501
+ # @return [Color, nil]
2552
3502
  # @!attribute [r] bold
2553
3503
  # @return [Boolean]
2554
3504
  # @!attribute [r] italic
@@ -2556,12 +3506,11 @@ module Tuile
2556
3506
  # @!attribute [r] underline
2557
3507
  # @return [Boolean]
2558
3508
  class Style
2559
- COLOR_SYMBOLS: ::Array[Symbol]
2560
3509
  DEFAULT: Style
2561
3510
 
2562
- # _@param_ `fg`
3511
+ # _@param_ `fg` — coerced via {Color.coerce}.
2563
3512
  #
2564
- # _@param_ `bg`
3513
+ # _@param_ `bg` — coerced via {Color.coerce}.
2565
3514
  #
2566
3515
  # _@param_ `bold`
2567
3516
  #
@@ -2569,18 +3518,13 @@ module Tuile
2569
3518
  #
2570
3519
  # _@param_ `underline`
2571
3520
  def self.new: (
2572
- ?fg: (Symbol | Integer | ::Array[Integer])?,
2573
- ?bg: (Symbol | Integer | ::Array[Integer])?,
3521
+ ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
3522
+ ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
2574
3523
  ?bold: bool,
2575
3524
  ?italic: bool,
2576
3525
  ?underline: bool
2577
3526
  ) -> Style
2578
3527
 
2579
- # _@param_ `color`
2580
- #
2581
- # _@param_ `which`
2582
- def self.validate_color!: (Object color, Symbol which) -> void
2583
-
2584
3528
  def default?: () -> bool
2585
3529
 
2586
3530
  # Returns a new {Style} with the given attributes overridden.
@@ -2588,9 +3532,9 @@ module Tuile
2588
3532
  # _@param_ `overrides`
2589
3533
  def merge: (**::Hash[Symbol, Object] overrides) -> Style
2590
3534
 
2591
- attr_reader fg: (Symbol | Integer | ::Array[Integer])?
3535
+ attr_reader fg: Color?
2592
3536
 
2593
- attr_reader bg: (Symbol | Integer | ::Array[Integer])?
3537
+ attr_reader bg: Color?
2594
3538
 
2595
3539
  attr_reader bold: bool
2596
3540
 
@@ -2656,6 +3600,8 @@ module Tuile
2656
3600
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
2657
3601
  # away and submitted events are thrown away. Intended for testing only.
2658
3602
  class FakeEventQueue
3603
+ def initialize: () -> void
3604
+
2659
3605
  def locked?: () -> bool
2660
3606
 
2661
3607
  def stop: () -> void
@@ -2668,6 +3614,127 @@ module Tuile
2668
3614
 
2669
3615
  # _@param_ `event`
2670
3616
  def post: (Object event) -> void
3617
+
3618
+ # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
3619
+ # only fires when a test calls {#tick_once}. The `fps` argument is
3620
+ # validated the same way the real queue validates it, then discarded —
3621
+ # the fake has no clock, so frame cadence is up to the test.
3622
+ #
3623
+ # _@param_ `fps` — firings per second, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
3624
+ def tick: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
3625
+
3626
+ # Test helper: fires every live ticker's user block once and prunes
3627
+ # cancelled tickers. No-op when no tickers are registered. Pumps once
3628
+ # per call regardless of any ticker's fps — the fake has no clock, so
3629
+ # tests pump N frames by calling this N times.
3630
+ def tick_once: () -> void
3631
+
3632
+ # Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
3633
+ # {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
3634
+ # the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
3635
+ class FakeTicker
3636
+ # _@param_ `block` — called as `block.call(tick_count)` on each {#fire}.
3637
+ def initialize: (Proc block) -> void
3638
+
3639
+ # _@return_ — true once {#cancel} has been called.
3640
+ def cancelled?: () -> bool
3641
+
3642
+ # Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
3643
+ # no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
3644
+ # pass.
3645
+ def cancel: () -> void
3646
+
3647
+ # Invokes the user block with the current tick counter, then advances.
3648
+ # No-op when {#cancelled?}. Typically driven by
3649
+ # {FakeEventQueue#tick_once}; safe to call directly from a test that
3650
+ # wants to drive a single ticker.
3651
+ def fire: () -> void
3652
+ end
3653
+ end
3654
+
3655
+ # Detects whether the terminal background is light or dark, so {Screen}
3656
+ # can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
3657
+ #
3658
+ # Two mechanisms, in order of reliability:
3659
+ #
3660
+ # 1. **OSC 11 query** — writes `ESC ] 11 ; ? BEL` to the terminal; modern
3661
+ # terminals (xterm, kitty, alacritty, wezterm, iTerm2, GNOME Terminal,
3662
+ # Windows Terminal) reply on stdin with the background color
3663
+ # (`\e]11;rgb:RRRR/GGGG/BBBB` + BEL or ST). The color's relative
3664
+ # luminance against a 0.5 threshold decides light vs dark. Terminals
3665
+ # that don't support the query simply never reply, so the read is
3666
+ # bounded by a short timeout.
3667
+ # 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
3668
+ # indices. Less reliable (stale across SSH/tmux, often unset); used
3669
+ # only when OSC 11 yields nothing.
3670
+ #
3671
+ # **Timing matters**: the OSC 11 reply arrives on stdin, so the query
3672
+ # must complete before {EventQueue#start_key_thread} owns stdin —
3673
+ # otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
3674
+ # calls {.detect} from its constructor, which apps run before
3675
+ # {Screen#run_event_loop}; don't call this after the event loop started.
3676
+ module TerminalBackground
3677
+ QUERY_TIMEOUT: Float
3678
+ QUERY: String
3679
+ REPLY: Regexp
3680
+ NOTIFY_ON: String
3681
+ NOTIFY_OFF: String
3682
+
3683
+ # Detects the terminal background. Queries OSC 11 when both `input`
3684
+ # and `output` are TTYs, falling back to `COLORFGBG`.
3685
+ #
3686
+ # _@param_ `input` — where the OSC 11 reply arrives (the TTY input).
3687
+ #
3688
+ # _@param_ `output` — where the query is written (the TTY output).
3689
+ #
3690
+ # _@param_ `env` — environment for the `COLORFGBG` fallback; defaults to `ENV` (which duck-types the `[]` lookup).
3691
+ #
3692
+ # _@param_ `timeout` — max seconds to wait for the OSC 11 reply.
3693
+ #
3694
+ # _@return_ — `:light`, `:dark`, or nil when undetectable.
3695
+ def self.detect: (
3696
+ ?input: IO,
3697
+ ?output: IO,
3698
+ ?env: ::Hash[String, String],
3699
+ ?timeout: Numeric
3700
+ ) -> Symbol?
3701
+
3702
+ # Writes the OSC 11 query and classifies the reply. The whole
3703
+ # exchange runs with `input` in raw mode: the reply has no trailing
3704
+ # newline, so a canonical-mode read would block past the timeout,
3705
+ # and echo would smear the reply bytes onto the screen.
3706
+ #
3707
+ # _@param_ `input`
3708
+ #
3709
+ # _@param_ `output`
3710
+ #
3711
+ # _@param_ `timeout`
3712
+ def self.query_osc11: (IO input, IO output, Numeric timeout) -> Symbol?
3713
+
3714
+ # Accumulates reply bytes until a BEL/ST terminator or the deadline.
3715
+ # Terminals that don't support OSC 11 never reply — returning
3716
+ # whatever arrived (usually nothing) lets the caller fail soft.
3717
+ #
3718
+ # _@param_ `input`
3719
+ #
3720
+ # _@param_ `timeout`
3721
+ def self.read_reply: (IO input, Numeric timeout) -> String
3722
+
3723
+ # Relative luminance of the reported background, scaled per
3724
+ # component hex width (xterm replies 4 digits per channel, others 2).
3725
+ #
3726
+ # _@param_ `components` — three hex strings.
3727
+ #
3728
+ # _@return_ — `:light` or `:dark`.
3729
+ def self.classify: (::Array[String] components) -> Symbol
3730
+
3731
+ # `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
3732
+ # ANSI palette indices. White-ish backgrounds — 7 (white) and the
3733
+ # bright range 9–15 — read as light; 0–6 and 8 as dark; anything
3734
+ # else (missing, `"default"`, out of range) is inconclusive.
3735
+ #
3736
+ # _@param_ `value`
3737
+ def self.from_colorfgbg: (String? value) -> Symbol?
2671
3738
  end
2672
3739
 
2673
3740
  # A vertical scrollbar that computes which character to draw at each row.