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.
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/terminal/color_mode'
4
+
5
+ module Sai
6
+ # Determine the color capabilities of the terminal
7
+ #
8
+ # @author {https://aaronmallen.me Aaron Allen}
9
+ # @since unreleased
10
+ #
11
+ # @api public
12
+ class Support
13
+ # Initialize a new instance of Support
14
+ #
15
+ # @author {https://aaronmallen.me Aaron Allen}
16
+ # @since unreleased
17
+ #
18
+ # @api private
19
+ #
20
+ # @param color_mode [Integer] the color mode
21
+ #
22
+ # @return [Support] the new instance of support
23
+ # @rbs (Integer color_mode) -> void
24
+ def initialize(color_mode)
25
+ @color_mode = color_mode
26
+ end
27
+
28
+ # Check if the terminal supports ANSI colors (4-bit)
29
+ #
30
+ # @author {https://aaronmallen.me Aaron Allen}
31
+ # @since unreleased
32
+ #
33
+ # @api public
34
+ #
35
+ # @example Check if the terminal supports ANSI colors
36
+ # Sai.ansi? # => true
37
+ #
38
+ # @return [Boolean] `true` if the terminal supports ANSI colors (4-bit), otherwise `false`
39
+ # @rbs () -> bool
40
+ def ansi?
41
+ @color_mode >= Terminal::ColorMode::ANSI
42
+ end
43
+ alias bit4? ansi?
44
+ alias four_bit? ansi?
45
+
46
+ # Check if the terminal supports basic colors (3-bit)
47
+ #
48
+ # @author {https://aaronmallen.me Aaron Allen}
49
+ # @since unreleased
50
+ #
51
+ # @api public
52
+ #
53
+ # @example Check if the terminal supports basic colors
54
+ # Sai.basic? # => true
55
+ #
56
+ # @return [Boolean] `true` if the terminal supports basic colors (3-bit), otherwise `false`
57
+ # @rbs () -> bool
58
+ def basic?
59
+ @color_mode >= Terminal::ColorMode::BASIC
60
+ end
61
+ alias bit3? basic?
62
+ alias three_bit? basic?
63
+
64
+ # Check if the terminal supports 256 colors (8-bit)
65
+ #
66
+ # @author {https://aaronmallen.me Aaron Allen}
67
+ # @since unreleased
68
+ #
69
+ # @api public
70
+ #
71
+ # @example Check if the terminal supports 256 colors
72
+ # Sai.bit_8? # => true
73
+ #
74
+ # @return [Boolean] `true` if the terminal supports 256 colors (8-bit), otherwise `false`
75
+ # @rbs () -> bool
76
+ def bit8?
77
+ @color_mode >= Terminal::ColorMode::BIT8
78
+ end
79
+ alias eight_bit? bit8?
80
+
81
+ # Check if the terminal supports color output
82
+ #
83
+ # @author {https://aaronmallen.me Aaron Allen}
84
+ # @since unreleased
85
+ #
86
+ # @api public
87
+ #
88
+ # @example Check if the terminal supports color
89
+ # Sai.color? # => true
90
+ #
91
+ # @return [Boolean] `true` if the terminal supports color output, otherwise `false`
92
+ # @rbs () -> bool
93
+ def color?
94
+ @color_mode > Terminal::ColorMode::NO_COLOR
95
+ end
96
+
97
+ # Check if the terminal supports true color (24-bit)
98
+ #
99
+ # @author {https://aaronmallen.me Aaron Allen}
100
+ # @since unreleased
101
+ #
102
+ # @api public
103
+ #
104
+ # @example Check if the terminal supports true color
105
+ # Sai.true_color? # => true
106
+ #
107
+ # @return [Boolean] `true` if the terminal supports true color (24-bit), otherwise `false`
108
+ # @rbs () -> bool
109
+ def true_color?
110
+ @color_mode >= Terminal::ColorMode::TRUE_COLOR
111
+ end
112
+ alias bit24? true_color?
113
+ alias twenty_four_bit? true_color?
114
+ end
115
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/terminal/color_mode'
4
+
5
+ module Sai
6
+ module Terminal
7
+ # Detect the color capabilities of the terminal
8
+ #
9
+ # @author {https://aaronmallen.me Aaron Allen}
10
+ # @since unreleased
11
+ #
12
+ # @api private
13
+ module Capabilities
14
+ class << self
15
+ # Detect the color capabilities of the current terminal
16
+ #
17
+ # @author {https://aaronmallen.me Aaron Allen}
18
+ # @since unreleased
19
+ #
20
+ # @api private
21
+ #
22
+ # @return [Integer] the {ColorMode} of the terminal
23
+ # @rbs () -> Integer
24
+ def detect_color_support
25
+ return ColorMode::NO_COLOR if no_color?
26
+ return ColorMode::TRUE_COLOR if true_color?
27
+ return ColorMode::BIT8 if bit8?
28
+ return ColorMode::ANSI if ansi?
29
+ return ColorMode::BASIC if basic?
30
+
31
+ ColorMode::NO_COLOR
32
+ end
33
+
34
+ private
35
+
36
+ # Check for ANSI color support
37
+ #
38
+ # @author {https://aaronmallen.me Aaron Allen}
39
+ # @since unreleased
40
+ #
41
+ # @api private
42
+ #
43
+ # @return [Boolean] `true` if the terminal supports basic ANSI colors, otherwise `false`
44
+ # @rbs () -> bool
45
+ def ansi?
46
+ return true if ENV.fetch('TERM', '').match?(/^(xterm|screen|vt100|ansi)/)
47
+
48
+ !ENV.fetch('COLORTERM', '').empty?
49
+ end
50
+
51
+ # Check for basic color support
52
+ #
53
+ # @author {https://aaronmallen.me Aaron Allen}
54
+ # @since unreleased
55
+ #
56
+ # @api private
57
+ #
58
+ # @return [Boolean] `true` if the terminal supports basic colors, otherwise `false`
59
+ # @rbs () -> bool
60
+ def basic?
61
+ !ENV.fetch('TERM', '').empty?
62
+ end
63
+
64
+ # Check for 256 color (8-bit) support
65
+ #
66
+ # @author {https://aaronmallen.me Aaron Allen}
67
+ # @since unreleased
68
+ #
69
+ # @api private
70
+ #
71
+ # @return [Boolean] `true` if the terminal supports 256 colors, otherwise `false`
72
+ # @rbs () -> bool
73
+ def bit8?
74
+ return true if ENV.fetch('TERM', '').end_with?('-256color')
75
+
76
+ ENV.fetch('COLORTERM', '0').to_i >= 256
77
+ end
78
+
79
+ # Check for NO_COLOR environment variable
80
+ #
81
+ # @author {https://aaronmallen.me Aaron Allen}
82
+ # @since unreleased
83
+ #
84
+ # @api private
85
+ #
86
+ # @see https://no-color.org
87
+ #
88
+ # @return [Boolean] `true` if the NO_COLOR environment variable is set, otherwise `false`
89
+ # @rbs () -> bool
90
+ def no_color?
91
+ !ENV.fetch('NO_COLOR', '').empty? || !$stdout.tty?
92
+ end
93
+
94
+ # Check for true color (24-bit) support
95
+ #
96
+ # @author {https://aaronmallen.me Aaron Allen}
97
+ # @since unreleased
98
+ #
99
+ # @api private
100
+ #
101
+ # @return [Boolean] `true` if the terminal supports true color, otherwise `false`
102
+ # @rbs () -> bool
103
+ def true_color?
104
+ return true if ENV.fetch('COLORTERM', '').match?(/^(truecolor|24bit)$/)
105
+
106
+ case ENV.fetch('TERM', nil)
107
+ when 'xterm-direct', 'xterm-truecolor'
108
+ return true
109
+ end
110
+
111
+ case ENV.fetch('TERM_PROGRAM', nil)
112
+ when 'iTerm.app', 'WezTerm', 'vscode'
113
+ return true
114
+ end
115
+
116
+ false
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sai
4
+ module Terminal
5
+ # Represents different color support levels for terminal interfaces
6
+ #
7
+ # @author {https://aaronmallen.me Aaron Allen}
8
+ # @since unreleased
9
+ #
10
+ # @api private
11
+ module ColorMode
12
+ # The terminal does not support color output
13
+ #
14
+ # @author {https://aaronmallen.me Aaron Allen}
15
+ # @since unreleased
16
+ #
17
+ # @api private
18
+ #
19
+ # @return [Integer] the color mode
20
+ NO_COLOR = 0 #: Integer
21
+
22
+ # The terminal supports 8 colors (3-bit)
23
+ #
24
+ # @author {https://aaronmallen.me Aaron Allen}
25
+ # @since unreleased
26
+ #
27
+ # @api private
28
+ #
29
+ # @return [Integer] the color mode
30
+ BASIC = 1 #: Integer
31
+
32
+ # The terminal supports 16 colors (4-bit)
33
+ #
34
+ # @author {https://aaronmallen.me Aaron Allen}
35
+ # @since unreleased
36
+ #
37
+ # @api private
38
+ #
39
+ # @return [Integer] the color mode
40
+ ANSI = 2 #: Integer
41
+
42
+ # The terminal supports 256 colors (8-bit)
43
+ #
44
+ # @author {https://aaronmallen.me Aaron Allen}
45
+ # @since unreleased
46
+ #
47
+ # @api private
48
+ #
49
+ # @return [Integer] the color mode
50
+ BIT8 = 3 #: Integer
51
+
52
+ # The terminal supports 16 million colors (24-bit)
53
+ #
54
+ # @author {https://aaronmallen.me Aaron Allen}
55
+ # @since unreleased
56
+ #
57
+ # @api private
58
+ #
59
+ # @return [Integer] the color mode
60
+ TRUE_COLOR = 4 #: Integer
61
+ end
62
+ end
63
+ end
data/lib/sai.rb ADDED
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/decorator'
4
+ require 'sai/support'
5
+ require 'sai/terminal/capabilities'
6
+ require 'singleton'
7
+
8
+ # An elegant color management system for crafting sophisticated CLI applications
9
+ #
10
+ # Sai (彩) - meaning 'coloring' or 'paint' in Japanese - is a powerful and intuitive system for managing color output in
11
+ # command-line applications. Drawing inspiration from traditional Japanese artistic techniques, Sai brings vibrancy and
12
+ # harmony to terminal interfaces through its sophisticated color management
13
+ #
14
+ # Sai empowers developers to create beautiful, colorful CLI applications that maintain visual consistency across
15
+ # different terminal capabilities. Like its artistic namesake, it combines simplicity and sophistication to bring rich,
16
+ # adaptive color to your terminal interfaces
17
+ #
18
+ # When included in a class or module, Sai provides the following instance methods:
19
+ # * {#decorator} - Returns a new instance of {Decorator} for method chaining
20
+ # * {#terminal_color_support} - Returns the color support capabilities of the current terminal
21
+ #
22
+ # The Sai module itself responds to all the same methods as {Decorator}, excluding methods used for applying
23
+ # decorations (apply, call, decorate, encode). These methods are directly delegated to a new {Decorator} instance
24
+ #
25
+ # @author {https://aaronmallen.me Aaron Allen}
26
+ # @since unreleased
27
+ #
28
+ # @api public
29
+ #
30
+ # @example Using Sai as a module
31
+ # class MyClass
32
+ # include Sai
33
+ # end
34
+ #
35
+ # my_class = MyClass.new
36
+ # my_class.decorator.red.on_blue.bold.decorate('Hello, World!')
37
+ # #=> "\e[38;2;205;0;0m\e[48;2;0;0;238m\e[1mHello, World!\e[0m"
38
+ #
39
+ # my_class.terminal_color_support.true_color? # => true
40
+ #
41
+ # @example Using Sai directly
42
+ # Sai.red.on_blue.bold.decorate('Hello, World!')
43
+ # #=> "\e[38;2;205;0;0m\e[48;2;0;0;238m\e[1mHello, World!\e[0m"
44
+ #
45
+ # Sai.support.true_color? # => true
46
+ module Sai
47
+ class << self
48
+ ignored_decorator_methods = %i[apply call decorate encode]
49
+ Decorator.instance_methods(false).reject { |m| ignored_decorator_methods.include?(m) }.each do |method|
50
+ define_method(method) do |*arguments, **keyword_arguments|
51
+ Decorator.new(send(:color_mode)).public_send(method, *arguments, **keyword_arguments)
52
+ end
53
+ end
54
+
55
+ # @rbs!
56
+ # def black: () -> self
57
+ # def blink: () -> self
58
+ # def blue: () -> self
59
+ # def bold: () -> self
60
+ # def bright_black: () -> self
61
+ # def bright_blue: () -> self
62
+ # def bright_cyan: () -> self
63
+ # def bright_green: () -> self
64
+ # def bright_magenta: () -> self
65
+ # def bright_red: () -> self
66
+ # def bright_white: () -> self
67
+ # def bright_yellow: () -> self
68
+ # def conceal: () -> self
69
+ # def cyan: () -> self
70
+ # def dim: () -> self
71
+ # def green: () -> self
72
+ # def italic: () -> self
73
+ # def magenta: () -> self
74
+ # def no_blink: () -> self
75
+ # def no_conceal: () -> self
76
+ # def no_italic: () -> self
77
+ # def no_reverse: () -> self
78
+ # def no_strike: () -> self
79
+ # def no_underline: () -> self
80
+ # def normal_intensity: () -> self
81
+ # def on_black: () -> self
82
+ # def on_blue: () -> self
83
+ # def on_bright_black: () -> self
84
+ # def on_bright_blue: () -> self
85
+ # def on_bright_cyan: () -> self
86
+ # def on_bright_green: () -> self
87
+ # def on_bright_magenta: () -> self
88
+ # def on_bright_red: () -> self
89
+ # def on_bright_white: () -> self
90
+ # def on_bright_yellow: () -> self
91
+ # def on_cyan: () -> self
92
+ # def on_green: () -> self
93
+ # def on_magenta: () -> self
94
+ # def on_red: () -> self
95
+ # def on_white: () -> self
96
+ # def on_yellow: () -> self
97
+ # def rapid_blink: () -> self
98
+ # def red: () -> self
99
+ # def reverse: () -> self
100
+ # def strike: () -> self
101
+ # def underline: () -> self
102
+ # def white: () -> self
103
+ # def yellow: () -> self
104
+
105
+ # The supported color modes for the terminal
106
+ #
107
+ # @author {https://aaronmallen.me Aaron Allen}
108
+ # @since unreleased
109
+ #
110
+ # @api public
111
+ #
112
+ # @example Check the color support of the terminal
113
+ # Sai.support.ansi? # => true
114
+ # Sai.support.basic? # => true
115
+ # Sai.support.bit8? # => true
116
+ # Sai.support.no_color? # => false
117
+ # Sai.support.true_color? # => true
118
+ #
119
+ # @return [Support] the color support
120
+ # @rbs () -> Support
121
+ def support
122
+ @support ||= Support.new(color_mode).freeze
123
+ end
124
+
125
+ private
126
+
127
+ # Detect the color capabilities of the terminal
128
+ #
129
+ # @author {https://aaronmallen.me Aaron Allen}
130
+ # @since unreleased
131
+ #
132
+ # @api private
133
+ #
134
+ # @return [Integer] the color mode
135
+ # @rbs () -> Integer
136
+ def color_mode
137
+ Thread.current[:sai_color_mode] ||= Terminal::Capabilities.detect_color_support
138
+ end
139
+ end
140
+
141
+ # A helper method to initialize an instance of {Decorator}
142
+ #
143
+ # @author {https://aaronmallen.me Aaron Allen}
144
+ # @since unreleased
145
+ #
146
+ # @api public
147
+ #
148
+ # @example Initialize a new instance of {Decorator}
149
+ # class MyClass
150
+ # include Sai
151
+ # end
152
+ #
153
+ # MyClass.new.decorator.blue.on_red.bold.decorate('Hello, world!')
154
+ # #=> "\e[38;5;21m\e[48;5;160m\e[1mHello, world!\e[0m"
155
+ #
156
+ # @return [Decorator] the Decorator instance
157
+ # @rbs () -> Decorator
158
+ def decorator
159
+ Decorator.new(Terminal::Capabilities.detect_color_support)
160
+ end
161
+
162
+ # The supported color modes for the terminal
163
+ #
164
+ # @author {https://aaronmallen.me Aaron Allen}
165
+ # @since unreleased
166
+ #
167
+ # @api public
168
+ #
169
+ # @example Check the color support of the terminal
170
+ # class MyClass
171
+ # include Sai
172
+ # end
173
+ #
174
+ # MyClass.new.terminal_color_support.ansi? # => true
175
+ # MyClass.new.terminal_color_support.basic? # => true
176
+ # MyClass.new.terminal_color_support.bit8? # => true
177
+ # MyClass.new.terminal_color_support.no_color? # => false
178
+ # MyClass.new.terminal_color_support.true_color? # => true
179
+ #
180
+ # @return [Support] the color support
181
+ # @rbs () -> Support
182
+ def terminal_color_support
183
+ Sai.support
184
+ end
185
+ end
data/sig/sai/ansi.rbs ADDED
@@ -0,0 +1,51 @@
1
+ # Generated from lib/sai/ansi.rb with RBS::Inline
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: untyped
20
+
21
+ # Standard ANSI color names and their RGB values
22
+ #
23
+ # @author {https://aaronmallen.me Aaron Allen}
24
+ # @since unreleased
25
+ #
26
+ # @api private
27
+ #
28
+ # @return [Hash{Symbol => Array<Integer>}] the color names and RGB values
29
+ COLOR_NAMES: untyped
30
+
31
+ # ANSI escape sequence for resetting text formatting
32
+ #
33
+ # @author {https://aaronmallen.me Aaron Allen}
34
+ # @since unreleased
35
+ #
36
+ # @api private
37
+ #
38
+ # @return [String] the ANSI escape sequence
39
+ RESET: ::String
40
+
41
+ # Standard ANSI style codes
42
+ #
43
+ # @author {https://aaronmallen.me Aaron Allen}
44
+ # @since unreleased
45
+ #
46
+ # @api private
47
+ #
48
+ # @return [Hash{Symbol => Integer}] the style codes
49
+ STYLES: untyped
50
+ end
51
+ end
@@ -0,0 +1,114 @@
1
+ # Generated from lib/sai/conversion/color_sequence.rb with RBS::Inline
2
+
3
+ module Sai
4
+ module Conversion
5
+ # ANSI escape sequence utilities
6
+ #
7
+ # @author {https://aaronmallen.me Aaron Allen}
8
+ # @since unreleased
9
+ #
10
+ # @api private
11
+ module ColorSequence
12
+ type style_type = :foreground | :background
13
+
14
+ # Convert a color to the appropriate ANSI escape sequence
15
+ #
16
+ # @author {https://aaronmallen.me Aaron Allen}
17
+ # @since unreleased
18
+ #
19
+ # @api private
20
+ #
21
+ # @param color [String, Array<Integer>] the color to convert
22
+ # @param mode [Integer] the terminal color mode
23
+ # @param style_type [Symbol] the type of color (foreground or background)
24
+ #
25
+ # @return [String] the ANSI escape sequence
26
+ # @rbs (Array[Integer] | String | Symbol color, Integer mode, ?style_type style_type) -> String
27
+ def self.resolve: (Array[Integer] | String | Symbol color, Integer mode, ?style_type style_type) -> String
28
+
29
+ # Convert RGB values to a 4-bit ANSI color sequence
30
+ #
31
+ # @author {https://aaronmallen.me Aaron Allen}
32
+ # @since unreleased
33
+ #
34
+ # @api private
35
+ #
36
+ # @param rgb [Array<Integer>] the RGB components
37
+ # @param style_type [Symbol] the type of color (foreground or background)
38
+ #
39
+ # @return [String] the ANSI escape sequence
40
+ # @rbs (Array[Integer] rgb, style_type style_type) -> String
41
+ private def self.ansi: (Array[Integer] rgb, style_type style_type) -> String
42
+
43
+ # Convert a base color to a foreground or background sequence
44
+ #
45
+ # @author {https://aaronmallen.me Aaron Allen}
46
+ # @since unreleased
47
+ #
48
+ # @api private
49
+ #
50
+ # @param base_code [Integer] the base color code
51
+ # @param style_type [Symbol] the type of color (foreground or background)
52
+ #
53
+ # @return [Integer] the code for the color sequence
54
+ # @rbs (Integer base_code, style_type style_type) -> Integer
55
+ private def self.base_color_for_style_type: (Integer base_code, style_type style_type) -> Integer
56
+
57
+ # Convert RGB values to a 3-bit basic color sequence
58
+ #
59
+ # @author {https://aaronmallen.me Aaron Allen}
60
+ # @since unreleased
61
+ #
62
+ # @api private
63
+ #
64
+ # @param rgb [Array<Integer>] the RGB components
65
+ # @param style_type [Symbol] the type of color (foreground or background)
66
+ #
67
+ # @return [String] the ANSI escape sequence
68
+ # @rbs (Array[Integer] rgb, style_type style_type) -> String
69
+ private def self.basic: (Array[Integer] rgb, style_type style_type) -> String
70
+
71
+ # Convert RGB values to an 8-bit color sequence
72
+ #
73
+ # @author {https://aaronmallen.me Aaron Allen}
74
+ # @since unreleased
75
+ #
76
+ # @api private
77
+ #
78
+ # @param rgb [Array<Integer>] the RGB components
79
+ # @param style_type [Symbol] the type of color (foreground or background)
80
+ #
81
+ # @return [String] the ANSI escape sequence
82
+ # @rbs (Array[Integer] rgb, style_type type) -> String
83
+ private def self.bit8: (Array[Integer] rgb, style_type type) -> String
84
+
85
+ # Convert RGB values to a true color (24-bit) sequence
86
+ #
87
+ # @author {https://aaronmallen.me Aaron Allen}
88
+ # @since unreleased
89
+ #
90
+ # @api private
91
+ #
92
+ # @param rgb [Array<Integer>] the RGB components
93
+ # @param style_type [Symbol] the type of color (foreground or background)
94
+ #
95
+ # @return [String] the ANSI escape sequence
96
+ # @rbs (Array[Integer] rgb, style_type type) -> String
97
+ private def self.true_color: (Array[Integer] rgb, style_type type) -> String
98
+
99
+ # Validate a color style type
100
+ #
101
+ # @author {https://aaronmallen.me Aaron Allen}
102
+ # @since unreleased
103
+ #
104
+ # @api private
105
+ #
106
+ # @param style_type [Symbol] the style type to validate
107
+ #
108
+ # @raise [ArgumentError] if the style type is invalid
109
+ # @return [Symbol] the validated style type
110
+ # @rbs (Symbol style_type) -> Symbol
111
+ private def self.validate_style_type: (Symbol style_type) -> Symbol
112
+ end
113
+ end
114
+ end