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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +151 -5
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +4 -3
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +122 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +13 -15
- data/lib/tuile/component/layout.rb +1 -1
- data/lib/tuile/component/list.rb +64 -31
- data/lib/tuile/component/log_window.rb +1 -0
- data/lib/tuile/component/picker_window.rb +4 -4
- data/lib/tuile/component/popup.rb +34 -3
- data/lib/tuile/component/text_area.rb +1 -2
- data/lib/tuile/component/text_field.rb +1 -2
- data/lib/tuile/component/text_input.rb +10 -15
- data/lib/tuile/component/text_view.rb +33 -13
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +75 -6
- data/lib/tuile/event_queue.rb +26 -2
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +9 -3
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +118 -20
- data/lib/tuile/screen_pane.rb +1 -1
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +125 -30
- data/lib/tuile/terminal_background.rb +137 -0
- data/lib/tuile/theme.rb +202 -0
- data/lib/tuile/theme_def.rb +85 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +0 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +755 -62
- metadata +12 -18
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -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
|
|
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,
|
|
49
|
-
#
|
|
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
|
-
|
|
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}.
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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].
|
|
226
|
-
|
|
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
|
data/lib/tuile/theme.rb
ADDED
|
@@ -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
|