tuile 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/tuile/screen.rb CHANGED
@@ -35,6 +35,9 @@ module Tuile
35
35
  @repainting = Set.new
36
36
  # Until the event loop is run, we pretend we're in the UI thread.
37
37
  @pretend_ui_lock = true
38
+ @scheme = detect_scheme
39
+ @theme_def = ThemeDef.default
40
+ @theme = @theme_def.for(@scheme)
38
41
  # Structural root of the component tree: holds tiled content, popup
39
42
  # stack and status bar.
40
43
  @pane = ScreenPane.new
@@ -93,6 +96,66 @@ module Tuile
93
96
  # @return [Size] current screen size.
94
97
  attr_reader :size
95
98
 
99
+ # The color {Theme} built-in components read at paint time: the member
100
+ # of {#theme_def} matching the terminal background detected at
101
+ # construction (see {TerminalBackground.detect}; inconclusive means
102
+ # dark). While the event loop runs, terminals supporting mode 2031
103
+ # push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
104
+ # screen re-picks from {#theme_def}.
105
+ # @return [Theme]
106
+ attr_reader :theme
107
+
108
+ # The app's {ThemeDef} — the dark/light {Theme} pair the screen picks
109
+ # {#theme} from, at startup and on every OS appearance flip. Starts as
110
+ # {ThemeDef.default} ({ThemeDef::DEFAULT} unless reassigned — tests
111
+ # do, see {ThemeDef.default=}). Assigning a custom definition is the
112
+ # durable way to theme an app: unlike a bare {#theme=}, it survives
113
+ # the user toggling the OS appearance.
114
+ # @return [ThemeDef]
115
+ attr_reader :theme_def
116
+
117
+ # Replaces the theme definition and immediately applies the member
118
+ # matching the current color scheme (via {#theme=}, so the whole UI
119
+ # restyles — or nothing repaints if that member equals the current
120
+ # theme).
121
+ # @param theme_def [ThemeDef]
122
+ # @return [void]
123
+ def theme_def=(theme_def)
124
+ raise TypeError, "expected ThemeDef, got #{theme_def.inspect}" unless theme_def.is_a?(ThemeDef)
125
+
126
+ check_locked
127
+ @theme_def = theme_def
128
+ self.theme = @theme_def.for(@scheme)
129
+ end
130
+
131
+ # Replaces the theme and restyles the whole UI: fires
132
+ # {Component#on_theme_changed} across the attached tree (so the app can
133
+ # rebuild styled content whose colors were derived from the old theme),
134
+ # refreshes the status bar and invalidates every attached component so
135
+ # the next repaint uses the new colors. No-op when `new_theme` equals
136
+ # the current theme.
137
+ #
138
+ # This is a transient override: the next OS appearance flip re-picks
139
+ # from {#theme_def} and replaces it. To theme an app durably, assign
140
+ # {#theme_def=} instead.
141
+ #
142
+ # Note status-bar hints supplied by the host as preformatted strings
143
+ # (see {#register_global_shortcut}) have their colors baked in and are
144
+ # not restyled by this.
145
+ # @param new_theme [Theme]
146
+ # @return [void]
147
+ def theme=(new_theme)
148
+ raise TypeError, "expected Theme, got #{new_theme.inspect}" unless new_theme.is_a?(Theme)
149
+
150
+ check_locked
151
+ return if @theme == new_theme
152
+
153
+ @theme = new_theme
154
+ @pane&.on_tree(&:on_theme_changed)
155
+ refresh_status_bar
156
+ needs_full_repaint
157
+ end
158
+
96
159
  # @return [Array<Component>] currently active popup components (forwarded
97
160
  # to {ScreenPane}). The array must not be modified!
98
161
  def popups = @pane.popups
@@ -172,7 +235,7 @@ module Tuile
172
235
  top_popup = @pane.popups.last
173
236
  globals = global_shortcut_hints(popup_open: !top_popup.nil?)
174
237
  @pane.status_bar.text = if top_popup.nil?
175
- ["q #{Rainbow("quit").cadetblue}", *globals,
238
+ ["q #{@theme.hint("quit")}", *globals,
176
239
  active_window&.keyboard_hint].compact.reject(&:empty?).join(" ")
177
240
  else
178
241
  [*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ")
@@ -224,10 +287,15 @@ module Tuile
224
287
  @pretend_ui_lock = false
225
288
  $stdin.echo = false
226
289
  print MouseEvent.start_tracking if capture_mouse
290
+ # Follow OS light/dark flips live: terminals supporting mode 2031
291
+ # push color-scheme reports that the key thread turns into
292
+ # {EventQueue::ColorSchemeEvent}s.
293
+ print TerminalBackground::NOTIFY_ON
227
294
  $stdin.raw do
228
295
  event_loop
229
296
  end
230
297
  ensure
298
+ print TerminalBackground::NOTIFY_OFF
231
299
  print MouseEvent.stop_tracking if capture_mouse
232
300
  print TTY::Cursor.show
233
301
  $stdin.echo = true
@@ -272,7 +340,7 @@ module Tuile
272
340
  #
273
341
  # screen.register_global_shortcut(Keys::CTRL_L,
274
342
  # over_popups: true,
275
- # hint: "^L #{Rainbow("log").cadetblue}") do
343
+ # hint: "^L #{screen.theme.hint("log")}") do
276
344
  # log_popup.open
277
345
  # end
278
346
  #
@@ -283,8 +351,9 @@ module Tuile
283
351
  # (default), the shortcut is suppressed while any popup is open and
284
352
  # the popup gets the key instead.
285
353
  # @param hint [String, nil] preformatted status-bar hint (e.g.
286
- # `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut
287
- # is silent in the status bar.
354
+ # `"^L #{screen.theme.hint("log")}"`). When nil (default) the shortcut
355
+ # is silent in the status bar. The colors are baked into the string,
356
+ # so a later {#theme=} does not restyle it — re-register if needed.
288
357
  # @yield invoked with no arguments when `key` is pressed.
289
358
  # @return [void]
290
359
  def register_global_shortcut(key, over_popups: false, hint: nil, &block)
@@ -476,6 +545,27 @@ module Tuile
476
545
 
477
546
  private
478
547
 
548
+ # Startup color scheme: `:light` when {TerminalBackground.detect}
549
+ # reports a light terminal background, `:dark` otherwise (including
550
+ # when detection is inconclusive). Runs in the constructor — the
551
+ # OSC 11 reply arrives on stdin, which is only safe to read before
552
+ # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
553
+ # to pin `:dark`, keeping specs deterministic and off the test
554
+ # runner's TTY.
555
+ # @return [Symbol] `:dark` or `:light`.
556
+ def detect_scheme
557
+ TerminalBackground.detect == :light ? :light : :dark
558
+ end
559
+
560
+ # An OS appearance flip arrived (mode-2031 report): remember the
561
+ # scheme and apply the matching member of {#theme_def}.
562
+ # @param scheme [Symbol] `:dark` or `:light`.
563
+ # @return [void]
564
+ def on_color_scheme(scheme)
565
+ @scheme = scheme
566
+ self.theme = @theme_def.for(@scheme)
567
+ end
568
+
479
569
  # Walks the current modal scope in pre-order, collects tab stops, and
480
570
  # advances focus by one (wrapping). When the focused component isn't in
481
571
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -596,8 +686,12 @@ module Tuile
596
686
  when EventQueue::TTYSizeEvent
597
687
  @size = event.size
598
688
  layout
689
+ when EventQueue::ColorSchemeEvent
690
+ on_color_scheme(event.scheme)
599
691
  when EventQueue::EmptyQueueEvent
600
692
  repaint
693
+ when Proc
694
+ event.call
601
695
  end
602
696
  rescue StandardError => e
603
697
  @on_error.call(e)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A sizing policy for a slot whose position is managed by a parent
5
+ # component (e.g. {Component::Window#footer}). Resolves one dimension at a
6
+ # time via {#resolve}, so the same value works for widths and heights.
7
+ #
8
+ # Three policies exist:
9
+ #
10
+ # - {FILL} — take everything the slot offers;
11
+ # - {WRAP_CONTENT} — take the component's natural extent (its
12
+ # {Component#content_size}), clamped to the slot;
13
+ # - {.fixed} — take exactly the given number of cells, clamped to the slot.
14
+ #
15
+ # Note that {WRAP_CONTENT} only makes sense for components that report a
16
+ # natural {Component#content_size} ({Component::Label}, {Component::Button},
17
+ # {Component::List}, …). Input components ({Component::TextField} et al.)
18
+ # report {Size::ZERO}, so a wrap-content slot collapses to zero width —
19
+ # i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
20
+ #
21
+ # @!attribute [r] mode
22
+ # @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
23
+ # @!attribute [r] amount
24
+ # @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
25
+ class Sizing < Data.define(:mode, :amount)
26
+ # @param amount [Integer] the number of cells to occupy; 0 or greater.
27
+ # @return [Sizing] a fixed-size policy.
28
+ def self.fixed(amount)
29
+ raise TypeError, "expected Integer, got #{amount.inspect}" unless amount.is_a?(Integer)
30
+ raise ArgumentError, "amount must not be negative, got #{amount}" if amount.negative?
31
+
32
+ new(mode: :fixed, amount: amount)
33
+ end
34
+
35
+ # Resolves one dimension of a slot.
36
+ # @param available [Integer] cells the slot offers; 0 or greater.
37
+ # @param content [Integer] the component's natural extent on this axis
38
+ # (one dimension of its {Component#content_size}).
39
+ # @return [Integer] the resolved extent, always in `0..available`.
40
+ def resolve(available, content)
41
+ case mode
42
+ when :fill then available
43
+ when :fixed then amount.clamp(0, available)
44
+ when :wrap_content then content.clamp(0, available)
45
+ else raise ArgumentError, "unknown mode #{mode.inspect}"
46
+ end
47
+ end
48
+
49
+ # Occupy everything the slot offers.
50
+ # @return [Sizing]
51
+ FILL = new(mode: :fill, amount: nil)
52
+
53
+ # Occupy the component's natural {Component#content_size}, clamped to the
54
+ # slot. Components reporting {Size::ZERO} collapse to invisibility — see
55
+ # the class doc.
56
+ # @return [Sizing]
57
+ WRAP_CONTENT = new(mode: :wrap_content, amount: nil)
58
+ end
59
+ end
@@ -53,18 +53,16 @@ module Tuile
53
53
  # Raised by {.parse} on malformed or unsupported escape sequences.
54
54
  class ParseError < Error; end
55
55
 
56
- # A frozen value type describing the visual style of a {Span}.
57
- #
58
- # `fg` and `bg` accept:
59
- # - `nil` the terminal default (SGR 39 / 49)
60
- # - a symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright ANSI colors
61
- # - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
62
- # - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
56
+ # A frozen value type describing the visual style of a {Span}. Colors are
57
+ # stored as {Color} instances (or `nil` for the terminal default); inputs
58
+ # to {.new} and {#merge} are coerced via {Color.coerce}, so the four
59
+ # accepted color forms — `nil`, Symbol, Integer 0..255, RGB Array work
60
+ # transparently.
63
61
  #
64
62
  # @!attribute [r] fg
65
- # @return [Symbol, Integer, Array<Integer>, nil]
63
+ # @return [Color, nil]
66
64
  # @!attribute [r] bg
67
- # @return [Symbol, Integer, Array<Integer>, nil]
65
+ # @return [Color, nil]
68
66
  # @!attribute [r] bold
69
67
  # @return [Boolean]
70
68
  # @!attribute [r] italic
@@ -72,42 +70,16 @@ module Tuile
72
70
  # @!attribute [r] underline
73
71
  # @return [Boolean]
74
72
  class Style < Data.define(:fg, :bg, :bold, :italic, :underline)
75
- # Symbolic color names recognized by {#fg} and {#bg}. Order is
76
- # significant: indices 0..7 map to standard ANSI colors (SGR 30..37 fg
77
- # / 40..47 bg); indices 8..15 map to bright variants (SGR 90..97 /
78
- # 100..107).
79
- # @return [Array<Symbol>]
80
- COLOR_SYMBOLS = %i[
81
- black red green yellow blue magenta cyan white
82
- bright_black bright_red bright_green bright_yellow
83
- bright_blue bright_magenta bright_cyan bright_white
84
- ].freeze
85
-
86
- # @param fg [Symbol, Integer, Array<Integer>, nil]
87
- # @param bg [Symbol, Integer, Array<Integer>, nil]
73
+ # @param fg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
74
+ # @param bg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
88
75
  # @param bold [Boolean]
89
76
  # @param italic [Boolean]
90
77
  # @param underline [Boolean]
91
78
  # @return [Style]
92
79
  # @raise [ArgumentError] when a color is not one of the accepted forms.
93
80
  def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false)
94
- validate_color!(fg, :fg)
95
- validate_color!(bg, :bg)
96
- super(fg:, bg:, bold:, italic:, underline:)
97
- end
98
-
99
- # @param color [Object]
100
- # @param which [Symbol]
101
- # @return [void]
102
- def self.validate_color!(color, which)
103
- return if color.nil? || COLOR_SYMBOLS.include?(color)
104
- return if color.is_a?(Integer) && color.between?(0, 255)
105
- return if color.is_a?(Array) && color.length == 3 &&
106
- color.all? { |v| v.is_a?(Integer) && v.between?(0, 255) }
107
-
108
- raise ArgumentError, "invalid #{which} color: #{color.inspect}"
81
+ super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:)
109
82
  end
110
- private_class_method :validate_color!
111
83
 
112
84
  # The style with no color and no attributes — what the terminal shows
113
85
  # without any SGR applied.
@@ -149,11 +121,11 @@ module Tuile
149
121
  # supported SGR alphabet raises {ParseError}.
150
122
  class Parser
151
123
  # @return [Array<Symbol>]
152
- STANDARD_COLORS = Style::COLOR_SYMBOLS[0, 8].freeze
124
+ STANDARD_COLORS = Color::COLOR_SYMBOLS[0, 8].freeze
153
125
  private_constant :STANDARD_COLORS
154
126
 
155
127
  # @return [Array<Symbol>]
156
- BRIGHT_COLORS = Style::COLOR_SYMBOLS[8, 8].freeze
128
+ BRIGHT_COLORS = Color::COLOR_SYMBOLS[8, 8].freeze
157
129
  private_constant :BRIGHT_COLORS
158
130
 
159
131
  # @param input [String]
@@ -487,9 +459,9 @@ module Tuile
487
459
  # `underline`). Useful for row-level highlights — the new bg overlays
488
460
  # without dropping foreground colors the original styling carried.
489
461
  #
490
- # @param bg [Symbol, Integer, Array<Integer>, nil] background color, in
491
- # any of the forms accepted by {Style.new}. `nil` clears bg back to
492
- # the terminal default.
462
+ # @param bg [Color, Symbol, Integer, Array<Integer>, nil] background
463
+ # color, coerced via {Color.coerce}. `nil` clears bg back to the
464
+ # terminal default.
493
465
  # @return [StyledString]
494
466
  def with_bg(bg)
495
467
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
@@ -500,9 +472,9 @@ module Tuile
500
472
  # `underline`). The new fg overlays without dropping background colors or
501
473
  # text attributes the original styling carried.
502
474
  #
503
- # @param fg [Symbol, Integer, Array<Integer>, nil] foreground color, in
504
- # any of the forms accepted by {Style.new}. `nil` clears fg back to
505
- # the terminal default.
475
+ # @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground
476
+ # color, coerced via {Color.coerce}. `nil` clears fg back to the
477
+ # terminal default.
506
478
  # @return [StyledString]
507
479
  def with_fg(fg)
508
480
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
@@ -556,26 +528,21 @@ module Tuile
556
528
  codes << (to.bold ? 1 : 22) if from.bold != to.bold
557
529
  codes << (to.italic ? 3 : 23) if from.italic != to.italic
558
530
  codes << (to.underline ? 4 : 24) if from.underline != to.underline
559
- codes.concat(color_codes(to.fg, base: 30, ext: 38)) if from.fg != to.fg
560
- codes.concat(color_codes(to.bg, base: 40, ext: 48)) if from.bg != to.bg
531
+ codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
532
+ codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
561
533
  return "" if codes.empty?
562
534
 
563
535
  "\e[#{codes.join(";")}m"
564
536
  end
565
537
 
566
- # @param color [Symbol, Integer, Array<Integer>, nil]
567
- # @param base [Integer] base SGR code — 30 for fg, 40 for bg.
568
- # @param ext [Integer] extended-color SGR code 38 for fg, 48 for bg.
569
- # @return [Array<Integer>]
570
- def color_codes(color, base:, ext:)
571
- case color
572
- when nil then [base + 9]
573
- when Symbol
574
- idx = Style::COLOR_SYMBOLS.index(color)
575
- idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
576
- when Integer then [ext, 5, color]
577
- when Array then [ext, 2, *color]
578
- end
538
+ # @param color [Color, nil]
539
+ # @param target [Symbol] `:fg` or `:bg`.
540
+ # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default" reset
541
+ # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
542
+ def color_codes(color, target:)
543
+ return [target == :fg ? 39 : 49] if color.nil?
544
+
545
+ color.sgr_codes(target)
579
546
  end
580
547
 
581
548
  # @param start_or_range [Integer, Range]
@@ -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