fatty 0.99.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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +2 -0
  3. data/.simplecov +23 -0
  4. data/.yardopts +4 -0
  5. data/CHANGELOG.md +34 -0
  6. data/CHANGELOG.org +38 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +31 -0
  9. data/README.org +166 -0
  10. data/Rakefile +15 -0
  11. data/TODO.org +163 -0
  12. data/examples/markdown/native-markdown.md +370 -0
  13. data/examples/markdown/ox-gfm-markdown.md +373 -0
  14. data/examples/markdown/ox-gfm-markdown.org +376 -0
  15. data/exe/fatty +275 -0
  16. data/fatty.gemspec +42 -0
  17. data/lib/fatty/accept_env.rb +32 -0
  18. data/lib/fatty/action.rb +103 -0
  19. data/lib/fatty/action_environment.rb +42 -0
  20. data/lib/fatty/actionable.rb +73 -0
  21. data/lib/fatty/alert.rb +93 -0
  22. data/lib/fatty/ansi/renderer.rb +168 -0
  23. data/lib/fatty/ansi.rb +352 -0
  24. data/lib/fatty/colors/color.rb +379 -0
  25. data/lib/fatty/colors/pairs.rb +73 -0
  26. data/lib/fatty/colors/palette.rb +73 -0
  27. data/lib/fatty/colors/rgb.txt +788 -0
  28. data/lib/fatty/colors.rb +5 -0
  29. data/lib/fatty/config.rb +86 -0
  30. data/lib/fatty/config_files/config.yml +50 -0
  31. data/lib/fatty/config_files/help.md +120 -0
  32. data/lib/fatty/config_files/help.org +124 -0
  33. data/lib/fatty/config_files/keybindings.yml +49 -0
  34. data/lib/fatty/config_files/keydefs.yml +23 -0
  35. data/lib/fatty/config_files/themes/mono.yml +76 -0
  36. data/lib/fatty/config_files/themes/nordic.yml +77 -0
  37. data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
  38. data/lib/fatty/config_files/themes/terminal.yml +90 -0
  39. data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
  40. data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
  41. data/lib/fatty/core_ext/string.rb +21 -0
  42. data/lib/fatty/core_ext.rb +3 -0
  43. data/lib/fatty/counter.rb +81 -0
  44. data/lib/fatty/curses/context.rb +279 -0
  45. data/lib/fatty/curses/curses_coder.rb +684 -0
  46. data/lib/fatty/curses/event_source.rb +230 -0
  47. data/lib/fatty/curses/key_decoder.rb +183 -0
  48. data/lib/fatty/curses/patch.rb +116 -0
  49. data/lib/fatty/curses/window_styling.rb +32 -0
  50. data/lib/fatty/curses.rb +16 -0
  51. data/lib/fatty/env.rb +100 -0
  52. data/lib/fatty/help.rb +41 -0
  53. data/lib/fatty/history/entry.rb +71 -0
  54. data/lib/fatty/history.rb +289 -0
  55. data/lib/fatty/input_buffer.rb +998 -0
  56. data/lib/fatty/input_field.rb +507 -0
  57. data/lib/fatty/key_event.rb +342 -0
  58. data/lib/fatty/key_map.rb +392 -0
  59. data/lib/fatty/keymaps/emacs.rb +189 -0
  60. data/lib/fatty/log_formats/json.rb +47 -0
  61. data/lib/fatty/log_formats/text.rb +67 -0
  62. data/lib/fatty/logger.rb +142 -0
  63. data/lib/fatty/markdown/ansi_renderer.rb +373 -0
  64. data/lib/fatty/markdown/render.rb +22 -0
  65. data/lib/fatty/markdown.rb +4 -0
  66. data/lib/fatty/menu_env.rb +22 -0
  67. data/lib/fatty/mouse_event.rb +32 -0
  68. data/lib/fatty/output_buffer.rb +78 -0
  69. data/lib/fatty/pager.rb +801 -0
  70. data/lib/fatty/prompt.rb +40 -0
  71. data/lib/fatty/renderer/curses.rb +697 -0
  72. data/lib/fatty/renderer/truecolor.rb +607 -0
  73. data/lib/fatty/renderer.rb +419 -0
  74. data/lib/fatty/screen.rb +96 -0
  75. data/lib/fatty/search.rb +43 -0
  76. data/lib/fatty/session/alert_session.rb +52 -0
  77. data/lib/fatty/session/input_session.rb +99 -0
  78. data/lib/fatty/session/isearch_session.rb +172 -0
  79. data/lib/fatty/session/keytest_session.rb +236 -0
  80. data/lib/fatty/session/modal_session.rb +61 -0
  81. data/lib/fatty/session/output_session.rb +105 -0
  82. data/lib/fatty/session/popup_session.rb +540 -0
  83. data/lib/fatty/session/prompt_session.rb +157 -0
  84. data/lib/fatty/session/search_session.rb +136 -0
  85. data/lib/fatty/session/shell_session.rb +566 -0
  86. data/lib/fatty/session.rb +173 -0
  87. data/lib/fatty/sessions.rb +14 -0
  88. data/lib/fatty/terminal/popup_owner.rb +26 -0
  89. data/lib/fatty/terminal/progress.rb +374 -0
  90. data/lib/fatty/terminal.rb +1067 -0
  91. data/lib/fatty/themes/loader.rb +136 -0
  92. data/lib/fatty/themes/manager.rb +71 -0
  93. data/lib/fatty/themes/registry.rb +64 -0
  94. data/lib/fatty/themes/resolver.rb +224 -0
  95. data/lib/fatty/themes/themes.rb +131 -0
  96. data/lib/fatty/themes.rb +6 -0
  97. data/lib/fatty/version.rb +5 -0
  98. data/lib/fatty/view/alert_view.rb +14 -0
  99. data/lib/fatty/view/cursor_view.rb +18 -0
  100. data/lib/fatty/view/input_view.rb +9 -0
  101. data/lib/fatty/view/output_view.rb +9 -0
  102. data/lib/fatty/view/status_view.rb +14 -0
  103. data/lib/fatty/view.rb +33 -0
  104. data/lib/fatty/viewport.rb +90 -0
  105. data/lib/fatty/views.rb +9 -0
  106. data/lib/fatty.rb +55 -0
  107. data/sig/fatty.rbs +4 -0
  108. metadata +250 -0
data/lib/fatty/ansi.rb ADDED
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi/renderer"
4
+
5
+ module Fatty
6
+ module Ansi
7
+ end
8
+ end
9
+
10
+ module Fatty
11
+ # Parse ANSI escape sequences (primarily SGR, i.e. "\e[...m") into styled text
12
+ # segments. This is intentionally curses-agnostic: the renderer/context decides
13
+ # how to map Style -> terminal attributes / color pairs.
14
+ #
15
+ # Supported SGR:
16
+ # - 0 reset
17
+ # - 1 bold
18
+ # - 22 normal intensity (clears bold)
19
+ # - 7 reverse
20
+ # - 27 reverse off
21
+ # - 30-37 / 90-97 foreground (16-color)
22
+ # - 40-47 / 100-107 background (16-color)
23
+ # - 38;5;n foreground (256-color index)
24
+ # - 48;5;n background (256-color index)
25
+ #
26
+ # Everything else is ignored (but does not break parsing).
27
+ module Ansi
28
+ ESC = "\e"
29
+ CSI = "#{ESC}["
30
+ COMBINING_MARK_RE = /\p{M}/
31
+
32
+ ANSI_ESCAPE = %r{
33
+ \e\[ [0-9;?]* [A-Za-z] | # CSI sequences
34
+ \e\] .*? (?:\a|\e\\) | # OSC sequences
35
+ \e[@-Z\\-_] # single-char escapes
36
+ }x
37
+
38
+ Style = Struct.new(
39
+ :fg,
40
+ :bg,
41
+ :bold,
42
+ :italic,
43
+ :underline,
44
+ :strike,
45
+ :reverse,
46
+ keyword_init: true,
47
+ ) do
48
+ def dup
49
+ self.class.new(
50
+ fg: fg,
51
+ bg: bg,
52
+ bold: bold,
53
+ italic: italic,
54
+ underline: underline,
55
+ strike: strike,
56
+ reverse: reverse,
57
+ )
58
+ end
59
+
60
+ def normalize!
61
+ self.bold = !!bold
62
+ self.italic = !!italic
63
+ self.underline = !!underline
64
+ self.strike = !!strike
65
+ self.reverse = !!reverse
66
+ self
67
+ end
68
+
69
+ def self.default
70
+ new(
71
+ fg: nil,
72
+ bg: nil,
73
+ bold: false,
74
+ italic: false,
75
+ underline: false,
76
+ strike: false,
77
+ reverse: false,
78
+ )
79
+ end
80
+ end
81
+
82
+ # Remove any ANSI escape sequences from text.
83
+ def self.strip(text)
84
+ text.to_s.gsub(ANSI_ESCAPE, "")
85
+ end
86
+
87
+ # Public: segment a string into [[text, Style], ...]
88
+ #
89
+ # The returned Style objects are independent copies; you can safely mutate
90
+ # them downstream if you want.
91
+ def self.segment(str, base: nil)
92
+ s = str.to_s
93
+ style = (base ? base.dup : Style.default).normalize!
94
+
95
+ out = []
96
+ buf = +""
97
+
98
+ i = 0
99
+ n = s.bytesize
100
+ while i < n
101
+ if s.getbyte(i) == 27 # ESC
102
+ # Flush buffered text before processing escape
103
+ unless buf.empty?
104
+ out << [buf, style.dup]
105
+ buf = +""
106
+ end
107
+
108
+ consumed = consume_escape!(s, i, style)
109
+ if consumed > 0
110
+ i += consumed
111
+ else
112
+ # Not a recognized/complete escape; treat ESC as literal.
113
+ buf << ESC
114
+ i += 1
115
+ end
116
+ else
117
+ buf << s.byteslice(i, 1)
118
+ i += 1
119
+ end
120
+ end
121
+
122
+ out << [buf, style.dup] unless buf.empty?
123
+ merge_adjacent_segments(out)
124
+ end
125
+
126
+ def self.merge_adjacent_segments(segments)
127
+ return segments if segments.length <= 1
128
+
129
+ merged = []
130
+ segments.each do |text, st|
131
+ if merged.empty?
132
+ merged << [text.dup, st]
133
+ else
134
+ prev_text, prev_st = merged[-1]
135
+ if same_style?(prev_st, st)
136
+ prev_text << text
137
+ else
138
+ merged << [text.dup, st]
139
+ end
140
+ end
141
+ end
142
+
143
+ merged
144
+ end
145
+
146
+ def self.same_style?(a, b)
147
+ a.fg == b.fg &&
148
+ a.bg == b.bg &&
149
+ a.bold == b.bold &&
150
+ a.italic == b.italic &&
151
+ a.underline == b.underline &&
152
+ a.strike == b.strike &&
153
+ a.reverse == b.reverse
154
+ end
155
+
156
+ # Internal: attempts to consume an ANSI escape starting at position i.
157
+ # Returns number of bytes consumed, or 0 if not recognized.
158
+ def self.consume_escape!(s, i, style)
159
+ consumed = 0
160
+
161
+ # Only support CSI sequences for now: ESC '[' ... final
162
+ if s.bytesize >= i + 2 && s.getbyte(i + 1) == 91 # '['
163
+ # We only actively handle SGR: ... 'm'
164
+ j = i + 2
165
+ params = +""
166
+
167
+ while j < s.bytesize
168
+ b = s.getbyte(j)
169
+ if b.between?(48, 57) || b == 59 # 0-9 or ';'
170
+ params << b
171
+ j += 1
172
+ else
173
+ final = b
174
+ consumed = (j - i) + 1
175
+ if final == 109 # 'm'
176
+ apply_sgr!(style, parse_params(params))
177
+ end
178
+ break
179
+ end
180
+ end
181
+ end
182
+ consumed
183
+ end
184
+
185
+ def self.parse_params(param_str)
186
+ # SGR with empty params means reset
187
+ parts =
188
+ if param_str.empty?
189
+ ["0"]
190
+ else
191
+ param_str.split(";")
192
+ end
193
+
194
+ parts.map do |p|
195
+ p = "0" if p.nil? || p.empty?
196
+ begin
197
+ Integer(p, 10)
198
+ rescue
199
+ 0
200
+ end
201
+ end
202
+ end
203
+
204
+ def self.apply_sgr!(style, params)
205
+ params = [0] if params.empty?
206
+ i = 0
207
+ while i < params.length
208
+ code = params[i].to_i
209
+ case code
210
+ when 0
211
+ style.fg = nil
212
+ style.bg = nil
213
+ style.bold = false
214
+ style.italic = false
215
+ style.underline = false
216
+ style.strike = false
217
+ style.reverse = false
218
+ when 1
219
+ style.bold = true
220
+ when 3
221
+ style.italic = true
222
+ when 4
223
+ style.underline = true
224
+ when 7
225
+ style.reverse = true
226
+ when 9
227
+ style.strike = true
228
+ when 22
229
+ style.bold = false
230
+ when 23
231
+ style.italic = false
232
+ when 24
233
+ style.underline = false
234
+ when 27
235
+ style.reverse = false
236
+ when 29
237
+ style.strike = false
238
+ when 30..37
239
+ style.fg = code - 30
240
+ when 39
241
+ style.fg = nil
242
+ when 40..47
243
+ style.bg = code - 40
244
+ when 49
245
+ style.bg = nil
246
+ when 90..97
247
+ style.fg = code - 90 + 8
248
+ when 100..107
249
+ style.bg = code - 100 + 8
250
+ when 38
251
+ if params[i + 1].to_i == 5 && params[i + 2]
252
+ style.fg = params[i + 2].to_i
253
+ i += 2
254
+ elsif params[i + 1].to_i == 2 && params[i + 2] && params[i + 3] && params[i + 4]
255
+ style.fg = [params[i + 2].to_i, params[i + 3].to_i, params[i + 4].to_i]
256
+ i += 4
257
+ end
258
+ when 48
259
+ if params[i + 1].to_i == 5 && params[i + 2]
260
+ style.bg = params[i + 2].to_i
261
+ i += 2
262
+ elsif params[i + 1].to_i == 2 && params[i + 2] && params[i + 3] && params[i + 4]
263
+ style.bg = [params[i + 2].to_i, params[i + 3].to_i, params[i + 4].to_i]
264
+ i += 4
265
+ end
266
+ end
267
+ i += 1
268
+ end
269
+ style.normalize!
270
+ end
271
+
272
+ def self.sgr_for(style)
273
+ codes = []
274
+ codes << 1 if style.bold
275
+ codes << 3 if style.italic
276
+ codes << 4 if style.underline
277
+ codes << 9 if style.strike
278
+ codes << 7 if style.reverse
279
+
280
+ if style.fg
281
+ if style.fg.between?(0, 7)
282
+ codes << 30 + style.fg
283
+ elsif style.fg.between?(8, 15)
284
+ codes << 90 + (style.fg - 8)
285
+ else
286
+ codes.push(38, 5, style.fg)
287
+ end
288
+ end
289
+
290
+ if style.bg
291
+ if style.bg.between?(0, 7)
292
+ codes << 40 + style.bg
293
+ elsif style.bg.between?(8, 15)
294
+ codes << 100 + (style.bg - 8)
295
+ else
296
+ codes.push(48, 5, style.bg)
297
+ end
298
+ end
299
+
300
+ if codes.empty?
301
+ ""
302
+ else
303
+ "\e[#{codes.join(';')}m"
304
+ end
305
+ end
306
+
307
+ def self.plain_text(str)
308
+ segment(str).map { |text, _style| text }.join
309
+ end
310
+
311
+ # Return the screen width taken up by an ANSI-encoded sequence, taking
312
+ # into account Unicode combining characters.
313
+ def self.visible_length(str)
314
+ segment(str.to_s).sum do |segment_text, _style|
315
+ segment_text.each_char.count { |ch| visible_char?(ch) }
316
+ end
317
+ end
318
+
319
+ # Don't count a "combining character" as visible in the sense that it
320
+ # contributes to width. It overlays the prior character so it does not
321
+ # add to the visible width.
322
+ def self.visible_char?(ch)
323
+ !ch.match?(COMBINING_MARK_RE)
324
+ end
325
+
326
+ def self.truncate_visible(text, max_width)
327
+ max_width = max_width.to_i
328
+ return "" if max_width <= 0
329
+
330
+ out = +""
331
+ visible = 0
332
+ scanner = StringScanner.new(text.to_s)
333
+
334
+ until scanner.eos? || visible >= max_width
335
+ if (esc = scanner.scan(ANSI_ESCAPE))
336
+ out << esc
337
+ else
338
+ ch = scanner.getch
339
+ width = visible_length(ch)
340
+ break if visible + width > max_width
341
+
342
+ out << ch
343
+ visible += width
344
+ end
345
+ end
346
+ out
347
+ end
348
+
349
+ private_class_method :consume_escape!, :parse_params, :apply_sgr!
350
+ private_class_method :merge_adjacent_segments, :same_style?, :sgr_for
351
+ end
352
+ end
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Color
5
+ DEFAULT_INDEX = -1
6
+
7
+ # Expected location for a bundled X11 rgb.txt (you provide it in the repo).
8
+ # Recommended path:
9
+ # lib/fatty/color/rgb.txt
10
+ RGB_TXT_PATH = File.expand_path("rgb.txt", __dir__)
11
+
12
+ # ANSI 0..15 names (de-facto standard names)
13
+ ANSI_NAMES = {
14
+ "black" => 0,
15
+ "red" => 1,
16
+ "green" => 2,
17
+ "yellow" => 3,
18
+ "blue" => 4,
19
+ "magenta" => 5,
20
+ "cyan" => 6,
21
+ "white" => 7,
22
+ "bright_black" => 8,
23
+ "bright_red" => 9,
24
+ "bright_green" => 10,
25
+ "bright_yellow" => 11,
26
+ "bright_blue" => 12,
27
+ "bright_magenta" => 13,
28
+ "bright_cyan" => 14,
29
+ "bright_white" => 15,
30
+ "gray" => 8,
31
+ "grey" => 8,
32
+ "bright_gray" => 15,
33
+ "bright_grey" => 15,
34
+ "default" => DEFAULT_INDEX,
35
+ }.freeze
36
+
37
+ # Small alias set (xterm-256 indices). Keep this small + opinionated.
38
+ # Users can always use integers/hex/X11 names.
39
+ ALIASES_256 = {
40
+ "navy" => 17,
41
+ "dark_blue" => 18,
42
+ "orange" => 208,
43
+ "pink" => 205,
44
+ "violet" => 141,
45
+ "sky" => 117,
46
+ "teal" => 37,
47
+ "lime" => 118,
48
+ "dark_grey" => 238,
49
+ "dark_gray" => 238,
50
+ "grey" => 244,
51
+ "gray" => 244,
52
+ "light_grey" => 250,
53
+ "light_gray" => 250,
54
+ }.freeze
55
+
56
+ # Approximate RGB for xterm-style ANSI 0..15.
57
+ # Used only when down-mapping to <=16 colors.
58
+ ANSI_RGB = {
59
+ 0 => [0, 0, 0],
60
+ 1 => [205, 0, 0],
61
+ 2 => [0, 205, 0],
62
+ 3 => [205, 205, 0],
63
+ 4 => [0, 0, 238],
64
+ 5 => [205, 0, 205],
65
+ 6 => [0, 205, 205],
66
+ 7 => [229, 229, 229],
67
+ 8 => [127, 127, 127],
68
+ 9 => [255, 0, 0],
69
+ 10 => [0, 255, 0],
70
+ 11 => [255, 255, 0],
71
+ 12 => [92, 92, 255],
72
+ 13 => [255, 0, 255],
73
+ 14 => [0, 255, 255],
74
+ 15 => [255, 255, 255],
75
+ }.freeze
76
+
77
+ # Resolve a color specification to an integer color index suitable for curses init_pair.
78
+ #
79
+ # Accepts:
80
+ # - Integer (e.g. 17, 226, -1)
81
+ # - ANSI name (e.g. "yellow", "bright_blue", "default")
82
+ # - Alias (e.g. "navy")
83
+ # - Hex string (#RRGGBB or #RGB)
84
+ # - X11 name (e.g. "MidnightBlue") resolved via bundled rgb.txt
85
+ #
86
+ # available_colors:
87
+ # - If <= 16, any resolved 256-color index is down-mapped to nearest ANSI 0..15.
88
+ # - If > 16, returns xterm-256 indices 0..255 (or -1 for default).
89
+ def self.resolve(spec, available_colors: 256)
90
+ spec_norm = spec
91
+
92
+ idx =
93
+ if spec_norm.is_a?(Integer)
94
+ spec_norm
95
+ elsif spec_norm.nil?
96
+ DEFAULT_INDEX
97
+ else
98
+ resolve_stringish(spec_norm.to_s, available_colors: available_colors)
99
+ end
100
+
101
+ if idx == DEFAULT_INDEX
102
+ idx
103
+ else
104
+ clamp_index(idx, available_colors: available_colors)
105
+ end
106
+ end
107
+
108
+ def self.resolve_stringish(str, available_colors: 256)
109
+ s = normalize_name(str)
110
+ # Disambiguation prefixes:
111
+ # - "ansi:yellow" forces ANSI 0..15 names
112
+ # - "x11:yellow" forces X11 rgb.txt lookup
113
+ #
114
+ # Without a prefix, prefer X11 names when 256 colors are available so
115
+ # common names like "yellow" match X11 "#FFFF00" rather than ANSI 3.
116
+ mode = nil
117
+ if s.start_with?("ansi:")
118
+ mode = :ansi
119
+ s = s.delete_prefix("ansi:")
120
+ elsif s.start_with?("x11:")
121
+ mode = :x11
122
+ s = s.delete_prefix("x11:")
123
+ end
124
+
125
+ idx = ALIASES_256[s]
126
+ return idx unless idx.nil?
127
+
128
+ rgb = parse_hex(s)
129
+ return xterm_index_for_rgb(rgb[0], rgb[1], rgb[2]) if rgb
130
+
131
+ prefer_x11 = (mode == :x11) || (mode.nil? && available_colors.to_i >= 256)
132
+
133
+ if prefer_x11
134
+ rgb2 = x11_rgb_for_name(s)
135
+ return xterm_index_for_rgb(rgb2[0], rgb2[1], rgb2[2]) if rgb2
136
+ end
137
+
138
+ idx = ANSI_NAMES[s]
139
+ return idx unless idx.nil?
140
+
141
+ unless prefer_x11
142
+ rgb2 = x11_rgb_for_name(s)
143
+ return xterm_index_for_rgb(rgb2[0], rgb2[1], rgb2[2]) if rgb2
144
+ end
145
+
146
+ # Unknown name: treat as default.
147
+ DEFAULT_INDEX
148
+ end
149
+
150
+ def self.normalize_name(str)
151
+ # normalize spaces/underscores/hyphens and case, so:
152
+ # "Light Sky Blue" == "light_sky_blue" == "lightskyblue"
153
+ s = str.to_s.strip.downcase
154
+ s = s.tr("-", "_")
155
+ s = s.gsub(/\s+/, "")
156
+ s
157
+ end
158
+
159
+ def self.parse_hex(s)
160
+ # Accept "#rgb" or "#rrggbb"
161
+ hex = s
162
+ if hex.start_with?("#")
163
+ hex = hex[1..]
164
+ end
165
+
166
+ if hex.match?(/\A[0-9a-f]{3}\z/i)
167
+ r = (hex[0] * 2).to_i(16)
168
+ g = (hex[1] * 2).to_i(16)
169
+ b = (hex[2] * 2).to_i(16)
170
+ [r, g, b]
171
+ elsif hex.match?(/\A[0-9a-f]{6}\z/i)
172
+ r = hex[0, 2].to_i(16)
173
+ g = hex[2, 2].to_i(16)
174
+ b = hex[4, 2].to_i(16)
175
+ [r, g, b]
176
+ end
177
+ end
178
+
179
+ def self.clamp_index(idx, available_colors:)
180
+ max = available_colors.to_i - 1
181
+ 0 if max < 0
182
+
183
+ i = idx.to_i
184
+
185
+ if available_colors.to_i <= 16
186
+ downmap_to_ansi16(i)
187
+ else
188
+ i.clamp(0, 255)
189
+ end
190
+ end
191
+
192
+ def self.rgb(spec)
193
+ if spec.is_a?(Integer)
194
+ xterm_rgb_for_index(spec)
195
+ elsif spec.nil?
196
+ nil
197
+ else
198
+ s = normalize_name(spec.to_s)
199
+
200
+ if s.start_with?("ansi:")
201
+ s = s.delete_prefix("ansi:")
202
+ elsif s.start_with?("x11:")
203
+ s = s.delete_prefix("x11:")
204
+ end
205
+
206
+ idx = ALIASES_256[s]
207
+ if idx
208
+ xterm_rgb_for_index(idx)
209
+ else
210
+ parsed = parse_hex(s)
211
+ parsed ||= x11_rgb_for_name(s)
212
+ parsed ||= begin
213
+ ansi = ANSI_NAMES[s]
214
+ xterm_rgb_for_index(ansi) unless ansi == DEFAULT_INDEX || ansi.nil?
215
+ end
216
+ parsed
217
+ end
218
+ end
219
+ end
220
+
221
+ # Convert any RGB to an xterm-256 index (16..255).
222
+ # We consider both the 6x6x6 cube and the grayscale ramp and pick the closer.
223
+ def self.xterm_index_for_rgb(r, g, b)
224
+ rr = clamp_byte(r)
225
+ gg = clamp_byte(g)
226
+ bb = clamp_byte(b)
227
+
228
+ cube = xterm_cube_index(rr, gg, bb)
229
+ gray = xterm_gray_index(rr, gg, bb)
230
+
231
+ cube_rgb = xterm_rgb_for_index(cube)
232
+ gray_rgb = xterm_rgb_for_index(gray)
233
+
234
+ if dist2(rr, gg, bb, gray_rgb[0], gray_rgb[1], gray_rgb[2]) <
235
+ dist2(rr, gg, bb, cube_rgb[0], cube_rgb[1], cube_rgb[2])
236
+ gray
237
+ else
238
+ cube
239
+ end
240
+ end
241
+
242
+ def self.xterm_cube_index(r, g, b)
243
+ levels = [0, 95, 135, 175, 215, 255]
244
+ ri = nearest_index(levels, r)
245
+ gi = nearest_index(levels, g)
246
+ bi = nearest_index(levels, b)
247
+ 16 + (36 * ri) + (6 * gi) + bi
248
+ end
249
+
250
+ def self.xterm_gray_index(r, g, b)
251
+ # grayscale ramp 232..255 maps to levels 8 + 10*n
252
+ avg = (r + g + b) / 3
253
+ if avg < 8
254
+ 16 # near black; keep in extended palette, not ANSI black
255
+ elsif avg > 238
256
+ 231 # near white; a cube white
257
+ else
258
+ n = ((avg - 8) / 10.0).round
259
+ n = 0 if n < 0
260
+ n = 23 if n > 23
261
+ 232 + n
262
+ end
263
+ end
264
+
265
+ def self.xterm_rgb_for_index(idx)
266
+ i = idx.to_i
267
+
268
+ if i.between?(232, 255)
269
+ v = 8 + (i - 232) * 10
270
+ [v, v, v]
271
+ elsif i.between?(16, 231)
272
+ j = i - 16
273
+ r = j / 36
274
+ g = (j % 36) / 6
275
+ b = j % 6
276
+ levels = [0, 95, 135, 175, 215, 255]
277
+ [levels[r], levels[g], levels[b]]
278
+ else
279
+ # for 0..15 we use ANSI_RGB as an approximation
280
+ ANSI_RGB[i] || [0, 0, 0]
281
+ end
282
+ end
283
+
284
+ def self.downmap_to_ansi16(idx)
285
+ if idx == DEFAULT_INDEX
286
+ DEFAULT_INDEX
287
+ elsif idx.between?(0, 15)
288
+ idx
289
+ else
290
+ rgb = xterm_rgb_for_index(idx)
291
+ nearest_ansi_index(rgb[0], rgb[1], rgb[2])
292
+ end
293
+ end
294
+
295
+ def self.nearest_ansi_index(r, g, b)
296
+ best = 0
297
+ best_d = nil
298
+
299
+ ANSI_RGB.each do |i, rgb|
300
+ d = dist2(r, g, b, rgb[0], rgb[1], rgb[2])
301
+ if best_d.nil? || d < best_d
302
+ best_d = d
303
+ best = i
304
+ end
305
+ end
306
+
307
+ best
308
+ end
309
+
310
+ def self.nearest_index(levels, value)
311
+ v = value.to_i
312
+ best_i = 0
313
+ best_d = nil
314
+
315
+ levels.each_with_index do |lvl, i|
316
+ d = (lvl - v).abs
317
+ if best_d.nil? || d < best_d
318
+ best_d = d
319
+ best_i = i
320
+ end
321
+ end
322
+
323
+ best_i
324
+ end
325
+
326
+ def self.clamp_byte(v)
327
+ x = v.to_i
328
+ x = 0 if x < 0
329
+ x = 255 if x > 255
330
+ x
331
+ end
332
+
333
+ def self.dist2(r1, g1, b1, r2, g2, b2)
334
+ dr = r1 - r2
335
+ dg = g1 - g2
336
+ db = b1 - b2
337
+ (dr * dr) + (dg * dg) + (db * db)
338
+ end
339
+
340
+ def self.x11_rgb_for_name(name_norm)
341
+ table = x11_table
342
+ table[name_norm]
343
+ end
344
+
345
+ def self.x11_table
346
+ path = RGB_TXT_PATH
347
+
348
+ if @x11_table_path != path
349
+ @x11_table = load_x11_rgb_txt(path)
350
+ @x11_table_path = path
351
+ end
352
+ @x11_table
353
+ end
354
+
355
+ def self.load_x11_rgb_txt(path)
356
+ table = {}
357
+
358
+ if File.file?(path)
359
+ File.foreach(path) do |line|
360
+ next if line.strip.empty?
361
+ next if line.lstrip.start_with?("!")
362
+
363
+ # Format: R G B <name...>
364
+ parts = line.strip.split(/\s+/)
365
+ next if parts.length < 4
366
+
367
+ r = parts[0].to_i
368
+ g = parts[1].to_i
369
+ b = parts[2].to_i
370
+ name = parts[3..].join(" ")
371
+ key = normalize_name(name)
372
+ table[key] = [r, g, b]
373
+ end
374
+ end
375
+
376
+ table
377
+ end
378
+ end
379
+ end