gouache 0.0.1
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 +7 -0
- data/LICENSE +21 -0
- data/Rakefile +11 -0
- data/gouache.gemspec +40 -0
- data/lib/gouache/base.rb +76 -0
- data/lib/gouache/builder.rb +153 -0
- data/lib/gouache/color.rb +161 -0
- data/lib/gouache/color_utils.rb +111 -0
- data/lib/gouache/emitter.rb +88 -0
- data/lib/gouache/layer.rb +97 -0
- data/lib/gouache/layer_proxy.rb +33 -0
- data/lib/gouache/layer_stack.rb +49 -0
- data/lib/gouache/stylesheet.rb +92 -0
- data/lib/gouache/term.rb +112 -0
- data/lib/gouache/utils.rb +36 -0
- data/lib/gouache/version.rb +5 -0
- data/lib/gouache/wrap.rb +14 -0
- data/lib/gouache.rb +102 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b882f0ae8489aa7349e5bdc4ffbd4057c5d10db0269fb6ea2af2251e133cb399
|
|
4
|
+
data.tar.gz: 8d8bd255f7110d76f0a70447ea26c9a5531f2a1da35f7385484a4be0f70dca5d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 29ff0da15b2c84db40051c822b6dfeebf1cd274250c3249066a3d30a92683fe2323457db6e01afd7c2c720e9079d0552ff35737fae0376b8938ca247e59727cf
|
|
7
|
+
data.tar.gz: eb30d3abeb27b3e21c5002587d339755f51fb7890e3238ce89b941b0e8bfc7c48a83c34ae64687632aff0409a914ba853ce85ad4f16191a8bf38d608740202bc
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Caio Chassot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/Rakefile
ADDED
data/gouache.gemspec
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/gouache/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "gouache"
|
|
7
|
+
spec.version = Gouache::VERSION
|
|
8
|
+
spec.authors = ["Caio Chassot"]
|
|
9
|
+
spec.email = ["dev@caiochassot.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "A flexible terminal color library for Ruby"
|
|
12
|
+
spec.description = "Gouache provides a powerful and flexible way to add colors and styling to terminal output in Ruby applications. It supports multiple color formats (RGB, OKLCH, 256-color, basic), fallback modes, custom stylesheets, refinements for String, and advanced features like color shifting and effects."
|
|
13
|
+
spec.homepage = "https://github.com/kch/gouache"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.4.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["source_code_uri"] = "https://github.com/kch/gouache"
|
|
20
|
+
# spec.metadata["changelog_uri"] = "https://github.com/kch/gouache/blob/main/CHANGELOG.md"
|
|
21
|
+
|
|
22
|
+
# Specify which files should be added to the gem when it is released.
|
|
23
|
+
spec.files = Dir.chdir(__dir__) do
|
|
24
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
25
|
+
(File.expand_path(f) == __FILE__) ||
|
|
26
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
spec.bindir = "exe"
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
# Runtime dependencies
|
|
35
|
+
spec.add_dependency "matrix", "~> 0.4.2"
|
|
36
|
+
|
|
37
|
+
# Development dependencies
|
|
38
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
39
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
40
|
+
end
|
data/lib/gouache/base.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Gouache
|
|
4
|
+
|
|
5
|
+
BASE_STYLES = {
|
|
6
|
+
reset: 0,
|
|
7
|
+
black: 30,
|
|
8
|
+
red: 31,
|
|
9
|
+
green: 32,
|
|
10
|
+
yellow: 33,
|
|
11
|
+
blue: 34,
|
|
12
|
+
magenta: 35,
|
|
13
|
+
cyan: 36,
|
|
14
|
+
white: 37,
|
|
15
|
+
default: 39,
|
|
16
|
+
black!: 90,
|
|
17
|
+
red!: 91,
|
|
18
|
+
green!: 92,
|
|
19
|
+
yellow!: 93,
|
|
20
|
+
blue!: 94,
|
|
21
|
+
magenta!: 95,
|
|
22
|
+
cyan!: 96,
|
|
23
|
+
white!: 97,
|
|
24
|
+
on_black: 40,
|
|
25
|
+
on_red: 41,
|
|
26
|
+
on_green: 42,
|
|
27
|
+
on_yellow: 43,
|
|
28
|
+
on_blue: 44,
|
|
29
|
+
on_magenta: 45,
|
|
30
|
+
on_cyan: 46,
|
|
31
|
+
on_white: 47,
|
|
32
|
+
on_default: 49,
|
|
33
|
+
on_black!: 100,
|
|
34
|
+
on_red!: 101,
|
|
35
|
+
on_green!: 102,
|
|
36
|
+
on_yellow!: 103,
|
|
37
|
+
on_blue!: 104,
|
|
38
|
+
on_magenta!: 105,
|
|
39
|
+
on_cyan!: 106,
|
|
40
|
+
on_white!: 107,
|
|
41
|
+
bold: 1,
|
|
42
|
+
dim: 2,
|
|
43
|
+
faint: 2,
|
|
44
|
+
italic: 3,
|
|
45
|
+
underline: 4,
|
|
46
|
+
double_underline: 21,
|
|
47
|
+
overline: 53,
|
|
48
|
+
blink: 5,
|
|
49
|
+
invert: 7,
|
|
50
|
+
inverse: 7,
|
|
51
|
+
reverse: 7,
|
|
52
|
+
hidden: 8,
|
|
53
|
+
hide: 8,
|
|
54
|
+
conceal: 8,
|
|
55
|
+
strike: 9,
|
|
56
|
+
strikeout: 9,
|
|
57
|
+
strikethrough: 9,
|
|
58
|
+
intensity_off: 22, # dim+bold
|
|
59
|
+
dim_off: ->{ it.dim = false },
|
|
60
|
+
bold_off: ->{ it.bold = false },
|
|
61
|
+
italic_off: 23,
|
|
62
|
+
underlines_off: 24, # under+double
|
|
63
|
+
blink_off: 25,
|
|
64
|
+
invert_off: 27,
|
|
65
|
+
inverse_off: 27,
|
|
66
|
+
reverse_off: 27,
|
|
67
|
+
hidden_off: 28,
|
|
68
|
+
hide_off: 28,
|
|
69
|
+
reveal: 28,
|
|
70
|
+
strike_off: 29,
|
|
71
|
+
strikeout_off: 29,
|
|
72
|
+
strikethrough_off: 29,
|
|
73
|
+
overline_off: 55,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "emitter"
|
|
4
|
+
require_relative "wrap"
|
|
5
|
+
require "strscan"
|
|
6
|
+
|
|
7
|
+
class Gouache
|
|
8
|
+
module Builder
|
|
9
|
+
|
|
10
|
+
using Wrap
|
|
11
|
+
|
|
12
|
+
### helpers
|
|
13
|
+
|
|
14
|
+
def self.safe_emit_sgr s, emitter:
|
|
15
|
+
unless s.has_sgr?
|
|
16
|
+
emitter << s
|
|
17
|
+
return nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ss = StringScanner.new s.wrap
|
|
21
|
+
wraps = 0
|
|
22
|
+
while text = ss.scan_until(RX_ESC_LA)
|
|
23
|
+
emitter << text
|
|
24
|
+
case
|
|
25
|
+
when ss.skip(WRAP_OPEN)
|
|
26
|
+
wraps += 1
|
|
27
|
+
emitter.begin_sgr
|
|
28
|
+
emitter << ss.scan_until(RX_ESC_LA)
|
|
29
|
+
when ss.skip(WRAP_CLOSE)
|
|
30
|
+
next if wraps == 0
|
|
31
|
+
wraps -= 1
|
|
32
|
+
emitter.end_sgr
|
|
33
|
+
when sgr = ss.scan(RX_SGR)
|
|
34
|
+
emitter.push_sgr sgr
|
|
35
|
+
else emitter << ss.scan(?\e)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
wraps.times{ emitter.end_sgr }
|
|
39
|
+
emitter << ss.rest
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.emit_content(x, emitter:)
|
|
44
|
+
case x
|
|
45
|
+
in Array then _compile(x, emitter:)
|
|
46
|
+
in String then safe_emit_sgr(x, emitter:)
|
|
47
|
+
in _ then emitter << x
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
### compile
|
|
52
|
+
|
|
53
|
+
def self._compile node, emitter:
|
|
54
|
+
return unless node&.any? # stop recursing
|
|
55
|
+
first, *rest = *node.slice_before{ it in Symbol | Color } # each symbol marks a new tag/node
|
|
56
|
+
head, *first = first if first in [Symbol | Color, *] # node may begin with tag|color or not
|
|
57
|
+
rest = rest.reverse_each.inject{|b,a| a << b } # nest symbol chains [:a,:b,:c] -> [:a,[:b,[:c]]]
|
|
58
|
+
tag = head if head in Symbol
|
|
59
|
+
color = head if head in Color
|
|
60
|
+
emitter.open_tag tag if tag
|
|
61
|
+
emitter.begin_sgr.push_sgr color.to_s(fallback: true) if color
|
|
62
|
+
first.each{ emit_content(it, emitter:) }
|
|
63
|
+
_compile(rest, emitter:)
|
|
64
|
+
emitter.end_sgr if color
|
|
65
|
+
emitter.close_tag if tag
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.compile root, instance:
|
|
70
|
+
emitter = instance.mk_emitter
|
|
71
|
+
_compile(root, emitter:)
|
|
72
|
+
emitter.emit!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
### block/chains
|
|
76
|
+
|
|
77
|
+
class UnfinishedChain < RuntimeError
|
|
78
|
+
def initialize(chain) = super "call chain #{chain.instance_exec{@tags}*?.} left dangling with no arguments"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class ChainProxy < BasicObject
|
|
82
|
+
def initialize parent, tag
|
|
83
|
+
@tags = [tag]
|
|
84
|
+
@parent = parent
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private def method_missing(m, *a, &b)
|
|
88
|
+
return super if %i[ to_s to_str to_ary ].include? m # prevent confusion if proxy leaks
|
|
89
|
+
|
|
90
|
+
if a.empty? && b.nil?
|
|
91
|
+
@tags << m
|
|
92
|
+
return self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@parent.instance_exec{ @chain = nil }
|
|
96
|
+
# inject tags into nested blocks and send to owner proxy
|
|
97
|
+
buildme = @tags.reverse_each.inject(->{ __send__(:_build!, m, *a, &b) }){|b, t| ->{ __send__(:_build!, t, &b) }}
|
|
98
|
+
@parent.instance_exec(&buildme)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Proxy < BasicObject
|
|
104
|
+
|
|
105
|
+
def self.for(instance, m, ...)
|
|
106
|
+
return unless m.nil? || instance.rules.tag?(m)
|
|
107
|
+
new(instance).__send__(:_build!, m, ...)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def initialize(instance)
|
|
111
|
+
# @instance = instance
|
|
112
|
+
@emitter = instance.mk_emitter
|
|
113
|
+
@tags = []
|
|
114
|
+
@nesting = 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# def call(...) = _build!(nil, ...) # TODO: Do we want this?
|
|
118
|
+
|
|
119
|
+
def <<(s) = ::Gouache::Builder.emit_content(s, emitter: @emitter)
|
|
120
|
+
|
|
121
|
+
private def method_missing(m, ...)
|
|
122
|
+
return super if %i[ to_s to_str to_ary ].include? m # prevent confusion if proxy leaks
|
|
123
|
+
# return super unless @instance.rules.tag? m # TODO: optional?
|
|
124
|
+
_build!(m, ...)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private def _build!(m, *content, &builder)
|
|
128
|
+
::Kernel.raise UnfinishedChain, @chain if @chain
|
|
129
|
+
if content.empty? && builder.nil?
|
|
130
|
+
@chain = ChainProxy.new(self, m)
|
|
131
|
+
return @chain
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@emitter.open_tag m if m
|
|
135
|
+
content.each{ ::Gouache::Builder.emit_content(it, emitter: @emitter) }
|
|
136
|
+
|
|
137
|
+
@nesting += 1
|
|
138
|
+
case builder&.arity
|
|
139
|
+
in nil
|
|
140
|
+
in 1 then builder[self] # {|x| x.tag ... } or { it.tag ... } form
|
|
141
|
+
in 0 then instance_exec(&builder) # { tag ... } form
|
|
142
|
+
in _ then ::Kernel.raise ::ArgumentError
|
|
143
|
+
end
|
|
144
|
+
@nesting -= 1
|
|
145
|
+
::Kernel.raise UnfinishedChain, @chain if @chain
|
|
146
|
+
@emitter.close_tag if m
|
|
147
|
+
@nesting == 0 ? @emitter.emit! : nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
end # Proxy
|
|
151
|
+
|
|
152
|
+
end # Builder
|
|
153
|
+
end # Gouache
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "color_utils"
|
|
4
|
+
require_relative "term"
|
|
5
|
+
require_relative "utils"
|
|
6
|
+
|
|
7
|
+
class Gouache
|
|
8
|
+
class Color
|
|
9
|
+
using RegexpWrap # enable .w below. wrap it in \A\z
|
|
10
|
+
|
|
11
|
+
FG = 38 # foreground role
|
|
12
|
+
BG = 48 # background role
|
|
13
|
+
I8 = 0..255 # 8bit int range, for 256 colors and rgb channels
|
|
14
|
+
IC = 0..5 # 216 color cube channel range
|
|
15
|
+
D8 = / 1?\d?\d | 2[0-4]\d | 25[0-5] /x # 0..255 string
|
|
16
|
+
RX_BASIC = / (?: 3|4|9|10 ) [0-7] | [34]9 /x.w # sgr basic ranges
|
|
17
|
+
RX_256 = / ([34]8) ; 5 ; (#{D8}) /x.w # sgr 256 color
|
|
18
|
+
RX_RGB = / ([34]8) ; 2 ; (#{D8}) ; (#{D8}) ; (#{D8}) /x.w # sgr truecolor
|
|
19
|
+
RX_HEX = / \#? (\h\h) (\h\h) (\h\h) /x.w # hex syntax for truecolor
|
|
20
|
+
|
|
21
|
+
def initialize(**kva)
|
|
22
|
+
# very unforgiving as public use
|
|
23
|
+
case kva
|
|
24
|
+
in sgr: 39 | 49 | 30..37 | 40..47 | 90..97 | 100..107 => n then @sgr_basic = n
|
|
25
|
+
in sgr: RX_BASIC => s then @sgr_basic = s.to_i
|
|
26
|
+
in sgr: RX_256 => s then @sgr = s; @role, @_256 = $~[1..].map(&:to_i)
|
|
27
|
+
in sgr: RX_RGB => s then @sgr = s; @role, *@rgb = $~[1..].map(&:to_i)
|
|
28
|
+
in role: FG|BG => rl, rgb: [I8, I8, I8] => rgb then @role = rl; @rgb = rgb
|
|
29
|
+
in role: FG|BG => rl, rgb: RX_HEX then @role = rl; @rgb = $~[1..].map{it.to_i(16)}
|
|
30
|
+
in role: FG|BG => rl, oklch: [0..1, 0.., Numeric] => lch then @role = rl; @oklch = lch
|
|
31
|
+
in role: FG|BG => rl, gray: 0..23 => gray then @role = rl; @_256 = 232 + gray
|
|
32
|
+
in role: FG|BG => rl, cube: [IC => r, IC => g, IC => b] then @role = rl; @_256 = 16 + 36*r + 6*g + b
|
|
33
|
+
in __private: [rl, sgr, _256, rgb, oklch] then @role, @sgr_basic, @_256, @rgb, @oklch = rl, sgr, _256, rgb, oklch
|
|
34
|
+
else raise ArgumentError, kva.inspect
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, kva.inspect unless @role in 38 | 48 | nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method def self.parse_rgb(args)
|
|
40
|
+
case args
|
|
41
|
+
in [ I8 => r, I8 => g, I8 => b ] then [r,g,b]
|
|
42
|
+
in [[I8 => r, I8 => g, I8 => b]] then [r,g,b]
|
|
43
|
+
in [RX_HEX] then $~[1..].map{it.to_i(16)}
|
|
44
|
+
else raise ArgumentError, args.inspect
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# constructors for styles
|
|
49
|
+
def self.sgr(sgr) = new(sgr:)
|
|
50
|
+
def self.ansi(sgr) = new(sgr:)
|
|
51
|
+
def self.rgb(*rgb) = new(role: FG, rgb: parse_rgb(rgb))
|
|
52
|
+
def self.on_rgb(*rgb) = new(role: BG, rgb: parse_rgb(rgb))
|
|
53
|
+
def self.hex(hs) = new(role: FG, rgb: hs)
|
|
54
|
+
def self.on_hex(hs) = new(role: BG, rgb: hs)
|
|
55
|
+
def self.cube(r,g,b) = new(role: FG, cube: [r,g,b])
|
|
56
|
+
def self.on_cube(r,g,b) = new(role: BG, cube: [r,g,b])
|
|
57
|
+
def self.gray(n) = new(role: FG, gray: n)
|
|
58
|
+
def self.on_gray(n) = new(role: BG, gray: n)
|
|
59
|
+
def self.oklch(l,c,h) = new(role: FG, oklch: [l,c,h])
|
|
60
|
+
def self.on_oklch(l,c,h) = new(role: BG, oklch: [l,c,h])
|
|
61
|
+
|
|
62
|
+
def role
|
|
63
|
+
@role || case @sgr_basic
|
|
64
|
+
in /\A38;/ | 39 | 30..37 | 90..97 then FG
|
|
65
|
+
in /\A48;/ | 49 | 40..47 | 100..107 then BG
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rgb
|
|
70
|
+
@rgb ||= case
|
|
71
|
+
when @oklch then ColorUtils.srgb8_from_oklch @oklch
|
|
72
|
+
when @_256 then Term.colors[@_256]
|
|
73
|
+
when (n = @sgr_basic)
|
|
74
|
+
case n
|
|
75
|
+
in 39 then Term.fg_color # fg default
|
|
76
|
+
in 49 then Term.bg_color # bg default
|
|
77
|
+
in 30..37 then Term.colors[n - 30] # fg basic color
|
|
78
|
+
in 40..47 then Term.colors[n - 30 - 10] # bg basic color
|
|
79
|
+
in 90..97 then Term.colors[n - 90 + 8] # fg bright color
|
|
80
|
+
in 100..107 then Term.colors[n - 90 - 10 + 8] # bg bright color
|
|
81
|
+
end
|
|
82
|
+
else raise
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def oklch = @oklch || ColorUtils.oklch_from_srgb8(rgb)
|
|
87
|
+
|
|
88
|
+
def sgr
|
|
89
|
+
@sgr ||= case
|
|
90
|
+
when @oklch then [role, 2, *rgb]*?;
|
|
91
|
+
when @rgb then [role, 2, *rgb]*?;
|
|
92
|
+
when @_256 then [role, 5, @_256]*?;
|
|
93
|
+
when @sgr_basic then @sgr_basic
|
|
94
|
+
else raise
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def _256 = @_256 || Term.nearest256(rgb)
|
|
99
|
+
|
|
100
|
+
def basic
|
|
101
|
+
return @sgr_basic if @sgr_basic
|
|
102
|
+
i = Term.nearest16(rgb) # get the nearest to rgb
|
|
103
|
+
x = 30 + i # go to 30 range for system colors
|
|
104
|
+
x += 60 - 8 if i > 7 # jump to 90 range for bright (≥8), -offset
|
|
105
|
+
x += 10 if role == 48 # jump to background ranges if bg
|
|
106
|
+
x # a plain basic color sgr
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_sgr(fallback: false)
|
|
110
|
+
return sgr unless fallback
|
|
111
|
+
fallback = Term.color_level if fallback == true # allow passing the fallback level explicity or true to determine from Term
|
|
112
|
+
case fallback
|
|
113
|
+
in :truecolor then sgr
|
|
114
|
+
in :_256 then (!@_256 && @sgr_basic) || [role, 5, @_256 || _256]*?;
|
|
115
|
+
in :basic then basic
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def to_s(...) = to_sgr(...).to_s
|
|
120
|
+
|
|
121
|
+
def change_role(new_role)
|
|
122
|
+
return self unless new_role != role
|
|
123
|
+
sgr_basic = @sgr_basic + { FG => -10, BG => 10 }[new_role] if @sgr_basic
|
|
124
|
+
Color.new __private: [new_role, sgr_basic, @_256, @rgb, @oklch]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.merge(*colors) = colors.group_by(&:role).transform_values{|cs| cs.inject(&:merge) }.values_at(FG, BG)
|
|
128
|
+
|
|
129
|
+
def merge(other)
|
|
130
|
+
raise ArgumentError, "different roles" if role != other.role
|
|
131
|
+
merge_vars = ->{ [@role, @sgr_basic, @_256, @rgb, @oklch] }
|
|
132
|
+
Color.new __private: merge_vars[].zip(other.instance_exec(&merge_vars)).map{ _1 || _2 }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def to_i = sgr.to_i
|
|
136
|
+
|
|
137
|
+
def apply_deltas(xs, ds)
|
|
138
|
+
raise ArgumentError unless ds in [Numeric | [Numeric], Numeric | [Numeric], Numeric | [Numeric]]
|
|
139
|
+
xs.zip(ds).map{|x,d| (d in [d_]) ? d_ : x + d }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def oklch_shift(*ds)
|
|
143
|
+
l, c, h = apply_deltas(oklch, ds)
|
|
144
|
+
Color.new role:, oklch: [l.clamp(0.0, 1.0), [0.0, c].max, h]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def rgb_shift(*ds)
|
|
148
|
+
Color.new role:, rgb: apply_deltas(rgb, ds).map{ it.clamp(0, 255).round }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def == x
|
|
152
|
+
case x
|
|
153
|
+
in Color then sgr == x.sgr
|
|
154
|
+
in Integer then sgr == x
|
|
155
|
+
in String then sgr.to_s == x
|
|
156
|
+
else super
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "matrix"
|
|
4
|
+
|
|
5
|
+
class Gouache
|
|
6
|
+
module ColorUtils
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
# constants
|
|
10
|
+
|
|
11
|
+
SRGB_MAX = 255.0
|
|
12
|
+
|
|
13
|
+
SRGB_GAMMA_THRESHOLD = 0.04045
|
|
14
|
+
SRGB_GAMMA_FACTOR = 12.92
|
|
15
|
+
SRGB_GAMMA_A = 0.055
|
|
16
|
+
SRGB_GAMMA_DIV = 1.055
|
|
17
|
+
SRGB_GAMMA_GAMMA = 2.4
|
|
18
|
+
|
|
19
|
+
LINEAR_SRGB_THRESHOLD = 0.0031308
|
|
20
|
+
LINEAR_SRGB_FACTOR = 12.92
|
|
21
|
+
LINEAR_SRGB_A = 0.055
|
|
22
|
+
LINEAR_SRGB_SCALE = 1.055
|
|
23
|
+
LINEAR_SRGB_GAMMA_INV = 1.0 / 2.4
|
|
24
|
+
|
|
25
|
+
DEG_PER_RAD = 180.0 / Math::PI
|
|
26
|
+
|
|
27
|
+
# matrices
|
|
28
|
+
|
|
29
|
+
LMS_FROM_LINEAR_SRGB = Matrix[
|
|
30
|
+
[0.4122214708, 0.5363325363, 0.0514459929],
|
|
31
|
+
[0.2119034982, 0.6806995451, 0.1073969566],
|
|
32
|
+
[0.0883024619, 0.2817188376, 0.6299787005]]
|
|
33
|
+
|
|
34
|
+
OKLAB_FROM_LMS = Matrix[
|
|
35
|
+
[0.2104542553, 0.7936177850, -0.0040720468],
|
|
36
|
+
[1.9779984951, -2.4285922050, 0.4505937099],
|
|
37
|
+
[0.0259040371, 0.7827717662, -0.8086757660]]
|
|
38
|
+
|
|
39
|
+
LMS_FROM_OKLAB = Matrix[
|
|
40
|
+
[1.0, 0.3963377774, 0.2158037573],
|
|
41
|
+
[1.0, -0.1055613458, -0.0638541728],
|
|
42
|
+
[1.0, -0.0894841775, -1.2914855480]]
|
|
43
|
+
|
|
44
|
+
LINEAR_SRGB_FROM_LMS = Matrix[
|
|
45
|
+
[ 4.0767416621, -3.3077115913, 0.2309699292],
|
|
46
|
+
[-1.2684380046, 2.6097574011, -0.3413193965],
|
|
47
|
+
[-0.0041960863, -0.7034186147, 1.7076147010]]
|
|
48
|
+
|
|
49
|
+
# helpers
|
|
50
|
+
|
|
51
|
+
def cbrt_vec(v)
|
|
52
|
+
Vector[*v.map{|x| x < 0 ? -(-x)**(1.0 / 3.0) : x**(1.0 / 3.0) }]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def linear_rgb_from_srgb8(srgb8)
|
|
56
|
+
Vector[*srgb8.map do |c|
|
|
57
|
+
c /= SRGB_MAX
|
|
58
|
+
next c / SRGB_GAMMA_FACTOR if c <= SRGB_GAMMA_THRESHOLD
|
|
59
|
+
((c + SRGB_GAMMA_A) / SRGB_GAMMA_DIV) ** SRGB_GAMMA_GAMMA
|
|
60
|
+
end]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def srgb8_from_linear_rgb(lin)
|
|
64
|
+
lin.map do |c|
|
|
65
|
+
c = c.clamp(0.0, 1.0)
|
|
66
|
+
next (c * LINEAR_SRGB_FACTOR * SRGB_MAX).round if c <= LINEAR_SRGB_THRESHOLD
|
|
67
|
+
((LINEAR_SRGB_SCALE * c**LINEAR_SRGB_GAMMA_INV - LINEAR_SRGB_A) * SRGB_MAX).round
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# conversions
|
|
72
|
+
|
|
73
|
+
def oklab_from_srgb8(srgb8)
|
|
74
|
+
lin = linear_rgb_from_srgb8(srgb8)
|
|
75
|
+
lms = LMS_FROM_LINEAR_SRGB * lin
|
|
76
|
+
(OKLAB_FROM_LMS * cbrt_vec(lms)).to_a
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def srgb8_from_oklab(oklab)
|
|
80
|
+
lms = LMS_FROM_OKLAB * Vector[*oklab]
|
|
81
|
+
lin = LINEAR_SRGB_FROM_LMS * Vector[*lms.map{ it**3 }]
|
|
82
|
+
srgb8_from_linear_rgb(lin.to_a)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def oklch_from_oklab((l,a,b))
|
|
86
|
+
c = Math.sqrt(a*a + b*b)
|
|
87
|
+
h = Math.atan2(b, a) * DEG_PER_RAD
|
|
88
|
+
h += 360 if h < 0
|
|
89
|
+
[l, c, h]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def oklab_from_oklch((l,c,h))
|
|
93
|
+
r = h / DEG_PER_RAD
|
|
94
|
+
[l, c * Math.cos(r), c * Math.sin(r)]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def oklch_from_srgb8(srgb8) = oklch_from_oklab oklab_from_srgb8 srgb8
|
|
98
|
+
|
|
99
|
+
def srgb8_from_oklch(oklch) = srgb8_from_oklab oklab_from_oklch oklch
|
|
100
|
+
|
|
101
|
+
# distance
|
|
102
|
+
|
|
103
|
+
DIST_WEIGHTS = [1.5, 1.0, 1.0]
|
|
104
|
+
def oklab_distance_from_srgb8(srgb8_a, srgb8_b, weights: DIST_WEIGHTS)
|
|
105
|
+
a = oklab_from_srgb8(srgb8_a)
|
|
106
|
+
b = oklab_from_srgb8(srgb8_b)
|
|
107
|
+
Math.sqrt a.zip(b, weights).sum{|a,b,w| ((b-a)*w)**2 }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "layer_stack"
|
|
4
|
+
require_relative "stylesheet"
|
|
5
|
+
|
|
6
|
+
class Gouache
|
|
7
|
+
class Emitter
|
|
8
|
+
|
|
9
|
+
def initialize(instance:)
|
|
10
|
+
@rules = instance.rules # stylesheet
|
|
11
|
+
@enabled = instance.enabled?
|
|
12
|
+
@layers = LayerStack.new # each tag or bare sgr emitted generates a layer
|
|
13
|
+
@flushed = @layers.base # keep layer state after each flush so next flush can diff against it
|
|
14
|
+
@queue = [] # accumulate sgr params to emit until we have text to style (we collapse to minimal set then)
|
|
15
|
+
@got_sgr = false # did we emit sgr at all? used to determine if reset in the end
|
|
16
|
+
@out = +""
|
|
17
|
+
enqueue @layers.diffpush @rules[:_base] if @rules.tag? :_base # special rule _base applies to all
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enqueue(sgr) = (@queue << sgr; self)
|
|
21
|
+
def open_tag(tag) = enqueue @layers.diffpush(@rules[tag], tag)
|
|
22
|
+
def begin_sgr = enqueue @layers.diffpush(nil, :@@sgr)
|
|
23
|
+
def push_sgr(sgr_text) = enqueue @layers.diffpush(Layer.from Gouache.scan_sgr sgr_text)
|
|
24
|
+
|
|
25
|
+
def end_sgr
|
|
26
|
+
sgr_begun = @layers.reverse_each.find{ it.tag != nil }&.tag == :@@sgr
|
|
27
|
+
enqueue @layers.diffpop_until{ it.top.tag == :@@sgr } if sgr_begun
|
|
28
|
+
enqueue @layers.diffpop if @layers.top.tag == :@@sgr
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def close_tag
|
|
33
|
+
top_is_tag = ->{ not it.top.tag in nil | :@@sgr }
|
|
34
|
+
top_is_tag[@layers] or enqueue @layers.diffpop_until(&top_is_tag)
|
|
35
|
+
top_is_tag[@layers] or raise "attempted to close tag without open tag"
|
|
36
|
+
enqueue @layers.diffpop
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def << s
|
|
40
|
+
s = s.to_s
|
|
41
|
+
return self if s.empty?
|
|
42
|
+
flush
|
|
43
|
+
@out << s
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private def flush
|
|
48
|
+
return self unless @enabled
|
|
49
|
+
return self unless @queue.any?
|
|
50
|
+
@flushed = Layer.from Layer.from(@queue).diff(@flushed)
|
|
51
|
+
sgr = @flushed.to_sgr(fallback: true)
|
|
52
|
+
@queue.clear
|
|
53
|
+
return self if sgr.empty?
|
|
54
|
+
@got_sgr = true
|
|
55
|
+
@out << CSI << sgr << ?m
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def emit!
|
|
60
|
+
return @emitted if @emitted
|
|
61
|
+
@out << CSI << "0m" if @got_sgr # this replaces the final flush. if no sgr emitted, don't bother resetting
|
|
62
|
+
@emitted, @out = @out, nil
|
|
63
|
+
@emitted
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
alias to_s emit!
|
|
67
|
+
|
|
68
|
+
# for debugging
|
|
69
|
+
def pretty_print(pp)
|
|
70
|
+
fmt_layer = ->l{ " [ %s ] %s" % [l.map{ "%2s" % it.to_s }.join(" "), l.tag] }
|
|
71
|
+
pp.group(1, "#<#{self.class}", ">") do
|
|
72
|
+
pp.breakable
|
|
73
|
+
pp.text "@layers =\n"
|
|
74
|
+
pp.text @layers.map(&fmt_layer).join("\n")
|
|
75
|
+
pp.breakable
|
|
76
|
+
pp.text "@flushed =\n"
|
|
77
|
+
pp.text fmt_layer[@flushed]
|
|
78
|
+
pp.breakable
|
|
79
|
+
pp.text "@queue = "
|
|
80
|
+
pp.pp @queue
|
|
81
|
+
pp.breakable
|
|
82
|
+
pp.text "@out = "
|
|
83
|
+
pp.pp @out
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "utils"
|
|
4
|
+
require_relative "color"
|
|
5
|
+
|
|
6
|
+
class Gouache
|
|
7
|
+
|
|
8
|
+
class Layer < Array
|
|
9
|
+
attr_accessor :effects
|
|
10
|
+
|
|
11
|
+
class LayerRange < RangeUnion
|
|
12
|
+
attr :label, :index, :off, :on
|
|
13
|
+
|
|
14
|
+
def initialize(xs, label:, index:)
|
|
15
|
+
@label = label
|
|
16
|
+
@index = index
|
|
17
|
+
@off = xs.last
|
|
18
|
+
@on = xs.first if xs.first.is_a? Integer
|
|
19
|
+
super(*xs)
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
RANGES = { # ends as { k: LayerRange, ... }; keys get used for label too
|
|
25
|
+
fg: [30..39, 90..97, 39],
|
|
26
|
+
bg: [40..49, 100..107, 49],
|
|
27
|
+
italic: [ 3, 23],
|
|
28
|
+
blink: [ 5, 25],
|
|
29
|
+
inverse: [ 7, 27],
|
|
30
|
+
hidden: [ 8, 28],
|
|
31
|
+
strike: [ 9, 29],
|
|
32
|
+
overline: [53, 55],
|
|
33
|
+
underline: [ 4, 21, 24], # underline + double_underline
|
|
34
|
+
underline_color: [58, 59], # affects underline + double_underline
|
|
35
|
+
bold: [ 1, 22],
|
|
36
|
+
dim: [ 2, 22],
|
|
37
|
+
}.zip(0..).to_h do |(k, xs), i|
|
|
38
|
+
[k, LayerRange.new(xs, index: i, label: k)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# return array of RANGE indices that cover sgr code x
|
|
42
|
+
def RANGES.for(x) = values.filter_map{|r| r.index if r.member? x }.then{ it if it.any? }
|
|
43
|
+
RANGES.freeze
|
|
44
|
+
|
|
45
|
+
BASE = new(RANGES.values.map(&:off).tap{ it[0,2] = it[0,2].map{ Color.sgr it }}).freeze
|
|
46
|
+
|
|
47
|
+
# transforms xs into a valid array of sgr codes
|
|
48
|
+
# special handling for dim/bold:
|
|
49
|
+
# - dim/bold turn on independently but are both turned off by 22
|
|
50
|
+
# - so we move 22 in front, off code goes first so any on code that follows actually applies
|
|
51
|
+
# also convert Color to sgr
|
|
52
|
+
def self.prepare_sgr(xs, fallback: false)
|
|
53
|
+
xs = xs.compact.uniq
|
|
54
|
+
sgr22 = xs.delete(22)
|
|
55
|
+
xs.map!{ it.respond_to?(:to_sgr) ? it.to_sgr(fallback:) : it }
|
|
56
|
+
[*sgr22, *xs]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.empty = new(RANGES.size, nil)
|
|
60
|
+
|
|
61
|
+
# create a new layer from the given sgr codes
|
|
62
|
+
def self.from(*sgr_codes)
|
|
63
|
+
layer = empty
|
|
64
|
+
effects, sgr_codes = sgr_codes.flatten.partition { it in Proc }
|
|
65
|
+
sgr_codes.each do |sgr|
|
|
66
|
+
case sgr
|
|
67
|
+
in 0 then layer.replace BASE
|
|
68
|
+
in _ then RANGES.for(sgr.to_i)&.each{|i| layer[i] = sgr }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
layer.effects = effects
|
|
72
|
+
layer
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# return a new layer with 'top' applied on top of 'self'
|
|
76
|
+
def overlay(top)
|
|
77
|
+
case top
|
|
78
|
+
in nil then overlay Layer.empty
|
|
79
|
+
in Layer then Layer.new zip(top).map{ _2 || _1 }
|
|
80
|
+
in _ then raise TypeError, "must be a Layer"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# return sgr codes to turn on self after other
|
|
85
|
+
def diff(other)
|
|
86
|
+
# special case: last 2 elems are bold/dim, compare as unit as both are turned off by SGR 22
|
|
87
|
+
group22 = ->a{ [*a[...-2], a[-2..]] } # => [..., [bold, dim]]
|
|
88
|
+
diff = group22[self].zip(group22[other]).filter_map{|a,b| a if a != b }
|
|
89
|
+
diff[-1, 1] = self.class.prepare_sgr diff[-1] if diff[-1] in Array
|
|
90
|
+
diff
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# return array of codes to emit for layer
|
|
94
|
+
def to_sgr(fallback: false) = self.class.prepare_sgr(self, fallback:)*?;
|
|
95
|
+
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Gouache
|
|
4
|
+
class LayerProxy
|
|
5
|
+
|
|
6
|
+
Layer::RANGES.each do |k, r|
|
|
7
|
+
if k in :fg | :bg
|
|
8
|
+
define_method(k) { @layer[r.index] }
|
|
9
|
+
define_method("#{k}=") do |v|
|
|
10
|
+
@layer[r.index] = \
|
|
11
|
+
case v
|
|
12
|
+
in Color then v
|
|
13
|
+
in String | Integer then Color.sgr(v)
|
|
14
|
+
in nil then nil
|
|
15
|
+
end&.change_role(Color.const_get(k.to_s.upcase))
|
|
16
|
+
end
|
|
17
|
+
else # not fg bg:
|
|
18
|
+
define_method("#{k}=") {|v| @layer[r.index] = v ? r.on : r.off }
|
|
19
|
+
if k != :underline
|
|
20
|
+
define_method("#{k}?") { not @layer[r.index] in nil | ^(r.off) }
|
|
21
|
+
else # is underline:
|
|
22
|
+
define_method(:double_underline=) { @layer[r.index] = it ? 21 : r.off }
|
|
23
|
+
define_method(:underline?) { @layer[r.index] == 4 }
|
|
24
|
+
define_method(:double_underline?) { @layer[r.index] == 21 }
|
|
25
|
+
end # if k underline
|
|
26
|
+
end # if k fg bg
|
|
27
|
+
end # RANGES.each
|
|
28
|
+
|
|
29
|
+
def initialize(layer) = (@layer = layer)
|
|
30
|
+
def __layer = @layer
|
|
31
|
+
|
|
32
|
+
end # LayerProxy
|
|
33
|
+
end # Gouache
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "layer"
|
|
4
|
+
require_relative "layer_proxy"
|
|
5
|
+
|
|
6
|
+
class Gouache
|
|
7
|
+
|
|
8
|
+
module LayerTags
|
|
9
|
+
attr_accessor :tag
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class LayerStack < Array
|
|
13
|
+
alias top last
|
|
14
|
+
alias base first
|
|
15
|
+
def base? = size == 1
|
|
16
|
+
def under = self[-2]
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
super [Layer::BASE.dup.extend(LayerTags).freeze]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def diffpush layer, tag=nil
|
|
23
|
+
self << top.overlay(layer)
|
|
24
|
+
top.extend LayerTags
|
|
25
|
+
top.tag = tag
|
|
26
|
+
layer&.effects&.each do
|
|
27
|
+
it.arity in 1..2 or raise ArgumentError
|
|
28
|
+
it.(*[top, under].take(it.arity).map{ LayerProxy.new it })
|
|
29
|
+
end
|
|
30
|
+
top.freeze
|
|
31
|
+
top.diff(under)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def diffpop
|
|
35
|
+
return base.to_sgr if base?
|
|
36
|
+
oldpop = pop
|
|
37
|
+
top.diff(oldpop)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# pops until but not including cond
|
|
41
|
+
def diffpop_until(&cond)
|
|
42
|
+
return [] if cond[self]
|
|
43
|
+
oldtop = top
|
|
44
|
+
pop until base? || cond[self]
|
|
45
|
+
top.diff(oldtop)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "base"
|
|
3
|
+
require_relative "layer"
|
|
4
|
+
require_relative "color"
|
|
5
|
+
require_relative "utils"
|
|
6
|
+
|
|
7
|
+
class Gouache
|
|
8
|
+
class Stylesheet
|
|
9
|
+
attr :layer_map
|
|
10
|
+
|
|
11
|
+
def initialize styles, base:
|
|
12
|
+
raise TypeError unless base in Stylesheet | nil
|
|
13
|
+
@layer_map = base&.then{ it.layer_map.dup } || {} # styles computed into sgr code seqs spread into Layer instances for overlaying
|
|
14
|
+
@styles = styles&.dup || {} # hash with declarations in many formats; will clear this as we compute rules
|
|
15
|
+
@sels = [] # selector stack to avoid circular refs
|
|
16
|
+
@styles.transform_keys!(&:to_sym)
|
|
17
|
+
compute_rule(@styles.first.first) until @styles.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def merge(*styles) = self.class.new(styles.inject(&:merge), base: self)
|
|
21
|
+
|
|
22
|
+
using RegexpWrap # enable .w below. wrap it in \A\z
|
|
23
|
+
D8 = /1?\d?\d|2[0-4]\d|25[0-5]/
|
|
24
|
+
D24 = /1?\d|2[0-3]/
|
|
25
|
+
RX_INT = /(?:#{D8})/.w
|
|
26
|
+
RX_SGR = /[\d;]+/.w
|
|
27
|
+
RX_SEL = /[a-z]\w*[?!]?/i.w
|
|
28
|
+
RU_BASIC = RangeUnion.new 39, 49, 30..37, 40..47, 90..97, 100..107 # sgr basic ranges
|
|
29
|
+
RX_BASIC = / (?: 3|4|9|10 ) [0-7] | [34]9 /x.w # same as above but for strings
|
|
30
|
+
RU_SGR_NC = RangeExclusion.new 0..107, RU_BASIC, 38, 48 # no-color, valid SGRs
|
|
31
|
+
RX_EXT_COLOR = /([34]8) ; (?: 5; (#{D8}) | 2; (#{D8}) ; (#{D8}) ; (#{D8}) )/x.w
|
|
32
|
+
RX_FN_CUBE = /(on)?#[0-5]{3}/.w
|
|
33
|
+
RX_FN_HEX = /(on)?#(\h{6})/.w
|
|
34
|
+
RX_FN_RGB = /(on_)? rgb \(\s* (#{D8}) \s*,\s* (#{D8}) \s*,\s* (#{D8}) \s*\)/x.w
|
|
35
|
+
RX_FN_256 = /(on_)? 256 \(\s* (#{D8}) \s* \)/x.w
|
|
36
|
+
RX_FN_GRAY = /(on_)? gray \(\s* (#{D24}) \s* \)/x.w
|
|
37
|
+
|
|
38
|
+
private def compute_decl(x)
|
|
39
|
+
Layer.from _compute_decl(x)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private def _compute_decl(x)
|
|
43
|
+
role = ->{ $1 ? 48 : 38 }
|
|
44
|
+
case x
|
|
45
|
+
in nil then []
|
|
46
|
+
in Proc then x
|
|
47
|
+
in Color then x
|
|
48
|
+
in Layer then x
|
|
49
|
+
in Symbol then compute_rule(x)
|
|
50
|
+
in Array then x.flat_map{ _compute_decl it }.partition{ Color === it }
|
|
51
|
+
.then{|cs,rs| [*Color.merge(*cs).flatten.compact, *rs] }
|
|
52
|
+
in RU_BASIC then Color.sgr x
|
|
53
|
+
in RX_BASIC then Color.sgr x
|
|
54
|
+
in RX_EXT_COLOR then Color.sgr x
|
|
55
|
+
in RX_FN_HEX then Color.new role: role[], rgb: $2
|
|
56
|
+
in RX_FN_RGB then Color.new role: role[], rgb: $~[2..].map(&:to_i)
|
|
57
|
+
in RX_FN_CUBE then Color.new role: role[], cube: x.scan(/\d/).map(&:to_i)
|
|
58
|
+
in RX_FN_GRAY then Color.new role: role[], gray: $2.to_i
|
|
59
|
+
in RX_FN_256 then Color.sgr [role[], 5, $2]*?;
|
|
60
|
+
in RU_SGR_NC then x
|
|
61
|
+
in RX_INT then _compute_decl(x.to_i)
|
|
62
|
+
in RX_SGR then Gouache.scan_sgr(x).map{ _compute_decl it }
|
|
63
|
+
in RX_SEL then compute_rule(x.to_sym)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private def compute_rule(sym)
|
|
68
|
+
return @layer_map[sym] if @layer_map.key?(sym) && !@styles.key?(sym)
|
|
69
|
+
raise "circular reference for '#{sym}'" if @sels.member? sym
|
|
70
|
+
@sels << sym
|
|
71
|
+
@layer_map[sym] = compute_decl(@styles.delete sym)
|
|
72
|
+
@sels.delete sym
|
|
73
|
+
@layer_map[sym]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tag?(key) = @layer_map.key?(key.to_sym)
|
|
77
|
+
def [](tag) = @layer_map[tag.to_sym]
|
|
78
|
+
|
|
79
|
+
# for inspection purposes mainly
|
|
80
|
+
def tags = @layer_map.keys
|
|
81
|
+
def to_h
|
|
82
|
+
@layer_map.transform_values do |decl|
|
|
83
|
+
decl.compact.uniq.map do |slot|
|
|
84
|
+
next slot.to_sgr if slot.is_a? Color
|
|
85
|
+
slot
|
|
86
|
+
end.then{ it.size == 1 ? it[0] : it }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
BASE = new BASE_STYLES, base: nil
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/gouache/term.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require_relative "color_utils"
|
|
5
|
+
|
|
6
|
+
class Gouache
|
|
7
|
+
module Term
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
RG_BASIC = (0..15)
|
|
11
|
+
RG_CUBE = (16..231)
|
|
12
|
+
RG_GRAY = (232..255)
|
|
13
|
+
|
|
14
|
+
ANSI16 = [ # default fallback, xterm defaults
|
|
15
|
+
[0, 0, 0], # 0 black
|
|
16
|
+
[205, 0, 0], # 1 red
|
|
17
|
+
[0, 205, 0], # 2 green
|
|
18
|
+
[205, 205, 0], # 3 yellow
|
|
19
|
+
[0, 0, 238], # 4 blue
|
|
20
|
+
[205, 0, 205], # 5 magenta
|
|
21
|
+
[0, 205, 205], # 6 cyan
|
|
22
|
+
[229, 229, 229], # 7 white
|
|
23
|
+
[127, 127, 127], # 8 bright black
|
|
24
|
+
[255, 0, 0], # 9 bright red
|
|
25
|
+
[0, 255, 0], # 10 bright green
|
|
26
|
+
[255, 255, 0], # 11 bright yellow
|
|
27
|
+
[92, 92, 255], # 12 bright blue
|
|
28
|
+
[255, 0, 255], # 13 bright magenta
|
|
29
|
+
[0, 255, 255], # 14 bright cyan
|
|
30
|
+
[255, 255, 255], # 15 bright white
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def rgb8_from_ansi_cube(i)
|
|
34
|
+
raise IndexError unless RG_CUBE.cover?(i)
|
|
35
|
+
n = i - 16
|
|
36
|
+
r = n / 36
|
|
37
|
+
g = (n / 6) % 6
|
|
38
|
+
b = n % 6
|
|
39
|
+
c = ->x { x == 0 ? 0 : 55 + x * 40 }
|
|
40
|
+
[r, g, b].map(&c)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rgb8_from_the_grays(g)
|
|
44
|
+
r1 = (0..23) # 0-based gray index
|
|
45
|
+
r2 = RG_GRAY # ansi index
|
|
46
|
+
g = g - r2.begin if r2.cover?(g)
|
|
47
|
+
raise IndexError, "#{g}" unless r1.cover?(g)
|
|
48
|
+
[8 + g*10] * 3
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
COLORS256 = (
|
|
52
|
+
ANSI16 +
|
|
53
|
+
RG_CUBE.map{ rgb8_from_ansi_cube it } +
|
|
54
|
+
RG_GRAY.map{ rgb8_from_the_grays it }
|
|
55
|
+
).freeze
|
|
56
|
+
|
|
57
|
+
def term_seq(*seq)
|
|
58
|
+
buf = +""
|
|
59
|
+
IO.console.raw do |tty|
|
|
60
|
+
tty << seq.join
|
|
61
|
+
tty.flush
|
|
62
|
+
loop do
|
|
63
|
+
break unless tty.wait_readable(0.05)
|
|
64
|
+
buf << tty.read_nonblock(4096)
|
|
65
|
+
rescue IO::WaitReadable, EOFError
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
buf
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
OSC_RGB = %r_\e\]\d{1,2}(?:;(\d{1,3}))?;rgb:(\h{2})\h{2}?/(\h{2})\h{2}?/(\h{2})\h{2}?(?:\a|\e\\)_
|
|
72
|
+
def scan_colors(s, len) = s.scan(OSC_RGB).to_h{|k,*rgb| [k&.to_i, rgb.map{ it&.to_i 16 }] }.then{ it.size == len ? it : nil }
|
|
73
|
+
def scan_color(s) = scan_colors(s, 1).values.first
|
|
74
|
+
def osc(*xs) = term_seq OSC, xs*?;, ST
|
|
75
|
+
def rgb_for(n) = scan_color osc(4, n, ??) # currently unused
|
|
76
|
+
def fg_color = (@fg_color ||= scan_color osc(10, ??))
|
|
77
|
+
def bg_color = (@bg_color ||= scan_color osc(11, ??))
|
|
78
|
+
def colors = (@colors ||= COLORS256.dup.tap{ it[RG_BASIC] = basic_colors }.freeze)
|
|
79
|
+
def basic_colors
|
|
80
|
+
return @basic_colors if @basic_colors
|
|
81
|
+
h = scan_colors(osc(4, *RG_BASIC.zip(Enumerator.produce{??})), RG_BASIC.size)
|
|
82
|
+
@basic_colors = (h ? RG_BASIC.map{ h[it] or raise } : ANSI16.dup).freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def color_level=(level)
|
|
86
|
+
raise ArgumentError unless level in :basic | :_256 | :truecolor | nil
|
|
87
|
+
@color_level = level
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def color_level
|
|
91
|
+
return @color_level if @color_level
|
|
92
|
+
return :truecolor if /truecolor/i =~ ENV["COLORTERM"]
|
|
93
|
+
case ENV["TERM"]
|
|
94
|
+
when /-256color$/ then :_256
|
|
95
|
+
when /^(xterm|screen|vt100|ansi)/ then :basic
|
|
96
|
+
else :basic # assume basic
|
|
97
|
+
# dumb term is handled by Gouache being disabled entirely
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@@color_indices = {}
|
|
102
|
+
private def nearest_color(rgb, list)
|
|
103
|
+
@@color_indices[rgb] ||= list.zip(0..).sort_by do |color, i|
|
|
104
|
+
ColorUtils.oklab_distance_from_srgb8 rgb, color
|
|
105
|
+
end.first.last # first of sort, last is index
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def nearest16(rgb) = nearest_color(rgb, basic_colors)
|
|
109
|
+
def nearest256(rgb) = nearest_color(rgb, colors)
|
|
110
|
+
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Gouache
|
|
4
|
+
|
|
5
|
+
module RegexpWrap
|
|
6
|
+
refine Regexp do
|
|
7
|
+
def w = /\A#{self}\z/ # wrap it in \A\z
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class RangeExclusion
|
|
12
|
+
def initialize range, *excludes
|
|
13
|
+
@range = range
|
|
14
|
+
@excludes = RangeUnion.new(*excludes)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def member?(x) = @range.member?(x) && !@excludes.member?(x)
|
|
18
|
+
alias === member?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class RangeUnion
|
|
22
|
+
def initialize *xs
|
|
23
|
+
@ranges = xs.map do |x|
|
|
24
|
+
case x
|
|
25
|
+
in RangeUnion then x
|
|
26
|
+
in Range then x
|
|
27
|
+
in Numeric then x..x
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def member?(x) = @ranges.any?{ it.member? x }
|
|
33
|
+
alias === member?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
end
|
data/lib/gouache/wrap.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Gouache
|
|
4
|
+
module Wrap
|
|
5
|
+
refine String do
|
|
6
|
+
|
|
7
|
+
def has_sgr? = match?(/\e\[[;\d]*m/)
|
|
8
|
+
def wrapped? = start_with?(WRAP_OPEN) && end_with?(WRAP_CLOSE)
|
|
9
|
+
def wrap! = [WRAP_OPEN, self, WRAP_CLOSE].join
|
|
10
|
+
def wrap = has_sgr? && !wrapped? ? wrap! : self
|
|
11
|
+
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/gouache.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "gouache/version"
|
|
4
|
+
require_relative "gouache/base"
|
|
5
|
+
require_relative "gouache/layer"
|
|
6
|
+
require_relative "gouache/layer_stack"
|
|
7
|
+
require_relative "gouache/stylesheet"
|
|
8
|
+
require_relative "gouache/emitter"
|
|
9
|
+
require_relative "gouache/builder"
|
|
10
|
+
require_relative "gouache/wrap"
|
|
11
|
+
require_relative "gouache/term"
|
|
12
|
+
require_relative "gouache/color"
|
|
13
|
+
require_relative "gouache/layer_proxy"
|
|
14
|
+
require "forwardable"
|
|
15
|
+
|
|
16
|
+
class Gouache
|
|
17
|
+
OSC = "\e]"
|
|
18
|
+
CSI = "\e["
|
|
19
|
+
ST = "\e\\"
|
|
20
|
+
CODE = "971" # meaningless magic number
|
|
21
|
+
WRAP_SEQ = [OSC, CODE].join
|
|
22
|
+
WRAP_OPEN = [OSC, CODE, 1, ST].join
|
|
23
|
+
WRAP_CLOSE = [OSC, CODE, 2, ST].join
|
|
24
|
+
RX_ESC_LA = /(?=\e)/
|
|
25
|
+
RX_SGR = /#{Regexp.escape(CSI)}[;\d]*m/
|
|
26
|
+
RX_UNPAINT = Regexp.union RX_SGR, WRAP_OPEN, WRAP_CLOSE
|
|
27
|
+
D8 = / 1?\d?\d | 2[0-4]\d | 25[0-5] /x # 0..255 string
|
|
28
|
+
RX_SGR_SEQ = /(?<=^|;|\[)(?: ( [34]8 ; (?: 5 ; #{D8} | 2 (?: ; #{D8} ){3} )) | (#{D8}) )(?=;|m|$)/x
|
|
29
|
+
|
|
30
|
+
attr :rules
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
using Wrap
|
|
34
|
+
def scan_sgr(s) = s.scan(RX_SGR_SEQ).map{|s,d| s ? s : d.to_i }
|
|
35
|
+
def unpaint(s) = s.gsub(RX_UNPAINT, "")
|
|
36
|
+
def wrap(s) = s.wrap
|
|
37
|
+
alias embed wrap
|
|
38
|
+
|
|
39
|
+
extend Forwardable
|
|
40
|
+
def_delegators "::Gouache::MAIN", :enable, :disable, :reopen, :enabled?, :puts, :print, :refinement
|
|
41
|
+
|
|
42
|
+
def method_missing(m, ...) = Builder::Proxy.for(MAIN, m, ...) || super
|
|
43
|
+
def [](*args, **styles, &b) = (styles.empty? ? MAIN : MAIN.dup(styles:))[*args, &b]
|
|
44
|
+
def new(*args, **kvargs, &b) = (go = super and block_given? ? go.(&b) : go)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(styles:{}, io:nil, enabled:nil, **kvstyles)
|
|
48
|
+
@io = io
|
|
49
|
+
@enabled = enabled
|
|
50
|
+
@rules = Stylesheet::BASE.merge(styles, kvstyles)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
MAIN = new # global instance
|
|
54
|
+
|
|
55
|
+
def dup(styles: nil)
|
|
56
|
+
go = self.class.new(io: @io, enabled: @enabled)
|
|
57
|
+
go.instance_variable_set(:@rules, @rules.merge(styles))
|
|
58
|
+
go
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def io = @io || $stdout
|
|
62
|
+
def enable = tap{ @enabled = true }
|
|
63
|
+
def disable = tap{ @enabled = false }
|
|
64
|
+
def enabled? = !@enabled.nil? ? @enabled : io.tty? && ENV["TERM"] != "dumb"
|
|
65
|
+
def reopen(io) = tap{ @io = io }
|
|
66
|
+
def puts(*x) = io.puts(*x.map{ printable it })
|
|
67
|
+
def print(*x) = io.print(*x.map{ printable it })
|
|
68
|
+
|
|
69
|
+
private def printable(x)
|
|
70
|
+
return x unless x.is_a? String
|
|
71
|
+
return unpaint(x) unless enabled?
|
|
72
|
+
return repaint(x) if x.include?(WRAP_SEQ)
|
|
73
|
+
return x
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def mk_emitter = Emitter.new(instance: self)
|
|
77
|
+
def repaint(s) = !enabled? ? unpaint(s) : mk_emitter.tap{ Builder.safe_emit_sgr(s, emitter: it) }.emit!
|
|
78
|
+
def unpaint(s) = self.class.unpaint(s)
|
|
79
|
+
def wrap(s) = self.class.wrap(s)
|
|
80
|
+
alias embed wrap
|
|
81
|
+
|
|
82
|
+
def method_missing(m, ...) = Builder::Proxy.for(self, m, ...) || super
|
|
83
|
+
def [](*args, &b) = b ? call(args, &b) : Builder.compile(args, instance: self)
|
|
84
|
+
|
|
85
|
+
def call(...)
|
|
86
|
+
raise ArgumentError unless block_given?
|
|
87
|
+
Builder::Proxy.for(self, nil, ...)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def refinement
|
|
91
|
+
instance = self
|
|
92
|
+
style_methods = instance.rules.tags
|
|
93
|
+
other_methods = %i[ unpaint repaint wrap ]
|
|
94
|
+
Module.new do
|
|
95
|
+
refine String do
|
|
96
|
+
style_methods.each{|m| define_method(m) { instance[m, self] } unless method_defined? m }
|
|
97
|
+
other_methods.each{|m| define_method(m) { instance.send m, self } unless method_defined? m }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gouache
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Caio Chassot
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-12-26 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: matrix
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.4.2
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 0.4.2
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
description: Gouache provides a powerful and flexible way to add colors and styling
|
|
56
|
+
to terminal output in Ruby applications. It supports multiple color formats (RGB,
|
|
57
|
+
OKLCH, 256-color, basic), fallback modes, custom stylesheets, refinements for String,
|
|
58
|
+
and advanced features like color shifting and effects.
|
|
59
|
+
email:
|
|
60
|
+
- dev@caiochassot.com
|
|
61
|
+
executables: []
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- LICENSE
|
|
66
|
+
- Rakefile
|
|
67
|
+
- gouache.gemspec
|
|
68
|
+
- lib/gouache.rb
|
|
69
|
+
- lib/gouache/base.rb
|
|
70
|
+
- lib/gouache/builder.rb
|
|
71
|
+
- lib/gouache/color.rb
|
|
72
|
+
- lib/gouache/color_utils.rb
|
|
73
|
+
- lib/gouache/emitter.rb
|
|
74
|
+
- lib/gouache/layer.rb
|
|
75
|
+
- lib/gouache/layer_proxy.rb
|
|
76
|
+
- lib/gouache/layer_stack.rb
|
|
77
|
+
- lib/gouache/stylesheet.rb
|
|
78
|
+
- lib/gouache/term.rb
|
|
79
|
+
- lib/gouache/utils.rb
|
|
80
|
+
- lib/gouache/version.rb
|
|
81
|
+
- lib/gouache/wrap.rb
|
|
82
|
+
homepage: https://github.com/kch/gouache
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
allowed_push_host: https://rubygems.org
|
|
87
|
+
homepage_uri: https://github.com/kch/gouache
|
|
88
|
+
source_code_uri: https://github.com/kch/gouache
|
|
89
|
+
post_install_message:
|
|
90
|
+
rdoc_options: []
|
|
91
|
+
require_paths:
|
|
92
|
+
- lib
|
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: 3.4.0
|
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
requirements: []
|
|
104
|
+
rubygems_version: 3.5.22
|
|
105
|
+
signing_key:
|
|
106
|
+
specification_version: 4
|
|
107
|
+
summary: A flexible terminal color library for Ruby
|
|
108
|
+
test_files: []
|