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.
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
@@ -135,6 +136,13 @@ module Tuile
135
136
  # _@param_ `point`
136
137
  def contains?: (Point point) -> bool
137
138
 
139
+ # _@param_ `other` — another rectangle.
140
+ #
141
+ # _@return_ — true if `other` lies entirely within this rectangle.
142
+ # Uses the same half-open edges as {#contains?} (right/bottom exclusive).
143
+ # An {#empty? empty} `other` covers no cells, so it is trivially contained.
144
+ def contains_rect?: (Rect other) -> bool
145
+
138
146
  def size: () -> Size
139
147
 
140
148
  def top_left: () -> Point
@@ -197,6 +205,9 @@ module Tuile
197
205
  #
198
206
  # A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
199
207
  # …) so callers can reach for `Color::RED` instead of building one each time.
208
+ # The 256-color palette gets the same treatment via {PALETTE_NAMES}:
209
+ # `Color::CADET_BLUE`, `Color::DODGER_BLUE1`, `Color::GREY37`, … — the
210
+ # standard xterm chart names for indices 16..255, each an exact palette cell.
200
211
  # {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
201
212
  # an existing {Color} (returned as-is), so APIs that accept colors typically
202
213
  # take `[Color, nil]` and pass through {.coerce}.
@@ -206,15 +217,26 @@ module Tuile
206
217
  # Color.new(42) # 256-color palette
207
218
  # Color.new([255, 100, 0]) # RGB
208
219
  # Color::RED # constant
220
+ # Color.palette(42) # 256-color palette, explicit
221
+ # Color.rgb(255, 100, 0) # 24-bit RGB, explicit
222
+ # Color.hex("#ff6400") # 24-bit RGB from a CSS-style hex string
209
223
  # Color.coerce(:red) # accepts raw forms, returns Color
210
224
  # Color.coerce(nil) # nil → nil
211
225
  # ```
212
226
  #
227
+ # Which entry point to use is a deliberate policy split. High-traffic
228
+ # call sites ({StyledString} and friends) stay lenient and {.coerce} raw
229
+ # forms — you don't want factory ceremony on every styled span.
230
+ # Declaration sites ({Theme}, defined once per app) are strict and take
231
+ # only {Color} instances, where `Color.palette(130)` documents itself in
232
+ # a way the bare `130` (palette index? RGB channel?) does not.
233
+ #
213
234
  # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
214
235
  # raw numeric codes so callers (notably {StyledString}) can combine them with
215
236
  # other SGR attributes in a single sequence.
216
237
  class Color
217
238
  COLOR_SYMBOLS: ::Array[Symbol]
239
+ PALETTE_NAMES: ::Hash[Symbol, Integer]
218
240
 
219
241
  # Coerces the input to a {Color}. `nil` passes through unchanged (callers
220
242
  # use `nil` for the terminal default); an existing {Color} is returned
@@ -223,6 +245,35 @@ module Tuile
223
245
  # _@param_ `value`
224
246
  def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
225
247
 
248
+ # A color from the 256-color palette (SGR 38;5;N / 48;5;N). Same as
249
+ # `Color.new(index)`, but the name says what the bare integer is.
250
+ #
251
+ # _@param_ `index` — palette index, 0..255.
252
+ def self.palette: (Integer index) -> Color
253
+
254
+ # A 24-bit RGB color (SGR 38;2;R;G;B / 48;2;R;G;B). Same as
255
+ # `Color.new([r, g, b])`, but with the channels spelled out.
256
+ #
257
+ # _@param_ `red` — 0..255.
258
+ #
259
+ # _@param_ `green` — 0..255.
260
+ #
261
+ # _@param_ `blue` — 0..255.
262
+ def self.rgb: (Integer red, Integer green, Integer blue) -> Color
263
+
264
+ # A 24-bit RGB color from a CSS-style hex string — for when the value
265
+ # comes from a hex source (a designer's palette, a CSS variable). The
266
+ # leading `#` is optional, digits are case-insensitive, and the CSS
267
+ # 3-digit shorthand expands as in CSS (`"#345"` → `"#334455"`).
268
+ # 4/8-digit alpha forms are rejected: SGR has no alpha channel, and
269
+ # silently dropping it would lie about the rendered color.
270
+ #
271
+ # _@param_ `string` — e.g. `"#333333"`, `"5F9EA0"`, `"#333"`.
272
+ #
273
+ # _@return_ — same value form as {.rgb} — `Color.hex("#333") ==
274
+ # Color.rgb(51, 51, 51)`.
275
+ def self.hex: (String string) -> Color
276
+
226
277
  # _@param_ `value` — see class-level docs for the three accepted forms.
227
278
  def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
228
279
 
@@ -268,6 +319,213 @@ module Tuile
268
319
  attr_reader y: Integer
269
320
  end
270
321
 
322
+ # A set of semantic colors the built-in components read when painting.
323
+ # The current theme lives at {Screen#theme}; components must look it up
324
+ # at paint time (inside `repaint`) rather than caching values, so that
325
+ # assigning {Screen#theme=} restyles everything via a single
326
+ # invalidate-everything pass.
327
+ #
328
+ # The primary API is the rendering helpers — {#active_bg},
329
+ # {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
330
+ # the token's SGR color (on the channel appropriate for the token's
331
+ # role) and reset:
332
+ #
333
+ # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
334
+ # screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
335
+ #
336
+ # The helpers pass content through verbatim, so input may carry other
337
+ # escape sequences (e.g. {Component::Window} feeds its border string,
338
+ # cursor moves included). For span-aware styling — applying a token to a
339
+ # {StyledString} while preserving per-span colors — use the `*_color`
340
+ # readers instead (e.g. {Component::List} highlights its cursor row via
341
+ # `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
342
+ # helper; structured text → `*_color` reader + {StyledString}.
343
+ #
344
+ # Two built-in themes are provided: {DARK} (the default; the colors Tuile
345
+ # has always used) and {LIGHT} (counterparts legible on light terminal
346
+ # backgrounds). A custom theme is one `with` away:
347
+ #
348
+ # screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
349
+ #
350
+ # Tokens deliberately cover only the *accents* Tuile paints. Everything
351
+ # else inherits the terminal's own default foreground/background, which
352
+ # already matches the user's terminal theme perfectly — that's why there
353
+ # is no global `bg`/`fg` token.
354
+ #
355
+ # Every token is a {Color} — and must be passed as one. Unlike the
356
+ # lenient {Color.coerce} call sites elsewhere in the framework, a theme
357
+ # is declared once per app, so it takes only {Color} instances: at a
358
+ # declaration site `Color.palette(130)` documents itself in a way the
359
+ # bare `130` does not (palette index? RGB channel?) — and the named
360
+ # palette constants (`Color::DARK_ORANGE3` *is* 130; see
361
+ # {Color::PALETTE_NAMES}) go one step further.
362
+ #
363
+ # ## App-specific tokens
364
+ #
365
+ # Beyond the built-in tokens, an app can carry its own colors in
366
+ # {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
367
+ # {#[]} (fail-fast: a typo raises `KeyError`) and render with the
368
+ # generic {#fg} / {#bg} helpers:
369
+ #
370
+ # theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
371
+ # theme[:accent] # => Color, e.g. for StyledString#with_fg
372
+ # theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
373
+ #
374
+ # Apps wanting semantic readers can subclass — `Data#with` preserves the
375
+ # subclass, so an `AppTheme` stays an `AppTheme` through `with`:
376
+ #
377
+ # class AppTheme < Tuile::Theme
378
+ # def accent(text) = fg(:accent, text)
379
+ # end
380
+ #
381
+ # Pair the dark and light variants in a {ThemeDef} and hand it to
382
+ # {Screen#theme_def=} so OS appearance flips pick the right one.
383
+ #
384
+ # @!attribute [r] active_bg_color
385
+ # Background highlight of the component the user is interacting with:
386
+ # the {Component::List} cursor row, the focused {Component::TextField} /
387
+ # {Component::TextArea} well, the focused {Component::Button}. "Active"
388
+ # matches the {Component#active?} focus-chain flag — this is the
389
+ # focus/selection highlight in conventional UI terms.
390
+ # @return [Color]
391
+ # @!attribute [r] active_border_color
392
+ # Foreground of a {Component::Window} border when the window is on the
393
+ # active (focus) chain.
394
+ # @return [Color]
395
+ # @!attribute [r] input_bg_color
396
+ # Resting background "well" of {Component::TextField} /
397
+ # {Component::TextArea} when *not* active — visibly a field, but
398
+ # distinctly subtler than {#active_bg_color}.
399
+ # @return [Color]
400
+ # @!attribute [r] hint_color
401
+ # Foreground of keyboard-shortcut captions in status-bar hints (the
402
+ # "quit" in "q quit") — see {#hint}.
403
+ # @return [Color]
404
+ # @!attribute [r] custom
405
+ # App-specific color tokens; empty in the built-in themes. Frozen —
406
+ # build a changed theme via `with(custom: ...)`. Prefer {#[]} for
407
+ # lookups (it fail-fasts on typos); read this directly to enumerate
408
+ # the tokens.
409
+ # @return [Hash{Symbol => Color}]
410
+ class Theme
411
+ DARK: Theme
412
+ LIGHT: Theme
413
+
414
+ # _@param_ `active_bg_color`
415
+ #
416
+ # _@param_ `active_border_color`
417
+ #
418
+ # _@param_ `input_bg_color`
419
+ #
420
+ # _@param_ `hint_color`
421
+ #
422
+ # _@param_ `custom` — app-specific tokens, see {#custom}.
423
+ def initialize: (
424
+ active_bg_color: Color,
425
+ active_border_color: Color,
426
+ input_bg_color: Color,
427
+ hint_color: Color,
428
+ ?custom: ::Hash[Symbol, Color]
429
+ ) -> void
430
+
431
+ # Looks up an app-specific token from {#custom}.
432
+ #
433
+ # _@param_ `token`
434
+ def []: (Symbol token) -> Color
435
+
436
+ # Renders `text` in the foreground color of the app-specific `token`
437
+ # — the generic counterpart of {#hint} for {#custom} tokens.
438
+ #
439
+ # _@param_ `token`
440
+ #
441
+ # _@param_ `text`
442
+ #
443
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
444
+ def fg: (Symbol token, String text) -> String
445
+
446
+ # Renders `text` on the background color of the app-specific `token`
447
+ # — the generic counterpart of {#active_bg} for {#custom} tokens.
448
+ #
449
+ # _@param_ `token`
450
+ #
451
+ # _@param_ `text`
452
+ #
453
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
454
+ def bg: (Symbol token, String text) -> String
455
+
456
+ # Renders `text` on the {#active_bg_color} background.
457
+ #
458
+ # _@param_ `text`
459
+ #
460
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
461
+ def active_bg: (String text) -> String
462
+
463
+ # Renders `text` in the {#active_border_color} foreground. Content
464
+ # passes through verbatim, so it may embed non-SGR escapes (cursor
465
+ # moves in a border string).
466
+ #
467
+ # _@param_ `text`
468
+ #
469
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
470
+ def active_border: (String text) -> String
471
+
472
+ # Renders `text` on the {#input_bg_color} background.
473
+ #
474
+ # _@param_ `text`
475
+ #
476
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
477
+ def input_bg: (String text) -> String
478
+
479
+ # Renders `text` in the {#hint_color} foreground, for status-bar hints,
480
+ # e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
481
+ # returned String, so strings built this way do *not* restyle when the
482
+ # theme changes — rebuild them instead (the framework's own call sites
483
+ # rebuild on every status-bar refresh).
484
+ #
485
+ # _@param_ `text`
486
+ #
487
+ # _@return_ — ANSI-rendered text, ending with an SGR reset.
488
+ def hint: (String text) -> String
489
+
490
+ # The single sanctioned place for verbatim SGR wrapping: `text` is not
491
+ # parsed or validated, so callers may embed non-SGR escapes. Emits the
492
+ # same bytes `StyledString.styled(text, ...).to_ansi` would for plain
493
+ # text.
494
+ #
495
+ # _@param_ `text`
496
+ #
497
+ # _@param_ `color`
498
+ #
499
+ # _@param_ `target` — `:fg` or `:bg`.
500
+ def wrap: (String text, Color color, Symbol target) -> String
501
+
502
+ # Background highlight of the component the user is interacting with:
503
+ # the {Component::List} cursor row, the focused {Component::TextField} /
504
+ # {Component::TextArea} well, the focused {Component::Button}. "Active"
505
+ # matches the {Component#active?} focus-chain flag — this is the
506
+ # focus/selection highlight in conventional UI terms.
507
+ attr_reader active_bg_color: Color
508
+
509
+ # Foreground of a {Component::Window} border when the window is on the
510
+ # active (focus) chain.
511
+ attr_reader active_border_color: Color
512
+
513
+ # Resting background "well" of {Component::TextField} /
514
+ # {Component::TextArea} when *not* active — visibly a field, but
515
+ # distinctly subtler than {#active_bg_color}.
516
+ attr_reader input_bg_color: Color
517
+
518
+ # Foreground of keyboard-shortcut captions in status-bar hints (the
519
+ # "quit" in "q quit") — see {#hint}.
520
+ attr_reader hint_color: Color
521
+
522
+ # App-specific color tokens; empty in the built-in themes. Frozen —
523
+ # build a changed theme via `with(custom: ...)`. Prefer {#[]} for
524
+ # lookups (it fail-fasts on typos); read this directly to enumerate
525
+ # the tokens.
526
+ attr_reader custom: ::Hash[Symbol, Color]
527
+ end
528
+
271
529
  # The TTY screen. There is exactly one screen per app.
272
530
  #
273
531
  # A screen runs the event loop; call {#run_event_loop} to do that.
@@ -386,7 +644,7 @@ module Tuile
386
644
  #
387
645
  # screen.register_global_shortcut(Keys::CTRL_L,
388
646
  # over_popups: true,
389
- # hint: "^L #{Rainbow("log").cadetblue}") do
647
+ # hint: "^L #{screen.theme.hint("log")}") do
390
648
  # log_popup.open
391
649
  # end
392
650
  #
@@ -394,7 +652,7 @@ module Tuile
394
652
  #
395
653
  # _@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.
396
654
  #
397
- # _@param_ `hint` — preformatted status-bar hint (e.g. `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut is silent in the status bar.
655
+ # _@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.
398
656
  def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
399
657
 
400
658
  # Removes a shortcut previously installed by {#register_global_shortcut}.
@@ -414,6 +672,15 @@ module Tuile
414
672
  # _@param_ `window`
415
673
  def remove_popup: (Component::Popup window) -> void
416
674
 
675
+ # Invalidates the entire attached tree, forcing every component to repaint
676
+ # on the next cycle. Needed whenever something overdraws the scene without
677
+ # clipping and then exposes what was underneath — a closing popup
678
+ # ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
679
+ # longer covers the cells it previously painted ({Component::Popup#rect=}).
680
+ # The popup-only fast path in {#repaint} can't clear those vacated cells on
681
+ # its own, so we accept the cost of a full repaint.
682
+ def needs_full_repaint: () -> void
683
+
417
684
  # Internal — use {Component::Popup#open?} instead.
418
685
  #
419
686
  # _@param_ `window`
@@ -462,6 +729,23 @@ module Tuile
462
729
  # but only one focused.
463
730
  def cursor_position: () -> Point?
464
731
 
732
+ # Startup color scheme: `:light` when {TerminalBackground.detect}
733
+ # reports a light terminal background, `:dark` otherwise (including
734
+ # when detection is inconclusive). Runs in the constructor — the
735
+ # OSC 11 reply arrives on stdin, which is only safe to read before
736
+ # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
737
+ # to pin `:dark`, keeping specs deterministic and off the test
738
+ # runner's TTY.
739
+ #
740
+ # _@return_ — `:dark` or `:light`.
741
+ def detect_scheme: () -> Symbol
742
+
743
+ # An OS appearance flip arrived (mode-2031 report): remember the
744
+ # scheme and apply the matching member of {#theme_def}.
745
+ #
746
+ # _@param_ `scheme` — `:dark` or `:light`.
747
+ def on_color_scheme: (Symbol scheme) -> void
748
+
465
749
  # Walks the current modal scope in pre-order, collects tab stops, and
466
750
  # advances focus by one (wrapping). When the focused component isn't in
467
751
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -487,10 +771,6 @@ module Tuile
487
771
  # starts. {#size} provides correct size of the terminal.
488
772
  def layout: () -> void
489
773
 
490
- # Called after a popup is closed. Since a popup can cover any window,
491
- # top-level component or other popups, we need to redraw everything.
492
- def needs_full_repaint: () -> void
493
-
494
774
  # A key has been pressed on the keyboard. Handle it, or forward to active
495
775
  # window.
496
776
  #
@@ -545,6 +825,22 @@ module Tuile
545
825
  # _@return_ — current screen size.
546
826
  attr_reader size: Size
547
827
 
828
+ # The color {Theme} built-in components read at paint time: the member
829
+ # of {#theme_def} matching the terminal background detected at
830
+ # construction (see {TerminalBackground.detect}; inconclusive means
831
+ # dark). While the event loop runs, terminals supporting mode 2031
832
+ # push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
833
+ # screen re-picks from {#theme_def}.
834
+ attr_accessor theme: Theme
835
+
836
+ # The app's {ThemeDef} — the dark/light {Theme} pair the screen picks
837
+ # {#theme} from, at startup and on every OS appearance flip. Starts as
838
+ # {ThemeDef.default} ({ThemeDef::DEFAULT} unless reassigned — tests
839
+ # do, see {ThemeDef.default=}). Assigning a custom definition is the
840
+ # durable way to theme an app: unlike a bare {#theme=}, it survives
841
+ # the user toggling the OS appearance.
842
+ attr_accessor theme_def: ThemeDef
843
+
548
844
  # _@return_ — the event queue.
549
845
  attr_reader event_queue: EventQueue
550
846
 
@@ -566,6 +862,52 @@ module Tuile
566
862
  end
567
863
  end
568
864
 
865
+ # A sizing policy for a slot whose position is managed by a parent
866
+ # component (e.g. {Component::Window#footer}). Resolves one dimension at a
867
+ # time via {#resolve}, so the same value works for widths and heights.
868
+ #
869
+ # Three policies exist:
870
+ #
871
+ # - {FILL} — take everything the slot offers;
872
+ # - {WRAP_CONTENT} — take the component's natural extent (its
873
+ # {Component#content_size}), clamped to the slot;
874
+ # - {.fixed} — take exactly the given number of cells, clamped to the slot.
875
+ #
876
+ # Note that {WRAP_CONTENT} only makes sense for components that report a
877
+ # natural {Component#content_size} ({Component::Label}, {Component::Button},
878
+ # {Component::List}, …). Input components ({Component::TextField} et al.)
879
+ # report {Size::ZERO}, so a wrap-content slot collapses to zero width —
880
+ # i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
881
+ #
882
+ # @!attribute [r] mode
883
+ # @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
884
+ # @!attribute [r] amount
885
+ # @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
886
+ class Sizing
887
+ FILL: Sizing
888
+ WRAP_CONTENT: Sizing
889
+
890
+ # _@param_ `amount` — the number of cells to occupy; 0 or greater.
891
+ #
892
+ # _@return_ — a fixed-size policy.
893
+ def self.fixed: (Integer amount) -> Sizing
894
+
895
+ # Resolves one dimension of a slot.
896
+ #
897
+ # _@param_ `available` — cells the slot offers; 0 or greater.
898
+ #
899
+ # _@param_ `content` — the component's natural extent on this axis (one dimension of its {Component#content_size}).
900
+ #
901
+ # _@return_ — the resolved extent, always in `0..available`.
902
+ def resolve: (Integer available, Integer content) -> Integer
903
+
904
+ # _@return_ — `:fill`, `:wrap_content` or `:fixed`.
905
+ attr_reader mode: Symbol
906
+
907
+ # _@return_ — the cell count for `:fixed`; `nil` otherwise.
908
+ attr_reader amount: Integer?
909
+ end
910
+
569
911
  # A UI component which is positioned on the screen and draws characters into
570
912
  # its bounding rectangle (in {#repaint}).
571
913
  #
@@ -700,13 +1042,19 @@ module Tuile
700
1042
  # _@param_ `child` — the just-detached child.
701
1043
  def on_child_removed: (Component child) -> void
702
1044
 
703
- # The {Size} big enough to show the entire component contents without
704
- # scrolling. Plain components have no intrinsic content and report
705
- # {Size::ZERO}; container/decorative components (e.g. {Label}, {List},
706
- # {Layout}, {Window}) override this to fold in their content's natural
707
- # extent. Used by callers like {Component::Popup} to auto-size to
708
- # whatever content was assigned, regardless of its concrete type.
709
- def content_size: () -> Size
1045
+ # Called by a child component whose {#content_size} just changed (fired
1046
+ # from the child's {#content_size=}). Does nothing by default a plain
1047
+ # container is not size-coupled to its children. Containers that derive
1048
+ # their own natural size or child layout from a child's natural size
1049
+ # override this (e.g. {Component::Window} re-lays-out a
1050
+ # {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
1051
+ # {Component::Popup} re-self-sizes). If the receiver's own
1052
+ # {#content_size} changes as a consequence, its {#content_size=} notifies
1053
+ # *its* parent in turn — so the event bubbles exactly as far as geometry
1054
+ # keeps changing, and stops where it doesn't.
1055
+ #
1056
+ # _@param_ `child` — the resized direct child.
1057
+ def on_child_content_size_changed: (Component child) -> void
710
1058
 
711
1059
  # Where the hardware terminal cursor should sit when this component is the
712
1060
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
@@ -759,6 +1107,35 @@ module Tuile
759
1107
  # no parent.
760
1108
  attr_accessor parent: Component?
761
1109
 
1110
+ # Called on every attached component (pre-order, popups included) when
1111
+ # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
1112
+ # assignment and on OS appearance flips.
1113
+ #
1114
+ # Built-in components read {Screen#theme} at paint time, so their accents
1115
+ # restyle automatically; this hook exists for *content* whose colors the
1116
+ # app baked in from the old theme — a {Label#text} / {List#lines} /
1117
+ # {TextView#text} {StyledString} styled with `theme[:accent]` and the
1118
+ # like. Only the app knows which of its colors were theme-derived (as
1119
+ # opposed to inherent to the data, e.g. log-level colors), so it rebuilds
1120
+ # them here, re-running the same code that rendered them initially.
1121
+ #
1122
+ # Runs on the UI thread; {Screen#theme} already returns the new theme.
1123
+ # Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
1124
+ # event-loop tick. Do not assign {Screen#theme=} from inside the hook.
1125
+ #
1126
+ # Subclasses overriding this should call `super` so an assigned
1127
+ # {#on_theme_changed=} listener keeps firing.
1128
+ attr_accessor on_theme_changed: Proc?
1129
+
1130
+ # The {Size} big enough to show the entire component contents without
1131
+ # scrolling. Plain components have no intrinsic content and report
1132
+ # {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
1133
+ # {TextView}, {Window}) maintain it eagerly via {#content_size=} from
1134
+ # their mutators, so reads are O(1). Used by callers like
1135
+ # {Component::Popup} to auto-size to whatever content was assigned,
1136
+ # regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
1137
+ attr_accessor content_size: Size
1138
+
762
1139
  # A scrollable list of items with cursor support.
763
1140
  #
764
1141
  # Items are modeled as {StyledString}s and painted directly into the
@@ -771,13 +1148,17 @@ module Tuile
771
1148
  #
772
1149
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
773
1150
  # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
774
- # list automatically. The cursor highlight overlays a dark background
775
- # while preserving each span's foreground color.
1151
+ # list automatically. The cursor highlight overlays
1152
+ # {Theme#active_bg_color} while preserving each span's foreground color.
776
1153
  class List < Component
777
- CURSOR_BG: Integer
778
-
779
1154
  def initialize: () -> void
780
1155
 
1156
+ # _@return_ — whether {#auto_scroll} is currently tailing. True
1157
+ # while the viewport sits at the last line; flips to false the moment
1158
+ # the user scrolls up, and back to true once they scroll to the bottom
1159
+ # again. Only consulted when {#auto_scroll} is enabled.
1160
+ def following?: () -> bool
1161
+
781
1162
  # Sets new lines. Each entry is coerced into a {StyledString} (a
782
1163
  # `String` is parsed via {StyledString.parse}, so embedded ANSI is
783
1164
  # honored; a {StyledString} is used as-is; anything else is stringified
@@ -814,8 +1195,6 @@ module Tuile
814
1195
  # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
815
1196
  def add_lines: (::Array[untyped] lines) -> void
816
1197
 
817
- def content_size: () -> Size
818
-
819
1198
  def focusable?: () -> bool
820
1199
 
821
1200
  def tab_stop?: () -> bool
@@ -867,6 +1246,19 @@ module Tuile
867
1246
  # is one, so the list snaps to the bottom on first paint.
868
1247
  def on_width_changed: () -> void
869
1248
 
1249
+ # Natural size from scratch: longest line's display width plus the two
1250
+ # single-space gutters {#pad_to_row} adds, × line count. An empty list
1251
+ # is {Size::ZERO} (no gutters for no content).
1252
+ def compute_content_size: () -> Size
1253
+
1254
+ # Incremental {#content_size} update for appends: folds just the
1255
+ # appended lines into the running maximum, keeping {#add_lines}
1256
+ # O(appended) instead of re-scanning the whole list (LogWindow appends
1257
+ # a line per log statement).
1258
+ #
1259
+ # _@param_ `appended` — the just-appended lines (already concatenated onto {@lines}).
1260
+ def grow_content_size: (::Array[StyledString] appended) -> void
1261
+
870
1262
  # Coerces and flattens a list of input entries into trimmed
871
1263
  # {StyledString} lines. Each entry becomes a {StyledString} (String
872
1264
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -935,6 +1327,10 @@ module Tuile
935
1327
  # _@return_ — the max value of {#top_line}.
936
1328
  def top_line_max: () -> Integer
937
1329
 
1330
+ # _@return_ — whether the viewport is pinned to the last line.
1331
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
1332
+ def at_bottom?: () -> bool
1333
+
938
1334
  # _@return_ — the number of visible lines.
939
1335
  def viewport_lines: () -> Integer
940
1336
 
@@ -951,6 +1347,11 @@ module Tuile
951
1347
  # which would leave `top_line` past the last item once a real rect
952
1348
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
953
1349
  # the snap-to-bottom intent is preserved.
1350
+ #
1351
+ # Gated on {#following?}: once the user scrolls up off the bottom the
1352
+ # cursor snap and viewport pin are both skipped, so reading older
1353
+ # content is not interrupted by incoming lines. {#top_line=} re-arms
1354
+ # `@follow` when the viewport returns to the bottom.
954
1355
  def update_top_line_if_auto_scroll: () -> void
955
1356
 
956
1357
  # _@return_ — whether the scrollbar should be drawn right now.
@@ -1006,7 +1407,10 @@ module Tuile
1006
1407
  attr_accessor on_cursor_changed: Proc?
1007
1408
 
1008
1409
  # _@return_ — if true and a line is added or new content is set,
1009
- # auto-scrolls to the bottom.
1410
+ # auto-scrolls to the bottom — but only while the viewport is already
1411
+ # pinned to the last line (see {#following?}). Scroll up to read older
1412
+ # content and appends stop yanking you back down; scroll back to the
1413
+ # bottom and tailing resumes.
1010
1414
  attr_accessor auto_scroll: bool
1011
1415
 
1012
1416
  # _@return_ — top line of the viewport. 0 or positive.
@@ -1154,11 +1558,6 @@ module Tuile
1154
1558
  class Label < Component
1155
1559
  def initialize: () -> void
1156
1560
 
1157
- # _@return_ — longest hard-line's display width × number of hard
1158
- # lines. Reported on the *unclipped* text — sizing is intrinsic to
1159
- # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
1160
- def content_size: () -> Size
1161
-
1162
1561
  # Paints the text into {#rect}.
1163
1562
  #
1164
1563
  # Skips the {Component#repaint} default's auto-clear: every row is
@@ -1169,6 +1568,11 @@ module Tuile
1169
1568
 
1170
1569
  def on_width_changed: () -> void
1171
1570
 
1571
+ # Natural size: longest hard-line's display width × number of hard
1572
+ # lines. Computed on the *unclipped* text — sizing is intrinsic to the
1573
+ # content, not the viewport. Empty text yields {Size::ZERO}.
1574
+ def compute_content_size: () -> Size
1575
+
1172
1576
  # Recomputes {@clipped_lines} for the current text and rect width.
1173
1577
  # Each line is ellipsized to fit, padded with trailing spaces out to
1174
1578
  # the full width, and pre-rendered to ANSI so {#repaint} is just a
@@ -1223,6 +1627,17 @@ module Tuile
1223
1627
 
1224
1628
  def focusable?: () -> bool
1225
1629
 
1630
+ # Reassigns the popup's rect, escalating to a full scene repaint when an
1631
+ # open popup shrinks or moves so its new rect no longer covers the cells
1632
+ # it previously painted. A popup overdraws the scene without clipping and
1633
+ # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
1634
+ # would repaint into the new rect and leave the vacated cells showing
1635
+ # stale content. When the new rect fully covers the old one (the popup
1636
+ # only grew), the fast path is correct and the full repaint is skipped.
1637
+ #
1638
+ # _@param_ `new_rect`
1639
+ def rect=: (Rect new_rect) -> void
1640
+
1226
1641
  # Mounts this popup on the {Screen}. Recomputes the popup's size from
1227
1642
  # the current content first, so reopening a popup whose content has
1228
1643
  # grown or shrunk while closed picks up the new size.
@@ -1255,6 +1670,14 @@ module Tuile
1255
1670
  # _@param_ `new_content`
1256
1671
  def content=: (Component? new_content) -> void
1257
1672
 
1673
+ # Re-sizes (and recenters, when open) whenever the wrapped content's
1674
+ # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
1675
+ # `add_line`, or a nested {Window} whose own content grew (the window
1676
+ # recomputes its {Component#content_size} and the change bubbles here).
1677
+ #
1678
+ # _@param_ `_child`
1679
+ def on_child_content_size_changed: (Component _child) -> void
1680
+
1258
1681
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
1259
1682
  def keyboard_hint: () -> String
1260
1683
 
@@ -1270,6 +1693,11 @@ module Tuile
1270
1693
 
1271
1694
  # Recompute width/height from {#content}'s natural size and recenter
1272
1695
  # if currently open. Called whenever content is (re)assigned.
1696
+ #
1697
+ # Computes the final (centered) rect and assigns it in one step rather
1698
+ # than positioning at the origin and then centering: the intermediate
1699
+ # origin rect rarely covers the previous one, which would make
1700
+ # {#rect=}'s shrink/move detection fire a full repaint on every resize.
1273
1701
  def update_rect: () -> void
1274
1702
 
1275
1703
  # _@param_ `event`
@@ -1277,9 +1705,6 @@ module Tuile
1277
1705
 
1278
1706
  def children: () -> ::Array[Component]
1279
1707
 
1280
- # _@param_ `rect`
1281
- def rect=: (Rect rect) -> void
1282
-
1283
1708
  def on_focus: () -> void
1284
1709
  end
1285
1710
 
@@ -1302,10 +1727,6 @@ module Tuile
1302
1727
 
1303
1728
  def tab_stop?: () -> bool
1304
1729
 
1305
- # _@return_ — natural width is `caption.length + 4` to fit
1306
- # `[ caption ]`; height is 1.
1307
- def content_size: () -> Size
1308
-
1309
1730
  # _@param_ `key`
1310
1731
  def handle_key: (String key) -> bool
1311
1732
 
@@ -1314,6 +1735,9 @@ module Tuile
1314
1735
 
1315
1736
  def repaint: () -> void
1316
1737
 
1738
+ # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
1739
+ def natural_size: () -> Size
1740
+
1317
1741
  # _@return_ — the button's label.
1318
1742
  attr_accessor caption: String
1319
1743
 
@@ -1408,11 +1832,21 @@ module Tuile
1408
1832
  # _@param_ `value`
1409
1833
  def scrollbar=: (bool value) -> void
1410
1834
 
1411
- # _@return_ the size needed to fit the window's content, footer
1412
- # (width only — footer overlays the bottom border), and caption,
1413
- # plus the 2-character border. Returns {Size}`.new(2, 2)` when the
1414
- # window has no content, footer, or caption.
1415
- def content_size: () -> Size
1835
+ # Sets the new content. Also recomputes the window's natural size.
1836
+ #
1837
+ # _@param_ `new_content`
1838
+ def content=: (Component? new_content) -> void
1839
+
1840
+ # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
1841
+ # size changes, and folds a content resize into the window's own
1842
+ # natural size (whose change then bubbles to the window's parent — e.g.
1843
+ # a {Popup} re-self-sizes). The footer deliberately does *not*
1844
+ # participate in the window's {#content_size}: it is decoration
1845
+ # overlaying the border, and must not drive the window's size — if it
1846
+ # doesn't fit, it is clipped to the inner width.
1847
+ #
1848
+ # _@param_ `child`
1849
+ def on_child_content_size_changed: (Component child) -> void
1416
1850
 
1417
1851
  # Fully repaints the window: both frame and contents.
1418
1852
  #
@@ -1446,6 +1880,17 @@ module Tuile
1446
1880
  # _@param_ `caption`
1447
1881
  def build_frame: (String caption) -> String
1448
1882
 
1883
+ # Recomputes the window's natural size: content's natural size (or the
1884
+ # caption, whichever is wider) plus the 2-character border. The footer
1885
+ # is deliberately excluded — see {#on_child_content_size_changed}. A
1886
+ # window with no content or caption sizes to `Size.new(2, 2)` (bare
1887
+ # border).
1888
+ def update_content_size: () -> void
1889
+
1890
+ # Positions the footer over the bottom border row, with its width
1891
+ # resolved by {#footer_sizing} against the inner width. A
1892
+ # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
1893
+ # rect — i.e. it is invisible, as if never assigned.
1449
1894
  def layout_footer: () -> void
1450
1895
 
1451
1896
  def on_focus: () -> void
@@ -1454,6 +1899,11 @@ module Tuile
1454
1899
  # row.
1455
1900
  attr_accessor footer: Component?
1456
1901
 
1902
+ # _@return_ — how the footer's width is computed from the window's
1903
+ # inner width; defaults to {Sizing::FILL} (the footer spans the full
1904
+ # inner width). The footer's height is always 1 (the border row).
1905
+ attr_accessor footer_sizing: Sizing
1906
+
1457
1907
  # _@return_ — the current caption, empty by default.
1458
1908
  attr_accessor caption: String
1459
1909
  end
@@ -1475,9 +1925,6 @@ module Tuile
1475
1925
  # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
1476
1926
  # callback may opt out of that by consuming Enter instead.
1477
1927
  class TextArea < Tuile::Component::TextInput
1478
- ACTIVE_BG_SGR: String
1479
- INACTIVE_BG_SGR: String
1480
-
1481
1928
  def initialize: () -> void
1482
1929
 
1483
1930
  def cursor_position: () -> Point?
@@ -1581,6 +2028,12 @@ module Tuile
1581
2028
  # O(total spans).
1582
2029
  def text: () -> StyledString
1583
2030
 
2031
+ # _@return_ — whether {#auto_scroll} is currently tailing. True
2032
+ # while the viewport sits at the last line; flips to false the moment
2033
+ # the user scrolls up, and back to true once they scroll to the bottom
2034
+ # again. Only consulted when {#auto_scroll} is enabled.
2035
+ def following?: () -> bool
2036
+
1584
2037
  # Replaces the text. Embedded `\n` characters become hard line breaks.
1585
2038
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
1586
2039
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
@@ -1912,8 +2365,16 @@ module Tuile
1912
2365
  # _@param_ `target` — desired top line; clamped to `[0, top_line_max]`.
1913
2366
  def move_top_line_to: (Integer target) -> void
1914
2367
 
2368
+ # Gated on {#following?}: once the user scrolls up off the bottom the
2369
+ # viewport pin is skipped, so reading older content is not interrupted
2370
+ # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
2371
+ # returns to the bottom.
1915
2372
  def update_top_line_if_auto_scroll: () -> void
1916
2373
 
2374
+ # _@return_ — whether the viewport is pinned to the last line.
2375
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
2376
+ def at_bottom?: () -> bool
2377
+
1917
2378
  def scrollbar_visible?: () -> bool
1918
2379
 
1919
2380
  # Pads `line` with trailing default-styled spaces out to `width` display
@@ -1946,7 +2407,10 @@ module Tuile
1946
2407
  attr_accessor scrollbar_visibility: Symbol
1947
2408
 
1948
2409
  # _@return_ — if true, mutating the text scrolls the viewport so
1949
- # the last line stays in view. Default `false`.
2410
+ # the last line stays in view but only while the viewport is already
2411
+ # pinned to the last line (see {#following?}). Scroll up to read older
2412
+ # content and appends stop yanking you back down; scroll back to the
2413
+ # bottom and tailing resumes. Default `false`.
1950
2414
  attr_accessor auto_scroll: bool
1951
2415
 
1952
2416
  # _@return_ — longest hard-line's display width × number of hard
@@ -2135,9 +2599,6 @@ module Tuile
2135
2599
  # positioned by {Screen} after each repaint cycle when this component is
2136
2600
  # focused; see {Component#cursor_position}.
2137
2601
  class TextField < Tuile::Component::TextInput
2138
- ACTIVE_BG_SGR: String
2139
- INACTIVE_BG_SGR: String
2140
-
2141
2602
  def initialize: () -> void
2142
2603
 
2143
2604
  def cursor_position: () -> Point?
@@ -2212,9 +2673,6 @@ module Tuile
2212
2673
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
2213
2674
  # keep the caret visible).
2214
2675
  class TextInput < Component
2215
- ACTIVE_BG_SGR: String
2216
- INACTIVE_BG_SGR: String
2217
-
2218
2676
  def initialize: () -> void
2219
2677
 
2220
2678
  # _@return_ — true iff {#text} is the empty string.
@@ -2231,6 +2689,16 @@ module Tuile
2231
2689
  # _@param_ `key`
2232
2690
  def handle_key: (String key) -> bool
2233
2691
 
2692
+ # Renders `text` on the field's background well, looked up from the
2693
+ # current {Screen#theme} at paint time: {Theme#active_bg} when this
2694
+ # input is on the active (focus) chain, {Theme#input_bg} otherwise —
2695
+ # visibly a field either way, distinctly highlighted when active.
2696
+ #
2697
+ # _@param_ `text`
2698
+ #
2699
+ # _@return_ — ANSI-rendered text.
2700
+ def background: (String text) -> String
2701
+
2234
2702
  # Input filter for {#text=}. Subclasses override to truncate or reject
2235
2703
  # invalid input. Default coerces to String.
2236
2704
  #
@@ -2398,6 +2866,71 @@ module Tuile
2398
2866
  end
2399
2867
  end
2400
2868
 
2869
+ # An app's theme definition: the {Theme} pair covering both terminal
2870
+ # appearances. {Screen} keeps one at {Screen#theme_def} (defaulting to
2871
+ # {DEFAULT}) and picks the member matching the detected background at
2872
+ # startup and on every OS appearance flip (mode 2031) — so a custom
2873
+ # definition survives the user toggling light/dark, where a bare
2874
+ # {Screen#theme=} assignment would be replaced.
2875
+ #
2876
+ # APP_THEME = Tuile::ThemeDef.new(
2877
+ # dark: Tuile::Theme::DARK.with(custom: { accent: Color::DARK_ORANGE }),
2878
+ # light: Tuile::Theme::LIGHT.with(custom: { accent: Color::DARK_ORANGE3 })
2879
+ # )
2880
+ # screen.theme_def = APP_THEME
2881
+ #
2882
+ # Both members must declare the same {Theme#custom} key set. Without
2883
+ # that, a token present only in one member would raise `KeyError` at
2884
+ # the unpredictable moment the user flips OS appearance; checking here
2885
+ # turns it into an immediate construction-time failure.
2886
+ #
2887
+ # @!attribute [r] dark
2888
+ # The theme applied on dark terminal backgrounds.
2889
+ # @return [Theme]
2890
+ # @!attribute [r] light
2891
+ # The theme applied on light terminal backgrounds.
2892
+ # @return [Theme]
2893
+ class ThemeDef
2894
+ DEFAULT: ThemeDef
2895
+
2896
+ # _@param_ `dark`
2897
+ #
2898
+ # _@param_ `light`
2899
+ def initialize: (dark: Theme, light: Theme) -> void
2900
+
2901
+ # The member for the given color scheme. Anything other than `:light`
2902
+ # selects {#dark}, matching {TerminalBackground.detect}'s
2903
+ # inconclusive-means-dark policy.
2904
+ #
2905
+ # _@param_ `scheme` — `:dark` or `:light`.
2906
+ def for: (Symbol scheme) -> Theme
2907
+
2908
+ # The definition newly-constructed {Screen}s start from (see
2909
+ # {Screen#theme_def}); initially {DEFAULT}. Reassigning affects
2910
+ # future screens only — an already-constructed screen keeps its
2911
+ # definition until {Screen#theme_def=}.
2912
+ #
2913
+ # Intended for test suites: production apps assign
2914
+ # {Screen#theme_def=} once at startup, but component specs build a
2915
+ # fresh {FakeScreen} per example, and a component reading a custom
2916
+ # token (`theme[:accent]`) would `KeyError` against the built-in
2917
+ # default. Point this at the app's definition once and every
2918
+ # {Screen.fake} carries it:
2919
+ #
2920
+ # Tuile::ThemeDef.default = APP_THEME # spec_helper.rb, once
2921
+ # before { Screen.fake } # theme[:accent] resolves
2922
+ def self.default: () -> ThemeDef
2923
+
2924
+ # _@param_ `theme_def`
2925
+ def self.default=: (ThemeDef value) -> ThemeDef
2926
+
2927
+ # The theme applied on dark terminal backgrounds.
2928
+ attr_reader dark: Theme
2929
+
2930
+ # The theme applied on light terminal backgrounds.
2931
+ attr_reader light: Theme
2932
+ end
2933
+
2401
2934
  # An event queue. The idea is that all UI-related updates run from the thread
2402
2935
  # which runs the event queue only; this removes any need for locking and/or
2403
2936
  # need for thread-safety mechanisms.
@@ -2523,6 +3056,29 @@ module Tuile
2523
3056
  attr_reader height: Integer
2524
3057
  end
2525
3058
 
3059
+ # The terminal's color scheme changed — the user flipped the OS between
3060
+ # light and dark appearance. Terminals supporting mode 2031 (kitty,
3061
+ # foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
3062
+ # (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
3063
+ # the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
3064
+ # it into this event and {Screen#event_loop} follows by assigning the
3065
+ # matching {Theme}.
3066
+ #
3067
+ # @!attribute [r] scheme
3068
+ # @return [Symbol] `:light` or `:dark`.
3069
+ class ColorSchemeEvent
3070
+ REPORT: Regexp
3071
+
3072
+ # _@param_ `key` — key read via {Keys.getkey}.
3073
+ #
3074
+ # _@return_ — nil when `key` is not a
3075
+ # color-scheme report.
3076
+ def self.parse: (String key) -> ColorSchemeEvent?
3077
+
3078
+ # _@return_ — `:light` or `:dark`.
3079
+ attr_reader scheme: Symbol
3080
+ end
3081
+
2526
3082
  # Emitted once when the queue is cleared, all messages are processed and the
2527
3083
  # event loop will block waiting for more messages. Perfect time for
2528
3084
  # repainting windows.
@@ -2597,6 +3153,11 @@ module Tuile
2597
3153
 
2598
3154
  def invalidated_clear: () -> void
2599
3155
 
3156
+ # No terminal probing in tests: skip {TerminalBackground.detect}
3157
+ # (which would write an OSC 11 query to the test runner's TTY and
3158
+ # steal its input) and pin the deterministic default.
3159
+ def detect_scheme: () -> Symbol
3160
+
2600
3161
  # _@return_ — whatever {#print} printed so far.
2601
3162
  attr_reader prints: ::Array[String]
2602
3163
  end
@@ -2751,7 +3312,7 @@ module Tuile
2751
3312
 
2752
3313
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
2753
3314
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
2754
- # `underline`). Spans are non-overlapping and fully tile the string — every
3315
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
2755
3316
  # character has exactly one resolved style, no overlay layers to merge.
2756
3317
  #
2757
3318
  # Where this differs from threading SGR escapes through a plain `String`:
@@ -2791,12 +3352,21 @@ module Tuile
2791
3352
  #
2792
3353
  # ## Parser
2793
3354
  #
2794
- # {.parse} is strict by design: it recognizes only the SGR codes
3355
+ # {.parse} is strict by default: it recognizes only the SGR codes
2795
3356
  # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
2796
- # underline). Anything else — unmodeled attributes (dim, blink, reverse,
2797
- # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
3357
+ # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
3358
+ # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
2798
3359
  # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
2799
3360
  # round-trip parse(to_ansi(x)) == x contract honest.
3361
+ #
3362
+ # Pass `lenient: true` to instead **discard** everything the parser can't
3363
+ # model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
3364
+ # apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
3365
+ # (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
3366
+ # silently dropped. This is the mode for piping in colored output you don't
3367
+ # control (e.g. `git --color` through a pager): "give me the colors, throw
3368
+ # the rest away." It is lossy by design — `parse(x, lenient: true)` does not
3369
+ # round-trip back to `x`.
2800
3370
  class StyledString
2801
3371
  EMPTY: StyledString
2802
3372
 
@@ -2816,7 +3386,9 @@ module Tuile
2816
3386
  # default-styled span.
2817
3387
  #
2818
3388
  # _@param_ `input`
2819
- def self.parse: ((String | StyledString)? input) -> StyledString
3389
+ #
3390
+ # _@param_ `lenient` — when true, unmodeled SGR codes and non-SGR escapes are discarded instead of raising — see {StyledString} "## Parser". Lossy: the result no longer round-trips to `input`.
3391
+ def self.parse: ((String | StyledString)? input, ?lenient: bool) -> StyledString
2820
3392
 
2821
3393
  # _@param_ `spans`
2822
3394
  def initialize: (?::Array[Span] spans) -> void
@@ -2903,7 +3475,7 @@ module Tuile
2903
3475
 
2904
3476
  # Returns a new {StyledString} with `bg` applied to every span, preserving
2905
3477
  # each span's text and other style attributes (`fg`, `bold`, `italic`,
2906
- # `underline`). Useful for row-level highlights — the new bg overlays
3478
+ # `underline`, `strikethrough`). Useful for row-level highlights — the new bg overlays
2907
3479
  # without dropping foreground colors the original styling carried.
2908
3480
  #
2909
3481
  # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
@@ -2911,7 +3483,7 @@ module Tuile
2911
3483
 
2912
3484
  # Returns a new {StyledString} with `fg` applied to every span, preserving
2913
3485
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
2914
- # `underline`). The new fg overlays without dropping background colors or
3486
+ # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
2915
3487
  # text attributes the original styling carried.
2916
3488
  #
2917
3489
  # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
@@ -3004,6 +3576,8 @@ module Tuile
3004
3576
  # @return [Boolean]
3005
3577
  # @!attribute [r] underline
3006
3578
  # @return [Boolean]
3579
+ # @!attribute [r] strikethrough
3580
+ # @return [Boolean]
3007
3581
  class Style
3008
3582
  DEFAULT: Style
3009
3583
 
@@ -3016,12 +3590,15 @@ module Tuile
3016
3590
  # _@param_ `italic`
3017
3591
  #
3018
3592
  # _@param_ `underline`
3593
+ #
3594
+ # _@param_ `strikethrough`
3019
3595
  def self.new: (
3020
3596
  ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
3021
3597
  ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
3022
3598
  ?bold: bool,
3023
3599
  ?italic: bool,
3024
- ?underline: bool
3600
+ ?underline: bool,
3601
+ ?strikethrough: bool
3025
3602
  ) -> Style
3026
3603
 
3027
3604
  def default?: () -> bool
@@ -3040,6 +3617,8 @@ module Tuile
3040
3617
  attr_reader italic: bool
3041
3618
 
3042
3619
  attr_reader underline: bool
3620
+
3621
+ attr_reader strikethrough: bool
3043
3622
  end
3044
3623
 
3045
3624
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -3065,14 +3644,18 @@ module Tuile
3065
3644
  # @api private
3066
3645
  # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
3067
3646
  # text accumulates into the current span; each `\e[...m` flushes the
3068
- # current span and updates the running {Style}. Anything outside the
3069
- # supported SGR alphabet raises {ParseError}.
3647
+ # current span and updates the running {Style}. In strict mode anything
3648
+ # outside the supported SGR alphabet raises {ParseError}; in lenient mode
3649
+ # it is consumed and discarded (see {StyledString} "## Parser").
3070
3650
  class Parser
3071
3651
  STANDARD_COLORS: ::Array[Symbol]
3072
3652
  BRIGHT_COLORS: ::Array[Symbol]
3653
+ STRING_INTRODUCERS: ::Array[String]
3073
3654
 
3074
3655
  # _@param_ `input`
3075
- def initialize: (String input) -> void
3656
+ #
3657
+ # _@param_ `lenient` — when true, discard unmodeled SGR codes and non-SGR escapes instead of raising {ParseError}.
3658
+ def initialize: (String input, ?lenient: bool) -> void
3076
3659
 
3077
3660
  def parse: () -> StyledString
3078
3661
 
@@ -3080,6 +3663,27 @@ module Tuile
3080
3663
 
3081
3664
  def consume_escape: () -> void
3082
3665
 
3666
+ # Consumes a CSI sequence (`\e[` already eaten). A well-formed SGR
3667
+ # (`\e[...m` with numeric/`;` params and no intermediates) is applied;
3668
+ # anything else is a non-SGR or malformed CSI — raises in strict mode,
3669
+ # swallowed in lenient. Scans the full CSI grammar (parameter bytes
3670
+ # `\x30-\x3F`, intermediate bytes `\x20-\x2F`, final byte) so lenient
3671
+ # mode consumes the whole sequence even for private-marker forms like
3672
+ # `\e[?25l`.
3673
+ def consume_csi: () -> void
3674
+
3675
+ # Lenient-only: discards a non-CSI escape (`\e` and `intro` already
3676
+ # eaten). OSC/DCS/string sequences run to their string terminator; an
3677
+ # nF escape (`\e( B`) eats its intermediates plus one final byte; any
3678
+ # other Fe/Fp/Fs escape was complete in `intro` alone.
3679
+ #
3680
+ # _@param_ `intro` — the byte after `\e` (never `"["`).
3681
+ def consume_non_csi: (String intro) -> void
3682
+
3683
+ # Lenient-only: swallows an OSC/DCS/string-sequence payload up to and
3684
+ # including its terminator (BEL, or ST `\e\\`), or to EOS if unterminated.
3685
+ def consume_string_sequence: () -> void
3686
+
3083
3687
  # _@param_ `params_str`
3084
3688
  def apply_sgr: (String params_str) -> void
3085
3689
 
@@ -3089,7 +3693,11 @@ module Tuile
3089
3693
  #
3090
3694
  # _@param_ `target` — either `:fg` or `:bg`.
3091
3695
  #
3092
- # _@return_ — how many SGR codes were consumed (3 for 256-color, 5 for RGB).
3696
+ # _@return_ — how many SGR codes were consumed. In lenient mode a
3697
+ # malformed color is skipped rather than applied, but the same count is
3698
+ # returned (3 for 256-color, 5 for RGB) so the running index advances
3699
+ # past its operands; an unknown selector skips just `38`/`48` + the
3700
+ # selector byte (2), letting the rest be reprocessed.
3093
3701
  def consume_extended_color: (::Array[Integer] codes, Integer index, Symbol target) -> Integer
3094
3702
 
3095
3703
  def flush: () -> void
@@ -3151,6 +3759,91 @@ module Tuile
3151
3759
  end
3152
3760
  end
3153
3761
 
3762
+ # Detects whether the terminal background is light or dark, so {Screen}
3763
+ # can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
3764
+ #
3765
+ # Two mechanisms, in order of reliability:
3766
+ #
3767
+ # 1. **OSC 11 query** — writes `ESC ] 11 ; ? BEL` to the terminal; modern
3768
+ # terminals (xterm, kitty, alacritty, wezterm, iTerm2, GNOME Terminal,
3769
+ # Windows Terminal) reply on stdin with the background color
3770
+ # (`\e]11;rgb:RRRR/GGGG/BBBB` + BEL or ST). The color's relative
3771
+ # luminance against a 0.5 threshold decides light vs dark. Terminals
3772
+ # that don't support the query simply never reply, so the read is
3773
+ # bounded by a short timeout.
3774
+ # 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
3775
+ # indices. Less reliable (stale across SSH/tmux, often unset); used
3776
+ # only when OSC 11 yields nothing.
3777
+ #
3778
+ # **Timing matters**: the OSC 11 reply arrives on stdin, so the query
3779
+ # must complete before {EventQueue#start_key_thread} owns stdin —
3780
+ # otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
3781
+ # calls {.detect} from its constructor, which apps run before
3782
+ # {Screen#run_event_loop}; don't call this after the event loop started.
3783
+ module TerminalBackground
3784
+ QUERY_TIMEOUT: Float
3785
+ QUERY: String
3786
+ REPLY: Regexp
3787
+ NOTIFY_ON: String
3788
+ NOTIFY_OFF: String
3789
+
3790
+ # Detects the terminal background. Queries OSC 11 when both `input`
3791
+ # and `output` are TTYs, falling back to `COLORFGBG`.
3792
+ #
3793
+ # _@param_ `input` — where the OSC 11 reply arrives (the TTY input).
3794
+ #
3795
+ # _@param_ `output` — where the query is written (the TTY output).
3796
+ #
3797
+ # _@param_ `env` — environment for the `COLORFGBG` fallback; defaults to `ENV` (which duck-types the `[]` lookup).
3798
+ #
3799
+ # _@param_ `timeout` — max seconds to wait for the OSC 11 reply.
3800
+ #
3801
+ # _@return_ — `:light`, `:dark`, or nil when undetectable.
3802
+ def self.detect: (
3803
+ ?input: IO,
3804
+ ?output: IO,
3805
+ ?env: ::Hash[String, String],
3806
+ ?timeout: Numeric
3807
+ ) -> Symbol?
3808
+
3809
+ # Writes the OSC 11 query and classifies the reply. The whole
3810
+ # exchange runs with `input` in raw mode: the reply has no trailing
3811
+ # newline, so a canonical-mode read would block past the timeout,
3812
+ # and echo would smear the reply bytes onto the screen.
3813
+ #
3814
+ # _@param_ `input`
3815
+ #
3816
+ # _@param_ `output`
3817
+ #
3818
+ # _@param_ `timeout`
3819
+ def self.query_osc11: (IO input, IO output, Numeric timeout) -> Symbol?
3820
+
3821
+ # Accumulates reply bytes until a BEL/ST terminator or the deadline.
3822
+ # Terminals that don't support OSC 11 never reply — returning
3823
+ # whatever arrived (usually nothing) lets the caller fail soft.
3824
+ #
3825
+ # _@param_ `input`
3826
+ #
3827
+ # _@param_ `timeout`
3828
+ def self.read_reply: (IO input, Numeric timeout) -> String
3829
+
3830
+ # Relative luminance of the reported background, scaled per
3831
+ # component hex width (xterm replies 4 digits per channel, others 2).
3832
+ #
3833
+ # _@param_ `components` — three hex strings.
3834
+ #
3835
+ # _@return_ — `:light` or `:dark`.
3836
+ def self.classify: (::Array[String] components) -> Symbol
3837
+
3838
+ # `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
3839
+ # ANSI palette indices. White-ish backgrounds — 7 (white) and the
3840
+ # bright range 9–15 — read as light; 0–6 and 8 as dark; anything
3841
+ # else (missing, `"default"`, out of range) is inconclusive.
3842
+ #
3843
+ # _@param_ `value`
3844
+ def self.from_colorfgbg: (String? value) -> Symbol?
3845
+ end
3846
+
3154
3847
  # A vertical scrollbar that computes which character to draw at each row.
3155
3848
  #
3156
3849
  # Uses `█` for the handle (filled track) and `░` for the empty track. There