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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +150 -4
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +1 -0
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +249 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +44 -16
- data/lib/tuile/component/list.rb +29 -19
- data/lib/tuile/component/picker_window.rb +2 -2
- data/lib/tuile/component/popup.rb +11 -1
- 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 +696 -58
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +74 -5
- data/lib/tuile/event_queue.rb +130 -11
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +10 -0
- data/lib/tuile/screen.rb +98 -4
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +28 -61
- 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/sig/tuile.rbs +1160 -93
- metadata +6 -15
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 #{
|
|
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 #{
|
|
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 #{
|
|
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)
|
data/lib/tuile/sizing.rb
ADDED
|
@@ -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
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -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
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
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 [
|
|
63
|
+
# @return [Color, nil]
|
|
66
64
|
# @!attribute [r] bg
|
|
67
|
-
# @return [
|
|
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
|
-
#
|
|
76
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
491
|
-
#
|
|
492
|
-
#
|
|
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
|
|
504
|
-
#
|
|
505
|
-
#
|
|
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,
|
|
560
|
-
codes.concat(color_codes(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 [
|
|
567
|
-
# @param
|
|
568
|
-
# @
|
|
569
|
-
#
|
|
570
|
-
def color_codes(color,
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|