tuile 0.13.0 → 0.14.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 +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list_dropdown.rb +18 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- metadata +8 -1
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Tuile
|
|
|
13
13
|
# The pane owns no chrome of its own — no status bar, no reserved row.
|
|
14
14
|
# {#content} gets the full pane rect, and an app that wants a status line
|
|
15
15
|
# builds one into its own layout and drives it from
|
|
16
|
-
# {Screen#on_focus_changed=} (`
|
|
16
|
+
# {Screen#on_focus_changed=} (`D_status_bar`).
|
|
17
17
|
#
|
|
18
18
|
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
19
19
|
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
@@ -31,9 +31,10 @@ module Tuile
|
|
|
31
31
|
|
|
32
32
|
# @return [Component, nil] the tiled content component.
|
|
33
33
|
attr_reader :content
|
|
34
|
-
# @return [Array<Component>]
|
|
35
|
-
# topmost. Holds both
|
|
36
|
-
# ({Component::
|
|
34
|
+
# @return [Array<Component::Overlay>] the open overlays in stacking order;
|
|
35
|
+
# last is topmost. Holds both {Component::Popup} modals and bare
|
|
36
|
+
# {Component::Overlay}s ({Component::Overlay#modal?}). The array must not
|
|
37
|
+
# be mutated by callers.
|
|
37
38
|
attr_reader :popups
|
|
38
39
|
|
|
39
40
|
def focusable? = false
|
|
@@ -54,21 +55,21 @@ module Tuile
|
|
|
54
55
|
layout
|
|
55
56
|
end
|
|
56
57
|
|
|
57
|
-
# Adds
|
|
58
|
-
# and grabs focus; a
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
58
|
+
# Adds an overlay and invalidates it for repaint. A {Component::Popup} is
|
|
59
|
+
# centered and grabs focus; a bare {Component::Overlay} is left wherever
|
|
60
|
+
# the caller positioned it and does *not* take focus, so the component that
|
|
61
|
+
# was focused keeps the cursor and keeps receiving keys — the overlay
|
|
62
|
+
# floats above the content, driven from app code.
|
|
62
63
|
#
|
|
63
|
-
# The *whole subtree* is invalidated, not just the
|
|
64
|
+
# The *whole subtree* is invalidated, not just the overlay wrapper (which
|
|
64
65
|
# paints nothing on its own): a reopened popup may land on cells that the
|
|
65
66
|
# tiled content has since overpainted, and if its rect is unchanged from
|
|
66
67
|
# last time its content components won't re-invalidate themselves — so
|
|
67
|
-
# without this the
|
|
68
|
-
# @param window [Component::
|
|
68
|
+
# without this the overlay's contents would stay blank on reopen.
|
|
69
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
69
70
|
# @return [void]
|
|
70
71
|
def add_popup(window)
|
|
71
|
-
raise TypeError, "expected
|
|
72
|
+
raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay
|
|
72
73
|
raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
|
|
73
74
|
|
|
74
75
|
@popup_prior_focus[window] = screen.focused
|
|
@@ -123,11 +124,11 @@ module Tuile
|
|
|
123
124
|
# @return [Boolean] true if this pane currently hosts the popup.
|
|
124
125
|
def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
|
|
125
126
|
|
|
126
|
-
# @return [Component::Popup, nil] the topmost
|
|
127
|
-
# only
|
|
127
|
+
# @return [Component::Popup, nil] the topmost modal overlay, or nil when
|
|
128
|
+
# only bare {Component::Overlay}s (or none) are open. This is the "modal
|
|
128
129
|
# owner": the popup that scopes key dispatch, blocks mouse clicks, and
|
|
129
|
-
# confines Tab cycling.
|
|
130
|
-
#
|
|
130
|
+
# confines Tab cycling. Bare overlays are excluded — they float above the
|
|
131
|
+
# content without capturing input.
|
|
131
132
|
def modal_popup = @popups.reverse_each.find(&:modal?)
|
|
132
133
|
|
|
133
134
|
# Re-lays out children whenever the pane's own rect changes.
|
|
@@ -139,7 +140,7 @@ module Tuile
|
|
|
139
140
|
end
|
|
140
141
|
|
|
141
142
|
# Gives {#content} the whole pane rect — the pane reserves nothing for
|
|
142
|
-
# itself. Each popup re-resolves its {Component::Popup#
|
|
143
|
+
# itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
|
|
143
144
|
# screen via {Component::Popup#reposition} — so a {Fraction} size tracks
|
|
144
145
|
# resize — repositioning itself (modal popups recenter; non-modal overlays
|
|
145
146
|
# keep the top-left their owner assigned).
|
|
@@ -203,12 +204,12 @@ module Tuile
|
|
|
203
204
|
# content beneath.
|
|
204
205
|
#
|
|
205
206
|
# A left click also *dismisses* the open popups it landed outside of that
|
|
206
|
-
# asked for it ({Component::
|
|
207
|
+
# asked for it ({Component::Overlay#close_on_outside_click?}). That is a
|
|
207
208
|
# second thing happening on a click, but not a second dispatch: the click is
|
|
208
209
|
# still delivered exactly once, down one chain, and a dismissed popup is
|
|
209
210
|
# closed rather than told.
|
|
210
211
|
#
|
|
211
|
-
# "Outside" is measured against the {Component::
|
|
212
|
+
# "Outside" is measured against the {Component::Overlay#owner} chain, not
|
|
212
213
|
# against one rect and not against stacking order: the popup the click hit
|
|
213
214
|
# is kept, and so is every popup that one *belongs to*, transitively. That
|
|
214
215
|
# is what stops a dialog being dismissed by a click on a dropdown its own
|
|
@@ -278,29 +279,30 @@ module Tuile
|
|
|
278
279
|
|
|
279
280
|
private
|
|
280
281
|
|
|
281
|
-
# The
|
|
282
|
-
#
|
|
283
|
-
# is any component, so it is resolved to the
|
|
284
|
-
# resolves to itself) — which keeps the
|
|
285
|
-
# rather than one frozen when the overlay
|
|
286
|
-
# a mis-wired cycle terminate instead of
|
|
287
|
-
#
|
|
288
|
-
# @
|
|
282
|
+
# The overlays a click counts as landing *inside*: the one it hit, plus
|
|
283
|
+
# every overlay that one belongs to, up the {Component::Overlay#owner}
|
|
284
|
+
# chain. An owner is any component, so it is resolved to the overlay
|
|
285
|
+
# enclosing it (an overlay resolves to itself) — which keeps the
|
|
286
|
+
# relationship a live tree question rather than one frozen when the overlay
|
|
287
|
+
# opened. The `include?` guard makes a mis-wired cycle terminate instead of
|
|
288
|
+
# hanging the UI thread.
|
|
289
|
+
# @param hit [Component::Overlay, nil] the overlay the click landed in, if any.
|
|
290
|
+
# @return [Array<Component::Overlay>]
|
|
289
291
|
def kept_by(hit)
|
|
290
292
|
kept = []
|
|
291
|
-
|
|
292
|
-
while
|
|
293
|
-
kept <<
|
|
294
|
-
|
|
293
|
+
overlay = hit
|
|
294
|
+
while overlay && !kept.include?(overlay)
|
|
295
|
+
kept << overlay
|
|
296
|
+
overlay = enclosing_popup(overlay.owner)
|
|
295
297
|
end
|
|
296
298
|
kept
|
|
297
299
|
end
|
|
298
300
|
|
|
299
301
|
# @param component [Component, nil]
|
|
300
|
-
# @return [Component::
|
|
301
|
-
# else the nearest
|
|
302
|
+
# @return [Component::Overlay, nil] `component` itself when it is an
|
|
303
|
+
# overlay, else the nearest overlay above it, else nil.
|
|
302
304
|
def enclosing_popup(component)
|
|
303
|
-
component = component.parent until component.nil? || component.is_a?(Component::
|
|
305
|
+
component = component.parent until component.nil? || component.is_a?(Component::Overlay)
|
|
304
306
|
component
|
|
305
307
|
end
|
|
306
308
|
|
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`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
6
|
+
# `underline`, `strikethrough`, `inverse`). Spans are non-overlapping and fully tile
|
|
7
7
|
# the string — every character has exactly one resolved style, no overlay
|
|
8
8
|
# layers to merge, so the style at any column is just its span's `style`
|
|
9
9
|
# rather than a replay of the SGR state machine. The book's chapter 9 is
|
|
@@ -34,7 +34,7 @@ module Tuile
|
|
|
34
34
|
# ## Parser
|
|
35
35
|
#
|
|
36
36
|
# {.parse} is strict by default — it recognizes only the SGR codes for
|
|
37
|
-
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
|
|
37
|
+
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough/inverse) and
|
|
38
38
|
# raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
|
|
39
39
|
# round-trip honest. Pass `lenient: true` to instead discard everything it
|
|
40
40
|
# can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
|
|
@@ -62,17 +62,22 @@ module Tuile
|
|
|
62
62
|
# @return [Boolean]
|
|
63
63
|
# @!attribute [r] strikethrough
|
|
64
64
|
# @return [Boolean]
|
|
65
|
-
|
|
65
|
+
# @!attribute [r] inverse
|
|
66
|
+
# @return [Boolean] swap the fg/bg *in effect* at the cell (SGR 7) —
|
|
67
|
+
# including terminal defaults, which `fg`/`bg` can't name.
|
|
68
|
+
class Style < Data.define(:fg, :bg, :bold, :italic, :underline, :strikethrough, :inverse)
|
|
66
69
|
# @param fg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
|
|
67
70
|
# @param bg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
|
|
68
71
|
# @param bold [Boolean]
|
|
69
72
|
# @param italic [Boolean]
|
|
70
73
|
# @param underline [Boolean]
|
|
71
74
|
# @param strikethrough [Boolean]
|
|
75
|
+
# @param inverse [Boolean]
|
|
72
76
|
# @return [Style]
|
|
73
77
|
# @raise [ArgumentError] when a color is not one of the accepted forms.
|
|
74
|
-
def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false, strikethrough: false
|
|
75
|
-
|
|
78
|
+
def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false, strikethrough: false,
|
|
79
|
+
inverse: false)
|
|
80
|
+
super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:, strikethrough:, inverse:)
|
|
76
81
|
end
|
|
77
82
|
|
|
78
83
|
# The style with no color and no attributes — what the terminal shows
|
|
@@ -104,6 +109,7 @@ module Tuile
|
|
|
104
109
|
codes << (other.italic ? 3 : 23) if italic != other.italic
|
|
105
110
|
codes << (other.underline ? 4 : 24) if underline != other.underline
|
|
106
111
|
codes << (other.strikethrough ? 9 : 29) if strikethrough != other.strikethrough
|
|
112
|
+
codes << (other.inverse ? 7 : 27) if inverse != other.inverse
|
|
107
113
|
codes.concat(color_codes(other.fg, target: :fg)) if fg != other.fg
|
|
108
114
|
codes.concat(color_codes(other.bg, target: :bg)) if bg != other.bg
|
|
109
115
|
return "" if codes.empty?
|
|
@@ -282,6 +288,8 @@ module Tuile
|
|
|
282
288
|
when 23 then @style = @style.merge(italic: false)
|
|
283
289
|
when 4 then @style = @style.merge(underline: true)
|
|
284
290
|
when 24 then @style = @style.merge(underline: false)
|
|
291
|
+
when 7 then @style = @style.merge(inverse: true)
|
|
292
|
+
when 27 then @style = @style.merge(inverse: false)
|
|
285
293
|
when 9 then @style = @style.merge(strikethrough: true)
|
|
286
294
|
when 29 then @style = @style.merge(strikethrough: false)
|
|
287
295
|
when 30..37 then @style = @style.merge(fg: STANDARD_COLORS[code - 30])
|
|
@@ -405,7 +413,7 @@ module Tuile
|
|
|
405
413
|
# the parts of anything else. That is the one setting never wrong in the
|
|
406
414
|
# dangerous direction: under-measuring lets a glyph overrun its cell, which
|
|
407
415
|
# shifts the rest of the row, desyncs the cursor and escapes the component's
|
|
408
|
-
# rect, while over-measuring leaves a blank column. `
|
|
416
|
+
# rect, while over-measuring leaves a blank column. `D_cluster_width` has the
|
|
409
417
|
# per-setting reasoning.
|
|
410
418
|
# @return [Symbol]
|
|
411
419
|
EMOJI_WIDTH = :rgi
|
|
@@ -605,6 +613,10 @@ module Tuile
|
|
|
605
613
|
# so a log line keeps its red error-level bg while its plain text picks up an
|
|
606
614
|
# inherited panel tint.
|
|
607
615
|
#
|
|
616
|
+
# An `inverse` span counts as backgrounded and is skipped even when its `bg`
|
|
617
|
+
# member is nil: SGR 7 swaps the pair in effect, so a bg filled under it
|
|
618
|
+
# would recolor the span's *glyphs*, not the ground behind them.
|
|
619
|
+
#
|
|
608
620
|
# @param bg [Color, Symbol, Integer, Array<Integer>, nil] background color,
|
|
609
621
|
# coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
610
622
|
# @return [StyledString]
|
|
@@ -613,7 +625,11 @@ module Tuile
|
|
|
613
625
|
|
|
614
626
|
bg = Color.coerce(bg)
|
|
615
627
|
self.class.new(@spans.map do |span|
|
|
616
|
-
span.style.bg.nil?
|
|
628
|
+
if span.style.bg.nil? && !span.style.inverse
|
|
629
|
+
Span.new(text: span.text, style: span.style.merge(bg: bg))
|
|
630
|
+
else
|
|
631
|
+
span
|
|
632
|
+
end
|
|
617
633
|
end)
|
|
618
634
|
end
|
|
619
635
|
|
|
@@ -670,6 +686,23 @@ module Tuile
|
|
|
670
686
|
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(underline:)) })
|
|
671
687
|
end
|
|
672
688
|
|
|
689
|
+
# Returns a new {StyledString} with `inverse` applied to every span,
|
|
690
|
+
# preserving each span's text and other style attributes. Inverse swaps
|
|
691
|
+
# whatever fg/bg are actually in effect at each cell — terminal defaults
|
|
692
|
+
# included — so a focus chip built with it reads as "backgrounded" on any
|
|
693
|
+
# terminal palette without picking a single color:
|
|
694
|
+
#
|
|
695
|
+
# StyledString.plain(" 1 VMs ").with_inverse # the inverted-chip idiom
|
|
696
|
+
#
|
|
697
|
+
# There is deliberately no `under_inverse`, for the reason {#with_bold}
|
|
698
|
+
# spells out. Pass `inverse: false` to clear it.
|
|
699
|
+
#
|
|
700
|
+
# @param inverse [Boolean] whether the spans should be inverted.
|
|
701
|
+
# @return [StyledString]
|
|
702
|
+
def with_inverse(inverse: true)
|
|
703
|
+
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(inverse:)) })
|
|
704
|
+
end
|
|
705
|
+
|
|
673
706
|
# @return [String]
|
|
674
707
|
def inspect
|
|
675
708
|
"#<#{self.class.name} #{to_s.inspect}>"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
|
-
# Detects
|
|
5
|
-
#
|
|
4
|
+
# Detects the terminal's background color — both the light/dark scheme
|
|
5
|
+
# {Screen} picks {Theme::LIGHT} or {Theme::DARK} from, and, when the
|
|
6
|
+
# terminal answers the query, the actual RGB behind it.
|
|
6
7
|
#
|
|
7
8
|
# Two mechanisms, in order of reliability:
|
|
8
9
|
#
|
|
@@ -15,14 +16,34 @@ module Tuile
|
|
|
15
16
|
# bounded by a short timeout.
|
|
16
17
|
# 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
|
|
17
18
|
# indices. Less reliable (stale across SSH/tmux, often unset); used
|
|
18
|
-
# only when OSC 11 yields nothing.
|
|
19
|
+
# only when OSC 11 yields nothing. Palette indices carry no RGB, so
|
|
20
|
+
# this path fills in {Result#scheme} and leaves {Result#color} nil.
|
|
19
21
|
#
|
|
20
22
|
# **Timing matters**: the OSC 11 reply arrives on stdin, so the query
|
|
21
23
|
# must complete before {EventQueue#start_key_thread} owns stdin —
|
|
22
24
|
# otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
|
|
23
25
|
# calls {.detect} from its constructor, which apps run before
|
|
24
26
|
# {Screen#run_event_loop}; don't call this after the event loop started.
|
|
27
|
+
#
|
|
28
|
+
# Once the loop *is* running the query is still available, from the other
|
|
29
|
+
# side: {Screen} writes {QUERY} on every OS appearance flip and the key
|
|
30
|
+
# thread — which owns stdin by then — reads the reply back through
|
|
31
|
+
# {Keys.getkey} and {.parse}. See {Screen#background_color}.
|
|
25
32
|
module TerminalBackground
|
|
33
|
+
# What a detection found: the light/dark `scheme`, and the background
|
|
34
|
+
# `color` when a terminal actually reported one.
|
|
35
|
+
#
|
|
36
|
+
# `color` is nil whenever the scheme came from the `COLORFGBG`
|
|
37
|
+
# fallback, so a consumer deriving a tint from the background must
|
|
38
|
+
# handle nil — plenty of terminals answer neither query.
|
|
39
|
+
#
|
|
40
|
+
# @!attribute [r] scheme
|
|
41
|
+
# @return [Symbol] `:light` or `:dark`.
|
|
42
|
+
# @!attribute [r] color
|
|
43
|
+
# @return [Color, nil] the reported background as 24-bit RGB, or nil
|
|
44
|
+
# when only `COLORFGBG` answered.
|
|
45
|
+
Result = Data.define(:scheme, :color)
|
|
46
|
+
|
|
26
47
|
# How long to wait for the OSC 11 reply. Generous for a local
|
|
27
48
|
# terminal; bounded so unsupporting terminals (which never reply)
|
|
28
49
|
# don't stall startup.
|
|
@@ -54,35 +75,63 @@ module Tuile
|
|
|
54
75
|
# Detects the terminal background. Queries OSC 11 when both `input`
|
|
55
76
|
# and `output` are TTYs, falling back to `COLORFGBG`.
|
|
56
77
|
#
|
|
78
|
+
# TerminalBackground.detect
|
|
79
|
+
# # => #<data Result scheme=:dark, color=#<Tuile::Color [30, 30, 46]>>
|
|
80
|
+
#
|
|
57
81
|
# @param input [IO] where the OSC 11 reply arrives (the TTY input).
|
|
58
82
|
# @param output [IO] where the query is written (the TTY output).
|
|
59
83
|
# @param env [Hash{String => String}] environment for the `COLORFGBG`
|
|
60
84
|
# fallback; defaults to `ENV` (which duck-types the `[]` lookup).
|
|
61
85
|
# @param timeout [Numeric] max seconds to wait for the OSC 11 reply.
|
|
62
|
-
# @return [
|
|
86
|
+
# @return [Result, nil] nil when the background is undetectable —
|
|
87
|
+
# neither mechanism answered.
|
|
63
88
|
def detect(input: $stdin, output: $stdout, env: ENV, timeout: QUERY_TIMEOUT)
|
|
64
89
|
osc = query_osc11(input, output, timeout) if input.tty? && output.tty?
|
|
65
|
-
osc
|
|
90
|
+
return osc if osc
|
|
91
|
+
|
|
92
|
+
scheme = from_colorfgbg(env["COLORFGBG"])
|
|
93
|
+
scheme && Result.new(scheme: scheme, color: nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Parses an OSC 11 reply — the terminal's answer to {QUERY}, matched
|
|
97
|
+
# anywhere in `reply`.
|
|
98
|
+
#
|
|
99
|
+
# TerminalBackground.parse("\e]11;rgb:1e1e/1e1e/2e2e\a").color
|
|
100
|
+
# # => #<Tuile::Color [30, 30, 46]>
|
|
101
|
+
#
|
|
102
|
+
# Public because a reply also arrives *mid-session*, long after
|
|
103
|
+
# {.detect}'s own bounded read: once the key thread owns stdin, a
|
|
104
|
+
# whole reply surfaces as one "key" from {Keys.getkey}.
|
|
105
|
+
#
|
|
106
|
+
# @param reply [String] raw terminal output that may contain a reply.
|
|
107
|
+
# @return [Result, nil] nil when `reply` holds no OSC 11 reply.
|
|
108
|
+
def parse(reply)
|
|
109
|
+
match = REPLY.match(reply)
|
|
110
|
+
return nil unless match
|
|
111
|
+
|
|
112
|
+
# Components arrive as 1–4 hex digits (terminals vary), so each is
|
|
113
|
+
# scaled by its own width: "ab" and "abab" are both ~0.67.
|
|
114
|
+
components = match.captures.map { |c| c.to_i(16).fdiv((16**c.length) - 1) }
|
|
115
|
+
Result.new(scheme: classify(components), color: to_color(components))
|
|
66
116
|
end
|
|
67
117
|
|
|
68
118
|
private
|
|
69
119
|
|
|
70
|
-
# Writes the OSC 11 query and
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
120
|
+
# Writes the OSC 11 query and parses the reply. The whole exchange
|
|
121
|
+
# runs with `input` in raw mode: the reply has no trailing newline,
|
|
122
|
+
# so a canonical-mode read would block past the timeout, and echo
|
|
123
|
+
# would smear the reply bytes onto the screen.
|
|
74
124
|
# @param input [IO]
|
|
75
125
|
# @param output [IO]
|
|
76
126
|
# @param timeout [Numeric]
|
|
77
|
-
# @return [
|
|
127
|
+
# @return [Result, nil]
|
|
78
128
|
def query_osc11(input, output, timeout)
|
|
79
129
|
reply = input.raw do
|
|
80
130
|
output.write(QUERY)
|
|
81
131
|
output.flush
|
|
82
132
|
read_reply(input, timeout)
|
|
83
133
|
end
|
|
84
|
-
|
|
85
|
-
match && classify(match.captures)
|
|
134
|
+
parse(reply)
|
|
86
135
|
rescue SystemCallError, IOError
|
|
87
136
|
nil
|
|
88
137
|
end
|
|
@@ -107,16 +156,25 @@ module Tuile
|
|
|
107
156
|
buffer
|
|
108
157
|
end
|
|
109
158
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# @param components [Array<String>] three hex strings.
|
|
159
|
+
# Light or dark, by relative luminance against a 0.5 threshold.
|
|
160
|
+
# @param components [Array<Float>] red, green and blue, each 0.0..1.0.
|
|
113
161
|
# @return [Symbol] `:light` or `:dark`.
|
|
114
162
|
def classify(components)
|
|
115
|
-
r, g, b = components
|
|
163
|
+
r, g, b = components
|
|
116
164
|
luminance = (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
|
|
117
165
|
luminance > 0.5 ? :light : :dark
|
|
118
166
|
end
|
|
119
167
|
|
|
168
|
+
# The reported background as a 24-bit {Color}. Quantizing xterm's
|
|
169
|
+
# 16-bit-per-channel reply down to 8 loses nothing a terminal can
|
|
170
|
+
# display, and keeps the exposed value in the type the rest of Tuile
|
|
171
|
+
# paints with.
|
|
172
|
+
# @param components [Array<Float>] red, green and blue, each 0.0..1.0.
|
|
173
|
+
# @return [Color]
|
|
174
|
+
def to_color(components)
|
|
175
|
+
Color.rgb(*components.map { (_1 * 255).round })
|
|
176
|
+
end
|
|
177
|
+
|
|
120
178
|
# `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
|
|
121
179
|
# ANSI palette indices. White-ish backgrounds — 7 (white) and the
|
|
122
180
|
# bright range 9–15 — read as light; 0–6 and 8 as dark; anything
|
data/lib/tuile/version.rb
CHANGED