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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
@@ -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=} (`D-status-bar`).
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>] overlay popups in stacking order; last is
35
- # topmost. Holds both modal popups and non-modal overlays
36
- # ({Component::Popup#modal?}). The array must not be mutated by callers.
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 a popup and invalidates it for repaint. A modal popup is centered
58
- # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
59
- # left wherever the caller positions it and does *not* take focus, so the
60
- # component that was focused keeps the cursor and keeps receiving keys —
61
- # the overlay floats above the content, driven from app code.
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 popup wrapper (which
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 popup's contents would stay blank on reopen.
68
- # @param window [Component::Popup]
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 Popup, got #{window.inspect}" unless window.is_a? Component::Popup
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 *modal* popup, or nil when
127
- # only non-modal overlays (or no popups) are open. This is the "modal
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. Non-modal overlays are excluded — they float above
130
- # the content without capturing input.
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#size} against the new
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::Popup#close_on_outside_click?}). That is a
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::Popup#owner} chain, not
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 popups a click counts as landing *inside*: the one it hit, plus every
282
- # popup that one belongs to, up the {Component::Popup#owner} chain. An owner
283
- # is any component, so it is resolved to the popup enclosing it (a popup
284
- # resolves to itself) — which keeps the relationship a live tree question
285
- # rather than one frozen when the overlay opened. The `include?` guard makes
286
- # a mis-wired cycle terminate instead of hanging the UI thread.
287
- # @param hit [Component::Popup, nil] the popup the click landed in, if any.
288
- # @return [Array<Component::Popup>]
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
- popup = hit
292
- while popup && !kept.include?(popup)
293
- kept << popup
294
- popup = enclosing_popup(popup.owner)
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::Popup, nil] `component` itself when it is a popup,
301
- # else the nearest popup above it, else nil.
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::Popup)
305
+ component = component.parent until component.nil? || component.is_a?(Component::Overlay)
304
306
  component
305
307
  end
306
308
 
@@ -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
- class Style < Data.define(:fg, :bg, :bold, :italic, :underline, :strikethrough)
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
- super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:, strikethrough:)
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. `D-cluster-width` has the
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? ? Span.new(text: span.text, style: span.style.merge(bg: bg)) : span
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 whether the terminal background is light or dark, so {Screen}
5
- # can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
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 [Symbol, nil] `:light`, `:dark`, or nil when undetectable.
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 || from_colorfgbg(env["COLORFGBG"])
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 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.
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 [Symbol, nil]
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
- match = REPLY.match(reply)
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
- # 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.
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.map { |c| c.to_i(16).fdiv((16**c.length) - 1) }
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.13.0"
5
+ VERSION = "0.14.0"
6
6
  end