kolor 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.
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+ # @!parse
3
+ # class String
4
+ # include Kolor::Extra
5
+ # end
6
+ require_relative '../kolor'
7
+ require_relative 'internal/enum'
8
+ require_relative 'internal/logger'
9
+ require_relative 'enum/foreground'
10
+ require_relative 'enum/theme'
11
+ # Kolor::Extra provides advanced terminal styling features.
12
+ #
13
+ # Features:
14
+ # - 256 color palette
15
+ # - RGB/True color support
16
+ # - Color gradients
17
+ # - Named themes
18
+ #
19
+ # @example 256 colors
20
+ # "text".color(196) # foreground
21
+ # "text".on_color(196) # background
22
+ #
23
+ # @example RGB colors
24
+ # "text".rgb(255, 0, 0) # foreground
25
+ # "text".on_rgb(255, 0, 0) # background
26
+ #
27
+ # @example Gradients
28
+ # "Hello World".gradient(:red, :blue)
29
+ # "Rainbow".rainbow
30
+ #
31
+ # @example Themes
32
+ # Kolor::Extra.theme(:success, :green, :bold)
33
+ # "Done!".success
34
+ module Kolor
35
+ # Extended functionality for Kolor including RGB colors, gradients, and themes
36
+ module Extra
37
+ # 256-color palette support
38
+ # @param code [Integer] color code (0-255)
39
+ # @return [String, ColorizedString] colorized string
40
+ def color(code)
41
+ create_colorized_string("\e[38;5;#{code}m")
42
+ end
43
+
44
+ # 256-color background palette support
45
+ # @param code [Integer] color code (0-255)
46
+ # @return [String, ColorizedString] colorized string
47
+ def on_color(code)
48
+ create_colorized_string("\e[48;5;#{code}m")
49
+ end
50
+
51
+ # RGB / True color support (foreground)
52
+ # @param red [Integer] red component (0-255)
53
+ # @param green [Integer] green component (0-255)
54
+ # @param blue [Integer] blue component (0-255)
55
+ # @return [String, ColorizedString] colorized string
56
+ def rgb(red, green, blue)
57
+ return to_s unless Kolor.enabled?
58
+ return to_s unless [red, green, blue].all? { |v| v.between?(0, 255) }
59
+ create_colorized_string("\e[38;2;#{red};#{green};#{blue}m")
60
+ end
61
+
62
+ # RGB / True color support (background)
63
+ # @param red [Integer] red component (0-255)
64
+ # @param green [Integer] green component (0-255)
65
+ # @param blue [Integer] blue component (0-255)
66
+ # @return [String, ColorizedString] colorized string
67
+ def on_rgb(red, green, blue)
68
+ return to_s unless Kolor.enabled?
69
+ return to_s unless [red, green, blue].all? { |v| v.between?(0, 255) }
70
+ create_colorized_string("\e[48;2;#{red};#{green};#{blue}m")
71
+ end
72
+
73
+ # Hex color support (foreground)
74
+ # @param hex_color [String] with_hex color code (with or without #)
75
+ # @return [String, ColorizedString] colorized string
76
+ def with_hex(hex_color)
77
+ return to_s unless Kolor.enabled?
78
+
79
+ hex_color = hex_color.delete('#')
80
+ return to_s unless hex_color.match?(/^[0-9A-Fa-f]{6}$/)
81
+
82
+ r = hex_color[0..1].to_i(16)
83
+ g = hex_color[2..3].to_i(16)
84
+ b = hex_color[4..5].to_i(16)
85
+ rgb(r, g, b)
86
+ end
87
+
88
+ # Hex color support (background)
89
+ # @param hex_color [String] with_hex color code (with or without #)
90
+ # @return [String, ColorizedString] colorized string
91
+ def on_hex(hex_color)
92
+ return to_s unless Kolor.enabled?
93
+
94
+ hex_color = hex_color.delete('#')
95
+ return to_s unless hex_color.match?(/^[0-9A-Fa-f]{6}$/)
96
+
97
+ r = hex_color[0..1].to_i(16)
98
+ g = hex_color[2..3].to_i(16)
99
+ b = hex_color[4..5].to_i(16)
100
+ on_rgb(r, g, b)
101
+ end
102
+
103
+ # Gradient between two colors
104
+ # @param start_color [Symbol] starting color name
105
+ # @param end_color [Symbol] ending color name
106
+ # @return [String] string with gradient effect
107
+ def gradient(start_color, end_color)
108
+ return to_s unless Kolor.enabled?
109
+
110
+ start_enum = Kolor::Enum::Foreground[start_color]
111
+ end_enum = Kolor::Enum::Foreground[end_color]
112
+
113
+ return to_s unless start_enum && end_enum
114
+
115
+ start_code = start_enum.value
116
+ end_code = end_enum.value
117
+
118
+ chars = to_s.chars
119
+ return Kolor.clear_code if chars.empty?
120
+
121
+ result = chars.map.with_index do |char, i|
122
+ progress = chars.length > 1 ? i.to_f / (chars.length - 1) : 0
123
+ color_code = start_code + ((end_code - start_code) * progress).round
124
+ "\e[#{color_code}m#{char}"
125
+ end
126
+
127
+ result.join + Kolor.clear_code
128
+ end
129
+
130
+ # Rainbow colors
131
+ # @return [String] string with rainbow effect
132
+ def rainbow
133
+ return to_s unless Kolor.enabled?
134
+
135
+ colors = %i[red yellow green cyan blue magenta]
136
+ chars = to_s.chars
137
+
138
+ return Kolor.clear_code if chars.empty?
139
+
140
+ result = chars.map.with_index do |char, i|
141
+ color = colors[i % colors.length]
142
+ enum = Kolor::Enum::Foreground[color]
143
+ code = enum.value
144
+ "\e[#{code}m#{char}"
145
+ end
146
+
147
+ result.join + Kolor.clear_code
148
+ end
149
+
150
+ # Define a custom theme
151
+ # @param name [Symbol] theme name
152
+ # @param styles [Array<Symbol>] list of color and style names to apply
153
+ # @example
154
+ # Kolor::Extra.theme(:error, :white, :on_red, :bold)
155
+ # "Error!".error # => white text on red background, bold
156
+ def self.theme(name, *styles)
157
+ begin
158
+ Kolor::Enum::Theme.entry(name, normalize_styles(styles))
159
+ define_theme_method(name, styles)
160
+ rescue ArgumentError => e
161
+ if e.message =~ /already assigned to (\w+)/
162
+ existing = Regexp.last_match(1)
163
+ Kolor::Logger.warn("Theme value already registered as :#{existing}. Skipping :#{name}.")
164
+ else
165
+ Kolor::Logger.error("Theme registration failed for #{name}: #{e.message}")
166
+ end
167
+ rescue TypeError => e
168
+ Kolor::Logger.error("Theme registration failed for #{name}: #{e.message}")
169
+ end
170
+ end
171
+
172
+
173
+
174
+ # Defines a theme method on String and ColorizedString
175
+ # @param name [Symbol] theme name
176
+ # @param styles [Array<Symbol>] list of style methods to apply
177
+ # @return [void]
178
+ def self.define_theme_method(name, styles)
179
+ # Define the method on both String and ColorizedString
180
+ [String, Kolor::ColorizedString].each do |klass|
181
+ klass.class_eval do
182
+ # Remove existing method if present to avoid warnings
183
+ remove_method(name) if method_defined?(name)
184
+
185
+ # Define the theme method
186
+ define_method(name) do
187
+ # Return plain string if colors are disabled
188
+ return to_s unless Kolor.enabled?
189
+
190
+ # Apply each style in sequence
191
+ result = self
192
+ styles.each do |style|
193
+ # Verify the style method exists before calling it
194
+ if result.respond_to?(style)
195
+ result = result.public_send(style)
196
+ else
197
+ # Log warning but continue with other styles
198
+ Kolor::Logger.debug "Style '#{style}' not found for theme '#{name}'"
199
+ end
200
+ end
201
+
202
+ result
203
+ end
204
+ end
205
+ end
206
+
207
+ Kolor::Logger.debug "Defined theme method '#{name}' with styles #{styles.inspect}"
208
+ end
209
+
210
+ # Normalizes a list of style symbols into a structured theme hash.
211
+ # Extracts foreground, background, and remaining styles.
212
+ #
213
+ # @param styles [Array<Symbol>] list of style names (e.g., :green, :on_red, :bold)
214
+ # @return [Hash{Symbol=>Symbol, Array<Symbol>}] normalized theme structure
215
+ # - :foreground → foreground color (Symbol or nil)
216
+ # - :background → background color (Symbol or nil)
217
+ # - :styles → remaining style modifiers (Array<Symbol>)
218
+ def self.normalize_styles(styles)
219
+ fg = styles.find { |s| Kolor::Enum::Foreground.keys.include?(s) }
220
+ bg = styles.find { |s| s.to_s.start_with?('on_') }
221
+ rest = styles - [fg, bg].compact
222
+ {
223
+ foreground: fg,
224
+ background: bg&.to_s&.sub(/^on_/, '')&.to_sym,
225
+ styles: rest
226
+ }
227
+ end
228
+
229
+ # Returns the list of all registered theme names.
230
+ #
231
+ # @return [Array<Symbol>] list of theme keys
232
+ def self.themes
233
+ Kolor::Enum::Theme.keys
234
+ end
235
+
236
+ # Retrieves the configuration object for a given theme.
237
+ #
238
+ # @param name [Symbol] theme name
239
+ # @return [Object, nil] theme configuration or nil if not found or invalid
240
+ # Expected keys:
241
+ # - :foreground → foreground color (Symbol or nil)
242
+ # - :background → background color (Symbol or nil)
243
+ # - :styles → style modifiers (Array<Symbol>)
244
+ def self.get_theme(name)
245
+ Kolor::Enum::Theme[name]&.value.then { |v| v.is_a?(Hash) ? v : nil }
246
+ end
247
+
248
+ def self.remove_theme(name)
249
+ theme = Kolor::Enum::Theme[name]
250
+ raise ArgumentError, "Theme #{name} not found" unless theme
251
+
252
+ built_in = %i[success error warning info debug]
253
+ raise ArgumentError, "Cannot remove built-in theme #{name}" if built_in.include?(name)
254
+
255
+ Kolor::Logger.info("Removing theme #{name}")
256
+ Kolor::Enum::Theme.remove(name)
257
+
258
+ [String, Kolor::ColorizedString].each do |klass|
259
+ klass.class_eval do
260
+ remove_method(name) if method_defined?(name)
261
+ end
262
+ end
263
+ end
264
+
265
+ # Defines all built-in themes from Kolor::Enum::Theme
266
+ # @return [void]
267
+ def self.define_all_themes
268
+ return if @themes_initialized
269
+ Kolor::Logger.info('Built-in themes initialized') unless @themes_initialized
270
+ @themes_initialized = true
271
+
272
+ Kolor::Enum::Theme.keys.each do |name|
273
+ config = Kolor::Enum::Theme[name].value
274
+ next unless config.is_a?(Hash)
275
+
276
+ styles = []
277
+ styles << config[:foreground] if config[:foreground]
278
+ styles << "on_#{config[:background]}".to_sym if config[:background]
279
+ styles += config[:styles] if config[:styles].is_a?(Array)
280
+
281
+ define_theme_method(name, styles)
282
+ end
283
+ end
284
+
285
+ # Check if a theme method is defined
286
+ # @param name [Symbol] theme name
287
+ # @return [Boolean] true if the method exists on String
288
+ def self.theme_defined?(name)
289
+ String.method_defined?(name.to_sym)
290
+ end
291
+
292
+ # Init built-in themes
293
+ self.define_all_themes
294
+ end
295
+ end
296
+
297
+ # Extend String class with Extra methods
298
+ String.include(Kolor::Extra)
299
+ Kolor::ColorizedString.include(Kolor::Extra)
300
+
301
+ # Init built-in themes
302
+ Kolor::Extra.define_all_themes
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+ require 'fileutils'
3
+ require_relative 'logger'
4
+
5
+ module Kolor
6
+ # Kolor::Config handles configuration loading and initialization for Kolor.
7
+ #
8
+ # It supports Ruby-based config files located in the user's home directory:
9
+ # - `.kolorrc.rb` — preferred format
10
+ # - `.kolorrc` — optional alias
11
+ #
12
+ # The config file can define themes using `Kolor::Extra.theme`.
13
+ # If no config is found, a default file is created automatically.
14
+ #
15
+ # @example Initialize and load config
16
+ # Kolor::Config.init
17
+ #
18
+ # @example Reload config manually
19
+ # Kolor::Config.reload!
20
+ #
21
+ # @example Access loaded themes
22
+ # Kolor::Config.themes_from_config
23
+ #
24
+ # @example Describe a theme
25
+ # Kolor::Config.describe_theme(:success)
26
+ module Config
27
+ HOME_PATH = ENV['HOME'] || ENV['USERPROFILE']
28
+ CONFIG_FILE_RB = HOME_PATH ? File.expand_path("#{HOME_PATH}/.kolorrc.rb") : nil
29
+ CONFIG_FILE_ALIAS = HOME_PATH ? File.expand_path("#{HOME_PATH}/.kolorrc") : nil
30
+
31
+ class << self
32
+ # Initializes configuration.
33
+ # Creates a default config file if none exists, then loads it.
34
+ #
35
+ # @return [void]
36
+ def init
37
+ create_default_config unless config_exists?
38
+ load_config
39
+ end
40
+
41
+ # Checks whether any supported config file exists.
42
+ #
43
+ # @return [Boolean] true if a config file is found, false otherwise
44
+ def config_exists?
45
+ !config_file_path.nil?
46
+ end
47
+
48
+ # Creates a default Ruby config file in the user's home directory.
49
+ # Skips creation if a config already exists.
50
+ # Logs warnings if HOME is missing or creation fails.
51
+ #
52
+ # @return [void]
53
+ def create_default_config
54
+ unless HOME_PATH
55
+ Kolor::Logger.warn 'No home directory found'
56
+ return
57
+ end
58
+
59
+ return unless find_existing_config_file.nil?
60
+
61
+ begin
62
+ if CONFIG_FILE_RB.nil?
63
+ raise LoadError, 'Config path is invalid'
64
+ end
65
+ FileUtils.cp(default_config_path, CONFIG_FILE_RB)
66
+ Kolor::Logger.warn "Created default configuration file at #{CONFIG_FILE_RB}"
67
+ rescue StandardError, LoadError => e
68
+ Kolor::Logger.warn "Failed to create default config file: #{e.message}" if e.is_a?(StandardError)
69
+ Kolor::Logger.warn e.message if e.is_a?(LoadError)
70
+ end
71
+ end
72
+
73
+ # Loads configuration from disk.
74
+ # Supports only Ruby-based config files (.rb or .kolorrc).
75
+ # Logs info and warnings during the process.
76
+ #
77
+ # @return [void]
78
+ def load_config
79
+ config_path = config_file_path
80
+
81
+ Kolor::Logger.info "load_config called, config_path: #{config_path.inspect}"
82
+
83
+ return unless config_path
84
+
85
+ Kolor::Logger.info "Loading config from: #{config_path}"
86
+
87
+ if config_path.end_with?('.rb') || config_path.end_with?('.kolorrc')
88
+ Kolor::Logger.info "Detected Ruby config"
89
+ load_ruby_config(config_path)
90
+ else
91
+ Kolor::Logger.warn "Unknown config file type: #{config_path}"
92
+ end
93
+ rescue StandardError => e
94
+ Kolor::Logger.warn "Error loading config file #{config_path}: #{e.message}"
95
+ end
96
+
97
+ # Reloads configuration from disk.
98
+ #
99
+ # @return [void]
100
+ def reload!
101
+ load_config
102
+ end
103
+
104
+ # Returns the theme configuration as a hash.
105
+ #
106
+ # @param name [Symbol, String] theme name
107
+ # @return [Hash{Symbol => Object}, nil] theme config or nil if not found
108
+ def describe_theme(name)
109
+ Kolor::Extra.get_theme(name.to_sym)
110
+ end
111
+
112
+ # Returns all theme names loaded from config.
113
+ #
114
+ # @return [Array<Symbol>] list of theme keys
115
+ def themes_from_config
116
+ Kolor::Extra.themes
117
+ end
118
+
119
+ private
120
+
121
+ # Returns the absolute path to the default config template.
122
+ #
123
+ # @return [String] path to default .kolorrc.rb file
124
+ def default_config_path
125
+ File.expand_path('../../../default_config/.kolorrc.rb', __dir__)
126
+ end
127
+
128
+ # Returns the path to the first existing config file.
129
+ #
130
+ # @return [String, nil] path to config file or nil if none found
131
+ def config_file_path
132
+ find_existing_config_file
133
+ end
134
+
135
+ # Finds the first existing config file among supported formats.
136
+ #
137
+ # @return [String, nil] path to config file or nil
138
+ def find_existing_config_file
139
+ return CONFIG_FILE_RB if !CONFIG_FILE_RB.nil? && File.exist?(CONFIG_FILE_RB)
140
+ return CONFIG_FILE_ALIAS if !CONFIG_FILE_ALIAS.nil? && File.exist?(CONFIG_FILE_ALIAS)
141
+
142
+ nil
143
+ end
144
+
145
+ # Loads and executes a Ruby config file.
146
+ # Ensures Kolor::Extra is loaded before execution.
147
+ #
148
+ # @param path [String] absolute path to config file
149
+ # @return [void]
150
+ def load_ruby_config(path)
151
+ ensure_extra_loaded
152
+ load path
153
+ rescue SyntaxError, LoadError => e
154
+ raise StandardError, e.message
155
+ end
156
+
157
+ # Ensures Kolor::Extra is loaded and included in String and ColorizedString.
158
+ # This method is idempotent and safe to call multiple times.
159
+ #
160
+ # @return [void]
161
+ def ensure_extra_loaded
162
+ unless defined?(Kolor::Extra)
163
+ require 'kolor/extra'
164
+ return
165
+ end
166
+
167
+ String.include(Kolor::Extra) unless String.included_modules.include?(Kolor::Extra)
168
+
169
+ if defined?(Kolor::ColorizedString)
170
+ unless Kolor::ColorizedString.included_modules.include?(Kolor::Extra)
171
+ Kolor::ColorizedString.include(Kolor::Extra)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kolor::Enum provides a declarative, type-safe registry for named values.
4
+ # Each entry is unique by both name and value. Values can be of any type,
5
+ # but you may optionally declare a type constraint using `type`.
6
+ #
7
+ # Enum entries are registered via `.entry(name, value)` and accessed via `.name` or `[]`.
8
+ # Each entry becomes a singleton method of the class and returns an instance of the enum.
9
+ #
10
+ # @example Define an enum
11
+ # class MyEnum < Kolor::Enum
12
+ # type Integer
13
+ # entry :low, 1
14
+ # entry :high, 2
15
+ # end
16
+ #
17
+ # @example Access values
18
+ # MyEnum[:low].value # => 1
19
+ # MyEnum.high.to_sym # => :high
20
+ # MyEnum.low == MyEnum[:low] # => true
21
+ module Kolor
22
+ class Enum
23
+ # @return [Object] the value associated with the enum entry
24
+ attr_reader :value
25
+
26
+ # Initializes a new enum instance with a given value
27
+ #
28
+ # @param value [Object] the raw value of the enum
29
+ def initialize(value)
30
+ @value = value
31
+ end
32
+
33
+ # Returns the symbolic name of the value as a string
34
+ #
35
+ # @return [String]
36
+ def to_s = self.class.name_for(value).to_s
37
+
38
+ # Returns the symbolic name of the value as a symbol
39
+ #
40
+ # @return [Symbol]
41
+ def to_sym = self.class.name_for(value)
42
+
43
+ # Returns a debug-friendly string representation
44
+ #
45
+ # @return [String]
46
+ def inspect = "#<#{self.class.name} #{to_sym.inspect}:#{value.inspect}>"
47
+
48
+ # Equality based on class and value
49
+ #
50
+ # @param other [Object]
51
+ # @return [Boolean]
52
+ def ==(other)
53
+ other.is_a?(self.class) && other.value == value
54
+ end
55
+
56
+ # Alias for `==`
57
+ #
58
+ # @param other [Object]
59
+ # @return [Boolean]
60
+ alias eql? ==
61
+
62
+ # Hash code based on value
63
+ #
64
+ # @return [Integer]
65
+ def hash = value.hash
66
+
67
+ class << self
68
+ # Declares the expected type of all enum values
69
+ #
70
+ # @param klass [Class] the type constraint for values
71
+ # @return [void]
72
+ def type(klass)
73
+ @value_type = klass
74
+ end
75
+
76
+ # Registers a new enum entry with a unique name and value
77
+ #
78
+ # @param name [Symbol, String] symbolic name of the entry
79
+ # @param value [Object] value of the entry
80
+ # @raise [ArgumentError] if name or value is already registered
81
+ # @raise [TypeError] if value does not match declared type
82
+ # @return [void]
83
+ def entry(name, value)
84
+ name = name.to_sym
85
+ @registry ||= {}
86
+ @values ||= {}
87
+
88
+ if defined?(@value_type) && !value.is_a?(@value_type)
89
+ raise TypeError, "Invalid value type for #{name}: expected #{@value_type}, got #{value.class}"
90
+ end
91
+
92
+ if @values.key?(value)
93
+ existing = @values[value]
94
+ raise ArgumentError, "Duplicate value #{value.inspect} for #{name}; already assigned to #{existing}"
95
+ end
96
+
97
+ if @registry.key?(name)
98
+ raise ArgumentError, "Duplicate name #{name}; already registered with value #{@registry[name].value.inspect}"
99
+ end
100
+
101
+ instance = new(value)
102
+ @registry[name] = instance
103
+ @values[value] = name
104
+
105
+ define_singleton_method(name) { instance }
106
+ end
107
+
108
+ # Retrieves an enum instance by name
109
+ #
110
+ # @param name [Symbol, String]
111
+ # @return [Kolor::Enum, nil]
112
+ def [](name)
113
+ @registry[name.to_sym]
114
+ end
115
+
116
+ # Resolves the symbolic name for a given value
117
+ #
118
+ # @param value [Object]
119
+ # @return [Symbol] name or :unknown
120
+ def name_for(value)
121
+ @values[value] || :unknown
122
+ end
123
+
124
+ # Returns all registered enum instances
125
+ #
126
+ # @return [Array<Kolor::Enum>]
127
+ def all
128
+ @registry.values
129
+ end
130
+
131
+ # Returns all registered names
132
+ #
133
+ # @return [Array<Symbol>]
134
+ def keys
135
+ @registry.keys
136
+ end
137
+
138
+ # Returns all raw values
139
+ #
140
+ # @return [Array<Object>]
141
+ def values
142
+ @registry.values.map(&:value)
143
+ end
144
+
145
+ # Removes an enum entry by name
146
+ #
147
+ # @param name [Symbol, String]
148
+ # @return [Kolor::Enum, nil] the removed entry or nil
149
+ def remove(name)
150
+ name = name.to_sym
151
+ entry = @registry.delete(name)
152
+ if entry
153
+ @values.delete_if { |_, v| v == name }
154
+ singleton_class.undef_method(name) if respond_to?(name)
155
+ end
156
+ entry
157
+ end
158
+ end
159
+ end
160
+ end