tuile 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -197,6 +198,9 @@ module Tuile
197
198
  #
198
199
  # A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
199
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.
200
204
  # {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
201
205
  # an existing {Color} (returned as-is), so APIs that accept colors typically
202
206
  # take `[Color, nil]` and pass through {.coerce}.
@@ -206,15 +210,26 @@ module Tuile
206
210
  # Color.new(42) # 256-color palette
207
211
  # Color.new([255, 100, 0]) # RGB
208
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
209
216
  # Color.coerce(:red) # accepts raw forms, returns Color
210
217
  # Color.coerce(nil) # nil → nil
211
218
  # ```
212
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
+ #
213
227
  # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
214
228
  # raw numeric codes so callers (notably {StyledString}) can combine them with
215
229
  # other SGR attributes in a single sequence.
216
230
  class Color
217
231
  COLOR_SYMBOLS: ::Array[Symbol]
232
+ PALETTE_NAMES: ::Hash[Symbol, Integer]
218
233
 
219
234
  # Coerces the input to a {Color}. `nil` passes through unchanged (callers
220
235
  # use `nil` for the terminal default); an existing {Color} is returned
@@ -223,6 +238,35 @@ module Tuile
223
238
  # _@param_ `value`
224
239
  def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
225
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
+
226
270
  # _@param_ `value` — see class-level docs for the three accepted forms.
227
271
  def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
228
272
 
@@ -268,6 +312,213 @@ module Tuile
268
312
  attr_reader y: Integer
269
313
  end
270
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
+
271
522
  # The TTY screen. There is exactly one screen per app.
272
523
  #
273
524
  # A screen runs the event loop; call {#run_event_loop} to do that.
@@ -386,7 +637,7 @@ module Tuile
386
637
  #
387
638
  # screen.register_global_shortcut(Keys::CTRL_L,
388
639
  # over_popups: true,
389
- # hint: "^L #{Rainbow("log").cadetblue}") do
640
+ # hint: "^L #{screen.theme.hint("log")}") do
390
641
  # log_popup.open
391
642
  # end
392
643
  #
@@ -394,7 +645,7 @@ module Tuile
394
645
  #
395
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.
396
647
  #
397
- # _@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.
398
649
  def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
399
650
 
400
651
  # Removes a shortcut previously installed by {#register_global_shortcut}.
@@ -462,6 +713,23 @@ module Tuile
462
713
  # but only one focused.
463
714
  def cursor_position: () -> Point?
464
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
+
465
733
  # Walks the current modal scope in pre-order, collects tab stops, and
466
734
  # advances focus by one (wrapping). When the focused component isn't in
467
735
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -545,6 +813,22 @@ module Tuile
545
813
  # _@return_ — current screen size.
546
814
  attr_reader size: Size
547
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
+
548
832
  # _@return_ — the event queue.
549
833
  attr_reader event_queue: EventQueue
550
834
 
@@ -566,6 +850,52 @@ module Tuile
566
850
  end
567
851
  end
568
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
+
569
899
  # A UI component which is positioned on the screen and draws characters into
570
900
  # its bounding rectangle (in {#repaint}).
571
901
  #
@@ -700,13 +1030,19 @@ module Tuile
700
1030
  # _@param_ `child` — the just-detached child.
701
1031
  def on_child_removed: (Component child) -> void
702
1032
 
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
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
710
1046
 
711
1047
  # Where the hardware terminal cursor should sit when this component is the
712
1048
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
@@ -759,6 +1095,35 @@ module Tuile
759
1095
  # no parent.
760
1096
  attr_accessor parent: Component?
761
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
+
762
1127
  # A scrollable list of items with cursor support.
763
1128
  #
764
1129
  # Items are modeled as {StyledString}s and painted directly into the
@@ -771,11 +1136,9 @@ module Tuile
771
1136
  #
772
1137
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
773
1138
  # 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.
1139
+ # list automatically. The cursor highlight overlays
1140
+ # {Theme#active_bg_color} while preserving each span's foreground color.
776
1141
  class List < Component
777
- CURSOR_BG: Integer
778
-
779
1142
  def initialize: () -> void
780
1143
 
781
1144
  # Sets new lines. Each entry is coerced into a {StyledString} (a
@@ -814,8 +1177,6 @@ module Tuile
814
1177
  # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
815
1178
  def add_lines: (::Array[untyped] lines) -> void
816
1179
 
817
- def content_size: () -> Size
818
-
819
1180
  def focusable?: () -> bool
820
1181
 
821
1182
  def tab_stop?: () -> bool
@@ -867,6 +1228,19 @@ module Tuile
867
1228
  # is one, so the list snaps to the bottom on first paint.
868
1229
  def on_width_changed: () -> void
869
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
+
870
1244
  # Coerces and flattens a list of input entries into trimmed
871
1245
  # {StyledString} lines. Each entry becomes a {StyledString} (String
872
1246
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -1154,11 +1528,6 @@ module Tuile
1154
1528
  class Label < Component
1155
1529
  def initialize: () -> void
1156
1530
 
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
1531
  # Paints the text into {#rect}.
1163
1532
  #
1164
1533
  # Skips the {Component#repaint} default's auto-clear: every row is
@@ -1169,6 +1538,11 @@ module Tuile
1169
1538
 
1170
1539
  def on_width_changed: () -> void
1171
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
+
1172
1546
  # Recomputes {@clipped_lines} for the current text and rect width.
1173
1547
  # Each line is ellipsized to fit, padded with trailing spaces out to
1174
1548
  # the full width, and pre-rendered to ANSI so {#repaint} is just a
@@ -1255,6 +1629,14 @@ module Tuile
1255
1629
  # _@param_ `new_content`
1256
1630
  def content=: (Component? new_content) -> void
1257
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
+
1258
1640
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
1259
1641
  def keyboard_hint: () -> String
1260
1642
 
@@ -1302,10 +1684,6 @@ module Tuile
1302
1684
 
1303
1685
  def tab_stop?: () -> bool
1304
1686
 
1305
- # _@return_ — natural width is `caption.length + 4` to fit
1306
- # `[ caption ]`; height is 1.
1307
- def content_size: () -> Size
1308
-
1309
1687
  # _@param_ `key`
1310
1688
  def handle_key: (String key) -> bool
1311
1689
 
@@ -1314,6 +1692,9 @@ module Tuile
1314
1692
 
1315
1693
  def repaint: () -> void
1316
1694
 
1695
+ # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
1696
+ def natural_size: () -> Size
1697
+
1317
1698
  # _@return_ — the button's label.
1318
1699
  attr_accessor caption: String
1319
1700
 
@@ -1408,11 +1789,21 @@ module Tuile
1408
1789
  # _@param_ `value`
1409
1790
  def scrollbar=: (bool value) -> void
1410
1791
 
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
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
1416
1807
 
1417
1808
  # Fully repaints the window: both frame and contents.
1418
1809
  #
@@ -1446,6 +1837,17 @@ module Tuile
1446
1837
  # _@param_ `caption`
1447
1838
  def build_frame: (String caption) -> String
1448
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.
1449
1851
  def layout_footer: () -> void
1450
1852
 
1451
1853
  def on_focus: () -> void
@@ -1454,6 +1856,11 @@ module Tuile
1454
1856
  # row.
1455
1857
  attr_accessor footer: Component?
1456
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
+
1457
1864
  # _@return_ — the current caption, empty by default.
1458
1865
  attr_accessor caption: String
1459
1866
  end
@@ -1475,9 +1882,6 @@ module Tuile
1475
1882
  # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
1476
1883
  # callback may opt out of that by consuming Enter instead.
1477
1884
  class TextArea < Tuile::Component::TextInput
1478
- ACTIVE_BG_SGR: String
1479
- INACTIVE_BG_SGR: String
1480
-
1481
1885
  def initialize: () -> void
1482
1886
 
1483
1887
  def cursor_position: () -> Point?
@@ -2135,9 +2539,6 @@ module Tuile
2135
2539
  # positioned by {Screen} after each repaint cycle when this component is
2136
2540
  # focused; see {Component#cursor_position}.
2137
2541
  class TextField < Tuile::Component::TextInput
2138
- ACTIVE_BG_SGR: String
2139
- INACTIVE_BG_SGR: String
2140
-
2141
2542
  def initialize: () -> void
2142
2543
 
2143
2544
  def cursor_position: () -> Point?
@@ -2212,9 +2613,6 @@ module Tuile
2212
2613
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
2213
2614
  # keep the caret visible).
2214
2615
  class TextInput < Component
2215
- ACTIVE_BG_SGR: String
2216
- INACTIVE_BG_SGR: String
2217
-
2218
2616
  def initialize: () -> void
2219
2617
 
2220
2618
  # _@return_ — true iff {#text} is the empty string.
@@ -2231,6 +2629,16 @@ module Tuile
2231
2629
  # _@param_ `key`
2232
2630
  def handle_key: (String key) -> bool
2233
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
+
2234
2642
  # Input filter for {#text=}. Subclasses override to truncate or reject
2235
2643
  # invalid input. Default coerces to String.
2236
2644
  #
@@ -2398,6 +2806,71 @@ module Tuile
2398
2806
  end
2399
2807
  end
2400
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
+
2401
2874
  # An event queue. The idea is that all UI-related updates run from the thread
2402
2875
  # which runs the event queue only; this removes any need for locking and/or
2403
2876
  # need for thread-safety mechanisms.
@@ -2523,6 +2996,29 @@ module Tuile
2523
2996
  attr_reader height: Integer
2524
2997
  end
2525
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
+
2526
3022
  # Emitted once when the queue is cleared, all messages are processed and the
2527
3023
  # event loop will block waiting for more messages. Perfect time for
2528
3024
  # repainting windows.
@@ -2597,6 +3093,11 @@ module Tuile
2597
3093
 
2598
3094
  def invalidated_clear: () -> void
2599
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
+
2600
3101
  # _@return_ — whatever {#print} printed so far.
2601
3102
  attr_reader prints: ::Array[String]
2602
3103
  end
@@ -3151,6 +3652,91 @@ module Tuile
3151
3652
  end
3152
3653
  end
3153
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?
3738
+ end
3739
+
3154
3740
  # A vertical scrollbar that computes which character to draw at each row.
3155
3741
  #
3156
3742
  # Uses `█` for the handle (filled track) and `░` for the empty track. There