grmenu 2.0.0 → 3.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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/GRmenu.rb +1570 -319
  3. data/README.md +425 -264
  4. data/data/help.txt +304 -0
  5. metadata +7 -3
data/GRmenu.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'io/console'
4
4
  require 'json'
5
+ require 'zlib'
6
+ require 'open3'
5
7
 
6
8
  class GRmenu
7
9
  CLEAR_SCREEN_SEQUENCE = "\e[H\e[2J\e[3J"
@@ -11,6 +13,58 @@ class GRmenu
11
13
  CLEAR_TO_EOL = "\e[K"
12
14
  CLEAR_TO_EOS = "\e[J"
13
15
 
16
+ def self.find_data_file(filename)
17
+ local_path = File.expand_path("data/#{filename}", __dir__)
18
+ return local_path if File.exist?(local_path)
19
+ parent_path = File.expand_path("../data/#{filename}", __dir__)
20
+ return parent_path if File.exist?(parent_path)
21
+ nil
22
+ end
23
+
24
+ def self.load_json_data(filename)
25
+ path = find_data_file(filename)
26
+ return {} unless path && File.exist?(path)
27
+ JSON.parse(File.read(path))
28
+ rescue StandardError
29
+ {}
30
+ end
31
+
32
+ COLORS = load_json_data('colors.json').freeze
33
+ BORDERS = load_json_data('borders.json').transform_keys(&:to_i).transform_values { |v| v.transform_keys(&:to_sym) }.freeze
34
+ FONTS = load_json_data('fonts.json').transform_keys(&:to_i).freeze
35
+
36
+ FONT_1 = FONTS[1] || {}
37
+ FONT_2 = FONTS[2] || {}
38
+ FONT_3 = FONTS[3] || {}
39
+ FONT_4 = FONTS[4] || {}
40
+ FONT_5 = FONTS[5] || {}
41
+ FONT_6 = FONTS[6] || {}
42
+ FONT_7 = FONTS[7] || {}
43
+ FONT_8 = FONTS[8] || {}
44
+ FONT_9 = FONTS[9] || {}
45
+ FONT_10 = FONTS[10] || {}
46
+
47
+ def self.rgb_color(tick, offset = 0.0)
48
+ t = tick.to_f + offset.to_f
49
+ r = (Math.sin(t) * 127 + 128).clamp(0, 255).to_i
50
+ g = (Math.sin(t + 2.0943951) * 127 + 128).clamp(0, 255).to_i
51
+ b = (Math.sin(t + 4.1887902) * 127 + 128).clamp(0, 255).to_i
52
+ "\e[38;2;#{r};#{g};#{b}m"
53
+ end
54
+
55
+ def self.ansi_color(color_name, level = 1)
56
+ name = color_name.to_s.downcase
57
+ return rgb_color(0.0) if name == "rgb" || name == "rainbow" || name == "chroma"
58
+ lvl_str = level.to_s
59
+ code_raw = COLORS.dig(name, lvl_str) || COLORS.dig(name, level.to_i) || COLORS[name]
60
+ return "\e[#{code_raw}" if code_raw
61
+ "\e[37m"
62
+ end
63
+
64
+ def self.ansi_reset
65
+ "\e[0m"
66
+ end
67
+
14
68
  module Color
15
69
  RESET = "\e[0m"
16
70
  BOLD = "\e[1m"
@@ -35,10 +89,51 @@ class GRmenu
35
89
  module_function
36
90
 
37
91
  def paint(text, color_name, level = 1)
92
+ c_str = color_name.to_s.downcase
93
+ if c_str == "rgb" || c_str == "rainbow" || c_str == "chroma"
94
+ return rgb(text)
95
+ end
38
96
  code = CODES.dig(color_name.to_sym, level) || "\e[37m"
39
97
  "#{code}#{text}#{RESET}"
40
98
  end
41
99
 
100
+ def rgb(text, offset = 0.0)
101
+ out = String.new("")
102
+ idx = 0
103
+ in_escape = false
104
+ escape_buf = String.new("")
105
+
106
+ text.to_s.each_char do |ch|
107
+ if ch == "\e"
108
+ in_escape = true
109
+ escape_buf << ch
110
+ next
111
+ end
112
+ if in_escape
113
+ escape_buf << ch
114
+ if ch =~ /[a-zA-Z]/
115
+ in_escape = false
116
+ out << escape_buf
117
+ escape_buf.clear
118
+ end
119
+ next
120
+ end
121
+
122
+ if ch == " " || ch == "\n" || ch == "\r" || ch == "\t"
123
+ out << ch
124
+ else
125
+ t = idx * 0.12 + offset
126
+ r = (Math.sin(t) * 127 + 128).clamp(0, 255).to_i
127
+ g = (Math.sin(t + 2.0943951) * 127 + 128).clamp(0, 255).to_i
128
+ b = (Math.sin(t + 4.1887902) * 127 + 128).clamp(0, 255).to_i
129
+ out << "\e[38;2;#{r};#{g};#{b}m#{ch}"
130
+ idx += 1
131
+ end
132
+ end
133
+ out << RESET
134
+ out
135
+ end
136
+
42
137
  def red(s); paint(s, :red, 1); end
43
138
  def bright_red(s); paint(s, :red, 2); end
44
139
  def dark_red(s); paint(s, :red, 1); end
@@ -98,69 +193,345 @@ class GRmenu
98
193
  16 => "~", 17 => "-", 18 => "◆", 19 => "●", 20 => "★"
99
194
  }.freeze
100
195
 
101
- COLORS = {
102
- "black" => { 1 => "\e[30m", 2 => "\e[90m" },
103
- "gray" => { 1 => "\e[90m", 2 => "\e[38;5;245m" },
104
- "grey" => { 1 => "\e[90m", 2 => "\e[38;5;245m" },
105
- "red" => { 1 => "\e[31m", 2 => "\e[91m" },
106
- "green" => { 1 => "\e[32m", 2 => "\e[92m" },
107
- "yellow" => { 1 => "\e[33m", 2 => "\e[93m" },
108
- "blue" => { 1 => "\e[34m", 2 => "\e[94m" },
109
- "magenta" => { 1 => "\e[35m", 2 => "\e[95m" },
110
- "purple" => { 1 => "\e[38;5;129m", 2 => "\e[38;5;141m" },
111
- "pink" => { 1 => "\e[38;5;205m", 2 => "\e[38;5;218m" },
112
- "cyan" => { 1 => "\e[36m", 2 => "\e[96m" },
113
- "aqua" => { 1 => "\e[38;5;45m", 2 => "\e[38;5;51m" },
114
- "orange" => { 1 => "\e[38;5;208m", 2 => "\e[38;5;214m" },
115
- "white" => { 1 => "\e[37m", 2 => "\e[97m" },
116
- "reset" => "\e[0m"
117
- }.freeze
196
+ class PNGDecoder
197
+ attr_reader :width, :height, :pixels
118
198
 
119
- BORDERS = {
120
- 1 => { h: "=-", v: "|", tl: "#", tr: "#", bl: "#", br: "#" },
121
- 2 => { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" },
122
- 3 => { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" },
123
- 4 => { h: "━", v: "┃", tl: "┏", tr: "┓", bl: "┗", br: "┛" },
124
- 5 => { h: "═", v: "│", tl: "╒", tr: "╕", bl: "╘", br: "╛" },
125
- 6 => { h: "─", v: "║", tl: "╓", tr: "╖", bl: "╙", br: "╜" },
126
- 7 => { h: "─", v: "│", tl: "╭", tr: "╮", bl: "╰", br: "╯" },
127
- 8 => { h: "▀", hb: "▄", v: "▌", vl: "", vr: "▐", tl: "▛", tr: "▜", bl: "▙", br: "▟" },
128
- 19 => { h: "●○", v: "●", tl: "●", tr: "●", bl: "●", br: "●" },
129
- 20 => { h: "★☆", v: "★", tl: "★", tr: "★", bl: "★", br: "★" }
130
- }.freeze
199
+ def self.load(filepath)
200
+ return nil unless filepath && File.exist?(filepath)
201
+ new.parse(File.binread(filepath))
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
206
+ def parse(data)
207
+ return nil unless data && data[0, 8] == "\x89PNG\r\n\x1a\n".b
208
+
209
+ offset = 8
210
+ idat_data = String.new("".b)
211
+ palette = nil
212
+
213
+ while offset < data.bytesize
214
+ len = data[offset, 4].unpack1("N")
215
+ type = data[offset + 4, 4]
216
+ chunk_data = data[offset + 8, len]
217
+ offset += 12 + len
218
+
219
+ case type
220
+ when "IHDR"
221
+ @width, @height, @bit_depth, @color_type = chunk_data.unpack("NNCC")
222
+ when "PLTE"
223
+ palette = chunk_data.bytes.each_slice(3).to_a
224
+ when "IDAT"
225
+ idat_data << chunk_data
226
+ when "IEND"
227
+ break
228
+ end
229
+ end
131
230
 
132
- def self._normalize_font(f)
133
- normalized = {}
134
- f.each do |key, lines|
135
- max_w = lines.map(&:length).max
136
- normalized[key] = lines.map { |line| line.ljust(max_w) }.freeze
231
+ channels = case @color_type
232
+ when 0 then 1
233
+ when 2 then 3
234
+ when 3 then 1
235
+ when 4 then 2
236
+ when 6 then 4
237
+ else return nil
238
+ end
239
+
240
+ bpp = [(@bit_depth * channels + 7) / 8, 1].max
241
+ stride = (@width * channels * @bit_depth + 7) / 8
242
+ scanline_len = stride + 1
243
+
244
+ raw = Zlib::Inflate.inflate(idat_data)
245
+ raw_bytes = raw.bytes
246
+ return nil if raw_bytes.length < (@height * scanline_len)
247
+
248
+ @pixels = Array.new(@height) { Array.new(@width) }
249
+ prev_row = Array.new(stride, 0)
250
+
251
+ @height.times do |y|
252
+ row_start = y * scanline_len
253
+ filter_type = raw_bytes[row_start]
254
+ curr_filtered = raw_bytes[(row_start + 1)...(row_start + scanline_len)]
255
+ curr_recon = Array.new(stride, 0)
256
+
257
+ stride.times do |i|
258
+ a = (i >= bpp) ? curr_recon[i - bpp] : 0
259
+ b = prev_row[i]
260
+ c = (i >= bpp) ? prev_row[i - bpp] : 0
261
+ x = curr_filtered[i]
262
+
263
+ recon_val = case filter_type
264
+ when 0 then x
265
+ when 1 then (x + a) & 0xFF
266
+ when 2 then (x + b) & 0xFF
267
+ when 3 then (x + ((a + b) / 2)) & 0xFF
268
+ when 4
269
+ p_val = a + b - c
270
+ pa = (p_val - a).abs
271
+ pb = (p_val - b).abs
272
+ pc = (p_val - c).abs
273
+ pr = if pa <= pb && pa <= pc
274
+ a
275
+ elsif pb <= pc
276
+ b
277
+ else
278
+ c
279
+ end
280
+ (x + pr) & 0xFF
281
+ else x
282
+ end
283
+ curr_recon[i] = recon_val
284
+ end
285
+
286
+ prev_row = curr_recon
287
+
288
+ if @bit_depth == 16
289
+ @width.times do |x|
290
+ idx = x * channels * 2
291
+ r = curr_recon[idx]
292
+ g = (channels >= 3) ? curr_recon[idx + 2] : r
293
+ b = (channels >= 3) ? curr_recon[idx + 4] : r
294
+ a = (channels == 4) ? curr_recon[idx + 6] : (channels == 2 ? curr_recon[idx + 2] : 255)
295
+ @pixels[y][x] = [r, g, b, a]
296
+ end
297
+ elsif @bit_depth == 8
298
+ @width.times do |x|
299
+ idx = x * channels
300
+ if @color_type == 3
301
+ p_idx = curr_recon[idx]
302
+ rgb_val = palette ? (palette[p_idx] || [0, 0, 0]) : [0, 0, 0]
303
+ @pixels[y][x] = [rgb_val[0], rgb_val[1], rgb_val[2], 255]
304
+ else
305
+ r = curr_recon[idx]
306
+ g = (channels >= 3) ? curr_recon[idx + 1] : r
307
+ b = (channels >= 3) ? curr_recon[idx + 2] : r
308
+ a = (channels == 4 || channels == 2) ? curr_recon[idx + channels - 1] : 255
309
+ @pixels[y][x] = [r, g, b, a]
310
+ end
311
+ end
312
+ end
313
+ end
314
+ self
315
+ end
316
+
317
+ def resample(target_w, target_h)
318
+ resampled = Array.new(target_h) { Array.new(target_w) }
319
+ x_step = @width.to_f / target_w
320
+ y_step = @height.to_f / target_h
321
+
322
+ target_h.times do |ty|
323
+ sy_start = (ty * y_step).to_i
324
+ sy_end = [((ty + 1) * y_step).to_i, @height].min
325
+
326
+ target_w.times do |tx|
327
+ sx_start = (tx * x_step).to_i
328
+ sx_end = [((tx + 1) * x_step).to_i, @width].min
329
+
330
+ r_sum = g_sum = b_sum = a_sum = count = 0
331
+
332
+ (sy_start...sy_end).each do |sy|
333
+ (sx_start...sx_end).each do |sx|
334
+ p = @pixels[sy][sx]
335
+ next unless p
336
+ if p[3] > 10
337
+ r_sum += p[0]
338
+ g_sum += p[1]
339
+ b_sum += p[2]
340
+ a_sum += p[3]
341
+ count += 1
342
+ end
343
+ end
344
+ end
345
+
346
+ if count > 0
347
+ resampled[ty][tx] = [(r_sum / count).clamp(0, 255), (g_sum / count).clamp(0, 255), (b_sum / count).clamp(0, 255), (a_sum / count).clamp(0, 255)]
348
+ else
349
+ mid_y = (sy_start + sy_end) / 2
350
+ mid_x = (sx_start + sx_end) / 2
351
+ resampled[ty][tx] = @pixels[mid_y][mid_x] || [0, 0, 0, 0]
352
+ end
353
+ end
354
+ end
355
+ resampled
356
+ end
357
+
358
+ def render_ansi_lines(target_w = 40, target_h = nil)
359
+ target_h ||= [((@height.to_f / @width) * target_w).round, 2].max
360
+ target_h += 1 if target_h.odd?
361
+
362
+ grid = resample(target_w, target_h)
363
+ lines = []
364
+
365
+ (0...target_h).step(2) do |y|
366
+ row_top = grid[y]
367
+ row_bot = grid[y + 1] || grid[y]
368
+ line = String.new("")
369
+
370
+ target_w.times do |x|
371
+ r1, g1, b1, a1 = row_top[x]
372
+ r2, g2, b2, a2 = row_bot[x]
373
+
374
+ if a1 < 32 && a2 < 32
375
+ line << "\e[0m "
376
+ elsif a1 < 32
377
+ line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
378
+ elsif a2 < 32
379
+ line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
380
+ else
381
+ line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
382
+ end
383
+ end
384
+ line << "\e[0m"
385
+ lines << line
386
+ end
387
+
388
+ lines
137
389
  end
138
- normalized.freeze
139
390
  end
140
391
 
141
- def self._load_fonts
142
- possible_paths = [
143
- File.expand_path("data/fonts.json", __dir__),
144
- File.expand_path("../data/fonts.json", __dir__),
145
- File.expand_path("fonts.json", __dir__)
146
- ]
147
- path = possible_paths.find { |p| File.file?(p) }
148
- return {}.freeze unless path
392
+ def self.char_width(char)
393
+ code = char.ord
394
+ return 0 if code == 0 || code == 0xFE0F || code == 0xFE0E || (code >= 0x0300 && code <= 0x036F) || (code >= 0x200B && code <= 0x200F)
395
+ return 0 if code < 32 || (code >= 0x7F && code < 0xA0)
396
+ return 1 if code == 0x1F5BC || code == 0x1F5B4 || code == 0x1F5B5 || code == 0x1F5C2
397
+ if (code >= 0x1100 && code <= 0x115F) ||
398
+ (code >= 0x2329 && code <= 0x232A) ||
399
+ (code >= 0x2E80 && code <= 0xA4CF && code != 0x303F) ||
400
+ (code >= 0xAC00 && code <= 0xD7A3) ||
401
+ (code >= 0xF900 && code <= 0xFAFF) ||
402
+ (code >= 0xFE10 && code <= 0xFE19) ||
403
+ (code >= 0xFE30 && code <= 0xFE6F) ||
404
+ (code >= 0xFF01 && code <= 0xFF60) ||
405
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
406
+ (code >= 0x1F300 && code <= 0x1F6FF) ||
407
+ (code >= 0x1F900 && code <= 0x1FAFF)
408
+ 2
409
+ else
410
+ 1
411
+ end
412
+ end
413
+
414
+ def self.display_width(str)
415
+ clean = str.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
416
+ clean.chars.map { |c| char_width(c) }.sum
417
+ end
418
+
419
+ def self.pad_to_width(str, target_width, align = :left)
420
+ current_w = display_width(str)
421
+ pad_needed = [target_width - current_w, 0].max
422
+ case align
423
+ when :right
424
+ (" " * pad_needed) + str.to_s
425
+ when :center
426
+ left_pad = " " * (pad_needed / 2)
427
+ right_pad = " " * (pad_needed - (pad_needed / 2))
428
+ left_pad + str.to_s + right_pad
429
+ else
430
+ str.to_s + (" " * pad_needed)
431
+ end
432
+ end
149
433
 
150
- raw_fonts = JSON.parse(File.read(path))
151
- loaded = {}
152
- raw_fonts.each do |font_key, chars|
153
- font_id = font_key.to_i
154
- loaded[font_id] = _normalize_font(chars)
434
+ def self.load_and_render_image(filepath, width = 40, height = nil, max_cols = terminal_width)
435
+ return [] unless filepath && File.exist?(filepath)
436
+
437
+ req_w = [width.to_i, max_cols - 6].min
438
+ req_w = [req_w, 10].max
439
+
440
+ conv_bin = `which convert 2>/dev/null`.strip
441
+ conv_bin = `which magick 2>/dev/null`.strip if conv_bin.empty?
442
+
443
+ if !conv_bin.empty?
444
+ info, _ = Open3.capture2("identify", "-format", "%w %h", filepath) rescue ["", nil]
445
+ orig_w, orig_h = info.strip.split.map(&:to_f)
446
+ aspect = (orig_w && orig_w > 0) ? (orig_h / orig_w) : 0.6
447
+ scale_h = height || (req_w * aspect).round
448
+ scale_h += 1 if scale_h.odd?
449
+ scale_h = [scale_h, 2].max
450
+
451
+ cmd = [conv_bin, filepath, "-filter", "Lanczos", "-resize", "#{req_w}x#{scale_h}!", "-depth", "8", "rgba:-"]
452
+ stdout, status = Open3.capture2(*cmd) rescue [nil, nil]
453
+ if status && status.success? && stdout.bytesize == (req_w * scale_h * 4)
454
+ raw = stdout.bytes
455
+ lines = []
456
+ (0...scale_h).step(2) do |y|
457
+ line = String.new("")
458
+ req_w.times do |x|
459
+ top_idx = (y * req_w + x) * 4
460
+ bot_idx = ((y + 1) * req_w + x) * 4
461
+ r1, g1, b1, a1 = raw[top_idx, 4]
462
+ r2, g2, b2, a2 = raw[bot_idx, 4]
463
+
464
+ if a1 < 32 && a2 < 32
465
+ line << "\e[0m "
466
+ elsif a1 < 32
467
+ line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
468
+ elsif a2 < 32
469
+ line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
470
+ else
471
+ line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
472
+ end
473
+ end
474
+ line << "\e[0m"
475
+ lines << line
476
+ end
477
+ return lines
478
+ end
479
+ end
480
+
481
+ png = PNGDecoder.load(filepath)
482
+ if png
483
+ return png.render_ansi_lines(req_w, height)
155
484
  end
156
- loaded.freeze
485
+
486
+ []
157
487
  rescue StandardError
158
- {}.freeze
488
+ []
159
489
  end
160
490
 
161
- FONTS = _load_fonts
162
- FONTS.each { |id, data| const_set("FONT_#{id}", data) }
163
- FONT = FONTS[1] || {}.freeze
491
+ def self.image(filepath, width: 40, height: nil, style: 3, color: "cyan", center: true)
492
+ term_w = terminal_width
493
+ raw_lines = load_and_render_image(filepath, width, height, term_w)
494
+ return nil if raw_lines.empty?
495
+
496
+ img_w = display_width(raw_lines.first)
497
+ box_w = img_w + 4
498
+ margin = (center && term_w > box_w) ? (" " * ((term_w - box_w) / 2)) : ""
499
+
500
+ if style && style > 0
501
+ border_cfg = BORDERS[style] || BORDERS[3]
502
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
503
+ color_code = is_rgb ? "" : ansi_color(color, 2)
504
+ reset_code = ansi_reset
505
+
506
+ h_top = border_cfg[:ht] || border_cfg[:h]
507
+ h_bot = border_cfg[:hb] || border_cfg[:h]
508
+ v_l = border_cfg[:vl] || border_cfg[:v]
509
+ v_r = border_cfg[:vr] || border_cfg[:v]
510
+
511
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
512
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
513
+
514
+ if is_rgb
515
+ Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
516
+ raw_lines.each do |line|
517
+ Kernel.print("#{margin}#{Color.rgb(v_l)} #{line} #{Color.rgb(v_r)}\r\n")
518
+ end
519
+ Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
520
+ else
521
+ Kernel.print("#{margin}#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
522
+ raw_lines.each do |line|
523
+ Kernel.print("#{margin}#{color_code}#{v_l}#{reset_code} #{line} #{color_code}#{v_r}#{reset_code}\r\n")
524
+ end
525
+ Kernel.print("#{margin}#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
526
+ end
527
+ else
528
+ raw_lines.each do |line|
529
+ Kernel.print("#{margin}#{line}\r\n")
530
+ end
531
+ end
532
+
533
+ true
534
+ end
164
535
 
165
536
  class ProgressBar
166
537
  attr_reader :total, :current, :title, :status
@@ -169,7 +540,7 @@ class GRmenu
169
540
  @total = [total.to_i, 1].max
170
541
  @current = 0
171
542
  @title = title
172
- @status = ""
543
+ @status = String.new("")
173
544
  @color = color.to_s.downcase
174
545
  @level = level.to_i
175
546
  @style = style.to_i
@@ -199,8 +570,9 @@ class GRmenu
199
570
  box_w = @width || [term_w - 4, 60].min
200
571
  box_w = [box_w, 36].max
201
572
 
202
- color_code = GRmenu::COLORS.dig(@color, @level) || "\e[1;96m"
203
- reset_code = GRmenu::COLORS["reset"]
573
+ is_rgb = (@color == "rgb" || @color == "rainbow" || @color == "chroma")
574
+ tick = (@current.to_f / @total) * 6.2831853
575
+
204
576
  border_cfg = GRmenu::BORDERS[@style] || GRmenu::BORDERS[3]
205
577
 
206
578
  v_l = border_cfg[:vl] || border_cfg[:v]
@@ -219,21 +591,58 @@ class GRmenu
219
591
  filled_len = ((@current.to_f / @total) * bar_w).round
220
592
  empty_len = bar_w - filled_len
221
593
 
222
- bar_str = "[#{"█" * filled_len}#{"░" * empty_len}] #{pct_str}"
223
- bar_line = bar_str.ljust(inner_w)[0...inner_w]
224
-
225
594
  lines = []
226
- lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
227
- if @title && !@title.empty?
228
- lines << "#{color_code}#{v_l}#{reset_code} #{@title.center(inner_w)} #{color_code}#{v_r}#{reset_code}"
229
- lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
230
- end
231
- lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
232
- if @status && !@status.empty?
233
- stat_line = @status.ljust(inner_w)[0...inner_w]
234
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(stat_line)} #{color_code}#{v_r}#{reset_code}"
595
+ if is_rgb
596
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", tick)
597
+ if @title && !@title.empty?
598
+ t_str = @title.to_s
599
+ pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
600
+ l_p = " " * (pad_t / 2)
601
+ r_p = " " * (pad_t - (pad_t / 2))
602
+ lines << "#{Color.rgb(v_l, tick)} #{l_p}#{Color.rgb(t_str, tick + 0.4)}#{r_p} #{Color.rgb(v_r, tick)}"
603
+ lines << Color.rgb("#{v_l}#{top_fill}#{v_r}", tick)
604
+ end
605
+
606
+ filled_part = Color.rgb("█" * filled_len, tick)
607
+ empty_part = Color.gray("░" * empty_len)
608
+ bar_raw_len = 2 + filled_len + empty_len + 1 + pct_str.length
609
+ pad_bar_len = [inner_w - bar_raw_len, 0].max
610
+ bar_line = "[#{filled_part}#{empty_part}] #{Color.bright_white(pct_str)}" + (" " * pad_bar_len)
611
+
612
+ lines << "#{Color.rgb(v_l, tick)} #{bar_line} #{Color.rgb(v_r, tick)}"
613
+ if @status && !@status.empty?
614
+ st_str = @status.to_s
615
+ pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
616
+ st_line = st_str + (" " * pad_st)
617
+ lines << "#{Color.rgb(v_l, tick)} #{Color.gray(st_line)} #{Color.rgb(v_r, tick)}"
618
+ end
619
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", tick)
620
+ else
621
+ color_code = GRmenu.ansi_color(@color, @level)
622
+ reset_code = GRmenu.ansi_reset
623
+
624
+ bar_str = "[#{"█" * filled_len}#{"░" * empty_len}] #{pct_str}"
625
+ pad_bar_len = [inner_w - GRmenu.display_width(bar_str), 0].max
626
+ bar_line = bar_str + (" " * pad_bar_len)
627
+
628
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
629
+ if @title && !@title.empty?
630
+ t_str = @title.to_s
631
+ pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
632
+ l_p = " " * (pad_t / 2)
633
+ r_p = " " * (pad_t - (pad_t / 2))
634
+ lines << "#{color_code}#{v_l}#{reset_code} #{l_p}#{t_str}#{r_p} #{color_code}#{v_r}#{reset_code}"
635
+ lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
636
+ end
637
+ lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
638
+ if @status && !@status.empty?
639
+ st_str = @status.to_s
640
+ pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
641
+ st_line = st_str + (" " * pad_st)
642
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(st_line)} #{color_code}#{v_r}#{reset_code}"
643
+ end
644
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
235
645
  end
236
- lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
237
646
 
238
647
  frame = lines.join("\r\n") + "\r\n"
239
648
 
@@ -255,16 +664,18 @@ class GRmenu
255
664
 
256
665
  def self.spinner(message = "Cargando...", color: "cyan", level: 2, delay: 0.08, &block)
257
666
  frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
258
- color_name = color.to_s.downcase
259
- color_code = COLORS.dig(color_name, level) || "\e[1;96m"
260
- reset_code = COLORS["reset"]
667
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
668
+ color_code = is_rgb ? "" : ansi_color(color, level)
669
+ reset_code = ansi_reset
261
670
 
262
671
  stop_spinner = false
263
672
  spinner_thread = Thread.new do
264
673
  frame_idx = 0
265
674
  while !stop_spinner
266
675
  f = frames[frame_idx % frames.length]
267
- Kernel.print("\r\e[K#{color_code}#{f}#{reset_code} #{message}")
676
+ f_color = is_rgb ? rgb_color(frame_idx * 0.3) : color_code
677
+ msg_out = is_rgb ? Color.rgb(message, frame_idx * 0.1) : message
678
+ Kernel.print("\r\e[K#{f_color}#{f}#{reset_code} #{msg_out}")
268
679
  $stdout.flush
269
680
  frame_idx += 1
270
681
  sleep(delay)
@@ -276,14 +687,14 @@ class GRmenu
276
687
  result = block ? block.call : nil
277
688
  stop_spinner = true
278
689
  spinner_thread.join
279
- success_color = COLORS.dig("green", 2) || "\e[1;92m"
280
- Kernel.print("\r\e[K#{success_color}✔#{reset_code} #{message} #{Color.gray("¡Listo!")}\r\n")
690
+ success_color = ansi_color("green", 2)
691
+ Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{message} #{Color.gray("Listo!")}\r\n")
281
692
  result
282
693
  rescue Exception => e
283
694
  stop_spinner = true
284
695
  spinner_thread.join rescue nil
285
- error_color = COLORS.dig("red", 2) || "\e[1;91m"
286
- Kernel.print("\r\e[K#{error_color}✖#{reset_code} #{message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
696
+ error_color = ansi_color("red", 2)
697
+ Kernel.print("\r\e[K#{error_color}[ERROR]#{reset_code} #{message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
287
698
  raise e
288
699
  ensure
289
700
  stop_spinner = true
@@ -304,6 +715,604 @@ class GRmenu
304
715
  end
305
716
  end
306
717
 
718
+ def self.confirm(question = "¿Confirmar acción?", default: true, color: "cyan", style: 3)
719
+ choice = default ? 0 : 1
720
+ term_w = terminal_width
721
+ q_w = display_width(question)
722
+ box_w = [q_w + 8, term_w - 4, 38].max
723
+ box_w = [box_w, 64].min
724
+ inner_w = box_w - 4
725
+
726
+ border_cfg = BORDERS[style] || BORDERS[3]
727
+ h_top = border_cfg[:ht] || border_cfg[:h]
728
+ h_bot = border_cfg[:hb] || border_cfg[:h]
729
+ v_l = border_cfg[:vl] || border_cfg[:v]
730
+ v_r = border_cfg[:vr] || border_cfg[:v]
731
+
732
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
733
+ color_code = is_rgb ? "" : ansi_color(color, 2)
734
+ reset_code = ansi_reset
735
+
736
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
737
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
738
+
739
+ drawn_lines = 0
740
+
741
+ render_confirm = lambda do
742
+ btn_yes = (choice == 0) ? Color.bright_green("> [ Sí ] <") : Color.gray(" [ Sí ] ")
743
+ btn_no = (choice == 1) ? Color.bright_red("> [ No ] <") : Color.gray(" [ No ] ")
744
+ raw_btns = (choice == 0 ? "> [ Sí ] <" : " [ Sí ] ") + " " + (choice == 1 ? "> [ No ] <" : " [ No ] ")
745
+ btns_vis_w = display_width(raw_btns)
746
+ pad_total = [inner_w - btns_vis_w, 0].max
747
+ left_p = " " * (pad_total / 2)
748
+ right_p = " " * (pad_total - (pad_total / 2))
749
+ btn_formatted_line = "#{left_p}#{btn_yes} #{btn_no}#{right_p}"
750
+
751
+ pad_q = [inner_w - display_width(question), 0].max
752
+ q_left = " " * (pad_q / 2)
753
+ q_right = " " * (pad_q - (pad_q / 2))
754
+
755
+ lines = []
756
+ if is_rgb
757
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")
758
+ lines << "#{Color.rgb(v_l)} #{q_left}#{question}#{q_right} #{Color.rgb(v_r)}"
759
+ lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
760
+ lines << "#{Color.rgb(v_l)} #{btn_formatted_line} #{Color.rgb(v_r)}"
761
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
762
+ else
763
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
764
+ lines << "#{color_code}#{v_l}#{reset_code} #{q_left}#{question}#{q_right} #{color_code}#{v_r}#{reset_code}"
765
+ lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
766
+ lines << "#{color_code}#{v_l}#{reset_code} #{btn_formatted_line} #{color_code}#{v_r}#{reset_code}"
767
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
768
+ end
769
+
770
+ frame = lines.join("\r\n") + "\r\n"
771
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
772
+ Kernel.print(frame)
773
+ $stdout.flush
774
+ drawn_lines = lines.length
775
+ end
776
+
777
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
778
+ result = false
779
+
780
+ begin
781
+ Kernel.print(HIDE_CURSOR)
782
+ render_confirm.call
783
+
784
+ reader = lambda do |stream|
785
+ while (key = GRmenu.read_key_raw(stream))
786
+ break if key == "q" || key == "Q" || key == "\x03" || key == "\e"
787
+ if key == "s" || key == "S" || key == "y" || key == "Y"
788
+ result = true
789
+ break
790
+ elsif key == "n" || key == "N"
791
+ result = false
792
+ break
793
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\t" || key == "\e[C" || key == "\eOC" || key == "\xe0M"
794
+ choice = 1 - choice
795
+ render_confirm.call
796
+ elsif key == "\r" || key == "\n" || key == " "
797
+ result = (choice == 0)
798
+ break
799
+ end
800
+ end
801
+ end
802
+
803
+ if is_tty
804
+ $stdin.raw { |s| reader.call(s) }
805
+ else
806
+ reader.call($stdin)
807
+ end
808
+ ensure
809
+ Kernel.print(SHOW_CURSOR)
810
+ end
811
+
812
+ result
813
+ end
814
+
815
+ def self.input(prompt_text = "Ingresa un valor:", default: "", password: false, color: "cyan", style: 3)
816
+ text = String.new(default.to_s)
817
+ term_w = terminal_width
818
+ p_w = display_width(prompt_text)
819
+ box_w = [p_w + 8, term_w - 4, 42].max
820
+ box_w = [box_w, 64].min
821
+ inner_w = box_w - 4
822
+
823
+ border_cfg = BORDERS[style] || BORDERS[3]
824
+ h_top = border_cfg[:ht] || border_cfg[:h]
825
+ h_bot = border_cfg[:hb] || border_cfg[:h]
826
+ v_l = border_cfg[:vl] || border_cfg[:v]
827
+ v_r = border_cfg[:vr] || border_cfg[:v]
828
+
829
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
830
+ color_code = is_rgb ? "" : ansi_color(color, 2)
831
+ reset_code = ansi_reset
832
+
833
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
834
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
835
+
836
+ drawn_lines = 0
837
+
838
+ render_input = lambda do
839
+ display_str = password ? ("*" * text.length) : text
840
+ input_raw = "> #{display_str}█"
841
+ pad_in = [inner_w - display_width(input_raw), 0].max
842
+ input_padded = input_raw + (" " * pad_in)
843
+
844
+ pad_p = [inner_w - display_width(prompt_text), 0].max
845
+ p_left = " " * (pad_p / 2)
846
+ p_right = " " * (pad_p - (pad_p / 2))
847
+
848
+ lines = []
849
+ if is_rgb
850
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")
851
+ lines << "#{Color.rgb(v_l)} #{Color.bright_yellow(p_left + prompt_text + p_right)} #{Color.rgb(v_r)}"
852
+ lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
853
+ lines << "#{Color.rgb(v_l)} #{Color.bright_white(input_padded)} #{Color.rgb(v_r)}"
854
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
855
+ else
856
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
857
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_left + prompt_text + p_right)} #{color_code}#{v_r}#{reset_code}"
858
+ lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
859
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_white(input_padded)} #{color_code}#{v_r}#{reset_code}"
860
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
861
+ end
862
+
863
+ frame = lines.join("\r\n") + "\r\n"
864
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
865
+ Kernel.print(frame)
866
+ $stdout.flush
867
+ drawn_lines = lines.length
868
+ end
869
+
870
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
871
+
872
+ begin
873
+ Kernel.print(HIDE_CURSOR)
874
+ render_input.call
875
+
876
+ reader = lambda do |stream|
877
+ while (key = GRmenu.read_key_raw(stream))
878
+ break if key == "\x03" || key == "\e"
879
+ if key == "\r" || key == "\n"
880
+ break
881
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
882
+ text.chop!
883
+ render_input.call
884
+ elsif key == "\x15"
885
+ text.clear
886
+ render_input.call
887
+ elsif key =~ /^[[:print:]]$/
888
+ text << key if text.length < (inner_w - 4)
889
+ render_input.call
890
+ end
891
+ end
892
+ end
893
+
894
+ if is_tty
895
+ $stdin.raw { |s| reader.call(s) }
896
+ else
897
+ reader.call($stdin)
898
+ end
899
+ ensure
900
+ Kernel.print(SHOW_CURSOR)
901
+ end
902
+
903
+ text
904
+ end
905
+
906
+ def self.checkbox(items, title: "Selección Múltiple", subtitle: "Espacio: Marcar/Desmarcar | a: Todos | n: Ninguno | i: Invertir | Enter: Confirmar", color: "cyan", style: 3, page_size: 8, min_width: nil, preselected: [])
907
+ item_list = items.is_a?(Array) ? items : Array(items)
908
+ return [] if item_list.empty?
909
+
910
+ parsed_items = item_list.map do |it|
911
+ case it
912
+ when Array
913
+ name = it[0].to_s
914
+ is_chk = it.length > 1 ? !!it[1] : false
915
+ desc = it.length > 2 ? it[2].to_s : ""
916
+ { name: name, checked: is_chk, desc: desc, original: it }
917
+ when Hash
918
+ name = (it[:name] || it["name"] || it[:title] || it["title"] || "Item").to_s
919
+ is_chk = !!(it[:checked] || it["checked"] || it[:selected] || it["selected"])
920
+ desc = (it[:desc] || it["desc"] || it[:description] || it["description"]).to_s
921
+ { name: name, checked: is_chk, desc: desc, original: it }
922
+ else
923
+ { name: it.to_s, checked: false, desc: "", original: it }
924
+ end
925
+ end
926
+
927
+ preselected.each do |p|
928
+ if p.is_a?(Integer) && parsed_items[p]
929
+ parsed_items[p][:checked] = true
930
+ else
931
+ it = parsed_items.find { |pi| pi[:name] == p.to_s }
932
+ it[:checked] = true if it
933
+ end
934
+ end
935
+
936
+ index = 0
937
+ rgb_tick = 0.0
938
+ drawn_lines = 0
939
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
940
+ border_cfg = BORDERS[style] || BORDERS[3]
941
+ h_top = border_cfg[:ht] || border_cfg[:h]
942
+ h_bot = border_cfg[:hb] || border_cfg[:h]
943
+ v_l = border_cfg[:vl] || border_cfg[:v]
944
+ v_r = border_cfg[:vr] || border_cfg[:v]
945
+
946
+ render_frame = lambda do
947
+ term_w = terminal_width
948
+ term_h = terminal_height
949
+
950
+ max_name_w = parsed_items.map { |it| display_width(it[:name]) }.max || 10
951
+ req_w = [max_name_w + 12, display_width(title) + 6, display_width(subtitle) + 4, min_width || 38].max
952
+ box_w = [req_w, term_w - 4].min
953
+ inner_w = box_w - 4
954
+
955
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
956
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
957
+ mid_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
958
+
959
+ total_items = parsed_items.length
960
+ max_visible = page_size ? [page_size, total_items, term_h - 10].min : [total_items, term_h - 10].min
961
+ max_visible = [max_visible, 1].max
962
+
963
+ start_idx = 0
964
+ end_idx = total_items - 1
965
+ if total_items > max_visible
966
+ half = max_visible / 2
967
+ start_idx = [[index - half, 0].max, total_items - max_visible].min
968
+ end_idx = start_idx + max_visible - 1
969
+ end
970
+
971
+ lines = []
972
+ if is_rgb
973
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", rgb_tick)
974
+ unless title.to_s.empty?
975
+ pad_t = [inner_w - display_width(title), 0].max
976
+ t_line = (" " * (pad_t / 2)) + title + (" " * (pad_t - (pad_t / 2)))
977
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(t_line, rgb_tick + 0.2)} #{Color.rgb(v_r, rgb_tick)}"
978
+ lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
979
+ end
980
+ if start_idx > 0
981
+ up_t = "▲ (+#{start_idx} arriba)"
982
+ pad_u = [inner_w - display_width(up_t), 0].max
983
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(" " * (pad_u / 2) + up_t + " " * (pad_u - (pad_u / 2)))} #{Color.rgb(v_r, rgb_tick)}"
984
+ end
985
+ (start_idx..end_idx).each do |i|
986
+ it = parsed_items[i]
987
+ mark = it[:checked] ? "[X]" : "[ ]"
988
+ is_active = (i == index)
989
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{it[:name]}"
990
+ pad_l = [inner_w - display_width(raw_line), 0].max
991
+ line_padded = raw_line + (" " * pad_l)
992
+ if is_active
993
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(line_padded, rgb_tick + 0.4)} #{Color.rgb(v_r, rgb_tick)}"
994
+ elsif it[:checked]
995
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.bright_green(line_padded)} #{Color.rgb(v_r, rgb_tick)}"
996
+ else
997
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.white(line_padded)} #{Color.rgb(v_r, rgb_tick)}"
998
+ end
999
+ end
1000
+ if end_idx < (total_items - 1)
1001
+ rem = total_items - 1 - end_idx
1002
+ dn_t = "▼ (+#{rem} abajo)"
1003
+ pad_d = [inner_w - display_width(dn_t), 0].max
1004
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(" " * (pad_d / 2) + dn_t + " " * (pad_d - (pad_d / 2)))} #{Color.rgb(v_r, rgb_tick)}"
1005
+ end
1006
+ unless subtitle.to_s.empty?
1007
+ lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
1008
+ pad_sub = [inner_w - display_width(subtitle), 0].max
1009
+ sub_padded = (" " * (pad_sub / 2)) + subtitle + (" " * (pad_sub - (pad_sub / 2)))
1010
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(sub_padded)} #{Color.rgb(v_r, rgb_tick)}"
1011
+ end
1012
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1013
+ else
1014
+ color_code = ansi_color(color, 2)
1015
+ reset_code = ansi_reset
1016
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1017
+ unless title.to_s.empty?
1018
+ pad_t = [inner_w - display_width(title), 0].max
1019
+ t_line = (" " * (pad_t / 2)) + title + (" " * (pad_t - (pad_t / 2)))
1020
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(t_line)} #{color_code}#{v_r}#{reset_code}"
1021
+ lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1022
+ end
1023
+ if start_idx > 0
1024
+ up_t = "▲ (+#{start_idx} arriba)"
1025
+ pad_u = [inner_w - display_width(up_t), 0].max
1026
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(" " * (pad_u / 2) + up_t + " " * (pad_u - (pad_u / 2)))} #{color_code}#{v_r}#{reset_code}"
1027
+ end
1028
+ (start_idx..end_idx).each do |i|
1029
+ it = parsed_items[i]
1030
+ mark = it[:checked] ? "[X]" : "[ ]"
1031
+ is_active = (i == index)
1032
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{it[:name]}"
1033
+ pad_l = [inner_w - display_width(raw_line), 0].max
1034
+ line_padded = raw_line + (" " * pad_l)
1035
+ if is_active
1036
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1037
+ elsif it[:checked]
1038
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1039
+ else
1040
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.white(line_padded)} #{color_code}#{v_r}#{reset_code}"
1041
+ end
1042
+ end
1043
+ if end_idx < (total_items - 1)
1044
+ rem = total_items - 1 - end_idx
1045
+ dn_t = "▼ (+#{rem} abajo)"
1046
+ pad_d = [inner_w - display_width(dn_t), 0].max
1047
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(" " * (pad_d / 2) + dn_t + " " * (pad_d - (pad_d / 2)))} #{color_code}#{v_r}#{reset_code}"
1048
+ end
1049
+ unless subtitle.to_s.empty?
1050
+ lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1051
+ pad_sub = [inner_w - display_width(subtitle), 0].max
1052
+ sub_padded = (" " * (pad_sub / 2)) + subtitle + (" " * (pad_sub - (pad_sub / 2)))
1053
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(sub_padded)} #{color_code}#{v_r}#{reset_code}"
1054
+ end
1055
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
1056
+ end
1057
+
1058
+ frame = lines.join("\r\n") + "\r\n"
1059
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
1060
+ Kernel.print(frame)
1061
+ $stdout.flush
1062
+ drawn_lines = lines.length
1063
+ end
1064
+
1065
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
1066
+ submitted = false
1067
+
1068
+ begin
1069
+ Kernel.print(HIDE_CURSOR)
1070
+ render_frame.call
1071
+
1072
+ reader = lambda do |stream|
1073
+ while true
1074
+ if is_rgb
1075
+ ready = false
1076
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
1077
+ begin
1078
+ sr = IO.select([stream], nil, nil, 0.035)
1079
+ ready = true if sr && sr[0] && !sr[0].empty?
1080
+ rescue StandardError
1081
+ ready = true
1082
+ end
1083
+ else
1084
+ ready = true
1085
+ end
1086
+ unless ready
1087
+ rgb_tick += 0.08
1088
+ render_frame.call
1089
+ next
1090
+ end
1091
+ end
1092
+
1093
+ key = GRmenu.read_key_raw(stream)
1094
+ break if key.nil? || key == "\x03" || key == "\x04" || key == "q" || key == "Q" || key == "\e"
1095
+
1096
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
1097
+ index = (index - 1) % parsed_items.length
1098
+ render_frame.call
1099
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
1100
+ index = (index + 1) % parsed_items.length
1101
+ render_frame.call
1102
+ elsif key == " "
1103
+ parsed_items[index][:checked] = !parsed_items[index][:checked]
1104
+ render_frame.call
1105
+ elsif key == "a" || key == "A"
1106
+ parsed_items.each { |it| it[:checked] = true }
1107
+ render_frame.call
1108
+ elsif key == "n" || key == "N"
1109
+ parsed_items.each { |it| it[:checked] = false }
1110
+ render_frame.call
1111
+ elsif key == "i" || key == "I"
1112
+ parsed_items.each { |it| it[:checked] = !it[:checked] }
1113
+ render_frame.call
1114
+ elsif key == "\r" || key == "\n"
1115
+ submitted = true
1116
+ break
1117
+ end
1118
+ end
1119
+ end
1120
+
1121
+ if is_tty
1122
+ $stdin.raw { |s| reader.call(s) }
1123
+ else
1124
+ reader.call($stdin)
1125
+ end
1126
+ ensure
1127
+ Kernel.print(SHOW_CURSOR)
1128
+ end
1129
+
1130
+ if submitted
1131
+ selected = parsed_items.select { |it| it[:checked] }
1132
+ selected.map { |it| it[:original] }
1133
+ else
1134
+ []
1135
+ end
1136
+ end
1137
+ class << self
1138
+ alias_method :select_multi, :checkbox
1139
+ alias_method :multiselect, :checkbox
1140
+ end
1141
+
1142
+ def self.slider(prompt = "Selecciona un valor:", min: 0, max: 100, step: 1, default: nil, unit: "", color: "cyan", style: 3, width: 46)
1143
+ val = (default || min).to_f.clamp(min.to_f, max.to_f)
1144
+ step_val = [step.to_f, 0.001].max
1145
+ drawn_lines = 0
1146
+ rgb_tick = 0.0
1147
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
1148
+
1149
+ border_cfg = BORDERS[style] || BORDERS[3]
1150
+ h_top = border_cfg[:ht] || border_cfg[:h]
1151
+ h_bot = border_cfg[:hb] || border_cfg[:h]
1152
+ v_l = border_cfg[:vl] || border_cfg[:v]
1153
+ v_r = border_cfg[:vr] || border_cfg[:v]
1154
+
1155
+ render_slider = lambda do
1156
+ term_w = terminal_width
1157
+ box_w = [width, term_w - 4, display_width(prompt) + 8, 38].max
1158
+ box_w = [box_w, term_w - 2].min
1159
+ inner_w = box_w - 4
1160
+
1161
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
1162
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
1163
+
1164
+ val_display = (val % 1 == 0) ? val.to_i.to_s : val.round(2).to_s
1165
+ val_str = unit.to_s.empty? ? val_display : "#{val_display} #{unit}"
1166
+
1167
+ range_span = (max - min).to_f
1168
+ range_span = 1.0 if range_span <= 0
1169
+ fraction = ((val - min).to_f / range_span).clamp(0.0, 1.0)
1170
+
1171
+ bar_w = [inner_w - val_str.length - 5, 10].max
1172
+ filled_len = (fraction * bar_w).round
1173
+ empty_len = bar_w - filled_len
1174
+
1175
+ pad_p = [inner_w - display_width(prompt), 0].max
1176
+ p_line = (" " * (pad_p / 2)) + prompt + (" " * (pad_p - (pad_p / 2)))
1177
+
1178
+ instr = "← / → Ajustar | Enter Guardar"
1179
+ pad_i = [inner_w - display_width(instr), 0].max
1180
+ i_line = (" " * (pad_i / 2)) + instr + (" " * (pad_i - (pad_i / 2)))
1181
+
1182
+ lines = []
1183
+ if is_rgb
1184
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", rgb_tick)
1185
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(p_line, rgb_tick + 0.3)} #{Color.rgb(v_r, rgb_tick)}"
1186
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{' ' * inner_w} #{Color.rgb(v_r, rgb_tick)}"
1187
+
1188
+ filled_part = Color.rgb("█" * filled_len, rgb_tick + 0.5)
1189
+ empty_part = Color.gray("░" * empty_len)
1190
+ bar_raw = "[#{filled_part}#{empty_part}] #{Color.bright_white(val_str)}"
1191
+ pad_b = [inner_w - (bar_w + 3 + val_str.length), 0].max
1192
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{bar_raw}#{' ' * pad_b} #{Color.rgb(v_r, rgb_tick)}"
1193
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{' ' * inner_w} #{Color.rgb(v_r, rgb_tick)}"
1194
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(i_line)} #{Color.rgb(v_r, rgb_tick)}"
1195
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1196
+ else
1197
+ color_code = ansi_color(color, 2)
1198
+ reset_code = ansi_reset
1199
+
1200
+ bar_raw = "[#{"█" * filled_len}#{"░" * empty_len}] #{val_str}"
1201
+ pad_b = [inner_w - display_width(bar_raw), 0].max
1202
+
1203
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1204
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_line)} #{color_code}#{v_r}#{reset_code}"
1205
+ lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1206
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_cyan(bar_raw)}#{' ' * pad_b} #{color_code}#{v_r}#{reset_code}"
1207
+ lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1208
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(i_line)} #{color_code}#{v_r}#{reset_code}"
1209
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
1210
+ end
1211
+
1212
+ frame = lines.join("\r\n") + "\r\n"
1213
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
1214
+ Kernel.print(frame)
1215
+ $stdout.flush
1216
+ drawn_lines = lines.length
1217
+ end
1218
+
1219
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
1220
+
1221
+ begin
1222
+ Kernel.print(HIDE_CURSOR)
1223
+ render_slider.call
1224
+
1225
+ reader = lambda do |stream|
1226
+ while true
1227
+ if is_rgb
1228
+ ready = false
1229
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
1230
+ begin
1231
+ sr = IO.select([stream], nil, nil, 0.035)
1232
+ ready = true if sr && sr[0] && !sr[0].empty?
1233
+ rescue StandardError
1234
+ ready = true
1235
+ end
1236
+ else
1237
+ ready = true
1238
+ end
1239
+ unless ready
1240
+ rgb_tick += 0.08
1241
+ render_slider.call
1242
+ next
1243
+ end
1244
+ end
1245
+
1246
+ key = GRmenu.read_key_raw(stream)
1247
+ break if key.nil? || key == "\x03" || key == "\x04" || key == "q" || key == "Q" || key == "\e"
1248
+
1249
+ if key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "h" || key == "H"
1250
+ val = (val - step_val).clamp(min.to_f, max.to_f)
1251
+ render_slider.call
1252
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M" || key == "l" || key == "L"
1253
+ val = (val + step_val).clamp(min.to_f, max.to_f)
1254
+ render_slider.call
1255
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
1256
+ val = (val - step_val * 5).clamp(min.to_f, max.to_f)
1257
+ render_slider.call
1258
+ elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
1259
+ val = (val + step_val * 5).clamp(min.to_f, max.to_f)
1260
+ render_slider.call
1261
+ elsif key == "\r" || key == "\n"
1262
+ break
1263
+ end
1264
+ end
1265
+ end
1266
+
1267
+ if is_tty
1268
+ $stdin.raw { |s| reader.call(s) }
1269
+ else
1270
+ reader.call($stdin)
1271
+ end
1272
+ ensure
1273
+ Kernel.print(SHOW_CURSOR)
1274
+ end
1275
+
1276
+ (val % 1 == 0) ? val.to_i : val.round(2)
1277
+ end
1278
+ class << self
1279
+ alias_method :range, :slider
1280
+ end
1281
+
1282
+ def self.read_key_raw(input_stream)
1283
+ unless input_stream.respond_to?(:tty?) && input_stream.tty?
1284
+ begin
1285
+ return input_stream.sysread(3) if input_stream.respond_to?(:sysread)
1286
+ return input_stream.read(1)
1287
+ rescue EOFError, Errno::EPIPE
1288
+ return nil
1289
+ end
1290
+ end
1291
+
1292
+ first_char = input_stream.getch
1293
+ return nil if first_char.nil?
1294
+
1295
+ if first_char == "\e"
1296
+ begin
1297
+ extra_chars = input_stream.read_nonblock(2)
1298
+ first_char << extra_chars
1299
+ rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1300
+ end
1301
+ elsif first_char == "\x00" || first_char == "\xe0"
1302
+ begin
1303
+ second_char = input_stream.read_nonblock(1)
1304
+ first_char << second_char
1305
+ rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1306
+ second_char = input_stream.getch rescue nil
1307
+ first_char << second_char if second_char
1308
+ end
1309
+ end
1310
+
1311
+ first_char
1312
+ rescue EOFError, Errno::EPIPE, Errno::ENOTTY
1313
+ nil
1314
+ end
1315
+
307
1316
  class SetStyle
308
1317
  def initialize(
309
1318
  border: { color: "cyan", level: 1 },
@@ -475,7 +1484,7 @@ class GRmenu
475
1484
  end
476
1485
  end
477
1486
 
478
- attr_accessor :functions, :title, :subtitle, :banner, :banner_style, :divider, :style, :index, :style_config, :center, :page_size
1487
+ attr_accessor :functions, :title, :subtitle, :banner, :banner_style, :divider, :style, :index, :style_config, :center, :page_size, :search, :columns, :query, :image, :image_width
479
1488
 
480
1489
  alias_method :options, :functions
481
1490
  alias_method :options=, :functions=
@@ -517,117 +1526,27 @@ class GRmenu
517
1526
 
518
1527
  def self.div(long = nil, color = "blue", level = 1, char = "─")
519
1528
  width = long || [terminal_width - 2, 64].min
520
- color_code = COLORS.dig(color.to_s.downcase, level) || "\e[34m"
521
- Kernel.print("#{color_code}#{char * width}#{COLORS['reset']}\r\n")
1529
+ if color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
1530
+ Kernel.print("#{Color.rgb(char * width)}\r\n")
1531
+ else
1532
+ color_code = ansi_color(color, level)
1533
+ reset_code = ansi_reset
1534
+ Kernel.print("#{color_code}#{char * width}#{reset_code}\r\n")
1535
+ end
522
1536
  end
523
1537
 
524
1538
  def self.help(section = :all)
525
- w = [terminal_width - 4, 70].min
526
- w = [w, 46].max
527
- inner_w = w - 2
528
- h_line = "═" * inner_w
529
- s_line = "─" * w
530
-
531
- Kernel.print "\r\n"
532
- Kernel.print "#{Color.bright_cyan("╔" + h_line + "╗")}\r\n"
533
- Kernel.print "#{Color.bright_cyan("║")}#{Color.bright_yellow("GRmenu - Guia y Referencia Completa (v2.0)".center(inner_w))}#{Color.bright_cyan("║")}\r\n"
534
- Kernel.print "#{Color.bright_cyan("║")}#{Color.gray("Menus interactivos, Banners 3D, Barras de Progreso y TTY".center(inner_w))}#{Color.bright_cyan("║")}\r\n"
535
- Kernel.print "#{Color.bright_cyan("╚" + h_line + "╝")}\r\n\r\n"
536
-
537
- Kernel.print "#{Color.bright_magenta("[1] HELPERS NATIVOS")}\r\n"
538
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
539
- Kernel.print " #{Color.bright_green("GRmenu.clear_screen")} #{Color.gray("(o GRmenu.clr)")}\r\n"
540
- Kernel.print " * Limpia la terminal al instante con secuencias ANSI.\r\n"
541
- Kernel.print " #{Color.bright_green("GRmenu.continue(mensaje)")}\r\n"
542
- Kernel.print " * Pausa interactiva: espera una sola tecla en modo TTY crudo.\r\n"
543
- Kernel.print " #{Color.bright_green("GRmenu.banner(texto, delay, color:, level:, style:, font:)")}\r\n"
544
- Kernel.print " * Renderiza banner ASCII 3D con marco y animacion opcional.\r\n"
545
- Kernel.print " #{Color.bright_green("GRmenu.spinner(mensaje, color:, level:, delay:, &bloque)")}\r\n"
546
- Kernel.print " * Animacion giratoria fluida para tareas de tiempo desconocido.\r\n"
547
- Kernel.print " * Ejemplo: #{Color.bright_white("GRmenu.spinner(\"Conectando...\") { conectar_db }")}\r\n"
548
- Kernel.print " #{Color.bright_green("GRmenu.progress(total, title:, color:, level:, style:, width:, &bloque)")}\r\n"
549
- Kernel.print " * Barra de progreso porcentual dentro de un recuadro estilizado.\r\n"
550
- Kernel.print " * El bloque recibe 'bar'. Metodos disponibles:\r\n"
551
- Kernel.print " - #{Color.cyan("bar.advance(n, status: \"...\")")} -> Avanza n pasos (alias: increment, step).\r\n"
552
- Kernel.print " - #{Color.cyan("bar.set(valor, status: \"...\")")} -> Fija el valor exacto actual.\r\n"
553
- Kernel.print " - #{Color.cyan("bar.finish(status: \"...\")")} -> Finaliza la barra al 100%.\r\n"
554
- Kernel.print " * Ejemplo: #{Color.bright_white("GRmenu.progress(10, title: \"Copia\") { |b| 10.times { b.advance(1) } }")}\r\n"
555
- Kernel.print " #{Color.bright_green("GRmenu.div(longitud, color, level, char)")}\r\n"
556
- Kernel.print " * Dibuja linea divisoria horizontal adaptable a la consola.\r\n\r\n"
557
-
558
- Kernel.print "#{Color.bright_magenta("[2] FORMATOS DE OPCIONES Y TOOLTIPS DINAMICOS")}\r\n"
559
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
560
- Kernel.print " #{Color.cyan("1. Metodo directo:")} #{Color.bright_white("method(:iniciar)")} #{Color.gray("(auto-capitaliza nombre)")}\r\n"
561
- Kernel.print " #{Color.cyan("2. Simbolo:")} #{Color.bright_white(":iniciar")}\r\n"
562
- Kernel.print " #{Color.cyan("3. Nombre propio:")} #{Color.bright_white("[\"Mi Accion\", method(:iniciar)]")}\r\n"
563
- Kernel.print " #{Color.cyan("4. Con Tooltip/Info:")} #{Color.bright_white("[\"Mi Accion\", method(:iniciar), \"Descripcion que sale abajo\"]")}\r\n"
564
- Kernel.print " #{Color.cyan("5. Lambda / Proc:")} #{Color.bright_white("[\"Test\", -> { puts \"Hola\" }, \"Tooltip opcional\"]")}\r\n"
565
- Kernel.print " #{Color.cyan("6. Hash:")} #{Color.bright_white("{ name: \"Test\", action: method(:iniciar), desc: \"Info\" }")}\r\n\r\n"
566
-
567
- Kernel.print "#{Color.bright_magenta("[3] PARAMETROS DE GRmenu.new(functions, ...)")}\r\n"
568
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
569
- Kernel.print " #{Color.bright_green("functions:")} #{Color.white("Array")} -> Lista de opciones (metodos, simbolos, arreglos, lambdas).\r\n"
570
- Kernel.print " #{Color.bright_green("banner:")} #{Color.white("String")} -> Texto grande a renderizar en arte ASCII 3D.\r\n"
571
- Kernel.print " #{Color.bright_green("title:")} #{Color.white("String")} -> Titulo en el encabezado del recuadro.\r\n"
572
- Kernel.print " #{Color.bright_green("subtitle:")} #{Color.white("String")} -> Subtitulo / descripcion (soporta \\n).\r\n"
573
- Kernel.print " #{Color.bright_green("page_size:")} #{Color.white("Integer")} -> Limite visible para scroll y paginacion automatica.\r\n"
574
- Kernel.print " #{Color.bright_green("font:")} #{Color.white("Integer")} -> Fuente ASCII del banner (1 al 10, default 1).\r\n"
575
- Kernel.print " #{Color.bright_green("style:")} #{Color.white("Integer")} -> Estilo de marco de opciones (1 al 20, default 19).\r\n"
576
- Kernel.print " #{Color.bright_green("banner_style:")} #{Color.white("Integer")} -> Estilo de marco del banner (1 al 20, default 3).\r\n"
577
- Kernel.print " #{Color.bright_green("divider:")} #{Color.white("Boolean")} -> Divisores alineados al banner (true/false).\r\n"
578
- Kernel.print " #{Color.bright_green("center:")} #{Color.white("Boolean")} -> Centrado simetrico de subtitulo y menu (default true).\r\n\r\n"
579
-
580
- Kernel.print "#{Color.bright_magenta("[4] AUTO-PAGINACION Y SCROLL")}\r\n"
581
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
582
- Kernel.print " * #{Color.white("100% Automatica:")} Si la lista tiene muchas opciones o la pantalla es pequena,\r\n"
583
- Kernel.print " GRmenu calcula el espacio disponible y genera una ventana deslizante suave.\r\n"
584
- Kernel.print " * Indicadores visuales: #{Color.bright_yellow("▲ (+N arriba)")} y #{Color.bright_yellow("▼ (+M abajo)")}.\r\n"
585
- Kernel.print " * Opcional: fija el limite con #{Color.bright_white("page_size: 8")} al instanciar #{Color.bright_green("GRmenu.new")}.\r\n\r\n"
586
-
587
- Kernel.print "#{Color.bright_magenta("[5] MODULO DE COLORES (Color / C)")}\r\n"
588
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
589
- Kernel.print " #{Color.cyan("Uso directo: ")}#{Color.bright_white("puts Color.green(\"Texto\")")} | #{Color.bright_white("puts Color.bright_cyan(\"Texto\")")}\r\n"
590
- Kernel.print " #{Color.cyan("Paleta: ")}#{Color.red("red")}, #{Color.green("green")}, #{Color.yellow("yellow")}, #{Color.blue("blue")}, #{Color.magenta("magenta")}, #{Color.purple("purple")}, #{Color.pink("pink")}, #{Color.cyan("cyan")}, #{Color.aqua("aqua")}, #{Color.orange("orange")}, #{Color.white("white")}, #{Color.gray("gray")}, #{Color.black("black")}.\r\n"
591
- Kernel.print " #{Color.cyan("Brillo: ")}#{Color.white("1")} = Normal, #{Color.bright_white("2")} = Brillante / Bold.\r\n\r\n"
592
-
593
- Kernel.print "#{Color.bright_magenta("[6] FUENTES ASCII 3D DEL BANNER (font: 1 al 10)")}\r\n"
594
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
595
- Kernel.print " #{Color.yellow("1")} -> #{Color.bright_white("ANSI Shadow 3D (Default)")} #{Color.cyan("[██████╗ ██╗ ██╗]")}\r\n"
596
- Kernel.print " #{Color.yellow("2")} -> #{Color.bright_white("Slant 3D (FIGlet)")} #{Color.cyan("[ ____ __ __]")}\r\n"
597
- Kernel.print " #{Color.yellow("3")} -> #{Color.bright_white("Doom / Standard 3D")} #{Color.cyan("[ ____ _ _]")}\r\n"
598
- Kernel.print " #{Color.yellow("4")} -> #{Color.bright_white("Graffiti Shadow 3D")} #{Color.cyan("[ ,---. ,--. ,--.]")}\r\n"
599
- Kernel.print " #{Color.yellow("5")} -> #{Color.bright_white("Small Slant / Mini 3D")} #{Color.cyan("[ ___ _ _]")}\r\n"
600
- Kernel.print " #{Color.yellow("6")} -> #{Color.bright_white("Modular Pipe 3D")} #{Color.cyan("[ _____ _____]")}\r\n"
601
- Kernel.print " #{Color.yellow("7")} -> #{Color.bright_white("Bubble / Round Gothic")} #{Color.cyan("[ ____ _ _]")}\r\n"
602
- Kernel.print " #{Color.yellow("8")} -> #{Color.bright_white("Double-Line Wire 3D")} #{Color.cyan("[ ╔═════╗ ║ ║]")}\r\n"
603
- Kernel.print " #{Color.yellow("9")} -> #{Color.bright_white("Solid Fat 3D Block")} #{Color.cyan("[ ██████▄ ██ ██]")}\r\n"
604
- Kernel.print " #{Color.yellow("10")}-> #{Color.bright_white("Arcade Stars Matrix")} #{Color.cyan("[ ★★★★ ★ ★]")}\r\n\r\n"
605
-
606
- Kernel.print "#{Color.bright_magenta("[7] ESTILOS DE MARCO (style / banner_style: 1 al 20)")}\r\n"
607
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
608
- Kernel.print " #{Color.yellow("3")} -> #{Color.bright_white("Doble linea")} #{Color.cyan("╔═══╗ ║ ║ ╚═══╝")} (Default en Banner)\r\n"
609
- Kernel.print " #{Color.yellow("7")} -> #{Color.bright_white("Curvas redondeadas")} #{Color.cyan("╭───╮ │ │ ╰───╯")}\r\n"
610
- Kernel.print " #{Color.yellow("4")} -> #{Color.bright_white("Linea gruesa")} #{Color.cyan("┏━━━┓ ┃ ┃ ┗━━━┛")}\r\n"
611
- Kernel.print " #{Color.yellow("2")} -> #{Color.bright_white("Linea simple")} #{Color.cyan("┌───┐ │ │ └───┘")}\r\n"
612
- Kernel.print " #{Color.yellow("8")} -> #{Color.bright_white("Bloques outline")} #{Color.cyan("▛▀▀▀▜ ▌ ▐ ▙▄▄▄▟")}\r\n"
613
- Kernel.print " #{Color.yellow("19")} -> #{Color.bright_white("Circulos")} #{Color.cyan("●○○○● ● ● ●○○○●")} (Default en Opciones)\r\n"
614
- Kernel.print " #{Color.yellow("20")} -> #{Color.bright_white("Estrellas")} #{Color.cyan("★☆☆☆★ ★ ★ ★☆☆☆★")}\r\n\r\n"
615
-
616
- Kernel.print "#{Color.bright_magenta("[8] METODOS DE CONFIGURACION (menu.set_style)")}\r\n"
617
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
618
- Kernel.print " #{Color.cyan("menu.set_style.font(id)")} -> Cambia fuente ASCII (1..10)\r\n"
619
- Kernel.print " #{Color.cyan("menu.set_style.banner(color, level)")} -> Color y brillo del banner ASCII\r\n"
620
- Kernel.print " #{Color.cyan("menu.set_style.title(color, level)")} -> Color y brillo del titulo\r\n"
621
- Kernel.print " #{Color.cyan("menu.set_style.subtitle(color, level)")} -> Color y brillo del subtitulo\r\n"
622
- Kernel.print " #{Color.cyan("menu.set_style.divider(color, level)")} -> Color y brillo de las lineas divisorias\r\n"
623
- Kernel.print " #{Color.cyan("menu.set_style.border(color, level)")} -> Color y brillo del marco de opciones\r\n"
624
- Kernel.print " #{Color.cyan("menu.set_style.options(color, level)")} -> Color y brillo de opciones no activas\r\n"
625
- Kernel.print " #{Color.cyan("menu.set_style.focus(color, level)")} -> Color y brillo de la opcion resaltada\r\n\r\n"
626
-
627
- Kernel.print "#{Color.bright_magenta("[9] EJECUCION (menu.draw)")}\r\n"
628
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
629
- Kernel.print " #{Color.bright_white("menu.draw(size_max: 38)")} -> Inicia el menu interactivo con ancho minimo.\r\n"
630
- Kernel.print "#{Color.bright_blue(s_line)}\r\n\r\n"
1539
+ path = find_data_file("help.txt")
1540
+ return unless path && File.exist?(path)
1541
+ content = File.read(path)
1542
+ COLORS.each do |c_name, lvls|
1543
+ if lvls.is_a?(Hash)
1544
+ content.gsub!("{#{c_name}}", ansi_color(c_name, 1))
1545
+ content.gsub!("{bright_#{c_name}}", ansi_color(c_name, 2))
1546
+ end
1547
+ end
1548
+ content.gsub!("{reset}", ansi_reset)
1549
+ Kernel.print("\r\n#{content}\r\n")
631
1550
  end
632
1551
 
633
1552
  def help
@@ -661,7 +1580,7 @@ class GRmenu
661
1580
  font_height.times { |i| lines[i] += fig[i] + pad }
662
1581
  end
663
1582
 
664
- max_len = lines.map(&:length).max
1583
+ max_len = lines.map { |l| display_width(l) }.max
665
1584
  return lines if (max_len + 6) <= max_cols
666
1585
  end
667
1586
 
@@ -670,8 +1589,9 @@ class GRmenu
670
1589
 
671
1590
  def self.banner(text, delay = 0, color: "magenta", level: 2, style: 3, font: 1)
672
1591
  cols = terminal_width
673
- color_code = COLORS.dig(color.to_s.downcase, level) || "\e[1;95m"
674
- reset_code = COLORS["reset"]
1592
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
1593
+ color_code = is_rgb ? "" : ansi_color(color, level)
1594
+ reset_code = ansi_reset
675
1595
 
676
1596
  ascii_rows = build_ascii_lines(text, cols, font)
677
1597
  border_cfg = BORDERS[style] || BORDERS[3]
@@ -681,26 +1601,47 @@ class GRmenu
681
1601
  v_r = border_cfg[:vr] || border_cfg[:v]
682
1602
 
683
1603
  if ascii_rows
684
- max_len = ascii_rows.map(&:length).max
1604
+ max_len = ascii_rows.map { |r| display_width(r) }.max
685
1605
  top_fill = (h_top * ((max_len + 4).to_f / h_top.length).ceil)[0...(max_len + 4)]
686
1606
  bot_fill = (h_bot * ((max_len + 4).to_f / h_bot.length).ceil)[0...(max_len + 4)]
687
1607
 
688
- Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
689
- ascii_rows.each do |line|
690
- pad = " " * (max_len - line.length)
691
- Kernel.print("#{color_code}#{v_l} #{line}#{pad} #{v_r}#{reset_code}\r\n")
692
- sleep(delay) if delay > 0
1608
+ if is_rgb
1609
+ Kernel.print("#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
1610
+ ascii_rows.each_with_index do |line, idx|
1611
+ pad = " " * (max_len - display_width(line))
1612
+ row_content = " #{line}#{pad} "
1613
+ Kernel.print("#{Color.rgb(v_l)}#{Color.rgb(row_content, idx * 0.2)}#{Color.rgb(v_r)}\r\n")
1614
+ sleep(delay) if delay > 0
1615
+ end
1616
+ Kernel.print("#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
1617
+ else
1618
+ Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
1619
+ ascii_rows.each do |line|
1620
+ pad = " " * (max_len - display_width(line))
1621
+ Kernel.print("#{color_code}#{v_l} #{line}#{pad} #{v_r}#{reset_code}\r\n")
1622
+ sleep(delay) if delay > 0
1623
+ end
1624
+ Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
693
1625
  end
694
- Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
695
1626
  else
696
1627
  clean_t = text.to_s.strip
697
- box_w = [clean_t.length + 6, cols - 2].min
1628
+ box_w = [display_width(clean_t) + 6, cols - 2].min
698
1629
  top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
699
- bot_fill = (h_bot * ((box_w - 2).to_f / h_b.length).ceil)[0...(box_w - 2)]
1630
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
1631
+
1632
+ pad_t = [box_w - 4 - display_width(clean_t), 0].max
1633
+ l_p = " " * (pad_t / 2)
1634
+ r_p = " " * (pad_t - (pad_t / 2))
700
1635
 
701
- Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
702
- Kernel.print("#{color_code}#{v_l} #{clean_t.center(box_w - 4)} #{v_r}#{reset_code}\r\n")
703
- Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
1636
+ if is_rgb
1637
+ Kernel.print("#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
1638
+ Kernel.print("#{Color.rgb(v_l)} #{Color.rgb(l_p + clean_t + r_p)} #{Color.rgb(v_r)}\r\n")
1639
+ Kernel.print("#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
1640
+ else
1641
+ Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
1642
+ Kernel.print("#{color_code}#{v_l} #{l_p}#{clean_t}#{r_p} #{v_r}#{reset_code}\r\n")
1643
+ Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
1644
+ end
704
1645
  end
705
1646
  end
706
1647
 
@@ -709,7 +1650,7 @@ class GRmenu
709
1650
  alias_method :logo, :banner
710
1651
  end
711
1652
 
712
- def initialize(functions, *positional_arguments, title: nil, banner: nil, subtitle: nil, description: nil, divider: nil, style: nil, banner_style: nil, center: true, font: nil, page_size: nil, **keyword_arguments)
1653
+ def initialize(functions, *positional_arguments, title: nil, banner: nil, subtitle: nil, description: nil, divider: nil, style: nil, banner_style: nil, center: true, font: nil, page_size: nil, search: false, columns: 1, image: nil, image_width: nil, **keyword_arguments)
713
1654
  @functions = functions.is_a?(Array) ? functions : Array(functions)
714
1655
 
715
1656
  pos_title = positional_arguments[0]
@@ -723,7 +1664,16 @@ class GRmenu
723
1664
  @banner_style = (banner_style || keyword_arguments[:banner_style] || 3).to_i
724
1665
  @center = center.nil? ? true : center
725
1666
  @page_size = (page_size || keyword_arguments[:page_size])&.to_i
1667
+ @search = search || keyword_arguments[:search] || false
1668
+ @columns = [(columns || keyword_arguments[:columns] || 1).to_i, 1].max
1669
+ @image = image || keyword_arguments[:image]
1670
+ @image_width = (image_width || keyword_arguments[:image_width])&.to_i
1671
+ @query = String.new("")
726
1672
  @index = 0
1673
+ @rgb_tick = 0.0
1674
+
1675
+ @cached_image_lines = nil
1676
+ @cached_image_cols = nil
727
1677
 
728
1678
  init_font = font || keyword_arguments[:font_style] || SetStyle.font || 1
729
1679
 
@@ -739,28 +1689,135 @@ class GRmenu
739
1689
  )
740
1690
  end
741
1691
 
1692
+ def current_matching_indices
1693
+ if @search && !@query.empty?
1694
+ indices = []
1695
+ @functions.each_with_index do |func, idx|
1696
+ name = extract_name_from_action(func)
1697
+ indices << idx if name.downcase.include?(@query.downcase)
1698
+ end
1699
+ indices
1700
+ else
1701
+ (0...@functions.length).to_a
1702
+ end
1703
+ end
1704
+
742
1705
  def move_up
743
- return @index if @functions.empty?
744
- @index = (@index - 1) % @functions.length
1706
+ matching = current_matching_indices
1707
+ return @index if matching.empty?
1708
+ cols = @columns
1709
+ pos = matching.index(@index) || 0
1710
+ if cols <= 1
1711
+ new_pos = (pos - 1) % matching.length
1712
+ else
1713
+ new_pos = pos - cols
1714
+ if new_pos < 0
1715
+ new_pos = pos
1716
+ while (new_pos + cols) < matching.length
1717
+ new_pos += cols
1718
+ end
1719
+ end
1720
+ end
1721
+ @index = matching[new_pos]
745
1722
  end
746
1723
  alias_method :_up, :move_up
747
1724
 
748
1725
  def move_down
749
- return @index if @functions.empty?
750
- @index = (@index + 1) % @functions.length
1726
+ matching = current_matching_indices
1727
+ return @index if matching.empty?
1728
+ cols = @columns
1729
+ pos = matching.index(@index) || 0
1730
+ if cols <= 1
1731
+ new_pos = (pos + 1) % matching.length
1732
+ else
1733
+ new_pos = pos + cols
1734
+ if new_pos >= matching.length
1735
+ new_pos = pos % cols
1736
+ end
1737
+ end
1738
+ @index = matching[new_pos]
751
1739
  end
752
1740
  alias_method :_down, :move_down
753
1741
 
754
- def colorize(text, color_config)
1742
+ def move_left
1743
+ matching = current_matching_indices
1744
+ return @index if matching.empty?
1745
+ cols = @columns
1746
+ pos = matching.index(@index) || 0
1747
+ if cols <= 1
1748
+ new_pos = (pos - 1) % matching.length
1749
+ else
1750
+ if (pos % cols) == 0
1751
+ new_pos = [pos + (cols - 1), matching.length - 1].min
1752
+ else
1753
+ new_pos = pos - 1
1754
+ end
1755
+ end
1756
+ @index = matching[new_pos]
1757
+ end
1758
+
1759
+ def move_right
1760
+ matching = current_matching_indices
1761
+ return @index if matching.empty?
1762
+ cols = @columns
1763
+ pos = matching.index(@index) || 0
1764
+ if cols <= 1
1765
+ new_pos = (pos + 1) % matching.length
1766
+ else
1767
+ if (pos % cols) == (cols - 1) || pos == (matching.length - 1)
1768
+ new_pos = pos - (pos % cols)
1769
+ else
1770
+ new_pos = pos + 1
1771
+ end
1772
+ end
1773
+ @index = matching[new_pos]
1774
+ end
1775
+
1776
+ def colorize(text, color_config, phase_offset = 0.0)
755
1777
  return text.to_s if color_config.nil? || color_config.empty?
756
1778
 
757
1779
  color_name = (color_config[:color] || color_config["color"]).to_s.downcase
758
1780
  brightness_level = (color_config[:level] || color_config["level"] || 1).to_i
759
1781
 
760
- color_code = COLORS.dig(color_name, brightness_level)
1782
+ if color_name == "rgb" || color_name == "rainbow" || color_name == "chroma"
1783
+ tick = @rgb_tick || 0.0
1784
+ out = String.new("")
1785
+ char_count = 0
1786
+ in_escape = false
1787
+ escape_buf = String.new("")
1788
+
1789
+ text.to_s.each_char do |ch|
1790
+ if ch == "\e"
1791
+ in_escape = true
1792
+ escape_buf << ch
1793
+ next
1794
+ end
1795
+ if in_escape
1796
+ escape_buf << ch
1797
+ if ch =~ /[a-zA-Z]/
1798
+ in_escape = false
1799
+ out << escape_buf
1800
+ escape_buf.clear
1801
+ end
1802
+ next
1803
+ end
1804
+
1805
+ if ch == " " || ch == "\t" || ch == "\r" || ch == "\n"
1806
+ out << ch
1807
+ else
1808
+ c_code = self.class.rgb_color(tick, char_count * 0.12 + phase_offset)
1809
+ out << "#{c_code}#{ch}"
1810
+ char_count += 1
1811
+ end
1812
+ end
1813
+ out << self.class.ansi_reset
1814
+ return out
1815
+ end
1816
+
1817
+ color_code = self.class.ansi_color(color_name, brightness_level)
761
1818
  return text.to_s unless color_code
762
1819
 
763
- "#{color_code}#{text}#{COLORS['reset']}"
1820
+ "#{color_code}#{text}#{self.class.ansi_reset}"
764
1821
  end
765
1822
  alias_method :_colorize, :colorize
766
1823
 
@@ -789,55 +1846,112 @@ class GRmenu
789
1846
  lines = []
790
1847
  box_w = 0
791
1848
  if ascii_rows
792
- content_w = ascii_rows.map(&:length).max
1849
+ content_w = ascii_rows.map { |r| GRmenu.display_width(r) }.max
793
1850
  box_w = content_w + 6
794
1851
  top_fill = build_horizontal_line(h_top, content_w + 4)
795
1852
  bot_fill = build_horizontal_line(h_bot, content_w + 4)
796
1853
 
797
- lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
798
- ascii_rows.each do |row|
799
- pad = " " * (content_w - row.length)
800
- lines << colorize("#{v_l} #{row}#{pad} #{v_r}", banner_color_cfg)
1854
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
1855
+ ascii_rows.each_with_index do |row, r_i|
1856
+ pad = " " * (content_w - GRmenu.display_width(row))
1857
+ lines << colorize("#{v_l} #{row}#{pad} #{v_r}", banner_color_cfg, r_i * 0.2)
801
1858
  end
802
- lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg)
1859
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 1.2)
803
1860
  else
804
1861
  clean_b = @banner.strip
805
- box_w = [clean_b.length + 6, term_cols - 2].min
1862
+ b_vis_w = GRmenu.display_width(clean_b)
1863
+ box_w = [b_vis_w + 6, term_cols - 2].min
806
1864
  top_fill = build_horizontal_line(h_top, box_w - 2)
807
1865
  bot_fill = build_horizontal_line(h_bot, box_w - 2)
808
1866
 
809
- lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
810
- lines << colorize("#{v_l} #{clean_b.center(box_w - 4)} #{v_r}", banner_color_cfg)
811
- lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg)
1867
+ pad_b = [box_w - 4 - b_vis_w, 0].max
1868
+ l_p = " " * (pad_b / 2)
1869
+ r_p = " " * (pad_b - (pad_b / 2))
1870
+
1871
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
1872
+ lines << colorize("#{v_l} #{l_p}#{clean_b}#{r_p} #{v_r}", banner_color_cfg, 0.4)
1873
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{border_cfg[:br]}", banner_color_cfg, 0.8)
812
1874
  end
813
1875
  [lines, box_w]
814
1876
  end
815
1877
 
1878
+ def render_image_lines(term_cols)
1879
+ return @cached_image_lines if @cached_image_lines && @cached_image_cols == term_cols
1880
+
1881
+ return [[], 0] unless @image && File.exist?(@image)
1882
+
1883
+ raw_lines = self.class.load_and_render_image(@image, @image_width || 40, nil, term_cols)
1884
+ return [[], 0] if raw_lines.empty?
1885
+
1886
+ img_w = self.class.display_width(raw_lines.first)
1887
+ box_w = img_w + 4
1888
+ banner_border = BORDERS[@banner_style] || BORDERS[3]
1889
+ banner_color_cfg = @style_config.banner
1890
+
1891
+ h_top = banner_border[:ht] || banner_border[:h]
1892
+ h_bot = banner_border[:hb] || banner_border[:h]
1893
+ v_l = banner_border[:vl] || banner_border[:v]
1894
+ v_r = banner_border[:vr] || banner_border[:v]
1895
+
1896
+ top_fill = build_horizontal_line(h_top, img_w + 2)
1897
+ bot_fill = build_horizontal_line(h_bot, img_w + 2)
1898
+
1899
+ lines = []
1900
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
1901
+ raw_lines.each do |r_line|
1902
+ lines << "#{colorize(v_l, banner_color_cfg)} #{r_line} #{colorize(v_r, banner_color_cfg)}"
1903
+ end
1904
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg)
1905
+
1906
+ @cached_image_cols = term_cols
1907
+ @cached_image_lines = [lines, box_w]
1908
+ @cached_image_lines
1909
+ end
1910
+
816
1911
  def render_lines(size_max = 20)
817
1912
  term_cols = self.class.terminal_width
818
1913
  term_rows = self.class.terminal_height
819
1914
  rendered_lines = []
820
1915
 
821
- banner_box_width = 0
822
- banner_lines_count = 0
1916
+ header_box_width = 0
1917
+ header_lines_count = 0
1918
+
1919
+ if @image && File.exist?(@image)
1920
+ img_lines, img_box_w = render_image_lines(term_cols)
1921
+ unless img_lines.empty?
1922
+ rendered_lines.concat(img_lines)
1923
+ rendered_lines << ""
1924
+ header_lines_count += img_lines.length + 1
1925
+ header_box_width = [header_box_width, img_box_w].max
1926
+ end
1927
+ end
1928
+
823
1929
  if @banner && !@banner.empty?
824
- banner_lines, banner_box_width = render_banner_lines(term_cols)
825
- rendered_lines.concat(banner_lines)
826
- rendered_lines << ""
827
- banner_lines_count = banner_lines.length + 1
1930
+ banner_lines, banner_box_w = render_banner_lines(term_cols)
1931
+ unless banner_lines.empty?
1932
+ rendered_lines.concat(banner_lines)
1933
+ rendered_lines << ""
1934
+ header_lines_count += banner_lines.length + 1
1935
+ header_box_width = [header_box_width, banner_box_w].max
1936
+ end
828
1937
  end
829
1938
 
1939
+ matching_indices = current_matching_indices
830
1940
  all_names = @functions.map { |func| extract_name_from_action(func) }
831
1941
  all_descriptions = @functions.map { |func| extract_description_from_action(func) }
832
1942
 
833
1943
  active_desc = all_descriptions[@index] || ""
834
1944
 
835
- calculated_width = [size_max, @title.length + 4].max
836
- calculated_width = ([calculated_width] + all_names.map { |name| name.length + 6 }).max
837
- calculated_width = ([calculated_width, active_desc.length + 8].max) unless active_desc.empty?
1945
+ cols = @columns
1946
+ max_item_len = all_names.empty? ? 10 : all_names.map { |n| GRmenu.display_width(n) }.max
1947
+ grid_suggested_w = (max_item_len + 6) * cols + 4
1948
+
1949
+ calculated_width = [size_max, GRmenu.display_width(@title) + 4, grid_suggested_w].max
1950
+ calculated_width = ([calculated_width, GRmenu.display_width(active_desc) + 8].max) unless active_desc.empty?
1951
+ calculated_width = ([calculated_width, GRmenu.display_width(@query) + 16].max) if @search
838
1952
  total_width = [calculated_width, term_cols - 2].min
839
1953
 
840
- reference_width = banner_box_width > 0 ? banner_box_width : total_width
1954
+ reference_width = header_box_width > 0 ? header_box_width : total_width
841
1955
  margin_left = (@center && reference_width > total_width) ? " " * ((reference_width - total_width) / 2) : ""
842
1956
 
843
1957
  subtitle_lines_count = 0
@@ -846,18 +1960,19 @@ class GRmenu
846
1960
  div_w = @divider.is_a?(Numeric) ? @divider.to_i : [reference_width, term_cols - 2].min
847
1961
 
848
1962
  if @divider
849
- rendered_lines << colorize("─" * div_w, @style_config.divider)
1963
+ rendered_lines << colorize("─" * div_w, @style_config.divider, 0.0)
850
1964
  subtitle_lines_count += 1
851
1965
  end
852
1966
 
853
- subtitle_lines.each do |sub_line|
854
- formatted_sub = @center ? sub_line.center(div_w) : sub_line
855
- rendered_lines << colorize(formatted_sub, @style_config.subtitle)
1967
+ subtitle_lines.each_with_index do |sub_line, s_i|
1968
+ pad_sub = [div_w - GRmenu.display_width(sub_line), 0].max
1969
+ formatted_sub = @center ? (" " * (pad_sub / 2) + sub_line + " " * (pad_sub - (pad_sub / 2))) : sub_line
1970
+ rendered_lines << colorize(formatted_sub, @style_config.subtitle, s_i * 0.3)
856
1971
  subtitle_lines_count += 1
857
1972
  end
858
1973
 
859
1974
  if @divider
860
- rendered_lines << colorize("─" * div_w, @style_config.divider)
1975
+ rendered_lines << colorize("─" * div_w, @style_config.divider, 0.6)
861
1976
  subtitle_lines_count += 1
862
1977
  end
863
1978
  rendered_lines << ""
@@ -871,32 +1986,38 @@ class GRmenu
871
1986
 
872
1987
  box_border = BORDERS[@style]
873
1988
 
874
- total_items = @functions.length
875
- overhead = banner_lines_count + subtitle_lines_count + 6
1989
+ overhead = header_lines_count + subtitle_lines_count + 6
876
1990
  overhead += 2 unless active_desc.empty?
877
- available_rows = [term_rows - overhead - 2, 3].max
1991
+ overhead += 2 if @search
1992
+ available_rows = [term_rows - overhead - 2, 2].max
1993
+
1994
+ rows_data = matching_indices.each_slice(cols).to_a
1995
+ total_rows = rows_data.length
878
1996
 
879
- effective_page_size = if @page_size && @page_size > 0
880
- [@page_size, total_items].min
881
- elsif total_items > available_rows
882
- available_rows
1997
+ effective_page_rows = if @page_size && @page_size > 0
1998
+ [@page_size, total_rows, available_rows].min
883
1999
  else
884
- total_items
2000
+ [total_rows, available_rows].min
885
2001
  end
2002
+ effective_page_rows = [effective_page_rows, 1].max
886
2003
 
887
- start_idx = 0
888
- end_idx = total_items - 1
889
- if total_items > effective_page_size
890
- half = effective_page_size / 2
891
- start_idx = [[@index - half, 0].max, total_items - effective_page_size].min
892
- end_idx = start_idx + effective_page_size - 1
2004
+ curr_matching_pos = matching_indices.index(@index) || 0
2005
+ curr_row = total_rows > 0 ? (curr_matching_pos / cols) : 0
2006
+
2007
+ start_row = 0
2008
+ end_row = [total_rows - 1, 0].max
2009
+ if total_rows > effective_page_rows
2010
+ half_r = effective_page_rows / 2
2011
+ start_row = [[curr_row - half_r, 0].max, total_rows - effective_page_rows].min
2012
+ end_row = start_row + effective_page_rows - 1
893
2013
  end
894
2014
 
895
- visible_indices = (start_idx..end_idx).to_a
896
- has_more_above = start_idx > 0
897
- has_more_below = end_idx < (total_items - 1)
2015
+ visible_rows_data = rows_data[start_row..end_row] || []
2016
+ has_more_above = start_row > 0
2017
+ has_more_below = end_row < (total_rows - 1)
898
2018
 
899
- avail_w = [total_width - 6, 1].max
2019
+ avail_w = [total_width - 4, 1].max
2020
+ col_w = [(avail_w - (cols - 1) * 2) / cols, 1].max
900
2021
 
901
2022
  if box_border
902
2023
  h_top = box_border[:ht] || box_border[:h]
@@ -908,51 +2029,89 @@ class GRmenu
908
2029
  bot_fill = build_horizontal_line(h_bot, total_width - 2)
909
2030
  mid_fill = build_horizontal_line(h_top, total_width - 2)
910
2031
 
911
- v_left = colorize(v_l_raw, border_color_cfg)
912
- v_right = colorize(v_r_raw, border_color_cfg)
2032
+ v_left = colorize(v_l_raw, border_color_cfg, 0.2)
2033
+ v_right = colorize(v_r_raw, border_color_cfg, 0.8)
913
2034
 
914
2035
  top_border_line = box_border[:tl] + top_fill + box_border[:tr]
915
- rendered_lines << "#{margin_left}#{colorize(top_border_line, border_color_cfg)}"
2036
+ rendered_lines << "#{margin_left}#{colorize(top_border_line, border_color_cfg, 0.0)}"
916
2037
 
917
2038
  unless @title.empty?
918
- centered_title = colorize(@title.center(total_width - 4), title_color_cfg)
2039
+ pad_t = [total_width - 4 - GRmenu.display_width(@title), 0].max
2040
+ title_padded = " " * (pad_t / 2) + @title + " " * (pad_t - (pad_t / 2))
2041
+ centered_title = colorize(title_padded, title_color_cfg, 0.4)
919
2042
  rendered_lines << "#{margin_left}#{v_left} #{centered_title} #{v_right}"
920
2043
 
921
2044
  separator_line = v_l_raw + mid_fill + v_r_raw
922
- rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg)}"
2045
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.6)}"
2046
+ end
2047
+
2048
+ if @search
2049
+ search_prompt = "Buscar: #{@query}█"
2050
+ pad_s = [avail_w - GRmenu.display_width(search_prompt), 0].max
2051
+ search_padded = search_prompt + (" " * pad_s)
2052
+ rendered_lines << "#{margin_left}#{v_left} #{Color.bright_yellow(search_padded)} #{v_right}"
2053
+ separator_line = v_l_raw + mid_fill + v_r_raw
2054
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.8)}"
923
2055
  end
924
2056
 
925
2057
  if has_more_above
926
- up_indicator = colorize("▲ (+#{start_idx} arriba)".center(avail_w + 2), { color: "gray", level: 2 })
2058
+ up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
2059
+ pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
2060
+ up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
927
2061
  rendered_lines << "#{margin_left}#{v_left} #{up_indicator} #{v_right}"
928
2062
  end
929
2063
 
930
- visible_indices.each do |current_index|
931
- option_name = all_names[current_index]
932
- if @index == current_index
933
- highlighted_text = colorize("> #{option_name.ljust(avail_w)}", focus_color_cfg)
934
- rendered_lines << "#{margin_left}#{v_left} #{highlighted_text} #{v_right}"
935
- else
936
- normal_text = colorize(" #{option_name.ljust(avail_w)}", options_color_cfg)
937
- rendered_lines << "#{margin_left}#{v_left} #{normal_text} #{v_right}"
2064
+ if rows_data.empty?
2065
+ no_res_txt = "(Sin resultados)"
2066
+ pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
2067
+ no_res = colorize(" " * (pad_no / 2) + no_res_txt + " " * (pad_no - (pad_no / 2)), { color: "gray", level: 1 })
2068
+ rendered_lines << "#{margin_left}#{v_left} #{no_res} #{v_right}"
2069
+ else
2070
+ visible_rows_data.each_with_index do |row_indices, r_idx|
2071
+ cells = []
2072
+ cols.times do |c_idx|
2073
+ item_idx = row_indices[c_idx]
2074
+ if item_idx
2075
+ op_name = all_names[item_idx]
2076
+ if @index == item_idx
2077
+ cell_raw = "> #{op_name}"
2078
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2079
+ cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
2080
+ else
2081
+ cell_raw = " #{op_name}"
2082
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2083
+ cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
2084
+ end
2085
+ else
2086
+ cells << (" " * col_w)
2087
+ end
2088
+ end
2089
+ row_str = cells.join(" ")
2090
+ pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
2091
+ row_padded = row_str + (" " * pad_r)
2092
+ rendered_lines << "#{margin_left}#{v_left} #{row_padded} #{v_right}"
938
2093
  end
939
2094
  end
940
2095
 
941
2096
  if has_more_below
942
- remaining_below = total_items - 1 - end_idx
943
- down_indicator = colorize("▼ (+#{remaining_below} abajo)".center(avail_w + 2), { color: "gray", level: 2 })
2097
+ remaining_below = total_rows - 1 - end_row
2098
+ down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
2099
+ pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
2100
+ down_indicator = colorize(" " * (pad_down / 2) + down_text + " " * (pad_down - (pad_down / 2)), { color: "gray", level: 2 })
944
2101
  rendered_lines << "#{margin_left}#{v_left} #{down_indicator} #{v_right}"
945
2102
  end
946
2103
 
947
2104
  unless active_desc.empty?
948
2105
  separator_line = v_l_raw + mid_fill + v_r_raw
949
- rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg)}"
950
- desc_text = colorize(" #{active_desc.ljust(avail_w)}", { color: "cyan", level: 1 })
2106
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 1.0)}"
2107
+ raw_desc = "* #{active_desc}"
2108
+ pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2109
+ desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
951
2110
  rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
952
2111
  end
953
2112
 
954
2113
  bottom_border_line = box_border[:bl] + bot_fill + box_border[:br]
955
- rendered_lines << "#{margin_left}#{colorize(bottom_border_line, border_color_cfg)}"
2114
+ rendered_lines << "#{margin_left}#{colorize(bottom_border_line, border_color_cfg, 1.4)}"
956
2115
  else
957
2116
  symbol_char = STYLES[@style] || "#"
958
2117
  solid_border = colorize(symbol_char, border_color_cfg)
@@ -961,36 +2120,73 @@ class GRmenu
961
2120
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
962
2121
 
963
2122
  unless @title.empty?
964
- centered_title = colorize(@title.center(total_width - 4), title_color_cfg)
2123
+ pad_t = [total_width - 4 - GRmenu.display_width(@title), 0].max
2124
+ title_padded = " " * (pad_t / 2) + @title + " " * (pad_t - (pad_t / 2))
2125
+ centered_title = colorize(title_padded, title_color_cfg)
965
2126
  rendered_lines << "#{margin_left}#{solid_border} #{centered_title} #{solid_border}"
966
2127
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
967
2128
  end
968
2129
 
2130
+ if @search
2131
+ search_prompt = "Buscar: #{@query}█"
2132
+ pad_s = [avail_w - GRmenu.display_width(search_prompt), 0].max
2133
+ search_padded = search_prompt + (" " * pad_s)
2134
+ rendered_lines << "#{margin_left}#{solid_border} #{Color.bright_yellow(search_padded)} #{solid_border}"
2135
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
2136
+ end
2137
+
969
2138
  if has_more_above
970
- up_indicator = colorize("▲ (+#{start_idx} arriba)".center(avail_w + 2), { color: "gray", level: 2 })
2139
+ up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
2140
+ pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
2141
+ up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
971
2142
  rendered_lines << "#{margin_left}#{solid_border} #{up_indicator} #{solid_border}"
972
2143
  end
973
2144
 
974
- visible_indices.each do |current_index|
975
- option_name = all_names[current_index]
976
- if @index == current_index
977
- highlighted_text = colorize("> #{option_name.ljust(avail_w)}", focus_color_cfg)
978
- rendered_lines << "#{margin_left}#{solid_border} #{highlighted_text} #{solid_border}"
979
- else
980
- normal_text = colorize(" #{option_name.ljust(avail_w)}", options_color_cfg)
981
- rendered_lines << "#{margin_left}#{solid_border} #{normal_text} #{solid_border}"
2145
+ if rows_data.empty?
2146
+ no_res_txt = "(Sin resultados)"
2147
+ pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
2148
+ no_res = colorize(" " * (pad_no / 2) + no_res_txt + " " * (pad_no - (pad_no / 2)), { color: "gray", level: 1 })
2149
+ rendered_lines << "#{margin_left}#{solid_border} #{no_res} #{solid_border}"
2150
+ else
2151
+ visible_rows_data.each_with_index do |row_indices, r_idx|
2152
+ cells = []
2153
+ cols.times do |c_idx|
2154
+ item_idx = row_indices[c_idx]
2155
+ if item_idx
2156
+ op_name = all_names[item_idx]
2157
+ if @index == item_idx
2158
+ cell_raw = "> #{op_name}"
2159
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2160
+ cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
2161
+ else
2162
+ cell_raw = " #{op_name}"
2163
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2164
+ cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
2165
+ end
2166
+ else
2167
+ cells << (" " * col_w)
2168
+ end
2169
+ end
2170
+ row_str = cells.join(" ")
2171
+ pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
2172
+ row_padded = row_str + (" " * pad_r)
2173
+ rendered_lines << "#{margin_left}#{solid_border} #{row_padded} #{solid_border}"
982
2174
  end
983
2175
  end
984
2176
 
985
2177
  if has_more_below
986
- remaining_below = total_items - 1 - end_idx
987
- down_indicator = colorize("▼ (+#{remaining_below} abajo)".center(avail_w + 2), { color: "gray", level: 2 })
2178
+ remaining_below = total_rows - 1 - end_row
2179
+ down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
2180
+ pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
2181
+ down_indicator = colorize(" " * (pad_down / 2) + down_text + " " * (pad_down - (pad_down / 2)), { color: "gray", level: 2 })
988
2182
  rendered_lines << "#{margin_left}#{solid_border} #{down_indicator} #{solid_border}"
989
2183
  end
990
2184
 
991
2185
  unless active_desc.empty?
992
2186
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
993
- desc_text = colorize(" #{active_desc.ljust(avail_w)}", { color: "cyan", level: 1 })
2187
+ raw_desc = "* #{active_desc}"
2188
+ pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2189
+ desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
994
2190
  rendered_lines << "#{margin_left}#{solid_border} #{desc_text} #{solid_border}"
995
2191
  end
996
2192
 
@@ -1000,6 +2196,26 @@ class GRmenu
1000
2196
  rendered_lines
1001
2197
  end
1002
2198
 
2199
+ def has_rgb_animation?
2200
+ configs = [
2201
+ @style_config.border,
2202
+ @style_config.options,
2203
+ @style_config.focus,
2204
+ @style_config.title,
2205
+ @style_config.banner,
2206
+ @style_config.subtitle,
2207
+ @style_config.divider
2208
+ ]
2209
+ configs.any? do |c|
2210
+ if c.is_a?(Hash)
2211
+ val = (c[:color] || c["color"]).to_s.downcase
2212
+ val == "rgb" || val == "rainbow" || val == "chroma"
2213
+ else
2214
+ false
2215
+ end
2216
+ end
2217
+ end
2218
+
1003
2219
  def draw(size_max: 20, min_width: nil)
1004
2220
  target_width = min_width || size_max || 20
1005
2221
  action_to_execute = nil
@@ -1045,19 +2261,84 @@ class GRmenu
1045
2261
  end
1046
2262
 
1047
2263
  def run_interactive_loop(input_stream, target_width)
2264
+ matching = current_matching_indices
2265
+ @index = matching.first || 0 unless matching.include?(@index)
2266
+ @rgb_tick = 0.0
1048
2267
  draw_frame(target_width)
1049
2268
 
1050
- while (key = read_single_key(input_stream))
1051
- break if key == "q" || key == "Q" || key == "\x03" || key == "\x04"
2269
+ animating = has_rgb_animation?
2270
+
2271
+ while true
2272
+ if animating
2273
+ ready = false
2274
+ if input_stream.respond_to?(:to_io) || input_stream.is_a?(IO)
2275
+ begin
2276
+ select_res = IO.select([input_stream], nil, nil, 0.035)
2277
+ ready = true if select_res && select_res[0] && !select_res[0].empty?
2278
+ rescue StandardError
2279
+ ready = true
2280
+ end
2281
+ else
2282
+ ready = true
2283
+ end
2284
+
2285
+ unless ready
2286
+ @rgb_tick += 0.08
2287
+ draw_frame(target_width)
2288
+ next
2289
+ end
2290
+ end
2291
+
2292
+ key = read_single_key(input_stream)
2293
+ break if key.nil? || key == "\x03" || key == "\x04"
2294
+
2295
+ if !@search && (key == "q" || key == "Q")
2296
+ break
2297
+ end
1052
2298
 
1053
- if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
2299
+ if key == "\e"
2300
+ if @search && !@query.empty?
2301
+ @query.clear
2302
+ matching = current_matching_indices
2303
+ @index = matching.first || 0
2304
+ draw_frame(target_width)
2305
+ else
2306
+ break
2307
+ end
2308
+ elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
1054
2309
  move_up
1055
2310
  draw_frame(target_width)
1056
2311
  elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
1057
2312
  move_down
1058
2313
  draw_frame(target_width)
2314
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K"
2315
+ move_left
2316
+ draw_frame(target_width)
2317
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
2318
+ move_right
2319
+ draw_frame(target_width)
2320
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
2321
+ if @search && !@query.empty?
2322
+ @query.chop!
2323
+ matching = current_matching_indices
2324
+ @index = matching.first || 0
2325
+ draw_frame(target_width)
2326
+ end
2327
+ elsif key == "\x15"
2328
+ if @search
2329
+ @query.clear
2330
+ matching = current_matching_indices
2331
+ @index = matching.first || 0
2332
+ draw_frame(target_width)
2333
+ end
1059
2334
  elsif key == "\r" || key == "\n"
1060
- return @functions[@index]
2335
+ matching = current_matching_indices
2336
+ return @functions[@index] if matching.include?(@index)
2337
+ elsif @search && key =~ /^[[:print:]]$/
2338
+ @query << key
2339
+ matching = current_matching_indices
2340
+ @index = matching.first || 0
2341
+ draw_frame(target_width)
1061
2342
  end
1062
2343
  end
1063
2344
 
@@ -1065,37 +2346,7 @@ class GRmenu
1065
2346
  end
1066
2347
 
1067
2348
  def read_single_key(input_stream)
1068
- unless input_stream.respond_to?(:tty?) && input_stream.tty?
1069
- begin
1070
- return input_stream.sysread(3) if input_stream.respond_to?(:sysread)
1071
- return input_stream.read(1)
1072
- rescue EOFError, Errno::EPIPE
1073
- return nil
1074
- end
1075
- end
1076
-
1077
- first_char = input_stream.getch
1078
- return nil if first_char.nil?
1079
-
1080
- if first_char == "\e"
1081
- begin
1082
- extra_chars = input_stream.read_nonblock(2)
1083
- first_char << extra_chars
1084
- rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1085
- end
1086
- elsif first_char == "\x00" || first_char == "\xe0"
1087
- begin
1088
- second_char = input_stream.read_nonblock(1)
1089
- first_char << second_char
1090
- rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1091
- second_char = input_stream.getch rescue nil
1092
- first_char << second_char if second_char
1093
- end
1094
- end
1095
-
1096
- first_char
1097
- rescue EOFError, Errno::EPIPE, Errno::ENOTTY
1098
- nil
2349
+ GRmenu.read_key_raw(input_stream)
1099
2350
  end
1100
2351
 
1101
2352
  def format_auto_name(raw_name)