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.
- checksums.yaml +7 -0
- data/.envrc +2 -0
- data/.simplecov +23 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +34 -0
- data/CHANGELOG.org +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +31 -0
- data/README.org +166 -0
- data/Rakefile +15 -0
- data/TODO.org +163 -0
- data/examples/markdown/native-markdown.md +370 -0
- data/examples/markdown/ox-gfm-markdown.md +373 -0
- data/examples/markdown/ox-gfm-markdown.org +376 -0
- data/exe/fatty +275 -0
- data/fatty.gemspec +42 -0
- data/lib/fatty/accept_env.rb +32 -0
- data/lib/fatty/action.rb +103 -0
- data/lib/fatty/action_environment.rb +42 -0
- data/lib/fatty/actionable.rb +73 -0
- data/lib/fatty/alert.rb +93 -0
- data/lib/fatty/ansi/renderer.rb +168 -0
- data/lib/fatty/ansi.rb +352 -0
- data/lib/fatty/colors/color.rb +379 -0
- data/lib/fatty/colors/pairs.rb +73 -0
- data/lib/fatty/colors/palette.rb +73 -0
- data/lib/fatty/colors/rgb.txt +788 -0
- data/lib/fatty/colors.rb +5 -0
- data/lib/fatty/config.rb +86 -0
- data/lib/fatty/config_files/config.yml +50 -0
- data/lib/fatty/config_files/help.md +120 -0
- data/lib/fatty/config_files/help.org +124 -0
- data/lib/fatty/config_files/keybindings.yml +49 -0
- data/lib/fatty/config_files/keydefs.yml +23 -0
- data/lib/fatty/config_files/themes/mono.yml +76 -0
- data/lib/fatty/config_files/themes/nordic.yml +77 -0
- data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
- data/lib/fatty/config_files/themes/terminal.yml +90 -0
- data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
- data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
- data/lib/fatty/core_ext/string.rb +21 -0
- data/lib/fatty/core_ext.rb +3 -0
- data/lib/fatty/counter.rb +81 -0
- data/lib/fatty/curses/context.rb +279 -0
- data/lib/fatty/curses/curses_coder.rb +684 -0
- data/lib/fatty/curses/event_source.rb +230 -0
- data/lib/fatty/curses/key_decoder.rb +183 -0
- data/lib/fatty/curses/patch.rb +116 -0
- data/lib/fatty/curses/window_styling.rb +32 -0
- data/lib/fatty/curses.rb +16 -0
- data/lib/fatty/env.rb +100 -0
- data/lib/fatty/help.rb +41 -0
- data/lib/fatty/history/entry.rb +71 -0
- data/lib/fatty/history.rb +289 -0
- data/lib/fatty/input_buffer.rb +998 -0
- data/lib/fatty/input_field.rb +507 -0
- data/lib/fatty/key_event.rb +342 -0
- data/lib/fatty/key_map.rb +392 -0
- data/lib/fatty/keymaps/emacs.rb +189 -0
- data/lib/fatty/log_formats/json.rb +47 -0
- data/lib/fatty/log_formats/text.rb +67 -0
- data/lib/fatty/logger.rb +142 -0
- data/lib/fatty/markdown/ansi_renderer.rb +373 -0
- data/lib/fatty/markdown/render.rb +22 -0
- data/lib/fatty/markdown.rb +4 -0
- data/lib/fatty/menu_env.rb +22 -0
- data/lib/fatty/mouse_event.rb +32 -0
- data/lib/fatty/output_buffer.rb +78 -0
- data/lib/fatty/pager.rb +801 -0
- data/lib/fatty/prompt.rb +40 -0
- data/lib/fatty/renderer/curses.rb +697 -0
- data/lib/fatty/renderer/truecolor.rb +607 -0
- data/lib/fatty/renderer.rb +419 -0
- data/lib/fatty/screen.rb +96 -0
- data/lib/fatty/search.rb +43 -0
- data/lib/fatty/session/alert_session.rb +52 -0
- data/lib/fatty/session/input_session.rb +99 -0
- data/lib/fatty/session/isearch_session.rb +172 -0
- data/lib/fatty/session/keytest_session.rb +236 -0
- data/lib/fatty/session/modal_session.rb +61 -0
- data/lib/fatty/session/output_session.rb +105 -0
- data/lib/fatty/session/popup_session.rb +540 -0
- data/lib/fatty/session/prompt_session.rb +157 -0
- data/lib/fatty/session/search_session.rb +136 -0
- data/lib/fatty/session/shell_session.rb +566 -0
- data/lib/fatty/session.rb +173 -0
- data/lib/fatty/sessions.rb +14 -0
- data/lib/fatty/terminal/popup_owner.rb +26 -0
- data/lib/fatty/terminal/progress.rb +374 -0
- data/lib/fatty/terminal.rb +1067 -0
- data/lib/fatty/themes/loader.rb +136 -0
- data/lib/fatty/themes/manager.rb +71 -0
- data/lib/fatty/themes/registry.rb +64 -0
- data/lib/fatty/themes/resolver.rb +224 -0
- data/lib/fatty/themes/themes.rb +131 -0
- data/lib/fatty/themes.rb +6 -0
- data/lib/fatty/version.rb +5 -0
- data/lib/fatty/view/alert_view.rb +14 -0
- data/lib/fatty/view/cursor_view.rb +18 -0
- data/lib/fatty/view/input_view.rb +9 -0
- data/lib/fatty/view/output_view.rb +9 -0
- data/lib/fatty/view/status_view.rb +14 -0
- data/lib/fatty/view.rb +33 -0
- data/lib/fatty/viewport.rb +90 -0
- data/lib/fatty/views.rb +9 -0
- data/lib/fatty.rb +55 -0
- data/sig/fatty.rbs +4 -0
- 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
|