Module: RichEngine::StringColors

Defined in:
lib/rich_engine/string_colors.rb

Overview

A refinement that adds color and style methods to String, plus helpers for resolving color specs.

Colors are emitted as 256-color (8-bit) escape sequences using only the theme-independent regions of the palette: the 6x6x6 color cube (16-231) and the grayscale ramp (232-255). Unlike the classic 16 ANSI colors, these render the same RGB values in every terminal.

Anywhere a color is accepted, you can pass:

  • a named color (Symbol): :red, :bright_cyan, ... (see PALETTE)
  • a hex string: "#ff8800" (also "ff8800" and shorthand "#f80")
  • an RGB array: [255, 136, 0]
  • a raw 256-color index (Integer): 208

Hex and RGB values snap to the nearest color in the fixed 256-color palette.

Examples:

using RichEngine::StringColors

"hello".fg(:red)            # named color
"hello".fg("#ff8800").bold  # custom color, chained with a style
"hello".bg([0, 0, 215])     # RGB background

Constant Summary collapse

PALETTE =

Named colors mapped to fixed color-cube/grayscale indices.

Returns:

  • (Hash{Symbol => Integer})
{
  black: 16,
  red: 160,
  green: 40,
  yellow: 184,
  orange: 208,
  blue: 20,
  magenta: 164,
  cyan: 44,
  white: 231,
  dark_gray: 238,
  dark_grey: 238,
  gray: 244,
  grey: 244,
  light_gray: 188,
  light_grey: 188,
  bright_red: 196,
  bright_green: 46,
  bright_yellow: 226,
  bright_blue: 21,
  bright_magenta: 201,
  bright_cyan: 51
}.freeze
CUBE_LEVELS =

Channel intensities used by the 6x6x6 color cube.

Returns:

  • (Array<Integer>)
[0, 95, 135, 175, 215, 255].freeze

Class Method Summary collapse

Class Method Details

.contrast_color(color) ⇒ Symbol

Returns :black or :white, whichever has the higher WCAG contrast ratio against the given color, like CSS's contrast-color() function. Ties go to :white.

Examples:

Readable labels on a dynamic background

label_color = StringColors.contrast_color(bg_color)
canvas.write_string("Score", x: 0, y: 0, fg: label_color, bg: bg_color)
contrast_color(:yellow)   # => :black
contrast_color("#0000d7") # => :white

Parameters:

  • color (Symbol, String, Array<Integer>, Integer)

    a color spec (raw indices 0-15 raise, since their RGB values are theme-dependent)

Returns:

  • (Symbol)

    :black or :white



99
100
101
102
103
104
105
# File 'lib/rich_engine/string_colors.rb', line 99

def self.contrast_color(color)
  luminance = relative_luminance(rgb_for(color))
  white_contrast = 1.05 / (luminance + 0.05)
  black_contrast = (luminance + 0.05) / 0.05

  (white_contrast >= black_contrast) ? :white : :black
end

.gray_index(level) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The grayscale ramp (232-255) covers intensities 8, 18, ... 238. Levels near the extremes snap to the cube's black (16) and white (231).



181
182
183
184
185
186
# File 'lib/rich_engine/string_colors.rb', line 181

def self.gray_index(level)
  return 16 if level < 4
  return 231 if level > 243

  232 + ((level - 8) / 10.0).round.clamp(0, 23)
end

.hex_to_rgb(hex) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Parses "#rrggbb", "rrggbb", or "#rgb" into [r, g, b] values.



152
153
154
155
156
# File 'lib/rich_engine/string_colors.rb', line 152

def self.hex_to_rgb(hex)
  digits = hex.delete_prefix("#")
  digits = digits.each_char.map { |c| c * 2 }.join if digits.length == 3
  digits.scan(/../).map { |pair| pair.to_i(16) }
end

.index_for(color) ⇒ Integer

Resolves a color spec into a 256-color index.

Examples:

index_for(:red)          # => 160 (named palette color)
index_for("#ff8800")     # => 208 (nearest cube color)
index_for([255, 136, 0]) # => 208 (nearest cube color)
index_for(208)           # => 208 (used as-is)

Parameters:

  • color (Symbol, String, Array<Integer>, Integer)

    a color spec

Returns:

  • (Integer)

    an index into the 256-color palette

Raises:

  • (ArgumentError)

    if the spec is not one of the supported types

  • (KeyError)

    if a named color is not in PALETTE



73
74
75
76
77
78
79
80
81
82
# File 'lib/rich_engine/string_colors.rb', line 73

def self.index_for(color)
  case color
  when Symbol then PALETTE.fetch(color)
  when Integer then color
  when Array then rgb_to_index(*color)
  when String then rgb_to_index(*hex_to_rgb(color))
  else
    raise ArgumentError, "invalid color: #{color.inspect}"
  end
end

.index_to_rgb(index) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Converts a 256-color index (16-255) back into [r, g, b] values.



124
125
126
127
128
129
130
131
132
133
134
# File 'lib/rich_engine/string_colors.rb', line 124

def self.index_to_rgb(index)
  if index.between?(16, 231)
    cube = index - 16
    [cube / 36, (cube % 36) / 6, cube % 6].map { |level| CUBE_LEVELS[level] }
  elsif index.between?(232, 255)
    level = 8 + (10 * (index - 232))
    [level, level, level]
  else
    raise ArgumentError, "color index #{index} is theme-dependent; use 16-255"
  end
end

.nearest_cube_level(channel) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Index of the cube level (0-5) closest to the given channel value.



173
174
175
# File 'lib/rich_engine/string_colors.rb', line 173

def self.nearest_cube_level(channel)
  CUBE_LEVELS.each_index.min_by { |i| (CUBE_LEVELS[i] - channel).abs }
end

.relative_luminance(rgb) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

WCAG 2 relative luminance of an sRGB color, from 0.0 (black) to 1.0 (white). See https://www.w3.org/TR/WCAG20/#relativeluminancedef



140
141
142
143
144
145
146
147
# File 'lib/rich_engine/string_colors.rb', line 140

def self.relative_luminance(rgb)
  r, g, b = rgb.map do |channel|
    value = channel / 255.0
    (value <= 0.04045) ? value / 12.92 : ((value + 0.055) / 1.055)**2.4
  end

  (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
end

.rgb_for(color) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Resolves a color spec into [r, g, b] channel values.



110
111
112
113
114
115
116
117
118
119
# File 'lib/rich_engine/string_colors.rb', line 110

def self.rgb_for(color)
  case color
  when Symbol then index_to_rgb(PALETTE.fetch(color))
  when Integer then index_to_rgb(color)
  when Array then color
  when String then hex_to_rgb(color)
  else
    raise ArgumentError, "invalid color: #{color.inspect}"
  end
end

.rgb_to_index(red, green, blue) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Snaps [r, g, b] values to the nearest cube/grayscale index.



161
162
163
164
165
166
167
168
# File 'lib/rich_engine/string_colors.rb', line 161

def self.rgb_to_index(red, green, blue)
  if red == green && green == blue
    gray_index(red)
  else
    r, g, b = [red, green, blue].map { |channel| nearest_cube_level(channel) }
    16 + (36 * r) + (6 * g) + b
  end
end