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.
@@ -3,7 +3,7 @@
3
3
  module Tuile
4
4
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
5
5
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
6
- # `underline`). Spans are non-overlapping and fully tile the string — every
6
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
7
7
  # character has exactly one resolved style, no overlay layers to merge.
8
8
  #
9
9
  # Where this differs from threading SGR escapes through a plain `String`:
@@ -43,12 +43,21 @@ module Tuile
43
43
  #
44
44
  # ## Parser
45
45
  #
46
- # {.parse} is strict by design: it recognizes only the SGR codes
46
+ # {.parse} is strict by default: it recognizes only the SGR codes
47
47
  # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
48
- # underline). Anything else — unmodeled attributes (dim, blink, reverse,
49
- # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
48
+ # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
49
+ # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
50
50
  # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
51
51
  # round-trip parse(to_ansi(x)) == x contract honest.
52
+ #
53
+ # Pass `lenient: true` to instead **discard** everything the parser can't
54
+ # model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
55
+ # apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
56
+ # (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
57
+ # silently dropped. This is the mode for piping in colored output you don't
58
+ # control (e.g. `git --color` through a pager): "give me the colors, throw
59
+ # the rest away." It is lossy by design — `parse(x, lenient: true)` does not
60
+ # round-trip back to `x`.
52
61
  class StyledString
53
62
  # Raised by {.parse} on malformed or unsupported escape sequences.
54
63
  class ParseError < Error; end
@@ -69,16 +78,19 @@ module Tuile
69
78
  # @return [Boolean]
70
79
  # @!attribute [r] underline
71
80
  # @return [Boolean]
72
- class Style < Data.define(:fg, :bg, :bold, :italic, :underline)
81
+ # @!attribute [r] strikethrough
82
+ # @return [Boolean]
83
+ class Style < Data.define(:fg, :bg, :bold, :italic, :underline, :strikethrough)
73
84
  # @param fg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
74
85
  # @param bg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
75
86
  # @param bold [Boolean]
76
87
  # @param italic [Boolean]
77
88
  # @param underline [Boolean]
89
+ # @param strikethrough [Boolean]
78
90
  # @return [Style]
79
91
  # @raise [ArgumentError] when a color is not one of the accepted forms.
80
- def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false)
81
- super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:)
92
+ def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false, strikethrough: false)
93
+ super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:, strikethrough:)
82
94
  end
83
95
 
84
96
  # The style with no color and no attributes — what the terminal shows
@@ -117,8 +129,9 @@ module Tuile
117
129
  # @api private
118
130
  # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
119
131
  # text accumulates into the current span; each `\e[...m` flushes the
120
- # current span and updates the running {Style}. Anything outside the
121
- # supported SGR alphabet raises {ParseError}.
132
+ # current span and updates the running {Style}. In strict mode anything
133
+ # outside the supported SGR alphabet raises {ParseError}; in lenient mode
134
+ # it is consumed and discarded (see {StyledString} "## Parser").
122
135
  class Parser
123
136
  # @return [Array<Symbol>]
124
137
  STANDARD_COLORS = Color::COLOR_SYMBOLS[0, 8].freeze
@@ -128,9 +141,20 @@ module Tuile
128
141
  BRIGHT_COLORS = Color::COLOR_SYMBOLS[8, 8].freeze
129
142
  private_constant :BRIGHT_COLORS
130
143
 
144
+ # ESC-introducers (the byte after `\e`) whose payload runs until a string
145
+ # terminator (ST `\e\\` or BEL): OSC `]`, DCS `P`, SOS `X`, PM `^`,
146
+ # APC `_`. In lenient mode the whole sequence — payload included — is
147
+ # swallowed so it never leaks into span text.
148
+ # @return [Array<String>]
149
+ STRING_INTRODUCERS = %w(] P X ^ _).freeze
150
+ private_constant :STRING_INTRODUCERS
151
+
131
152
  # @param input [String]
132
- def initialize(input)
153
+ # @param lenient [Boolean] when true, discard unmodeled SGR codes and
154
+ # non-SGR escapes instead of raising {ParseError}.
155
+ def initialize(input, lenient: false)
133
156
  @scanner = StringScanner.new(input)
157
+ @lenient = lenient
134
158
  @style = Style::DEFAULT
135
159
  @text = +""
136
160
  @spans = []
@@ -160,16 +184,70 @@ module Tuile
160
184
  # @return [void]
161
185
  def consume_escape
162
186
  @scanner.getch # \e
163
- bracket = @scanner.getch
164
- raise ParseError, "expected '[' after ESC, got #{bracket.inspect}" if bracket != "["
187
+ intro = @scanner.getch
188
+ case intro
189
+ when "[" then consume_csi
190
+ when nil then raise ParseError, "unterminated escape sequence" unless @lenient
191
+ else
192
+ raise ParseError, "expected '[' after ESC, got #{intro.inspect}" unless @lenient
193
+
194
+ consume_non_csi(intro)
195
+ end
196
+ end
165
197
 
166
- params = @scanner.scan(/[\d;]*/) || ""
198
+ # Consumes a CSI sequence (`\e[` already eaten). A well-formed SGR
199
+ # (`\e[...m` with numeric/`;` params and no intermediates) is applied;
200
+ # anything else is a non-SGR or malformed CSI — raises in strict mode,
201
+ # swallowed in lenient. Scans the full CSI grammar (parameter bytes
202
+ # `\x30-\x3F`, intermediate bytes `\x20-\x2F`, final byte) so lenient
203
+ # mode consumes the whole sequence even for private-marker forms like
204
+ # `\e[?25l`.
205
+ # @return [void]
206
+ def consume_csi
207
+ params = @scanner.scan(/[\x30-\x3F]*/) || ""
208
+ intermediates = @scanner.scan(/[\x20-\x2F]*/) || ""
167
209
  final = @scanner.getch
168
- raise ParseError, "unterminated escape sequence" if final.nil?
169
- raise ParseError, "non-SGR CSI sequence (final byte #{final.inspect})" if final != "m"
170
210
 
211
+ if final == "m" && intermediates.empty? && params.match?(/\A[\d;]*\z/)
212
+ flush
213
+ return apply_sgr(params)
214
+ end
215
+
216
+ raise ParseError, "unterminated escape sequence" if final.nil? && !@lenient
217
+ raise ParseError, "non-SGR CSI sequence (final byte #{final.inspect})" unless @lenient
218
+
219
+ flush
220
+ end
221
+
222
+ # Lenient-only: discards a non-CSI escape (`\e` and `intro` already
223
+ # eaten). OSC/DCS/string sequences run to their string terminator; an
224
+ # nF escape (`\e( B`) eats its intermediates plus one final byte; any
225
+ # other Fe/Fp/Fs escape was complete in `intro` alone.
226
+ # @param intro [String] the byte after `\e` (never `"["`).
227
+ # @return [void]
228
+ def consume_non_csi(intro)
171
229
  flush
172
- apply_sgr(params)
230
+ if STRING_INTRODUCERS.include?(intro)
231
+ consume_string_sequence
232
+ elsif intro.match?(/[\x20-\x2F]/)
233
+ @scanner.scan(/[\x20-\x2F]*/)
234
+ @scanner.getch
235
+ end
236
+ end
237
+
238
+ # Lenient-only: swallows an OSC/DCS/string-sequence payload up to and
239
+ # including its terminator (BEL, or ST `\e\\`), or to EOS if unterminated.
240
+ # @return [void]
241
+ def consume_string_sequence
242
+ until @scanner.eos?
243
+ ch = @scanner.getch
244
+ break if ch == "\a"
245
+
246
+ if ch == "\e"
247
+ @scanner.getch if @scanner.peek(1) == "\\"
248
+ break
249
+ end
250
+ end
173
251
  end
174
252
 
175
253
  # @param params_str [String]
@@ -187,6 +265,8 @@ module Tuile
187
265
  when 23 then @style = @style.merge(italic: false)
188
266
  when 4 then @style = @style.merge(underline: true)
189
267
  when 24 then @style = @style.merge(underline: false)
268
+ when 9 then @style = @style.merge(strikethrough: true)
269
+ when 29 then @style = @style.merge(strikethrough: false)
190
270
  when 30..37 then @style = @style.merge(fg: STANDARD_COLORS[code - 30])
191
271
  when 38
192
272
  i += consume_extended_color(codes, i, :fg)
@@ -199,7 +279,7 @@ module Tuile
199
279
  when 49 then @style = @style.merge(bg: nil)
200
280
  when 90..97 then @style = @style.merge(fg: BRIGHT_COLORS[code - 90])
201
281
  when 100..107 then @style = @style.merge(bg: BRIGHT_COLORS[code - 100])
202
- else raise ParseError, "unsupported SGR code #{code}"
282
+ else raise ParseError, "unsupported SGR code #{code}" unless @lenient
203
283
  end
204
284
  i += 1
205
285
  end
@@ -208,27 +288,37 @@ module Tuile
208
288
  # @param codes [Array<Integer>]
209
289
  # @param index [Integer]
210
290
  # @param target [Symbol] either `:fg` or `:bg`.
211
- # @return [Integer] how many SGR codes were consumed (3 for 256-color, 5 for RGB).
291
+ # @return [Integer] how many SGR codes were consumed. In lenient mode a
292
+ # malformed color is skipped rather than applied, but the same count is
293
+ # returned (3 for 256-color, 5 for RGB) so the running index advances
294
+ # past its operands; an unknown selector skips just `38`/`48` + the
295
+ # selector byte (2), letting the rest be reprocessed.
212
296
  def consume_extended_color(codes, index, target)
213
297
  mode = codes[index + 1]
214
298
  case mode
215
299
  when 5
216
300
  n = codes[index + 2]
217
- raise ParseError, "invalid 256-color index #{n.inspect}" unless n&.between?(0, 255)
218
-
219
- @style = @style.merge(target => n)
301
+ if n&.between?(0, 255)
302
+ @style = @style.merge(target => n)
303
+ elsif !@lenient
304
+ raise ParseError, "invalid 256-color index #{n.inspect}"
305
+ end
220
306
  3
221
307
  when 2
222
308
  r = codes[index + 2]
223
309
  g = codes[index + 3]
224
310
  b = codes[index + 4]
225
- [r, g, b].each do |v|
226
- raise ParseError, "invalid RGB component #{v.inspect}" unless v&.between?(0, 255)
311
+ if [r, g, b].all? { |v| v&.between?(0, 255) }
312
+ @style = @style.merge(target => [r, g, b])
313
+ elsif !@lenient
314
+ bad = [r, g, b].find { |v| !v&.between?(0, 255) }
315
+ raise ParseError, "invalid RGB component #{bad.inspect}"
227
316
  end
228
- @style = @style.merge(target => [r, g, b])
229
317
  5
230
318
  else
231
- raise ParseError, "unsupported extended-color selector #{mode.inspect}"
319
+ raise ParseError, "unsupported extended-color selector #{mode.inspect}" unless @lenient
320
+
321
+ 2
232
322
  end
233
323
  end
234
324
 
@@ -268,10 +358,14 @@ module Tuile
268
358
  # default-styled span.
269
359
  #
270
360
  # @param input [String, StyledString, nil]
361
+ # @param lenient [Boolean] when true, unmodeled SGR codes and non-SGR
362
+ # escapes are discarded instead of raising — see {StyledString}
363
+ # "## Parser". Lossy: the result no longer round-trips to `input`.
271
364
  # @return [StyledString]
272
- # @raise [ParseError] on unsupported or malformed escape sequences.
365
+ # @raise [ParseError] on unsupported or malformed escape sequences
366
+ # (strict mode only).
273
367
  # @raise [TypeError] when `input` is none of String, StyledString, nil.
274
- def parse(input)
368
+ def parse(input, lenient: false)
275
369
  case input
276
370
  when nil then EMPTY
277
371
  when StyledString then input
@@ -279,7 +373,7 @@ module Tuile
279
373
  return EMPTY if input.empty?
280
374
  return new([Span.new(text: input, style: Style::DEFAULT)]) unless input.include?("\e")
281
375
 
282
- Parser.new(input).parse
376
+ Parser.new(input, lenient:).parse
283
377
  else
284
378
  raise TypeError, "cannot parse #{input.class}"
285
379
  end
@@ -456,7 +550,7 @@ module Tuile
456
550
 
457
551
  # Returns a new {StyledString} with `bg` applied to every span, preserving
458
552
  # each span's text and other style attributes (`fg`, `bold`, `italic`,
459
- # `underline`). Useful for row-level highlights — the new bg overlays
553
+ # `underline`, `strikethrough`). Useful for row-level highlights — the new bg overlays
460
554
  # without dropping foreground colors the original styling carried.
461
555
  #
462
556
  # @param bg [Color, Symbol, Integer, Array<Integer>, nil] background
@@ -469,7 +563,7 @@ module Tuile
469
563
 
470
564
  # Returns a new {StyledString} with `fg` applied to every span, preserving
471
565
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
472
- # `underline`). The new fg overlays without dropping background colors or
566
+ # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
473
567
  # text attributes the original styling carried.
474
568
  #
475
569
  # @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground
@@ -528,6 +622,7 @@ module Tuile
528
622
  codes << (to.bold ? 1 : 22) if from.bold != to.bold
529
623
  codes << (to.italic ? 3 : 23) if from.italic != to.italic
530
624
  codes << (to.underline ? 4 : 24) if from.underline != to.underline
625
+ codes << (to.strikethrough ? 9 : 29) if from.strikethrough != to.strikethrough
531
626
  codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
532
627
  codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
533
628
  return "" if codes.empty?
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # Detects whether the terminal background is light or dark, so {Screen}
5
+ # can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
6
+ #
7
+ # Two mechanisms, in order of reliability:
8
+ #
9
+ # 1. **OSC 11 query** — writes `ESC ] 11 ; ? BEL` to the terminal; modern
10
+ # terminals (xterm, kitty, alacritty, wezterm, iTerm2, GNOME Terminal,
11
+ # Windows Terminal) reply on stdin with the background color
12
+ # (`\e]11;rgb:RRRR/GGGG/BBBB` + BEL or ST). The color's relative
13
+ # luminance against a 0.5 threshold decides light vs dark. Terminals
14
+ # that don't support the query simply never reply, so the read is
15
+ # bounded by a short timeout.
16
+ # 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
17
+ # indices. Less reliable (stale across SSH/tmux, often unset); used
18
+ # only when OSC 11 yields nothing.
19
+ #
20
+ # **Timing matters**: the OSC 11 reply arrives on stdin, so the query
21
+ # must complete before {EventQueue#start_key_thread} owns stdin —
22
+ # otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
23
+ # calls {.detect} from its constructor, which apps run before
24
+ # {Screen#run_event_loop}; don't call this after the event loop started.
25
+ module TerminalBackground
26
+ # How long to wait for the OSC 11 reply. Generous for a local
27
+ # terminal; bounded so unsupporting terminals (which never reply)
28
+ # don't stall startup.
29
+ # @return [Float] seconds.
30
+ QUERY_TIMEOUT = 0.1
31
+
32
+ # The OSC 11 background-color query, BEL-terminated.
33
+ # @return [String]
34
+ QUERY = "\e]11;?\a"
35
+
36
+ # Matches the OSC 11 reply. Components are 1–4 hex digits each
37
+ # (terminals vary); `rgba:` (4 components) also matches — the alpha
38
+ # tail is ignored.
39
+ # @return [Regexp]
40
+ REPLY = %r{\e\]11;rgba?:(\h{1,4})/(\h{1,4})/(\h{1,4})}
41
+
42
+ # Enables mode 2031: the terminal pushes a color-scheme report
43
+ # (`\e[?997;1n` dark / `\e[?997;2n` light) whenever the OS appearance
44
+ # flips — see {EventQueue::ColorSchemeEvent}. Terminals without
45
+ # support ignore the sequence. Written by {Screen#run_event_loop}.
46
+ # @return [String]
47
+ NOTIFY_ON = "\e[?2031h"
48
+
49
+ # Disables mode 2031 again; written when the event loop exits.
50
+ # @return [String]
51
+ NOTIFY_OFF = "\e[?2031l"
52
+
53
+ class << self
54
+ # Detects the terminal background. Queries OSC 11 when both `input`
55
+ # and `output` are TTYs, falling back to `COLORFGBG`.
56
+ #
57
+ # @param input [IO] where the OSC 11 reply arrives (the TTY input).
58
+ # @param output [IO] where the query is written (the TTY output).
59
+ # @param env [Hash{String => String}] environment for the `COLORFGBG`
60
+ # fallback; defaults to `ENV` (which duck-types the `[]` lookup).
61
+ # @param timeout [Numeric] max seconds to wait for the OSC 11 reply.
62
+ # @return [Symbol, nil] `:light`, `:dark`, or nil when undetectable.
63
+ def detect(input: $stdin, output: $stdout, env: ENV, timeout: QUERY_TIMEOUT)
64
+ osc = query_osc11(input, output, timeout) if input.tty? && output.tty?
65
+ osc || from_colorfgbg(env["COLORFGBG"])
66
+ end
67
+
68
+ private
69
+
70
+ # Writes the OSC 11 query and classifies the reply. The whole
71
+ # exchange runs with `input` in raw mode: the reply has no trailing
72
+ # newline, so a canonical-mode read would block past the timeout,
73
+ # and echo would smear the reply bytes onto the screen.
74
+ # @param input [IO]
75
+ # @param output [IO]
76
+ # @param timeout [Numeric]
77
+ # @return [Symbol, nil]
78
+ def query_osc11(input, output, timeout)
79
+ reply = input.raw do
80
+ output.write(QUERY)
81
+ output.flush
82
+ read_reply(input, timeout)
83
+ end
84
+ match = REPLY.match(reply)
85
+ match && classify(match.captures)
86
+ rescue SystemCallError, IOError
87
+ nil
88
+ end
89
+
90
+ # Accumulates reply bytes until a BEL/ST terminator or the deadline.
91
+ # Terminals that don't support OSC 11 never reply — returning
92
+ # whatever arrived (usually nothing) lets the caller fail soft.
93
+ # @param input [IO]
94
+ # @param timeout [Numeric]
95
+ # @return [String]
96
+ def read_reply(input, timeout)
97
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
98
+ buffer = +""
99
+ loop do
100
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ return buffer if remaining <= 0 || IO.select([input], nil, nil, remaining).nil?
102
+
103
+ buffer << input.readpartial(256)
104
+ return buffer if buffer.include?("\a") || buffer.include?("\e\\")
105
+ end
106
+ rescue EOFError
107
+ buffer
108
+ end
109
+
110
+ # Relative luminance of the reported background, scaled per
111
+ # component hex width (xterm replies 4 digits per channel, others 2).
112
+ # @param components [Array<String>] three hex strings.
113
+ # @return [Symbol] `:light` or `:dark`.
114
+ def classify(components)
115
+ r, g, b = components.map { |c| c.to_i(16).fdiv((16**c.length) - 1) }
116
+ luminance = (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
117
+ luminance > 0.5 ? :light : :dark
118
+ end
119
+
120
+ # `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
121
+ # ANSI palette indices. White-ish backgrounds — 7 (white) and the
122
+ # bright range 9–15 — read as light; 0–6 and 8 as dark; anything
123
+ # else (missing, `"default"`, out of range) is inconclusive.
124
+ # @param value [String, nil]
125
+ # @return [Symbol, nil]
126
+ def from_colorfgbg(value)
127
+ bg = value&.split(";")&.last
128
+ return nil unless bg&.match?(/\A\d+\z/)
129
+
130
+ case bg.to_i
131
+ when 0..6, 8 then :dark
132
+ when 7, 9..15 then :light
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A set of semantic colors the built-in components read when painting.
5
+ # The current theme lives at {Screen#theme}; components must look it up
6
+ # at paint time (inside `repaint`) rather than caching values, so that
7
+ # assigning {Screen#theme=} restyles everything via a single
8
+ # invalidate-everything pass.
9
+ #
10
+ # The primary API is the rendering helpers — {#active_bg},
11
+ # {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
12
+ # the token's SGR color (on the channel appropriate for the token's
13
+ # role) and reset:
14
+ #
15
+ # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
16
+ # screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
17
+ #
18
+ # The helpers pass content through verbatim, so input may carry other
19
+ # escape sequences (e.g. {Component::Window} feeds its border string,
20
+ # cursor moves included). For span-aware styling — applying a token to a
21
+ # {StyledString} while preserving per-span colors — use the `*_color`
22
+ # readers instead (e.g. {Component::List} highlights its cursor row via
23
+ # `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
24
+ # helper; structured text → `*_color` reader + {StyledString}.
25
+ #
26
+ # Two built-in themes are provided: {DARK} (the default; the colors Tuile
27
+ # has always used) and {LIGHT} (counterparts legible on light terminal
28
+ # backgrounds). A custom theme is one `with` away:
29
+ #
30
+ # screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
31
+ #
32
+ # Tokens deliberately cover only the *accents* Tuile paints. Everything
33
+ # else inherits the terminal's own default foreground/background, which
34
+ # already matches the user's terminal theme perfectly — that's why there
35
+ # is no global `bg`/`fg` token.
36
+ #
37
+ # Every token is a {Color} — and must be passed as one. Unlike the
38
+ # lenient {Color.coerce} call sites elsewhere in the framework, a theme
39
+ # is declared once per app, so it takes only {Color} instances: at a
40
+ # declaration site `Color.palette(130)` documents itself in a way the
41
+ # bare `130` does not (palette index? RGB channel?) — and the named
42
+ # palette constants (`Color::DARK_ORANGE3` *is* 130; see
43
+ # {Color::PALETTE_NAMES}) go one step further.
44
+ #
45
+ # ## App-specific tokens
46
+ #
47
+ # Beyond the built-in tokens, an app can carry its own colors in
48
+ # {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
49
+ # {#[]} (fail-fast: a typo raises `KeyError`) and render with the
50
+ # generic {#fg} / {#bg} helpers:
51
+ #
52
+ # theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
53
+ # theme[:accent] # => Color, e.g. for StyledString#with_fg
54
+ # theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
55
+ #
56
+ # Apps wanting semantic readers can subclass — `Data#with` preserves the
57
+ # subclass, so an `AppTheme` stays an `AppTheme` through `with`:
58
+ #
59
+ # class AppTheme < Tuile::Theme
60
+ # def accent(text) = fg(:accent, text)
61
+ # end
62
+ #
63
+ # Pair the dark and light variants in a {ThemeDef} and hand it to
64
+ # {Screen#theme_def=} so OS appearance flips pick the right one.
65
+ #
66
+ # @!attribute [r] active_bg_color
67
+ # Background highlight of the component the user is interacting with:
68
+ # the {Component::List} cursor row, the focused {Component::TextField} /
69
+ # {Component::TextArea} well, the focused {Component::Button}. "Active"
70
+ # matches the {Component#active?} focus-chain flag — this is the
71
+ # focus/selection highlight in conventional UI terms.
72
+ # @return [Color]
73
+ # @!attribute [r] active_border_color
74
+ # Foreground of a {Component::Window} border when the window is on the
75
+ # active (focus) chain.
76
+ # @return [Color]
77
+ # @!attribute [r] input_bg_color
78
+ # Resting background "well" of {Component::TextField} /
79
+ # {Component::TextArea} when *not* active — visibly a field, but
80
+ # distinctly subtler than {#active_bg_color}.
81
+ # @return [Color]
82
+ # @!attribute [r] hint_color
83
+ # Foreground of keyboard-shortcut captions in status-bar hints (the
84
+ # "quit" in "q quit") — see {#hint}.
85
+ # @return [Color]
86
+ # @!attribute [r] custom
87
+ # App-specific color tokens; empty in the built-in themes. Frozen —
88
+ # build a changed theme via `with(custom: ...)`. Prefer {#[]} for
89
+ # lookups (it fail-fasts on typos); read this directly to enumerate
90
+ # the tokens.
91
+ # @return [Hash{Symbol => Color}]
92
+ class Theme < Data.define(:active_bg_color, :active_border_color, :input_bg_color, :hint_color, :custom)
93
+ # @param active_bg_color [Color]
94
+ # @param active_border_color [Color]
95
+ # @param input_bg_color [Color]
96
+ # @param hint_color [Color]
97
+ # @param custom [Hash{Symbol => Color}] app-specific tokens, see {#custom}.
98
+ # @raise [TypeError] when a token is not a {Color}, or `custom` is not a
99
+ # `Hash{Symbol => Color}`.
100
+ def initialize(active_bg_color:, active_border_color:, input_bg_color:, hint_color:, custom: {})
101
+ { active_bg_color:, active_border_color:, input_bg_color:, hint_color: }.each do |name, value|
102
+ raise TypeError, "#{name} must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
103
+ end
104
+ raise TypeError, "custom must be a Hash, got #{custom.inspect}" unless custom.is_a?(Hash)
105
+
106
+ custom.each do |key, value|
107
+ raise TypeError, "custom key must be a Symbol, got #{key.inspect}" unless key.is_a?(Symbol)
108
+ raise TypeError, "custom[#{key.inspect}] must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
109
+ end
110
+ super(active_bg_color:, active_border_color:, input_bg_color:, hint_color:, custom: custom.dup.freeze)
111
+ end
112
+
113
+ # Looks up an app-specific token from {#custom}.
114
+ # @param token [Symbol]
115
+ # @return [Color]
116
+ # @raise [KeyError] when the token is not present — a typo should fail
117
+ # loudly, not paint in a default.
118
+ def [](token) = custom.fetch(token)
119
+
120
+ # Renders `text` in the foreground color of the app-specific `token`
121
+ # — the generic counterpart of {#hint} for {#custom} tokens.
122
+ # @param token [Symbol]
123
+ # @param text [String]
124
+ # @return [String] ANSI-rendered text, ending with an SGR reset.
125
+ # @raise [KeyError] when the token is not present.
126
+ def fg(token, text) = wrap(text, self[token], :fg)
127
+
128
+ # Renders `text` on the background color of the app-specific `token`
129
+ # — the generic counterpart of {#active_bg} for {#custom} tokens.
130
+ # @param token [Symbol]
131
+ # @param text [String]
132
+ # @return [String] ANSI-rendered text, ending with an SGR reset.
133
+ # @raise [KeyError] when the token is not present.
134
+ def bg(token, text) = wrap(text, self[token], :bg)
135
+
136
+ # Renders `text` on the {#active_bg_color} background.
137
+ # @param text [String]
138
+ # @return [String] ANSI-rendered text, ending with an SGR reset.
139
+ def active_bg(text) = wrap(text, active_bg_color, :bg)
140
+
141
+ # Renders `text` in the {#active_border_color} foreground. Content
142
+ # passes through verbatim, so it may embed non-SGR escapes (cursor
143
+ # moves in a border string).
144
+ # @param text [String]
145
+ # @return [String] ANSI-rendered text, ending with an SGR reset.
146
+ def active_border(text) = wrap(text, active_border_color, :fg)
147
+
148
+ # Renders `text` on the {#input_bg_color} background.
149
+ # @param text [String]
150
+ # @return [String] ANSI-rendered text, ending with an SGR reset.
151
+ def input_bg(text) = wrap(text, input_bg_color, :bg)
152
+
153
+ # Renders `text` in the {#hint_color} foreground, for status-bar hints,
154
+ # e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
155
+ # returned String, so strings built this way do *not* restyle when the
156
+ # theme changes — rebuild them instead (the framework's own call sites
157
+ # rebuild on every status-bar refresh).
158
+ # @param text [String]
159
+ # @return [String] ANSI-rendered text, ending with an SGR reset.
160
+ def hint(text) = wrap(text, hint_color, :fg)
161
+
162
+ # The colors Tuile used before themes existed, tuned for dark terminal
163
+ # backgrounds. GREY37 (palette 59) is what Rainbow emits for
164
+ # `:darkslategray`, LIGHT_SKY_BLUE3 (109) for `:cadetblue`; GREY27
165
+ # (238, ~#444444) sits in the grayscale ramp, bright enough to stand
166
+ # out against non-pure-black dark terminal themes (Gruvbox/Solarized/
167
+ # OneDark base backgrounds sit in the #1d–#2d range) yet distinctly
168
+ # darker than the active highlight at 59 (~#5f5f5f).
169
+ # @return [Theme]
170
+ DARK = new(active_bg_color: Color::GREY37,
171
+ active_border_color: Color::GREEN,
172
+ input_bg_color: Color::GREY27,
173
+ hint_color: Color::LIGHT_SKY_BLUE3)
174
+
175
+ # Counterparts legible on light terminal backgrounds: grayscale-ramp
176
+ # highlights just below white (GREY82 = 252 ~#d0d0d0, GREY85 = 253
177
+ # ~#dadada — dark enough to read as a "well" against white, one step
178
+ # lighter than the active highlight) and a dark teal (TURQUOISE4 = 30,
179
+ # ~#008787) keeping the hint hue. `active_border_color` stays the
180
+ # named green — named ANSI colors are remapped by the terminal's own
181
+ # palette, so the theme picks a light-appropriate green for us.
182
+ # @return [Theme]
183
+ LIGHT = new(active_bg_color: Color::GREY82,
184
+ active_border_color: Color::GREEN,
185
+ input_bg_color: Color::GREY85,
186
+ hint_color: Color::TURQUOISE4)
187
+
188
+ private
189
+
190
+ # The single sanctioned place for verbatim SGR wrapping: `text` is not
191
+ # parsed or validated, so callers may embed non-SGR escapes. Emits the
192
+ # same bytes `StyledString.styled(text, ...).to_ansi` would for plain
193
+ # text.
194
+ # @param text [String]
195
+ # @param color [Color]
196
+ # @param target [Symbol] `:fg` or `:bg`.
197
+ # @return [String]
198
+ def wrap(text, color, target)
199
+ "#{color.to_ansi(target)}#{text}#{Ansi::RESET}"
200
+ end
201
+ end
202
+ end