Module: Fatty::Color

Defined in:
lib/fatty/colors/color.rb

Constant Summary collapse

DEFAULT_INDEX =
-1
RGB_TXT_PATH =

Expected location for a bundled X11 rgb.txt (you provide it in the repo). Recommended path: lib/fatty/color/rgb.txt

File.expand_path("rgb.txt", __dir__)
ANSI_NAMES =

ANSI 0..15 names (de-facto standard names)

{
  "black" => 0,
  "red" => 1,
  "green" => 2,
  "yellow" => 3,
  "blue" => 4,
  "magenta" => 5,
  "cyan" => 6,
  "white" => 7,
  "bright_black" => 8,
  "bright_red" => 9,
  "bright_green" => 10,
  "bright_yellow" => 11,
  "bright_blue" => 12,
  "bright_magenta" => 13,
  "bright_cyan" => 14,
  "bright_white" => 15,
  "gray" => 8,
  "grey" => 8,
  "bright_gray" => 15,
  "bright_grey" => 15,
  "default" => DEFAULT_INDEX,
}.freeze
ALIASES_256 =

Small alias set (xterm-256 indices). Keep this small + opinionated. Users can always use integers/hex/X11 names.

{
  "navy" => 17,
  "dark_blue" => 18,
  "orange" => 208,
  "pink" => 205,
  "violet" => 141,
  "sky" => 117,
  "teal" => 37,
  "lime" => 118,
  "dark_grey" => 238,
  "dark_gray" => 238,
  "grey" => 244,
  "gray" => 244,
  "light_grey" => 250,
  "light_gray" => 250,
}.freeze
ANSI_RGB =

Approximate RGB for xterm-style ANSI 0..15. Used only when down-mapping to <=16 colors.

{
  0 => [0, 0, 0],
  1 => [205, 0, 0],
  2 => [0, 205, 0],
  3 => [205, 205, 0],
  4 => [0, 0, 238],
  5 => [205, 0, 205],
  6 => [0, 205, 205],
  7 => [229, 229, 229],
  8 => [127, 127, 127],
  9 => [255, 0, 0],
  10 => [0, 255, 0],
  11 => [255, 255, 0],
  12 => [92, 92, 255],
  13 => [255, 0, 255],
  14 => [0, 255, 255],
  15 => [255, 255, 255],
}.freeze

Class Method Summary collapse

Class Method Details

.clamp_byte(v) ⇒ Object



326
327
328
329
330
331
# File 'lib/fatty/colors/color.rb', line 326

def self.clamp_byte(v)
  x = v.to_i
  x = 0 if x < 0
  x = 255 if x > 255
  x
end

.clamp_index(idx, available_colors:) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/fatty/colors/color.rb', line 179

def self.clamp_index(idx, available_colors:)
  max = available_colors.to_i - 1
  0 if max < 0

  i = idx.to_i

  if available_colors.to_i <= 16
    downmap_to_ansi16(i)
  else
    i.clamp(0, 255)
  end
end

.dist2(r1, g1, b1, r2, g2, b2) ⇒ Object



333
334
335
336
337
338
# File 'lib/fatty/colors/color.rb', line 333

def self.dist2(r1, g1, b1, r2, g2, b2)
  dr = r1 - r2
  dg = g1 - g2
  db = b1 - b2
  (dr * dr) + (dg * dg) + (db * db)
end

.downmap_to_ansi16(idx) ⇒ Object



284
285
286
287
288
289
290
291
292
293
# File 'lib/fatty/colors/color.rb', line 284

def self.downmap_to_ansi16(idx)
  if idx == DEFAULT_INDEX
    DEFAULT_INDEX
  elsif idx.between?(0, 15)
    idx
  else
    rgb = xterm_rgb_for_index(idx)
    nearest_ansi_index(rgb[0], rgb[1], rgb[2])
  end
end

.load_x11_rgb_txt(path) ⇒ Object



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'lib/fatty/colors/color.rb', line 355

def self.load_x11_rgb_txt(path)
  table = {}

  if File.file?(path)
    File.foreach(path) do |line|
      next if line.strip.empty?
      next if line.lstrip.start_with?("!")

      # Format: R G B <name...>
      parts = line.strip.split(/\s+/)
      next if parts.length < 4

      r = parts[0].to_i
      g = parts[1].to_i
      b = parts[2].to_i
      name = parts[3..].join(" ")
      key = normalize_name(name)
      table[key] = [r, g, b]
    end
  end

  table
end

.nearest_ansi_index(r, g, b) ⇒ Object



295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/fatty/colors/color.rb', line 295

def self.nearest_ansi_index(r, g, b)
  best = 0
  best_d = nil

  ANSI_RGB.each do |i, rgb|
    d = dist2(r, g, b, rgb[0], rgb[1], rgb[2])
    if best_d.nil? || d < best_d
      best_d = d
      best = i
    end
  end

  best
end

.nearest_index(levels, value) ⇒ Object



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/fatty/colors/color.rb', line 310

def self.nearest_index(levels, value)
  v = value.to_i
  best_i = 0
  best_d = nil

  levels.each_with_index do |lvl, i|
    d = (lvl - v).abs
    if best_d.nil? || d < best_d
      best_d = d
      best_i = i
    end
  end

  best_i
end

.normalize_name(str) ⇒ Object



150
151
152
153
154
155
156
157
# File 'lib/fatty/colors/color.rb', line 150

def self.normalize_name(str)
  # normalize spaces/underscores/hyphens and case, so:
  # "Light Sky Blue" == "light_sky_blue" == "lightskyblue"
  s = str.to_s.strip.downcase
  s = s.tr("-", "_")
  s = s.gsub(/\s+/, "")
  s
end

.parse_hex(s) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/fatty/colors/color.rb', line 159

def self.parse_hex(s)
  # Accept "#rgb" or "#rrggbb"
  hex = s
  if hex.start_with?("#")
    hex = hex[1..]
  end

  if hex.match?(/\A[0-9a-f]{3}\z/i)
    r = (hex[0] * 2).to_i(16)
    g = (hex[1] * 2).to_i(16)
    b = (hex[2] * 2).to_i(16)
    [r, g, b]
  elsif hex.match?(/\A[0-9a-f]{6}\z/i)
    r = hex[0, 2].to_i(16)
    g = hex[2, 2].to_i(16)
    b = hex[4, 2].to_i(16)
    [r, g, b]
  end
end

.resolve(spec, available_colors: 256) ⇒ Object

Resolve a color specification to an integer color index suitable for curses init_pair.

Accepts:

  • Integer (e.g. 17, 226, -1)
  • ANSI name (e.g. "yellow", "bright_blue", "default")
  • Alias (e.g. "navy")
  • Hex string (#RRGGBB or #RGB)
  • X11 name (e.g. "MidnightBlue") resolved via bundled rgb.txt

available_colors:

  • If <= 16, any resolved 256-color index is down-mapped to nearest ANSI 0..15.
  • If > 16, returns xterm-256 indices 0..255 (or -1 for default).


89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/fatty/colors/color.rb', line 89

def self.resolve(spec, available_colors: 256)
  spec_norm = spec

  idx =
    if spec_norm.is_a?(Integer)
      spec_norm
    elsif spec_norm.nil?
      DEFAULT_INDEX
    else
      resolve_stringish(spec_norm.to_s, available_colors: available_colors)
    end

  if idx == DEFAULT_INDEX
    idx
  else
    clamp_index(idx, available_colors: available_colors)
  end
end

.resolve_stringish(str, available_colors: 256) ⇒ Object



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/fatty/colors/color.rb', line 108

def self.resolve_stringish(str, available_colors: 256)
  s = normalize_name(str)
  # Disambiguation prefixes:
  # - "ansi:yellow" forces ANSI 0..15 names
  # - "x11:yellow" forces X11 rgb.txt lookup
  #
  # Without a prefix, prefer X11 names when 256 colors are available so
  # common names like "yellow" match X11 "#FFFF00" rather than ANSI 3.
  mode = nil
  if s.start_with?("ansi:")
    mode = :ansi
    s = s.delete_prefix("ansi:")
  elsif s.start_with?("x11:")
    mode = :x11
    s = s.delete_prefix("x11:")
  end

  idx = ALIASES_256[s]
  return idx unless idx.nil?

  rgb = parse_hex(s)
  return xterm_index_for_rgb(rgb[0], rgb[1], rgb[2]) if rgb

  prefer_x11 = (mode == :x11) || (mode.nil? && available_colors.to_i >= 256)

  if prefer_x11
    rgb2 = x11_rgb_for_name(s)
    return xterm_index_for_rgb(rgb2[0], rgb2[1], rgb2[2]) if rgb2
  end

  idx = ANSI_NAMES[s]
  return idx unless idx.nil?

  unless prefer_x11
    rgb2 = x11_rgb_for_name(s)
    return xterm_index_for_rgb(rgb2[0], rgb2[1], rgb2[2]) if rgb2
  end

  # Unknown name: treat as default.
  DEFAULT_INDEX
end

.rgb(spec) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/fatty/colors/color.rb', line 192

def self.rgb(spec)
  if spec.is_a?(Integer)
    xterm_rgb_for_index(spec)
  elsif spec.nil?
    nil
  else
    s = normalize_name(spec.to_s)

    if s.start_with?("ansi:")
      s = s.delete_prefix("ansi:")
    elsif s.start_with?("x11:")
      s = s.delete_prefix("x11:")
    end

    idx = ALIASES_256[s]
    if idx
      xterm_rgb_for_index(idx)
    else
      parsed = parse_hex(s)
      parsed ||= x11_rgb_for_name(s)
      parsed ||= begin
        ansi = ANSI_NAMES[s]
        xterm_rgb_for_index(ansi) unless ansi == DEFAULT_INDEX || ansi.nil?
      end
      parsed
    end
  end
end

.x11_rgb_for_name(name_norm) ⇒ Object



340
341
342
343
# File 'lib/fatty/colors/color.rb', line 340

def self.x11_rgb_for_name(name_norm)
  table = x11_table
  table[name_norm]
end

.x11_tableObject



345
346
347
348
349
350
351
352
353
# File 'lib/fatty/colors/color.rb', line 345

def self.x11_table
  path = RGB_TXT_PATH

  if @x11_table_path != path
    @x11_table = load_x11_rgb_txt(path)
    @x11_table_path = path
  end
  @x11_table
end

.xterm_cube_index(r, g, b) ⇒ Object



242
243
244
245
246
247
248
# File 'lib/fatty/colors/color.rb', line 242

def self.xterm_cube_index(r, g, b)
  levels = [0, 95, 135, 175, 215, 255]
  ri = nearest_index(levels, r)
  gi = nearest_index(levels, g)
  bi = nearest_index(levels, b)
  16 + (36 * ri) + (6 * gi) + bi
end

.xterm_gray_index(r, g, b) ⇒ Object



250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/fatty/colors/color.rb', line 250

def self.xterm_gray_index(r, g, b)
  # grayscale ramp 232..255 maps to levels 8 + 10*n
  avg = (r + g + b) / 3
  if avg < 8
    16 # near black; keep in extended palette, not ANSI black
  elsif avg > 238
    231 # near white; a cube white
  else
    n = ((avg - 8) / 10.0).round
    n = 0 if n < 0
    n = 23 if n > 23
    232 + n
  end
end

.xterm_index_for_rgb(r, g, b) ⇒ Object

Convert any RGB to an xterm-256 index (16..255). We consider both the 6x6x6 cube and the grayscale ramp and pick the closer.



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/fatty/colors/color.rb', line 223

def self.xterm_index_for_rgb(r, g, b)
  rr = clamp_byte(r)
  gg = clamp_byte(g)
  bb = clamp_byte(b)

  cube = xterm_cube_index(rr, gg, bb)
  gray = xterm_gray_index(rr, gg, bb)

  cube_rgb = xterm_rgb_for_index(cube)
  gray_rgb = xterm_rgb_for_index(gray)

  if dist2(rr, gg, bb, gray_rgb[0], gray_rgb[1], gray_rgb[2]) <
     dist2(rr, gg, bb, cube_rgb[0], cube_rgb[1], cube_rgb[2])
    gray
  else
    cube
  end
end

.xterm_rgb_for_index(idx) ⇒ Object



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/fatty/colors/color.rb', line 265

def self.xterm_rgb_for_index(idx)
  i = idx.to_i

  if i.between?(232, 255)
    v = 8 + (i - 232) * 10
    [v, v, v]
  elsif i.between?(16, 231)
    j = i - 16
    r = j / 36
    g = (j % 36) / 6
    b = j % 6
    levels = [0, 95, 135, 175, 215, 255]
    [levels[r], levels[g], levels[b]]
  else
    # for 0..15 we use ANSI_RGB as an approximation
    ANSI_RGB[i] || [0, 0, 0]
  end
end