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 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/test_*.rb"]
9
+ end
10
+
11
+ task default: :test
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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Gouache
4
+ VERSION = "0.0.1"
5
+ end
@@ -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: []