tint_me 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop_todo.yml +7 -0
- data/.serena/project.yml +68 -0
- data/.simplecov +24 -0
- data/.yardopts +6 -0
- data/AGENTS.md +60 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/RELEASING.md +202 -0
- data/Rakefile +18 -0
- data/benchmark/2025-09-08-style-caching-optimization/01_baseline_results.txt +39 -0
- data/benchmark/2025-09-08-style-caching-optimization/02_with_full_caching_results.txt +54 -0
- data/benchmark/2025-09-08-style-caching-optimization/03_prefix_only_caching_results.txt +107 -0
- data/benchmark/2025-09-08-style-caching-optimization/04_baseline_vs_optimized_analysis.txt +65 -0
- data/benchmark/2025-09-08-style-caching-optimization/05_caching_approaches_comparison.txt +59 -0
- data/benchmark/2025-09-08-style-caching-optimization/06_append_operator_results.txt +107 -0
- data/benchmark/2025-09-08-style-caching-optimization/07_string_concatenation_comparison.txt +66 -0
- data/benchmark/2025-09-08-style-caching-optimization/08_with_freeze_optimization_results.txt +107 -0
- data/benchmark/2025-09-08-style-caching-optimization/09_freeze_optimization_analysis.txt +49 -0
- data/benchmark/2025-09-08-style-caching-optimization/10_constant_access_results.txt +107 -0
- data/benchmark/2025-09-08-style-caching-optimization/11_constant_vs_cache_analysis.txt +74 -0
- data/benchmark/2025-09-08-style-caching-optimization/12_empty_prefix_analysis.txt +81 -0
- data/benchmark/2025-09-08-style-caching-optimization/13_nil_check_optimization_results.txt +107 -0
- data/benchmark/2025-09-08-style-caching-optimization/14_nil_vs_empty_check_analysis.txt +81 -0
- data/benchmark/2025-09-08-style-caching-optimization/README.md +45 -0
- data/benchmark/2025-09-08-style-caching-optimization/benchmark_script.rb +180 -0
- data/docs/agents/git-pr.md +298 -0
- data/docs/agents/languages.md +388 -0
- data/docs/agents/rubocop.md +55 -0
- data/lib/tint_me/error.rb +6 -0
- data/lib/tint_me/sgr_builder.rb +259 -0
- data/lib/tint_me/style/schema.rb +22 -0
- data/lib/tint_me/style/types.rb +50 -0
- data/lib/tint_me/style.rb +286 -0
- data/lib/tint_me/version.rb +8 -0
- data/lib/tint_me.rb +62 -0
- data/mise.toml +5 -0
- data/sig/tint_me.rbs +61 -0
- metadata +131 -0
@@ -0,0 +1,55 @@
|
|
1
|
+
# RuboCop Fix Workflow for AI Agents
|
2
|
+
|
3
|
+
This guide outlines a safe, repeatable process to fix RuboCop offenses while preserving behavior and keeping PRs focused.
|
4
|
+
|
5
|
+
## Goals
|
6
|
+
- Keep changes minimal and behavior‑preserving; prefer small, focused PRs.
|
7
|
+
- Use safe autocorrect first; apply unsafe corrections only when reviewed and covered by tests.
|
8
|
+
- Centralize rule changes in `.rubocop.yml`; never hand‑edit `.rubocop_todo.yml`.
|
9
|
+
|
10
|
+
## Quick Commands
|
11
|
+
- Full lint: `rake rubocop`
|
12
|
+
- File/dir lint: `rubocop path/to/file.rb`
|
13
|
+
- Safe autocorrect: `rubocop -a`
|
14
|
+
- Targeted unsafe: `rubocop -A --only Cop/Name`
|
15
|
+
- Run tests: `rake spec`
|
16
|
+
- Regenerate TODO: `docquet regenerate-todo`
|
17
|
+
- Temporarily enable a TODO‑disabled cop: comment out its block in `.rubocop_todo.yml`
|
18
|
+
|
19
|
+
## Workflow
|
20
|
+
1) Reproduce locally
|
21
|
+
- Run `rake rubocop` and note failing cops/files.
|
22
|
+
|
23
|
+
2) Safe autocorrect first
|
24
|
+
- Run `rubocop -a` on specific files or directories to reduce blast radius.
|
25
|
+
- Re‑run `rake rubocop` and `rake spec` to verify no behavior changes.
|
26
|
+
|
27
|
+
3) Target specific cops (if still failing)
|
28
|
+
- For stylistic issues, fix manually or run `rubocop -A --only Cop/Name` on the smallest scope.
|
29
|
+
- For logic‑sensitive cops (e.g., Performance, Lint), prefer manual fixes with tests.
|
30
|
+
|
31
|
+
3a) If the cop is disabled in `.rubocop_todo.yml`
|
32
|
+
- Open `.rubocop_todo.yml`, locate the `Cop/Name` entry, and temporarily comment out (or remove locally) that entire block. RuboCop respects TODO disables even with `--only`, so this is required to surface offenses.
|
33
|
+
- Run `rubocop --only Cop/Name` (optionally `-A`) on a narrow path and fix findings.
|
34
|
+
- Validate with `rake rubocop` and `rake spec`.
|
35
|
+
- Refresh the TODO: run `docquet regenerate-todo` to update/remove the entry based on remaining offenses. Commit code changes and the regenerated `.rubocop_todo.yml` together in the same commit/PR.
|
36
|
+
|
37
|
+
4) Update configuration (rare)
|
38
|
+
- If a rule conflicts with project style, adjust `.rubocop.yml` with rationale in the PR body.
|
39
|
+
- Do not edit `.rubocop_todo.yml` manually. After large cleanups, run `docquet regenerate-todo` and commit only that file.
|
40
|
+
|
41
|
+
5) No inline disables
|
42
|
+
- Do not use comment-based disables (`# rubocop:disable ...`). This project forbids inline disables.
|
43
|
+
- If a finding cannot be safely fixed, pause and ask the maintainer for guidance. Options may include refining the rule in `.rubocop.yml` or adjusting the approach.
|
44
|
+
|
45
|
+
6) Validate and commit
|
46
|
+
- Ensure `rake` (tests + lint) is green.
|
47
|
+
- Commit messages must start with a GitHub `:emoji:` and use imperative mood.
|
48
|
+
Examples:
|
49
|
+
- `:lipstick: RuboCop: safe autocorrect in lib/tint_me/style.rb`
|
50
|
+
- `:rotating_light: RuboCop: fix Lint/UnusedMethodArgument`
|
51
|
+
|
52
|
+
## PR Guidance
|
53
|
+
- Keep changes small and single‑purpose (e.g., “fix Style/StringLiterals in lib/tint_me”).
|
54
|
+
- Include before/after snippets if unsafe autocorrect or refactor was applied.
|
55
|
+
- Link any rule changes to rationale and project conventions.
|
@@ -0,0 +1,259 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TIntMe
|
4
|
+
# Builds ANSI SGR (Select Graphic Rendition) escape sequences for terminal styling
|
5
|
+
#
|
6
|
+
# This class is responsible for generating the correct ANSI escape sequences
|
7
|
+
# for colors and text effects. It supports standard colors, bright colors,
|
8
|
+
# RGB colors, and various text decorations.
|
9
|
+
#
|
10
|
+
# @api private
|
11
|
+
class SGRBuilder
|
12
|
+
# ANSI escape sequence components
|
13
|
+
ESC = "\e"
|
14
|
+
public_constant :ESC
|
15
|
+
|
16
|
+
CSI = "#{ESC}[".freeze # Control Sequence Introducer
|
17
|
+
public_constant :CSI
|
18
|
+
|
19
|
+
SGR_END = "m"
|
20
|
+
public_constant :SGR_END
|
21
|
+
|
22
|
+
RESET_CODE = "#{CSI}0#{SGR_END}".freeze
|
23
|
+
public_constant :RESET_CODE
|
24
|
+
|
25
|
+
# Standard colors + bright colors
|
26
|
+
COLORS = {
|
27
|
+
black: 30,
|
28
|
+
red: 31,
|
29
|
+
green: 32,
|
30
|
+
yellow: 33,
|
31
|
+
blue: 34,
|
32
|
+
magenta: 35,
|
33
|
+
cyan: 36,
|
34
|
+
white: 37,
|
35
|
+
default: 39,
|
36
|
+
gray: 90,
|
37
|
+
# Bright colors (90-97)
|
38
|
+
bright_black: 90,
|
39
|
+
bright_red: 91,
|
40
|
+
bright_green: 92,
|
41
|
+
bright_yellow: 93,
|
42
|
+
bright_blue: 94,
|
43
|
+
bright_magenta: 95,
|
44
|
+
bright_cyan: 96,
|
45
|
+
bright_white: 97
|
46
|
+
}.freeze
|
47
|
+
public_constant :COLORS
|
48
|
+
|
49
|
+
# Effects mapping (SGR parameters)
|
50
|
+
EFFECTS = {
|
51
|
+
bold: 1,
|
52
|
+
faint: 2,
|
53
|
+
italic: 3,
|
54
|
+
underline: 4,
|
55
|
+
blink: 5,
|
56
|
+
inverse: 7,
|
57
|
+
conceal: 8,
|
58
|
+
overline: 53,
|
59
|
+
double_underline: 21
|
60
|
+
}.freeze
|
61
|
+
public_constant :EFFECTS
|
62
|
+
|
63
|
+
# Returns the singleton instance of SGRBuilder
|
64
|
+
# @return [SGRBuilder] The singleton instance
|
65
|
+
def self.instance
|
66
|
+
@instance ||= new
|
67
|
+
end
|
68
|
+
|
69
|
+
# Make new private to enforce singleton pattern
|
70
|
+
private_class_method :new
|
71
|
+
|
72
|
+
# Generates SGR prefix codes for the given styles
|
73
|
+
#
|
74
|
+
# Parameters are processed in this order for predictable output:
|
75
|
+
# 1. Foreground color (30-37, 90-97, or 38;2;r;g;b for RGB)
|
76
|
+
# 2. Background color (40-47, 100-107, or 48;2;r;g;b for RGB)
|
77
|
+
# 3. Text effects in numerical SGR code order:
|
78
|
+
# bold(1), faint(2), italic(3), underline(4), blink(5), inverse(7),
|
79
|
+
# conceal(8), double_underline(21), overline(53)
|
80
|
+
#
|
81
|
+
# @param foreground [Symbol, String, nil] Foreground color
|
82
|
+
# - Symbol: :red, :green, :blue, :yellow, :magenta, :cyan, :white, :black,
|
83
|
+
# :gray, :bright_red, :bright_green, etc.
|
84
|
+
# - String: Hex colors like "#FF0000", "FF0000", "#F00", "F00"
|
85
|
+
# @param background [Symbol, String, nil] Background color (same format as foreground)
|
86
|
+
# @param bold [Boolean, nil] Bold text effect
|
87
|
+
# @param faint [Boolean, nil] Faint text effect
|
88
|
+
# @param italic [Boolean, nil] Italic text effect
|
89
|
+
# @param underline [Boolean, Symbol, nil] Underline effect (true, :double, or nil)
|
90
|
+
# @param blink [Boolean, nil] Blink effect
|
91
|
+
# @param inverse [Boolean, nil] Inverse/reverse effect
|
92
|
+
# @param conceal [Boolean, nil] Conceal/hide effect
|
93
|
+
# @param overline [Boolean, nil] Overline effect
|
94
|
+
# @return [String] The SGR escape sequence string
|
95
|
+
#
|
96
|
+
# @example Standard colors
|
97
|
+
# prefix_codes(foreground: :red, bold: true)
|
98
|
+
# # => "\e[31;1m"
|
99
|
+
#
|
100
|
+
# @example 256-color RGB (true color)
|
101
|
+
# prefix_codes(foreground: "#FF6B35", background: "#F7931E")
|
102
|
+
# # => "\e[38;2;255;107;53;48;2;247;147;30m"
|
103
|
+
#
|
104
|
+
# @example Multiple effects with colors (colors first, then effects in numerical order)
|
105
|
+
# prefix_codes(foreground: :red, background: :blue, bold: true, italic: true)
|
106
|
+
# # => "\e[31;44;1;3m" (red=31, blue_bg=44, bold=1, italic=3)
|
107
|
+
#
|
108
|
+
# @example Double underline
|
109
|
+
# prefix_codes(foreground: :blue, underline: :double)
|
110
|
+
# # => "\e[34;21m"
|
111
|
+
def prefix_codes(
|
112
|
+
foreground: nil,
|
113
|
+
background: nil,
|
114
|
+
bold: nil,
|
115
|
+
faint: nil,
|
116
|
+
italic: nil,
|
117
|
+
underline: nil,
|
118
|
+
blink: nil,
|
119
|
+
inverse: nil,
|
120
|
+
conceal: nil,
|
121
|
+
overline: nil
|
122
|
+
)
|
123
|
+
parameters = build_parameters(
|
124
|
+
foreground:,
|
125
|
+
background:,
|
126
|
+
bold:,
|
127
|
+
faint:,
|
128
|
+
italic:,
|
129
|
+
underline:,
|
130
|
+
blink:,
|
131
|
+
inverse:,
|
132
|
+
conceal:,
|
133
|
+
overline:
|
134
|
+
)
|
135
|
+
build_sgr_sequence(parameters)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Returns the SGR reset code
|
139
|
+
# @return [String] The reset escape sequence
|
140
|
+
def reset_code
|
141
|
+
RESET_CODE
|
142
|
+
end
|
143
|
+
|
144
|
+
# Converts RGB values to SGR parameters
|
145
|
+
# @param red [Integer] Red component (0-255)
|
146
|
+
# @param green [Integer] Green component (0-255)
|
147
|
+
# @param blue [Integer] Blue component (0-255)
|
148
|
+
# @param background [Boolean] Whether this is for background color
|
149
|
+
# @return [String] The SGR parameter string for RGB color
|
150
|
+
private def rgb_to_sgr(red, green, blue, background: false)
|
151
|
+
base = background ? 48 : 38
|
152
|
+
"#{base};2;#{red};#{green};#{blue}"
|
153
|
+
end
|
154
|
+
|
155
|
+
private def build_parameters(
|
156
|
+
foreground:,
|
157
|
+
background:,
|
158
|
+
bold:,
|
159
|
+
faint:,
|
160
|
+
italic:,
|
161
|
+
underline:,
|
162
|
+
blink:,
|
163
|
+
inverse:,
|
164
|
+
conceal:,
|
165
|
+
overline:
|
166
|
+
)
|
167
|
+
parameters = []
|
168
|
+
|
169
|
+
# Process foreground color
|
170
|
+
parameters << process_foreground_color(foreground) if foreground
|
171
|
+
|
172
|
+
# Process background color
|
173
|
+
parameters << process_background_color(background) if background
|
174
|
+
|
175
|
+
# Collect effects and sort by SGR code for predictable order
|
176
|
+
effects = []
|
177
|
+
effects << EFFECTS[:bold] if bold == true
|
178
|
+
effects << EFFECTS[:faint] if faint == true
|
179
|
+
effects << EFFECTS[:italic] if italic == true
|
180
|
+
case underline
|
181
|
+
when true
|
182
|
+
effects << EFFECTS[:underline]
|
183
|
+
when :double
|
184
|
+
effects << EFFECTS[:double_underline]
|
185
|
+
when nil
|
186
|
+
# No underline
|
187
|
+
else
|
188
|
+
raise ArgumentError, "Invalid underline value: #{underline.inspect}"
|
189
|
+
end
|
190
|
+
effects << EFFECTS[:blink] if blink == true
|
191
|
+
effects << EFFECTS[:inverse] if inverse == true
|
192
|
+
effects << EFFECTS[:conceal] if conceal == true
|
193
|
+
effects << EFFECTS[:overline] if overline == true
|
194
|
+
|
195
|
+
parameters.concat(effects.sort)
|
196
|
+
end
|
197
|
+
|
198
|
+
private def process_foreground_color(foreground)
|
199
|
+
case foreground
|
200
|
+
when String
|
201
|
+
raise ArgumentError, "Invalid hex color: #{foreground}" unless hex_color?(foreground)
|
202
|
+
|
203
|
+
red, green, blue = hex_to_rgb(foreground)
|
204
|
+
rgb_to_sgr(red, green, blue, background: false)
|
205
|
+
when Symbol
|
206
|
+
raise ArgumentError, "Unknown foreground color: #{foreground}" unless COLORS.key?(foreground)
|
207
|
+
|
208
|
+
COLORS[foreground]
|
209
|
+
else
|
210
|
+
raise ArgumentError, "Invalid foreground type: #{foreground.class}"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
private def process_background_color(background)
|
215
|
+
case background
|
216
|
+
when String
|
217
|
+
raise ArgumentError, "Invalid hex color: #{background}" unless hex_color?(background)
|
218
|
+
|
219
|
+
red, green, blue = hex_to_rgb(background)
|
220
|
+
rgb_to_sgr(red, green, blue, background: true)
|
221
|
+
when Symbol
|
222
|
+
raise ArgumentError, "Unknown background color: #{background}" unless COLORS.key?(background)
|
223
|
+
|
224
|
+
color_code = COLORS[background]
|
225
|
+
# Convert to background color code
|
226
|
+
color_code += 10 if color_code.between?(30, 37)
|
227
|
+
color_code += 10 if color_code.between?(90, 97)
|
228
|
+
color_code
|
229
|
+
else
|
230
|
+
raise ArgumentError, "Invalid background type: #{background.class}"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
private def build_sgr_sequence(parameters)
|
235
|
+
return "" if parameters.empty?
|
236
|
+
|
237
|
+
"#{CSI}#{parameters.join(";")}#{SGR_END}"
|
238
|
+
end
|
239
|
+
|
240
|
+
private def hex_color?(value)
|
241
|
+
value.match?(/\A#?[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?\z/)
|
242
|
+
end
|
243
|
+
|
244
|
+
private def hex_to_rgb(hex)
|
245
|
+
# Remove # if present
|
246
|
+
hex = hex.delete_prefix("#")
|
247
|
+
|
248
|
+
# Expand 3-digit hex to 6-digit
|
249
|
+
hex = hex.chars.map {|c| c * 2 }.join if hex.length == 3
|
250
|
+
|
251
|
+
# Convert to RGB values
|
252
|
+
[
|
253
|
+
hex[0..1].to_i(16),
|
254
|
+
hex[2..3].to_i(16),
|
255
|
+
hex[4..5].to_i(16)
|
256
|
+
]
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-schema"
|
4
|
+
|
5
|
+
module TIntMe
|
6
|
+
class Style
|
7
|
+
# Schema for validating Style initialization arguments
|
8
|
+
Schema = Dry::Schema.define {
|
9
|
+
optional(:foreground).maybe(Types::Color)
|
10
|
+
optional(:background).maybe(Types::Color)
|
11
|
+
optional(:inverse).value(Types::BooleanOption)
|
12
|
+
optional(:bold).value(Types::BooleanOption)
|
13
|
+
optional(:faint).value(Types::BooleanOption)
|
14
|
+
optional(:underline).value(Types::UnderlineOption)
|
15
|
+
optional(:overline).value(Types::BooleanOption)
|
16
|
+
optional(:blink).value(Types::BooleanOption)
|
17
|
+
optional(:italic).value(Types::BooleanOption)
|
18
|
+
optional(:conceal).value(Types::BooleanOption)
|
19
|
+
}
|
20
|
+
private_constant :Schema
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-types"
|
4
|
+
|
5
|
+
module TIntMe
|
6
|
+
class Style
|
7
|
+
# Type definitions for Style attributes using dry-types
|
8
|
+
module Types
|
9
|
+
include Dry.Types()
|
10
|
+
|
11
|
+
# Standard ANSI color names + bright colors
|
12
|
+
ColorSymbol = Symbol.enum(
|
13
|
+
:default,
|
14
|
+
:reset,
|
15
|
+
:black,
|
16
|
+
:red,
|
17
|
+
:green,
|
18
|
+
:yellow,
|
19
|
+
:blue,
|
20
|
+
:magenta,
|
21
|
+
:cyan,
|
22
|
+
:white,
|
23
|
+
:gray,
|
24
|
+
# Bright colors (90-97)
|
25
|
+
:bright_black,
|
26
|
+
:bright_red,
|
27
|
+
:bright_green,
|
28
|
+
:bright_yellow,
|
29
|
+
:bright_blue,
|
30
|
+
:bright_magenta,
|
31
|
+
:bright_cyan,
|
32
|
+
:bright_white
|
33
|
+
)
|
34
|
+
public_constant :ColorSymbol
|
35
|
+
|
36
|
+
# Hex color strings (3 or 6 digits, with or without #)
|
37
|
+
ColorString = String.constrained(format: /\A#?\h{3}(?:\h{3})?\z/)
|
38
|
+
public_constant :ColorString
|
39
|
+
|
40
|
+
Color = ColorSymbol | ColorString
|
41
|
+
public_constant :Color
|
42
|
+
|
43
|
+
BooleanOption = (Bool | Symbol.enum(:reset)).optional
|
44
|
+
public_constant :BooleanOption
|
45
|
+
|
46
|
+
UnderlineOption = (Bool | Symbol.enum(:double, :reset)).optional
|
47
|
+
public_constant :UnderlineOption
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,286 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TIntMe
|
4
|
+
Style = Data.define(
|
5
|
+
:foreground,
|
6
|
+
:background,
|
7
|
+
:inverse,
|
8
|
+
:bold,
|
9
|
+
:faint,
|
10
|
+
:underline,
|
11
|
+
:overline,
|
12
|
+
:blink,
|
13
|
+
:italic,
|
14
|
+
:conceal
|
15
|
+
)
|
16
|
+
|
17
|
+
# A style class for applying ANSI colors and text effects to terminal output.
|
18
|
+
#
|
19
|
+
# This class provides an immutable way to define and compose terminal styling options.
|
20
|
+
# It supports foreground/background colors, text decorations (bold, italic, underline, etc.),
|
21
|
+
# and composition via the >> operator for layering styles.
|
22
|
+
#
|
23
|
+
# @example Basic usage
|
24
|
+
# style = Style.new(foreground: :red, bold: true)
|
25
|
+
# puts style.call("Hello") # or style["Hello"] or style.("Hello")
|
26
|
+
#
|
27
|
+
# @example Style composition
|
28
|
+
# base = Style.new(foreground: :blue)
|
29
|
+
# emphasis = Style.new(bold: true, underline: true)
|
30
|
+
# combined = base >> emphasis
|
31
|
+
# puts combined.call("Styled text") # or combined["Styled text"] or combined.("Styled text")
|
32
|
+
#
|
33
|
+
# @example Color and decoration options
|
34
|
+
# Style.new(
|
35
|
+
# foreground: :red, # :default, :red, :green, :blue, etc. or hex "#FF0000"
|
36
|
+
# background: :yellow, # same as foreground
|
37
|
+
# bold: true, # nil (unset), false (off), true (on)
|
38
|
+
# faint: false, # mutually exclusive with bold
|
39
|
+
# underline: :double, # nil, false, true, :double
|
40
|
+
# italic: true, # nil, false, true
|
41
|
+
# inverse: true, # nil, false, true
|
42
|
+
# # ... other boolean effects: overline, blink, conceal
|
43
|
+
# )
|
44
|
+
class Style
|
45
|
+
# Returns the singleton instance of SGRBuilder
|
46
|
+
# @return [SGRBuilder] The SGR builder instance
|
47
|
+
# @api private
|
48
|
+
def self.sgr_builder
|
49
|
+
SGRBuilder.instance
|
50
|
+
end
|
51
|
+
# @!attribute [r] foreground
|
52
|
+
# @return [Symbol, String] The foreground color (:red, :blue, :default, :reset, hex "#FF0000", etc.)
|
53
|
+
|
54
|
+
# @!attribute [r] background
|
55
|
+
# @return [Symbol, String] The background color (same format as foreground)
|
56
|
+
|
57
|
+
# @!attribute [r] inverse
|
58
|
+
# @return [nil, true, false, :reset] Whether to reverse foreground/background colors
|
59
|
+
|
60
|
+
# @!attribute [r] bold
|
61
|
+
# @return [nil, true, false, :reset] Whether text is bold (mutually exclusive with faint)
|
62
|
+
|
63
|
+
# @!attribute [r] faint
|
64
|
+
# @return [nil, true, false, :reset] Whether text is faint/dim (mutually exclusive with bold)
|
65
|
+
|
66
|
+
# @!attribute [r] underline
|
67
|
+
# @return [nil, true, false, :double, :reset] Underline decoration type
|
68
|
+
|
69
|
+
# @!attribute [r] overline
|
70
|
+
# @return [nil, true, false, :reset] Whether text has overline decoration
|
71
|
+
|
72
|
+
# @!attribute [r] blink
|
73
|
+
# @return [nil, true, false, :reset] Whether text blinks
|
74
|
+
|
75
|
+
# @!attribute [r] italic
|
76
|
+
# @return [nil, true, false, :reset] Whether text is italic
|
77
|
+
|
78
|
+
# @!attribute [r] conceal
|
79
|
+
# @return [nil, true, false, :reset] Whether text is hidden/concealed
|
80
|
+
|
81
|
+
# Initialize a new Style with the given attributes
|
82
|
+
#
|
83
|
+
# @param foreground [Symbol, String] Foreground color. Accepts color names (:red, :green, :blue, etc.),
|
84
|
+
# :default for terminal default, :reset for composition clearing, or hex strings ("#FF0000", "FF0000")
|
85
|
+
# @param background [Symbol, String] Background color. Same format as foreground
|
86
|
+
# @param inverse [nil, true, false, :reset] Reverse foreground/background colors
|
87
|
+
# @param bold [nil, true, false, :reset] Bold text (mutually exclusive with faint)
|
88
|
+
# @param faint [nil, true, false, :reset] Faint/dim text (mutually exclusive with bold)
|
89
|
+
# @param underline [nil, true, false, :double, :reset] Underline decoration
|
90
|
+
# @param overline [nil, true, false, :reset] Overline decoration
|
91
|
+
# @param blink [nil, true, false, :reset] Blinking text
|
92
|
+
# @param italic [nil, true, false, :reset] Italic text
|
93
|
+
# @param conceal [nil, true, false, :reset] Hidden/concealed text
|
94
|
+
# @raise [ArgumentError] If both bold and faint are true
|
95
|
+
# @raise [ArgumentError] If any parameter has invalid type or value
|
96
|
+
# @example Valid usage
|
97
|
+
# Style.new(foreground: :red, bold: true)
|
98
|
+
# Style.new(underline: :double, background: "#FF0000")
|
99
|
+
# @example Invalid usage (raises ArgumentError)
|
100
|
+
# Style.new(foreground: 123) # Invalid color type
|
101
|
+
# Style.new(bold: "true") # Invalid boolean type
|
102
|
+
# Style.new(underline: :invalid) # Invalid underline option
|
103
|
+
# Style.new(bold: true, faint: true) # Mutually exclusive options
|
104
|
+
def initialize(
|
105
|
+
foreground: nil,
|
106
|
+
background: nil,
|
107
|
+
inverse: nil,
|
108
|
+
bold: nil,
|
109
|
+
faint: nil,
|
110
|
+
underline: nil,
|
111
|
+
overline: nil,
|
112
|
+
blink: nil,
|
113
|
+
italic: nil,
|
114
|
+
conceal: nil
|
115
|
+
)
|
116
|
+
# Schema validation
|
117
|
+
result = Schema.call({
|
118
|
+
foreground:,
|
119
|
+
background:,
|
120
|
+
inverse:,
|
121
|
+
bold:,
|
122
|
+
faint:,
|
123
|
+
underline:,
|
124
|
+
overline:,
|
125
|
+
blink:,
|
126
|
+
italic:,
|
127
|
+
conceal:
|
128
|
+
})
|
129
|
+
|
130
|
+
raise ArgumentError, result.errors.to_h unless result.success?
|
131
|
+
|
132
|
+
# Handle bold/faint mutual exclusion
|
133
|
+
if bold && faint
|
134
|
+
raise ArgumentError, "Cannot specify both bold and faint simultaneously"
|
135
|
+
end
|
136
|
+
|
137
|
+
# Pre-compute SGR sequences before freezing
|
138
|
+
# (Data.define freezes the instance after super)
|
139
|
+
sgr_builder = self.class.sgr_builder
|
140
|
+
|
141
|
+
# Prepare color values
|
142
|
+
foreground_color = foreground if foreground && foreground != :default
|
143
|
+
background_color = background if background && background != :default
|
144
|
+
|
145
|
+
# Handle underline effect
|
146
|
+
underline_effect = case underline
|
147
|
+
when true then true
|
148
|
+
when :double then :double
|
149
|
+
when nil, false then nil
|
150
|
+
else raise ArgumentError, "Invalid underline value: #{underline.inspect}"
|
151
|
+
end
|
152
|
+
|
153
|
+
# Calculate prefix once
|
154
|
+
@prefix = sgr_builder.prefix_codes(
|
155
|
+
foreground: foreground_color,
|
156
|
+
background: background_color,
|
157
|
+
bold: bold == true ? true : nil,
|
158
|
+
faint: faint == true ? true : nil,
|
159
|
+
italic: italic == true ? true : nil,
|
160
|
+
underline: underline_effect,
|
161
|
+
blink: blink == true ? true : nil,
|
162
|
+
inverse: inverse == true ? true : nil,
|
163
|
+
conceal: conceal == true ? true : nil,
|
164
|
+
overline: overline == true ? true : nil
|
165
|
+
)
|
166
|
+
|
167
|
+
# Pre-compute reset code
|
168
|
+
@reset_code = sgr_builder.reset_code
|
169
|
+
|
170
|
+
super
|
171
|
+
end
|
172
|
+
|
173
|
+
# Apply the style to the given text using native ANSI escape sequences
|
174
|
+
#
|
175
|
+
# @param text [String] The text to apply styling to
|
176
|
+
# @return [String] The styled text with ANSI escape codes, or original text if no styles are set
|
177
|
+
# @example
|
178
|
+
# style = Style.new(foreground: :red, bold: true)
|
179
|
+
# style.call("Hello") # => "\e[31;1mHello\e[0m"
|
180
|
+
# style["World"] # => "\e[31;1mWorld\e[0m" (alias)
|
181
|
+
def call(text)
|
182
|
+
return text if @prefix.empty?
|
183
|
+
|
184
|
+
"#{@prefix}#{text}#{@reset_code}"
|
185
|
+
end
|
186
|
+
|
187
|
+
alias [] call
|
188
|
+
|
189
|
+
# Compose this style with another style, creating a new Style instance.
|
190
|
+
# The right-hand style takes precedence for non-nil values.
|
191
|
+
# Handles bold/faint mutual exclusion automatically.
|
192
|
+
#
|
193
|
+
# == Composition Rules
|
194
|
+
#
|
195
|
+
# For `a >> b`, the resulting value for each attribute is determined by:
|
196
|
+
#
|
197
|
+
# <table>
|
198
|
+
# <tr>
|
199
|
+
# <th>b's value</th>
|
200
|
+
# <th>Result</th>
|
201
|
+
# <th>Description</th>
|
202
|
+
# </tr>
|
203
|
+
# <tr>
|
204
|
+
# <td><code>nil</code></td>
|
205
|
+
# <td><code>a</code>'s value</td>
|
206
|
+
# <td>Preserves the original value</td>
|
207
|
+
# </tr>
|
208
|
+
# <tr>
|
209
|
+
# <td><code>:reset</code></td>
|
210
|
+
# <td><code>nil</code></td>
|
211
|
+
# <td>Explicitly resets to no styling</td>
|
212
|
+
# </tr>
|
213
|
+
# <tr>
|
214
|
+
# <td>any other</td>
|
215
|
+
# <td><code>b</code>'s value</td>
|
216
|
+
# <td>Adopts the new value</td>
|
217
|
+
# </tr>
|
218
|
+
# </table>
|
219
|
+
#
|
220
|
+
# This applies to all attributes: colors (foreground, background) and
|
221
|
+
# style attributes (bold, italic, underline, inverse, overline, blink, conceal).
|
222
|
+
#
|
223
|
+
# @param other [Style] The style to compose with this one
|
224
|
+
# @return [Style] A new Style instance with composed attributes
|
225
|
+
#
|
226
|
+
# @example Basic composition
|
227
|
+
# base = Style.new(foreground: :red, bold: true)
|
228
|
+
# overlay = Style.new(background: :blue, underline: true)
|
229
|
+
# result = base >> overlay # => red text, blue background, bold and underlined
|
230
|
+
#
|
231
|
+
# @example Using nil to preserve values
|
232
|
+
# styled = Style.new(foreground: :red, bold: true)
|
233
|
+
# partial = Style.new(foreground: nil, italic: true) # nil preserves red
|
234
|
+
# result = styled >> partial # => foreground: :red, bold: true, italic: true
|
235
|
+
#
|
236
|
+
# @example Using :reset to clear styles
|
237
|
+
# styled = Style.new(foreground: :red, bold: true)
|
238
|
+
# reset = Style.new(foreground: :reset, bold: :reset)
|
239
|
+
# result = styled >> reset # => foreground: nil, bold: nil
|
240
|
+
#
|
241
|
+
# @example Bold/faint mutual exclusion
|
242
|
+
# bold_style = Style.new(bold: true)
|
243
|
+
# faint_style = Style.new(faint: true)
|
244
|
+
# result1 = bold_style >> faint_style # => faint wins (right-hand takes precedence)
|
245
|
+
# result2 = faint_style >> bold_style # => bold wins (right-hand takes precedence)
|
246
|
+
def >>(other)
|
247
|
+
# Handle bold/faint mutual exclusion in composition
|
248
|
+
composed_bold = compose_attribute(bold, other.bold)
|
249
|
+
composed_faint = compose_attribute(faint, other.faint)
|
250
|
+
|
251
|
+
# If other explicitly sets bold, clear faint; if other explicitly sets faint, clear bold
|
252
|
+
if other.bold == true
|
253
|
+
composed_faint = false
|
254
|
+
elsif other.faint == true
|
255
|
+
composed_bold = false
|
256
|
+
elsif other.bold == :reset
|
257
|
+
composed_bold = nil
|
258
|
+
elsif other.faint == :reset
|
259
|
+
composed_faint = nil
|
260
|
+
end
|
261
|
+
|
262
|
+
Style.new(
|
263
|
+
foreground: compose_attribute(foreground, other.foreground),
|
264
|
+
background: compose_attribute(background, other.background),
|
265
|
+
inverse: compose_attribute(inverse, other.inverse),
|
266
|
+
bold: composed_bold,
|
267
|
+
faint: composed_faint,
|
268
|
+
underline: compose_attribute(underline, other.underline),
|
269
|
+
overline: compose_attribute(overline, other.overline),
|
270
|
+
blink: compose_attribute(blink, other.blink),
|
271
|
+
italic: compose_attribute(italic, other.italic),
|
272
|
+
conceal: compose_attribute(conceal, other.conceal)
|
273
|
+
)
|
274
|
+
end
|
275
|
+
|
276
|
+
private def compose_attribute(current, other)
|
277
|
+
if other.nil?
|
278
|
+
current
|
279
|
+
elsif other == :reset
|
280
|
+
nil
|
281
|
+
else
|
282
|
+
other
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|