sai 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1856d33db7e7b71a3ad164df38fc64a732e806540584e5e207ed6ee6220d0e07
4
+ data.tar.gz: f2aa1b1182b297dcdd14c97b01928e33b56e2bee79268e3f344ea4df315a338f
5
+ SHA512:
6
+ metadata.gz: 318ead79460d995f10d20a031d8d9be16277bd5180d5663f32ba7a0f1aac3dbdfa5f7e7fba6c71c62662cbf9486db35b7cb95e6562ff3df9860cd837f2ca9125
7
+ data.tar.gz: a82a2ce509d1d6159cbac457d788e48bf88bb07498449beffedbf1fb10aa08a2e614c375f0af3207a22b4b6b32fe0b595137e6b36276f011e11489a786ec51af
data/.yardopts ADDED
@@ -0,0 +1,11 @@
1
+ lib/**/*.rb
2
+ --title Sai
3
+ --readme README.md
4
+ --no-private
5
+ --protected
6
+ --markup markdown
7
+ --markup-provider redcarpet
8
+ --embed-mixins
9
+ --tag rbs
10
+ --hide-tag rbs
11
+ --files LICENSE
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog], and this project adheres to [Break Versioning].
6
+
7
+ ## [Unreleased]
8
+
9
+ ## 0.1.0 - 2025-01-19
10
+
11
+ * Initial release
12
+
13
+ [Keep a Changelog]: https://keepachangelog.com/en/1.0.0/
14
+ [Break Versioning]: https://www.taoensso.com/break-versioning
15
+
16
+ <!-- versions -->
17
+
18
+ [Unreleased]: https://github.com/aaronmallen/sai/compare/0.1.0..HEAD
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Aaron Allen
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # Sai
2
+
3
+ [![Sai Version](https://img.shields.io/gem/v/sai?style=for-the-badge&logo=rubygems&logoColor=white&logoSize=auto&label=Gem%20Version)](https://rubygems.org/gems/sai)
4
+ [![Sai License](https://img.shields.io/github/license/aaronmallen/sai?style=for-the-badge&logo=opensourceinitiative&logoColor=white&logoSize=auto)](./LICENSE)
5
+ [![Sai Docs](https://img.shields.io/badge/rubydoc-blue?style=for-the-badge&logo=readthedocs&logoColor=white&logoSize=auto&label=docs)](https://rubydoc.info/gems/sai/0.1.0)
6
+ [![Sai Open Issues](https://img.shields.io/github/issues-search/aaronmallen/sai?query=state%3Aopen&style=for-the-badge&logo=github&logoColor=white&logoSize=auto&label=issues&color=red)](https://github.com/aaronmallen/sai/issues?q=state%3Aopen%20)
7
+
8
+ An elegant color management system for crafting sophisticated CLI applications
9
+
10
+ ```ruby
11
+ puts Sai.rgb(255, 128, 0).bold.decorate('Create beautiful CLI applications')
12
+ puts Sai.hex('#4834d4').italic.decorate('with intuitive color management')
13
+ puts Sai.bright_cyan.on_blue.underline.decorate('that adapts to any terminal')
14
+ ```
15
+
16
+ Sai (彩) - meaning 'coloring' or 'paint' in Japanese - is a powerful and intuitive system for managing color output in
17
+ command-line applications. Drawing inspiration from traditional Japanese artistic techniques, Sai brings vibrancy and
18
+ harmony to terminal interfaces through its sophisticated color management.
19
+
20
+ Sai empowers developers to create beautiful, colorful CLI applications that maintain visual consistency across different
21
+ terminal capabilities. Like its artistic namesake, it combines simplicity and sophistication to bring rich, adaptive
22
+ color to your terminal interfaces.
23
+
24
+ ## Features
25
+
26
+ * Automatic color mode detection and downgrading
27
+ * Support for True Color (24-bit), 256 colors (8-bit), ANSI colors (4-bit), and basic colors (3-bit)
28
+ * Rich set of ANSI text styles
29
+ * Named color support with bright variants
30
+ * RGB and Hex color support
31
+ * Foreground and background colors
32
+ * Respects NO_COLOR environment variable
33
+ * Can be used directly or included in classes/modules
34
+
35
+ ## Installation
36
+
37
+ Add this line to your application's Gemfile:
38
+
39
+ ```ruby
40
+ gem 'sai'
41
+ ```
42
+
43
+ Or install it yourself as:
44
+
45
+ ```bash
46
+ gem install sai
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ Sai can be used directly or included in your own classes and modules.
52
+
53
+ ### Direct Usage
54
+
55
+ ```ruby
56
+ require 'sai'
57
+
58
+ # Using named colors
59
+ puts Sai.red.decorate('Error!')
60
+ puts Sai.bright_blue.on_white.decorate('Info')
61
+
62
+ # Using RGB colors
63
+ puts Sai.rgb(255, 128, 0).decorate('Custom color')
64
+ puts Sai.on_rgb(0, 255, 128).decorate('Custom background')
65
+
66
+ # Using hex colors
67
+ puts Sai.hex('#FF8000').decorate('Hex color')
68
+ puts Sai.on_hex('#00FF80').decorate('Hex background')
69
+
70
+ # Applying styles
71
+ puts Sai.bold.underline.decorate('Important')
72
+ puts Sai.red.bold.italic.decorate('Error!')
73
+
74
+ # Complex combinations
75
+ puts Sai.bright_cyan
76
+ .on_blue
77
+ .bold
78
+ .italic
79
+ .decorate('Styled text')
80
+ ```
81
+
82
+ ### Module Inclusion
83
+
84
+ ```ruby
85
+ class CLI
86
+ include Sai
87
+
88
+ def error(message)
89
+ puts decorator.red.bold.decorate(message)
90
+ end
91
+
92
+ def info(message)
93
+ puts decorator.bright_blue.decorate(message)
94
+ end
95
+
96
+ def success(message)
97
+ puts decorator.green.decorate(message)
98
+ end
99
+ end
100
+
101
+ cli = CLI.new
102
+ cli.error('Something went wrong!')
103
+ cli.info('Processing...')
104
+ cli.success('Done!')
105
+ ```
106
+
107
+ ### Defining Reusable Styles
108
+
109
+ Sai decorators can be assigned to constants to create reusable styles throughout your application:
110
+
111
+ ```ruby
112
+ module Style
113
+ ERROR = Sai.bold.bright_white.on_red
114
+ WARNING = Sai.black.on_yellow
115
+ SUCCESS = Sai.bright_green
116
+ INFO = Sai.bright_blue
117
+ HEADER = Sai.bold.bright_cyan.underline
118
+ end
119
+
120
+ # Use your defined styles
121
+ puts Style::ERROR.decorate('Something went wrong!')
122
+ puts Style::SUCCESS.decorate('Operation completed successfully')
123
+
124
+ # Styles can be further customized when needed
125
+ puts Style::ERROR.italic.decorate('Critical error!')
126
+ ```
127
+
128
+ > [!TIP]
129
+ > This pattern is particularly useful for maintaining consistent styling across your application and creating theme
130
+ > systems. You can even build on existing styles:
131
+ >
132
+ > ```ruby
133
+ > module Theme
134
+ > PRIMARY = Sai.rgb(63, 81, 181)
135
+ > SECONDARY = Sai.rgb(255, 87, 34)
136
+ >
137
+ > BUTTON = PRIMARY.bold
138
+ > LINK = SECONDARY.underline
139
+ > HEADER = PRIMARY.bold.underline
140
+ > end
141
+ > ```
142
+
143
+ ## Features
144
+
145
+ ### Color Support
146
+
147
+ Sai supports up to 16.7 million colors (true color/24-bit) depending on your terminal's capabilities. Colors can be
148
+ specified in several ways:
149
+
150
+ #### RGB Colors
151
+
152
+ ```ruby
153
+ # Specify any RGB color (0-255 per channel)
154
+ Sai.rgb(255, 128, 0).decorate('Orange text')
155
+ Sai.on_rgb(0, 255, 128).decorate('Custom green background')
156
+ ```
157
+
158
+ #### Hex Colors
159
+
160
+ ```ruby
161
+ # Use any hex color code
162
+ Sai.hex('#FF8000').decorate('Orange text')
163
+ Sai.on_hex('#00FF80').decorate('Custom green background')
164
+ ```
165
+
166
+ #### Named Color Shortcuts
167
+
168
+ For convenience, Sai provides shortcuts for common ANSI colors:
169
+
170
+ Standard colors:
171
+
172
+ ```ruby
173
+ Sai.red.decorate('Red text')
174
+ Sai.blue.decorate('Blue text')
175
+ Sai.on_green.decorate('Green background')
176
+ ```
177
+
178
+ Bright variants:
179
+
180
+ ```ruby
181
+ Sai.bright_red.decorate('Bright red text')
182
+ Sai.bright_blue.decorate('Bright blue text')
183
+ Sai.on_bright_green.decorate('Bright green background')
184
+ ```
185
+
186
+ Available named colors:
187
+
188
+ * black/bright_black
189
+ * red/bright_red
190
+ * green/bright_green
191
+ * yellow/bright_yellow
192
+ * blue/bright_blue
193
+ * magenta/bright_magenta
194
+ * cyan/bright_cyan
195
+ * white/bright_white
196
+
197
+ > [!TIP]
198
+ > While named colors provide convenient shortcuts, remember that Sai supports the full RGB color space. Don't feel
199
+ > limited to just these predefined colors!
200
+
201
+ ### Text Styles
202
+
203
+ Available styles:
204
+
205
+ * bold
206
+ * dim
207
+ * italic
208
+ * underline
209
+ * blink
210
+ * rapid_blink
211
+ * reverse
212
+ * conceal
213
+ * strike
214
+
215
+ Style removal:
216
+
217
+ * no_blink
218
+ * no_italic
219
+ * no_underline
220
+ * no_reverse
221
+ * no_conceal
222
+ * no_strike
223
+ * normal_intensity
224
+
225
+ ### Color Mode Detection & Downgrading
226
+
227
+ Sai automatically detects your terminal's color capabilities and adapts the output appropriately:
228
+
229
+ * True Color terminals (24-bit): Full access to all 16.7 million colors
230
+ * 256-color terminals (8-bit): Colors are mapped to the closest available color in the 256-color palette
231
+ * ANSI terminals (4-bit): Colors are mapped to the 16 standard ANSI colors
232
+ * Basic terminals (3-bit): Colors are mapped to the 8 basic ANSI colors
233
+ * NO_COLOR: All color sequences are stripped when the NO_COLOR environment variable is set
234
+
235
+ > [!NOTE]
236
+ > This automatic downgrading ensures your application looks great across all terminal types without any extra code!
237
+
238
+ ### Terminal Support Detection
239
+
240
+ You can check the terminal's capabilities:
241
+
242
+ ```ruby
243
+ # Using directly
244
+ Sai.support.true_color? # => true/false
245
+ Sai.support.bit8? # => true/false
246
+ Sai.support.ansi? # => true/false
247
+ Sai.support.basic? # => true/false
248
+ Sai.support.color? # => true/false
249
+
250
+ # Using included module
251
+ class CLI
252
+ include Sai
253
+
254
+ def check_support
255
+ if terminal_color_support.true_color?
256
+ puts "Terminal supports true color!"
257
+ end
258
+ end
259
+ end
260
+ ```
261
+
262
+ ## Contributing
263
+
264
+ We welcome contributions! Please see our [Contributing Guidelines](docs/CONTRIBUTING.md) for:
265
+
266
+ * Development setup and workflow
267
+ * Code style and documentation standards
268
+ * Testing requirements
269
+ * Pull request process
270
+
271
+ Before contributing, please review our [Code of Conduct](docs/CODE_OF_CONDUCT.md).
272
+
273
+ ## License
274
+
275
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
data/lib/sai/ansi.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sai
4
+ # ANSI constants for encoding text styles and colors
5
+ #
6
+ # @author {https://aaronmallen.me Aaron Allen}
7
+ # @since unreleased
8
+ #
9
+ # @api private
10
+ module ANSI
11
+ # ANSI color code mappings
12
+ #
13
+ # @author {https://aaronmallen.me Aaron Allen}
14
+ # @since unreleased
15
+ #
16
+ # @api private
17
+ #
18
+ # @return [Hash{Symbol => Integer}] the color codes
19
+ COLOR_CODES = {
20
+ black: 0,
21
+ red: 1,
22
+ green: 2,
23
+ yellow: 3,
24
+ blue: 4,
25
+ magenta: 5,
26
+ cyan: 6,
27
+ white: 7
28
+ }.freeze # Hash[Symbol, Integer]
29
+
30
+ # Standard ANSI color names and their RGB values
31
+ #
32
+ # @author {https://aaronmallen.me Aaron Allen}
33
+ # @since unreleased
34
+ #
35
+ # @api private
36
+ #
37
+ # @return [Hash{Symbol => Array<Integer>}] the color names and RGB values
38
+ COLOR_NAMES = {
39
+ black: [0, 0, 0],
40
+ red: [205, 0, 0],
41
+ green: [0, 205, 0],
42
+ yellow: [205, 205, 0],
43
+ blue: [0, 0, 238],
44
+ magenta: [205, 0, 205],
45
+ cyan: [0, 205, 205],
46
+ white: [229, 229, 229],
47
+ bright_black: [127, 127, 127],
48
+ bright_red: [255, 0, 0],
49
+ bright_green: [0, 255, 0],
50
+ bright_yellow: [255, 255, 0],
51
+ bright_blue: [92, 92, 255],
52
+ bright_magenta: [255, 0, 255],
53
+ bright_cyan: [0, 255, 255],
54
+ bright_white: [255, 255, 255]
55
+ }.freeze # Hash[Symbol, Array[Integer]]
56
+
57
+ # ANSI escape sequence for resetting text formatting
58
+ #
59
+ # @author {https://aaronmallen.me Aaron Allen}
60
+ # @since unreleased
61
+ #
62
+ # @api private
63
+ #
64
+ # @return [String] the ANSI escape sequence
65
+ RESET = "\e[0m" # String
66
+
67
+ # Standard ANSI style codes
68
+ #
69
+ # @author {https://aaronmallen.me Aaron Allen}
70
+ # @since unreleased
71
+ #
72
+ # @api private
73
+ #
74
+ # @return [Hash{Symbol => Integer}] the style codes
75
+ STYLES = {
76
+ bold: 1,
77
+ dim: 2,
78
+ italic: 3,
79
+ underline: 4,
80
+ blink: 5,
81
+ rapid_blink: 6,
82
+ reverse: 7,
83
+ conceal: 8,
84
+ strike: 9,
85
+ normal_intensity: 22,
86
+ no_italic: 23,
87
+ no_underline: 24,
88
+ no_blink: 25,
89
+ no_reverse: 27,
90
+ no_conceal: 28,
91
+ no_strike: 29
92
+ }.freeze # Hash[Symbol, Integer]
93
+ end
94
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/ansi'
4
+ require 'sai/conversion/rgb'
5
+ require 'sai/terminal/color_mode'
6
+
7
+ module Sai
8
+ module Conversion
9
+ # ANSI escape sequence utilities
10
+ #
11
+ # @author {https://aaronmallen.me Aaron Allen}
12
+ # @since unreleased
13
+ #
14
+ # @api private
15
+ module ColorSequence
16
+ # @rbs!
17
+ # type style_type = :foreground | :background
18
+
19
+ class << self
20
+ # Convert a color to the appropriate ANSI escape sequence
21
+ #
22
+ # @author {https://aaronmallen.me Aaron Allen}
23
+ # @since unreleased
24
+ #
25
+ # @api private
26
+ #
27
+ # @param color [String, Array<Integer>] the color to convert
28
+ # @param mode [Integer] the terminal color mode
29
+ # @param style_type [Symbol] the type of color (foreground or background)
30
+ #
31
+ # @return [String] the ANSI escape sequence
32
+ # @rbs (Array[Integer] | String | Symbol color, Integer mode, ?style_type style_type) -> String
33
+ def resolve(color, mode, style_type = :foreground)
34
+ rgb = RGB.resolve(color)
35
+ style_type = validate_style_type(style_type) #: style_type
36
+
37
+ case mode
38
+ when Terminal::ColorMode::TRUE_COLOR then true_color(rgb, style_type)
39
+ when Terminal::ColorMode::BIT8 then bit8(rgb, style_type)
40
+ when Terminal::ColorMode::ANSI then ansi(rgb, style_type)
41
+ when Terminal::ColorMode::BASIC then basic(rgb, style_type)
42
+ else
43
+ ''
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Convert RGB values to a 4-bit ANSI color sequence
50
+ #
51
+ # @author {https://aaronmallen.me Aaron Allen}
52
+ # @since unreleased
53
+ #
54
+ # @api private
55
+ #
56
+ # @param rgb [Array<Integer>] the RGB components
57
+ # @param style_type [Symbol] the type of color (foreground or background)
58
+ #
59
+ # @return [String] the ANSI escape sequence
60
+ # @rbs (Array[Integer] rgb, style_type style_type) -> String
61
+ def ansi(rgb, style_type)
62
+ r, g, b = rgb.map { |c| c / 255.0 } #: [Float, Float, Float]
63
+ brightness = (r + g + b) / 3.0
64
+ is_bright = brightness > 0.5
65
+
66
+ color = RGB.closest_ansi_color(r, g, b)
67
+ code = base_color_for_style_type(ANSI::COLOR_CODES[color], style_type)
68
+ code += 60 if is_bright
69
+ "\e[#{code}m"
70
+ end
71
+
72
+ # Convert a base color to a foreground or background sequence
73
+ #
74
+ # @author {https://aaronmallen.me Aaron Allen}
75
+ # @since unreleased
76
+ #
77
+ # @api private
78
+ #
79
+ # @param base_code [Integer] the base color code
80
+ # @param style_type [Symbol] the type of color (foreground or background)
81
+ #
82
+ # @return [Integer] the code for the color sequence
83
+ # @rbs (Integer base_code, style_type style_type) -> Integer
84
+ def base_color_for_style_type(base_code, style_type)
85
+ style_type == :background ? base_code + 40 : base_code + 30
86
+ end
87
+
88
+ # Convert RGB values to a 3-bit basic color sequence
89
+ #
90
+ # @author {https://aaronmallen.me Aaron Allen}
91
+ # @since unreleased
92
+ #
93
+ # @api private
94
+ #
95
+ # @param rgb [Array<Integer>] the RGB components
96
+ # @param style_type [Symbol] the type of color (foreground or background)
97
+ #
98
+ # @return [String] the ANSI escape sequence
99
+ # @rbs (Array[Integer] rgb, style_type style_type) -> String
100
+ def basic(rgb, style_type)
101
+ r, g, b = rgb.map { |c| c / 255.0 } #: [Float, Float, Float]
102
+ color = RGB.closest_ansi_color(r, g, b)
103
+ code = base_color_for_style_type(ANSI::COLOR_CODES[color], style_type)
104
+ "\e[#{code}m"
105
+ end
106
+
107
+ # Convert RGB values to an 8-bit color sequence
108
+ #
109
+ # @author {https://aaronmallen.me Aaron Allen}
110
+ # @since unreleased
111
+ #
112
+ # @api private
113
+ #
114
+ # @param rgb [Array<Integer>] the RGB components
115
+ # @param style_type [Symbol] the type of color (foreground or background)
116
+ #
117
+ # @return [String] the ANSI escape sequence
118
+ # @rbs (Array[Integer] rgb, style_type type) -> String
119
+ def bit8(rgb, style_type)
120
+ code = style_type == :background ? 48 : 38
121
+ color_code = if rgb.uniq.size == 1
122
+ RGB.to_grayscale_index(rgb)
123
+ else
124
+ RGB.to_color_cube_index(rgb)
125
+ end
126
+
127
+ "\e[#{code};5;#{color_code}m"
128
+ end
129
+
130
+ # Convert RGB values to a true color (24-bit) sequence
131
+ #
132
+ # @author {https://aaronmallen.me Aaron Allen}
133
+ # @since unreleased
134
+ #
135
+ # @api private
136
+ #
137
+ # @param rgb [Array<Integer>] the RGB components
138
+ # @param style_type [Symbol] the type of color (foreground or background)
139
+ #
140
+ # @return [String] the ANSI escape sequence
141
+ # @rbs (Array[Integer] rgb, style_type type) -> String
142
+ def true_color(rgb, style_type)
143
+ code = style_type == :background ? 48 : 38
144
+ "\e[#{code};2;#{rgb.join(';')}m"
145
+ end
146
+
147
+ # Validate a color style type
148
+ #
149
+ # @author {https://aaronmallen.me Aaron Allen}
150
+ # @since unreleased
151
+ #
152
+ # @api private
153
+ #
154
+ # @param style_type [Symbol] the style type to validate
155
+ #
156
+ # @raise [ArgumentError] if the style type is invalid
157
+ # @return [Symbol] the validated style type
158
+ # @rbs (Symbol style_type) -> Symbol
159
+ def validate_style_type(style_type)
160
+ raise ArgumentError, "Invalid style type: #{style_type}" unless %i[foreground background].include?(style_type)
161
+
162
+ style_type
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end