grmenu 0.1.6 → 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 +1834 -293
  3. data/README.md +430 -225
  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,59 +89,100 @@ 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
 
42
- def red(s) = paint(s, :red, 1)
43
- def bright_red(s) = paint(s, :red, 2)
44
- def dark_red(s) = paint(s, :red, 1)
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
45
136
 
46
- def green(s) = paint(s, :green, 1)
47
- def bright_green(s) = paint(s, :green, 2)
48
- def dark_green(s) = paint(s, :green, 1)
137
+ def red(s); paint(s, :red, 1); end
138
+ def bright_red(s); paint(s, :red, 2); end
139
+ def dark_red(s); paint(s, :red, 1); end
49
140
 
50
- def yellow(s) = paint(s, :yellow, 1)
51
- def bright_yellow(s) = paint(s, :yellow, 2)
141
+ def green(s); paint(s, :green, 1); end
142
+ def bright_green(s); paint(s, :green, 2); end
143
+ def dark_green(s); paint(s, :green, 1); end
52
144
 
53
- def blue(s) = paint(s, :blue, 1)
54
- def bright_blue(s) = paint(s, :blue, 2)
145
+ def yellow(s); paint(s, :yellow, 1); end
146
+ def bright_yellow(s); paint(s, :yellow, 2); end
55
147
 
56
- def magenta(s) = paint(s, :magenta, 1)
57
- def bright_magenta(s) = paint(s, :magenta, 2)
148
+ def blue(s); paint(s, :blue, 1); end
149
+ def bright_blue(s); paint(s, :blue, 2); end
58
150
 
59
- def purple(s) = paint(s, :purple, 1)
60
- def bright_purple(s) = paint(s, :purple, 2)
151
+ def magenta(s); paint(s, :magenta, 1); end
152
+ def bright_magenta(s); paint(s, :magenta, 2); end
61
153
 
62
- def pink(s) = paint(s, :pink, 1)
63
- def bright_pink(s) = paint(s, :pink, 2)
154
+ def purple(s); paint(s, :purple, 1); end
155
+ def bright_purple(s); paint(s, :purple, 2); end
64
156
 
65
- def cyan(s) = paint(s, :cyan, 1)
66
- def bright_cyan(s) = paint(s, :cyan, 2)
157
+ def pink(s); paint(s, :pink, 1); end
158
+ def bright_pink(s); paint(s, :pink, 2); end
67
159
 
68
- def aqua(s) = paint(s, :aqua, 1)
69
- def bright_aqua(s) = paint(s, :aqua, 2)
160
+ def cyan(s); paint(s, :cyan, 1); end
161
+ def bright_cyan(s); paint(s, :cyan, 2); end
70
162
 
71
- def orange(s) = paint(s, :orange, 1)
72
- def bright_orange(s) = paint(s, :orange, 2)
163
+ def aqua(s); paint(s, :aqua, 1); end
164
+ def bright_aqua(s); paint(s, :aqua, 2); end
73
165
 
74
- def white(s) = paint(s, :white, 1)
75
- def bright_white(s) = paint(s, :white, 2)
166
+ def orange(s); paint(s, :orange, 1); end
167
+ def bright_orange(s); paint(s, :orange, 2); end
76
168
 
77
- def black(s) = paint(s, :black, 1)
78
- def gray(s) = paint(s, :gray, 1)
79
- def bright_gray(s) = paint(s, :gray, 2)
80
- def grey(s) = gray(s)
169
+ def white(s); paint(s, :white, 1); end
170
+ def bright_white(s); paint(s, :white, 2); end
81
171
 
82
- def r(s) = bright_red(s)
83
- def dr(s) = dark_red(s)
84
- def g(s) = bright_green(s)
85
- def y(s) = bright_yellow(s)
86
- def w(s) = bright_white(s)
87
- def gr(s) = gray(s)
88
- def cy(s) = bright_cyan(s)
89
- def mg(s) = bright_magenta(s)
90
- def bl(s) = bright_blue(s)
172
+ def black(s); paint(s, :black, 1); end
173
+ def gray(s); paint(s, :gray, 1); end
174
+ def bright_gray(s); paint(s, :gray, 2); end
175
+ def grey(s); gray(s); end
176
+
177
+ def r(s); bright_red(s); end
178
+ def dr(s); dark_red(s); end
179
+ def g(s); bright_green(s); end
180
+ def y(s); bright_yellow(s); end
181
+ def w(s); bright_white(s); end
182
+ def gr(s); gray(s); end
183
+ def cy(s); bright_cyan(s); end
184
+ def mg(s); bright_magenta(s); end
185
+ def bl(s); bright_blue(s); end
91
186
  end
92
187
  C = Color
93
188
 
@@ -98,69 +193,1125 @@ 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
230
+
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
131
387
 
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
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
149
418
 
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)
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
433
+
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
155
479
  end
156
- loaded.freeze
480
+
481
+ png = PNGDecoder.load(filepath)
482
+ if png
483
+ return png.render_ansi_lines(req_w, height)
484
+ end
485
+
486
+ []
157
487
  rescue StandardError
158
- {}.freeze
488
+ []
489
+ end
490
+
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
535
+
536
+ class ProgressBar
537
+ attr_reader :total, :current, :title, :status
538
+
539
+ def initialize(total = 100, title: nil, color: "cyan", level: 2, style: 3, width: nil)
540
+ @total = [total.to_i, 1].max
541
+ @current = 0
542
+ @title = title
543
+ @status = String.new("")
544
+ @color = color.to_s.downcase
545
+ @level = level.to_i
546
+ @style = style.to_i
547
+ @width = width
548
+ @closed = false
549
+ @drawn_lines_count = 0
550
+ end
551
+
552
+ def advance(step = 1, status: nil)
553
+ return if @closed
554
+ @current = [(@current + step), @total].min
555
+ @status = status.to_s if status
556
+ render
557
+ end
558
+ alias_method :increment, :advance
559
+ alias_method :step, :advance
560
+
561
+ def set(value, status: nil)
562
+ return if @closed
563
+ @current = [[value.to_i, 0].max, @total].min
564
+ @status = status.to_s if status
565
+ render
566
+ end
567
+
568
+ def render
569
+ term_w = GRmenu.terminal_width
570
+ box_w = @width || [term_w - 4, 60].min
571
+ box_w = [box_w, 36].max
572
+
573
+ is_rgb = (@color == "rgb" || @color == "rainbow" || @color == "chroma")
574
+ tick = (@current.to_f / @total) * 6.2831853
575
+
576
+ border_cfg = GRmenu::BORDERS[@style] || GRmenu::BORDERS[3]
577
+
578
+ v_l = border_cfg[:vl] || border_cfg[:v]
579
+ v_r = border_cfg[:vr] || border_cfg[:v]
580
+ h_t = border_cfg[:ht] || border_cfg[:h]
581
+ h_b = border_cfg[:hb] || border_cfg[:h]
582
+
583
+ top_fill = (h_t * ((box_w - 2).to_f / h_t.length).ceil)[0...(box_w - 2)]
584
+ bot_fill = (h_b * ((box_w - 2).to_f / h_b.length).ceil)[0...(box_w - 2)]
585
+
586
+ pct = ((@current.to_f / @total) * 100).round
587
+ pct_str = "#{pct}% (#{@current}/#{@total})"
588
+
589
+ inner_w = box_w - 4
590
+ bar_w = [inner_w - pct_str.length - 3, 10].max
591
+ filled_len = ((@current.to_f / @total) * bar_w).round
592
+ empty_len = bar_w - filled_len
593
+
594
+ lines = []
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}"
645
+ end
646
+
647
+ frame = lines.join("\r\n") + "\r\n"
648
+
649
+ if @drawn_lines_count && @drawn_lines_count > 0
650
+ Kernel.print("\e[#{@drawn_lines_count}A\e[J")
651
+ end
652
+ Kernel.print(frame)
653
+ $stdout.flush
654
+ @drawn_lines_count = lines.length
655
+ end
656
+
657
+ def finish(status: "¡Completado!")
658
+ return if @closed
659
+ set(@total, status: status)
660
+ @closed = true
661
+ Kernel.print(GRmenu::SHOW_CURSOR)
662
+ end
663
+ end
664
+
665
+ def self.spinner(message = "Cargando...", color: "cyan", level: 2, delay: 0.08, &block)
666
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
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
670
+
671
+ stop_spinner = false
672
+ spinner_thread = Thread.new do
673
+ frame_idx = 0
674
+ while !stop_spinner
675
+ f = frames[frame_idx % frames.length]
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}")
679
+ $stdout.flush
680
+ frame_idx += 1
681
+ sleep(delay)
682
+ end
683
+ end
684
+
685
+ begin
686
+ Kernel.print(HIDE_CURSOR)
687
+ result = block ? block.call : nil
688
+ stop_spinner = true
689
+ spinner_thread.join
690
+ success_color = ansi_color("green", 2)
691
+ Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{message} #{Color.gray("Listo!")}\r\n")
692
+ result
693
+ rescue Exception => e
694
+ stop_spinner = true
695
+ spinner_thread.join rescue nil
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")
698
+ raise e
699
+ ensure
700
+ stop_spinner = true
701
+ Kernel.print(SHOW_CURSOR)
702
+ end
703
+ end
704
+
705
+ def self.progress(total = 100, title: nil, color: "cyan", level: 2, style: 3, width: nil, &block)
706
+ bar = ProgressBar.new(total, title: title, color: color, level: level, style: style, width: width)
707
+ Kernel.print(HIDE_CURSOR)
708
+ bar.render
709
+ begin
710
+ result = block ? block.call(bar) : bar
711
+ bar.finish
712
+ result
713
+ ensure
714
+ Kernel.print(SHOW_CURSOR)
715
+ end
716
+ end
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
159
1280
  end
160
1281
 
161
- FONTS = _load_fonts
162
- FONTS.each { |id, data| const_set("FONT_#{id}", data) }
163
- FONT = FONTS[1] || {}.freeze
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
164
1315
 
165
1316
  class SetStyle
166
1317
  def initialize(
@@ -333,7 +1484,7 @@ class GRmenu
333
1484
  end
334
1485
  end
335
1486
 
336
- attr_accessor :functions, :title, :subtitle, :banner, :banner_style, :divider, :style, :index, :style_config, :center
1487
+ attr_accessor :functions, :title, :subtitle, :banner, :banner_style, :divider, :style, :index, :style_config, :center, :page_size, :search, :columns, :query, :image, :image_width
337
1488
 
338
1489
  alias_method :options, :functions
339
1490
  alias_method :options=, :functions=
@@ -344,11 +1495,11 @@ class GRmenu
344
1495
  alias_method :description, :subtitle
345
1496
  alias_method :description=, :subtitle=
346
1497
 
347
- def self.STYLES = STYLES
348
- def self.COLORS = COLORS
349
- def self.BORDERS = BORDERS
350
- def self.FONTS = FONTS
351
- def self.FONT = FONT_1
1498
+ def self.STYLES; STYLES; end
1499
+ def self.COLORS; COLORS; end
1500
+ def self.BORDERS; BORDERS; end
1501
+ def self.FONTS; FONTS; end
1502
+ def self.FONT; FONT_1; end
352
1503
 
353
1504
  def self.terminal_width
354
1505
  cols = $stdout.winsize[1] rescue nil
@@ -358,6 +1509,14 @@ class GRmenu
358
1509
  80
359
1510
  end
360
1511
 
1512
+ def self.terminal_height
1513
+ rows = $stdout.winsize[0] rescue nil
1514
+ rows = $stdin.winsize[0] rescue nil if rows.nil? || rows <= 0
1515
+ (rows && rows > 0) ? rows : (ENV['LINES'] ? ENV['LINES'].to_i : 24)
1516
+ rescue StandardError
1517
+ 24
1518
+ end
1519
+
361
1520
  def self.clear_screen
362
1521
  Kernel.print(CLEAR_SCREEN_SEQUENCE)
363
1522
  end
@@ -367,91 +1526,27 @@ class GRmenu
367
1526
 
368
1527
  def self.div(long = nil, color = "blue", level = 1, char = "─")
369
1528
  width = long || [terminal_width - 2, 64].min
370
- color_code = COLORS.dig(color.to_s.downcase, level) || "\e[34m"
371
- Kernel.print("#{color_code}#{char * width}#{COLORS['reset']}\r\n")
372
- end
373
-
374
- def self.help
375
- w = [terminal_width - 4, 64].min
376
- w = [w, 42].max
377
- inner_w = w - 2
378
- h_line = "═" * inner_w
379
- s_line = "─" * w
380
-
381
- Kernel.print "\r\n"
382
- Kernel.print "#{Color.bright_cyan("╔" + h_line + "╗")}\r\n"
383
- Kernel.print "#{Color.bright_cyan("║")}#{Color.bright_yellow("GRmenu - Guia y Referencia Rapida".center(inner_w))}#{Color.bright_cyan("║")}\r\n"
384
- Kernel.print "#{Color.bright_cyan("║")}#{Color.gray("Navegacion interactiva en terminal TTY".center(inner_w))}#{Color.bright_cyan("║")}\r\n"
385
- Kernel.print "#{Color.bright_cyan("╚" + h_line + "╝")}\r\n\r\n"
386
-
387
- Kernel.print "#{Color.bright_magenta("[1] HELPERS NATIVOS EN MODO CRUDO")}\r\n"
388
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
389
- Kernel.print " #{Color.bright_green("GRmenu.clear_screen")} #{Color.gray("(o GRmenu.clr)")}\r\n"
390
- Kernel.print " * Limpia la terminal al instante con secuencias ANSI.\r\n"
391
- Kernel.print " #{Color.bright_green("GRmenu.continue(mensaje)")}\r\n"
392
- Kernel.print " * Pausa interactiva: espera una sola tecla en modo TTY crudo.\r\n"
393
- Kernel.print " #{Color.bright_green("GRmenu.banner(texto, delay, color:, level:, style:, font:)")}\r\n"
394
- Kernel.print " * Renderiza banner ASCII 3D con marco y animacion opcional.\r\n"
395
- Kernel.print " #{Color.bright_green("GRmenu.div(longitud, color, level, char)")}\r\n"
396
- Kernel.print " * Dibuja linea divisoria horizontal adaptable a la consola.\r\n"
397
- Kernel.print " #{Color.bright_green("GRmenu.help")}\r\n"
398
- Kernel.print " * Imprime esta guia visual interactiva en consola.\r\n\r\n"
399
-
400
- Kernel.print "#{Color.bright_magenta("[2] MODULO DE COLORES (Color / C)")}\r\n"
401
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
402
- Kernel.print " #{Color.cyan("Uso directo: ")}#{Color.bright_white("puts Color.green(\"Texto\")")} | #{Color.bright_white("puts Color.bright_cyan(\"Texto\")")}\r\n"
403
- 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"
404
- Kernel.print " #{Color.cyan("Brillo: ")}#{Color.white("1")} = Normal, #{Color.bright_white("2")} = Brillante / Bold.\r\n\r\n"
405
-
406
- Kernel.print "#{Color.bright_magenta("[3] FUENTES ASCII 3D DEL BANNER (font: 1 al 10)")}\r\n"
407
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
408
- Kernel.print " #{Color.yellow("1")} -> #{Color.bright_white("ANSI Shadow 3D (Default)")} #{Color.cyan("[██████╗ ██╗ ██╗]")}\r\n"
409
- Kernel.print " #{Color.yellow("2")} -> #{Color.bright_white("Slant 3D (FIGlet)")} #{Color.cyan("[ ____ __ __]")}\r\n"
410
- Kernel.print " #{Color.yellow("3")} -> #{Color.bright_white("Doom / Standard 3D")} #{Color.cyan("[ ____ _ _]")}\r\n"
411
- Kernel.print " #{Color.yellow("4")} -> #{Color.bright_white("Graffiti Shadow 3D")} #{Color.cyan("[ ,---. ,--. ,--.]")}\r\n"
412
- Kernel.print " #{Color.yellow("5")} -> #{Color.bright_white("Small Slant / Mini 3D")} #{Color.cyan("[ ___ _ _]")}\r\n"
413
- Kernel.print " #{Color.yellow("6")} -> #{Color.bright_white("Modular Pipe 3D")} #{Color.cyan("[ _____ _____]")}\r\n"
414
- Kernel.print " #{Color.yellow("7")} -> #{Color.bright_white("Bubble / Round Gothic")} #{Color.cyan("[ ____ _ _]")}\r\n"
415
- Kernel.print " #{Color.yellow("8")} -> #{Color.bright_white("Double-Line Wire 3D")} #{Color.cyan("[ ╔═════╗ ║ ║]")}\r\n"
416
- Kernel.print " #{Color.yellow("9")} -> #{Color.bright_white("Solid Fat 3D Block")} #{Color.cyan("[ ██████▄ ██ ██]")}\r\n"
417
- Kernel.print " #{Color.yellow("10")}-> #{Color.bright_white("Arcade Stars Matrix")} #{Color.cyan("[ ★★★★ ★ ★]")}\r\n\r\n"
418
-
419
- Kernel.print "#{Color.bright_magenta("[4] ESTILOS DE MARCO (style / banner_style: 1 al 20)")}\r\n"
420
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
421
- Kernel.print " #{Color.yellow("3")} -> #{Color.bright_white("Doble linea")} #{Color.cyan("╔═══╗ ║ ║ ╚═══╝")} (Default en Banner)\r\n"
422
- Kernel.print " #{Color.yellow("7")} -> #{Color.bright_white("Curvas redondeadas")} #{Color.cyan("╭───╮ │ │ ╰───╯")}\r\n"
423
- Kernel.print " #{Color.yellow("4")} -> #{Color.bright_white("Linea gruesa")} #{Color.cyan("┏━━━┓ ┃ ┃ ┗━━━┛")}\r\n"
424
- Kernel.print " #{Color.yellow("2")} -> #{Color.bright_white("Linea simple")} #{Color.cyan("┌───┐ │ │ └───┘")}\r\n"
425
- Kernel.print " #{Color.yellow("8")} -> #{Color.bright_white("Bloques outline")} #{Color.cyan("▛▀▀▀▜ ▌ ▌ ▙▄▄▄▟")}\r\n"
426
- Kernel.print " #{Color.yellow("19")} -> #{Color.bright_white("Circulos")} #{Color.cyan("●○○○● ● ● ●○○○●")} (Default en Opciones)\r\n"
427
- Kernel.print " #{Color.yellow("20")} -> #{Color.bright_white("Estrellas")} #{Color.cyan("★☆☆☆★ ★ ★ ★☆☆☆★")}\r\n\r\n"
428
-
429
- Kernel.print "#{Color.bright_magenta("[5] PARAMETROS DE GRmenu.new(functions, ...)")}\r\n"
430
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
431
- Kernel.print " #{Color.bright_green("banner:")} #{Color.white("String")} -> Texto grande a renderizar en arte ASCII 3D.\r\n"
432
- Kernel.print " #{Color.bright_green("title:")} #{Color.white("String")} -> Titulo en el encabezado del recuadro.\r\n"
433
- Kernel.print " #{Color.bright_green("subtitle:")} #{Color.white("String")} -> Subtitulo / descripcion (soporta \\n).\r\n"
434
- Kernel.print " #{Color.bright_green("font:")} #{Color.white("Integer")} -> Fuente ASCII del banner (1 al 10, default 1).\r\n"
435
- Kernel.print " #{Color.bright_green("style:")} #{Color.white("Integer")} -> Estilo de marco de opciones (1 al 20, default 19).\r\n"
436
- Kernel.print " #{Color.bright_green("banner_style:")} #{Color.white("Integer")} -> Estilo de marco del banner (1 al 20, default 3).\r\n"
437
- Kernel.print " #{Color.bright_green("divider:")} #{Color.white("Boolean")} -> Divisores alineados al banner (true/false).\r\n"
438
- Kernel.print " #{Color.bright_green("center:")} #{Color.white("Boolean")} -> Centrado simetrico de subtitulo y menu (default true).\r\n\r\n"
439
-
440
- Kernel.print "#{Color.bright_magenta("[6] METODOS DE CONFIGURACION (menu.set_style)")}\r\n"
441
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
442
- Kernel.print " #{Color.cyan("menu.set_style.font(id)")} -> Cambia fuente ASCII (1..10)\r\n"
443
- Kernel.print " #{Color.cyan("menu.set_style.banner(color, level)")} -> Color y brillo del banner ASCII\r\n"
444
- Kernel.print " #{Color.cyan("menu.set_style.title(color, level)")} -> Color y brillo del titulo\r\n"
445
- Kernel.print " #{Color.cyan("menu.set_style.subtitle(color, level)")} -> Color y brillo del subtitulo\r\n"
446
- Kernel.print " #{Color.cyan("menu.set_style.divider(color, level)")} -> Color y brillo de las lineas divisorias\r\n"
447
- Kernel.print " #{Color.cyan("menu.set_style.border(color, level)")} -> Color y brillo del marco de opciones\r\n"
448
- Kernel.print " #{Color.cyan("menu.set_style.options(color, level)")} -> Color y brillo de opciones no activas\r\n"
449
- Kernel.print " #{Color.cyan("menu.set_style.focus(color, level)")} -> Color y brillo de la opcion resaltada\r\n\r\n"
450
-
451
- Kernel.print "#{Color.bright_magenta("[7] EJECUCION (menu.draw)")}\r\n"
452
- Kernel.print "#{Color.bright_blue(s_line)}\r\n"
453
- Kernel.print " #{Color.bright_white("menu.draw(size_max: 38)")} -> Inicia el menu interactivo con ancho minimo.\r\n"
454
- Kernel.print "#{Color.bright_blue(s_line)}\r\n\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
1536
+ end
1537
+
1538
+ def self.help(section = :all)
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")
455
1550
  end
456
1551
 
457
1552
  def help
@@ -485,7 +1580,7 @@ class GRmenu
485
1580
  font_height.times { |i| lines[i] += fig[i] + pad }
486
1581
  end
487
1582
 
488
- max_len = lines.map(&:length).max
1583
+ max_len = lines.map { |l| display_width(l) }.max
489
1584
  return lines if (max_len + 6) <= max_cols
490
1585
  end
491
1586
 
@@ -494,30 +1589,59 @@ class GRmenu
494
1589
 
495
1590
  def self.banner(text, delay = 0, color: "magenta", level: 2, style: 3, font: 1)
496
1591
  cols = terminal_width
497
- color_code = COLORS.dig(color.to_s.downcase, level) || "\e[1;95m"
498
- 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
499
1595
 
500
1596
  ascii_rows = build_ascii_lines(text, cols, font)
501
1597
  border_cfg = BORDERS[style] || BORDERS[3]
1598
+ h_top = border_cfg[:ht] || border_cfg[:h]
1599
+ h_bot = border_cfg[:hb] || border_cfg[:h]
1600
+ v_l = border_cfg[:vl] || border_cfg[:v]
1601
+ v_r = border_cfg[:vr] || border_cfg[:v]
502
1602
 
503
1603
  if ascii_rows
504
- max_len = ascii_rows.map(&:length).max
505
- h_fill = (border_cfg[:h] * ((max_len + 4).to_f / border_cfg[:h].length).ceil)[0...(max_len + 4)]
506
-
507
- Kernel.print("#{color_code}#{border_cfg[:tl]}#{h_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
508
- ascii_rows.each do |line|
509
- pad = " " * (max_len - line.length)
510
- Kernel.print("#{color_code}#{border_cfg[:v]} #{line}#{pad} #{border_cfg[:v]}#{reset_code}\r\n")
511
- sleep(delay) if delay > 0
1604
+ max_len = ascii_rows.map { |r| display_width(r) }.max
1605
+ top_fill = (h_top * ((max_len + 4).to_f / h_top.length).ceil)[0...(max_len + 4)]
1606
+ bot_fill = (h_bot * ((max_len + 4).to_f / h_bot.length).ceil)[0...(max_len + 4)]
1607
+
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")
512
1625
  end
513
- Kernel.print("#{color_code}#{border_cfg[:bl]}#{h_fill}#{border_cfg[:br]}#{reset_code}\r\n")
514
1626
  else
515
1627
  clean_t = text.to_s.strip
516
- box_w = [clean_t.length + 6, cols - 2].min
517
- h_fill = (border_cfg[:h] * ((box_w - 2).to_f / border_cfg[:h].length).ceil)[0...(box_w - 2)]
518
- Kernel.print("#{color_code}#{border_cfg[:tl]}#{h_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
519
- Kernel.print("#{color_code}#{border_cfg[:v]} #{clean_t.center(box_w - 4)} #{border_cfg[:v]}#{reset_code}\r\n")
520
- Kernel.print("#{color_code}#{border_cfg[:bl]}#{h_fill}#{border_cfg[:br]}#{reset_code}\r\n")
1628
+ box_w = [display_width(clean_t) + 6, cols - 2].min
1629
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.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))
1635
+
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
521
1645
  end
522
1646
  end
523
1647
 
@@ -526,7 +1650,7 @@ class GRmenu
526
1650
  alias_method :logo, :banner
527
1651
  end
528
1652
 
529
- def initialize(functions, *positional_arguments, title: nil, banner: nil, subtitle: nil, description: nil, divider: nil, style: nil, banner_style: nil, center: true, font: 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)
530
1654
  @functions = functions.is_a?(Array) ? functions : Array(functions)
531
1655
 
532
1656
  pos_title = positional_arguments[0]
@@ -539,8 +1663,17 @@ class GRmenu
539
1663
  @style = (style || pos_style || keyword_arguments[:style] || 19).to_i
540
1664
  @banner_style = (banner_style || keyword_arguments[:banner_style] || 3).to_i
541
1665
  @center = center.nil? ? true : center
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("")
542
1672
  @index = 0
543
- @clear_seq = CLEAR_SCREEN_SEQUENCE
1673
+ @rgb_tick = 0.0
1674
+
1675
+ @cached_image_lines = nil
1676
+ @cached_image_cols = nil
544
1677
 
545
1678
  init_font = font || keyword_arguments[:font_style] || SetStyle.font || 1
546
1679
 
@@ -556,28 +1689,135 @@ class GRmenu
556
1689
  )
557
1690
  end
558
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
+
559
1705
  def move_up
560
- return @index if @functions.empty?
561
- @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]
562
1722
  end
563
1723
  alias_method :_up, :move_up
564
1724
 
565
1725
  def move_down
566
- return @index if @functions.empty?
567
- @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]
568
1739
  end
569
1740
  alias_method :_down, :move_down
570
1741
 
571
- 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)
572
1777
  return text.to_s if color_config.nil? || color_config.empty?
573
1778
 
574
1779
  color_name = (color_config[:color] || color_config["color"]).to_s.downcase
575
1780
  brightness_level = (color_config[:level] || color_config["level"] || 1).to_i
576
1781
 
577
- 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)
578
1818
  return text.to_s unless color_code
579
1819
 
580
- "#{color_code}#{text}#{COLORS['reset']}"
1820
+ "#{color_code}#{text}#{self.class.ansi_reset}"
581
1821
  end
582
1822
  alias_method :_colorize, :colorize
583
1823
 
@@ -598,74 +1838,145 @@ class GRmenu
598
1838
  banner_border = BORDERS[@banner_style] || BORDERS[3]
599
1839
  banner_color_cfg = @style_config.banner
600
1840
 
601
- lines = []
602
- box_w = 0
603
1841
  h_top = banner_border[:ht] || banner_border[:h]
604
1842
  h_bot = banner_border[:hb] || banner_border[:h]
605
1843
  v_l = banner_border[:vl] || banner_border[:v]
606
1844
  v_r = banner_border[:vr] || banner_border[:v]
607
1845
 
1846
+ lines = []
1847
+ box_w = 0
608
1848
  if ascii_rows
609
- content_w = ascii_rows.map(&:length).max
1849
+ content_w = ascii_rows.map { |r| GRmenu.display_width(r) }.max
610
1850
  box_w = content_w + 6
611
1851
  top_fill = build_horizontal_line(h_top, content_w + 4)
612
1852
  bot_fill = build_horizontal_line(h_bot, content_w + 4)
613
1853
 
614
- lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
615
- ascii_rows.each do |row|
616
- pad = " " * (content_w - row.length)
617
- 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)
618
1858
  end
619
- 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)
620
1860
  else
621
1861
  clean_b = @banner.strip
622
- 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
623
1864
  top_fill = build_horizontal_line(h_top, box_w - 2)
624
1865
  bot_fill = build_horizontal_line(h_bot, box_w - 2)
625
1866
 
626
- lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
627
- lines << colorize("#{v_l} #{clean_b.center(box_w - 4)} #{v_r}", banner_color_cfg)
628
- 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)
629
1874
  end
630
1875
  [lines, box_w]
631
1876
  end
632
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
+
633
1911
  def render_lines(size_max = 20)
634
1912
  term_cols = self.class.terminal_width
1913
+ term_rows = self.class.terminal_height
635
1914
  rendered_lines = []
636
1915
 
637
- banner_box_width = 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
+
638
1929
  if @banner && !@banner.empty?
639
- banner_lines, banner_box_width = render_banner_lines(term_cols)
640
- rendered_lines.concat(banner_lines)
641
- rendered_lines << ""
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
642
1937
  end
643
1938
 
644
- option_names = @functions.map { |func| extract_name_from_action(func) }
645
- calculated_width = [size_max, @title.length + 4].max
646
- calculated_width = ([calculated_width] + option_names.map { |name| name.length + 6 }).max
1939
+ matching_indices = current_matching_indices
1940
+ all_names = @functions.map { |func| extract_name_from_action(func) }
1941
+ all_descriptions = @functions.map { |func| extract_description_from_action(func) }
1942
+
1943
+ active_desc = all_descriptions[@index] || ""
1944
+
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
647
1952
  total_width = [calculated_width, term_cols - 2].min
648
1953
 
649
- reference_width = banner_box_width > 0 ? banner_box_width : total_width
1954
+ reference_width = header_box_width > 0 ? header_box_width : total_width
650
1955
  margin_left = (@center && reference_width > total_width) ? " " * ((reference_width - total_width) / 2) : ""
651
1956
 
1957
+ subtitle_lines_count = 0
652
1958
  if @subtitle && !@subtitle.empty?
653
1959
  subtitle_lines = @subtitle.lines.map(&:chomp)
654
1960
  div_w = @divider.is_a?(Numeric) ? @divider.to_i : [reference_width, term_cols - 2].min
655
1961
 
656
1962
  if @divider
657
- rendered_lines << colorize("─" * div_w, @style_config.divider)
1963
+ rendered_lines << colorize("─" * div_w, @style_config.divider, 0.0)
1964
+ subtitle_lines_count += 1
658
1965
  end
659
1966
 
660
- subtitle_lines.each do |sub_line|
661
- formatted_sub = @center ? sub_line.center(div_w) : sub_line
662
- 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)
1971
+ subtitle_lines_count += 1
663
1972
  end
664
1973
 
665
1974
  if @divider
666
- rendered_lines << colorize("─" * div_w, @style_config.divider)
1975
+ rendered_lines << colorize("─" * div_w, @style_config.divider, 0.6)
1976
+ subtitle_lines_count += 1
667
1977
  end
668
1978
  rendered_lines << ""
1979
+ subtitle_lines_count += 1
669
1980
  end
670
1981
 
671
1982
  border_color_cfg = @style_config.border
@@ -675,6 +1986,39 @@ class GRmenu
675
1986
 
676
1987
  box_border = BORDERS[@style]
677
1988
 
1989
+ overhead = header_lines_count + subtitle_lines_count + 6
1990
+ overhead += 2 unless active_desc.empty?
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
1996
+
1997
+ effective_page_rows = if @page_size && @page_size > 0
1998
+ [@page_size, total_rows, available_rows].min
1999
+ else
2000
+ [total_rows, available_rows].min
2001
+ end
2002
+ effective_page_rows = [effective_page_rows, 1].max
2003
+
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
2013
+ end
2014
+
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)
2018
+
2019
+ avail_w = [total_width - 4, 1].max
2020
+ col_w = [(avail_w - (cols - 1) * 2) / cols, 1].max
2021
+
678
2022
  if box_border
679
2023
  h_top = box_border[:ht] || box_border[:h]
680
2024
  h_bot = box_border[:hb] || box_border[:h]
@@ -685,33 +2029,89 @@ class GRmenu
685
2029
  bot_fill = build_horizontal_line(h_bot, total_width - 2)
686
2030
  mid_fill = build_horizontal_line(h_top, total_width - 2)
687
2031
 
688
- v_left = colorize(v_l_raw, border_color_cfg)
689
- 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)
690
2034
 
691
2035
  top_border_line = box_border[:tl] + top_fill + box_border[:tr]
692
- 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)}"
693
2037
 
694
2038
  unless @title.empty?
695
- 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)
696
2042
  rendered_lines << "#{margin_left}#{v_left} #{centered_title} #{v_right}"
697
2043
 
698
2044
  separator_line = v_l_raw + mid_fill + v_r_raw
699
- rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg)}"
2045
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.6)}"
700
2046
  end
701
2047
 
702
- option_names.each_with_index do |option_name, current_index|
703
- avail_w = [total_width - 6, 1].max
704
- if @index == current_index
705
- highlighted_text = colorize("> #{option_name.ljust(avail_w)}", focus_color_cfg)
706
- rendered_lines << "#{margin_left}#{v_left} #{highlighted_text} #{v_right}"
707
- else
708
- normal_text = colorize(" #{option_name.ljust(avail_w)}", options_color_cfg)
709
- rendered_lines << "#{margin_left}#{v_left} #{normal_text} #{v_right}"
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)}"
2055
+ end
2056
+
2057
+ if has_more_above
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 })
2061
+ rendered_lines << "#{margin_left}#{v_left} #{up_indicator} #{v_right}"
2062
+ end
2063
+
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}"
710
2093
  end
711
2094
  end
712
2095
 
2096
+ if has_more_below
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 })
2101
+ rendered_lines << "#{margin_left}#{v_left} #{down_indicator} #{v_right}"
2102
+ end
2103
+
2104
+ unless active_desc.empty?
2105
+ separator_line = v_l_raw + mid_fill + v_r_raw
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 })
2110
+ rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
2111
+ end
2112
+
713
2113
  bottom_border_line = box_border[:bl] + bot_fill + box_border[:br]
714
- 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)}"
715
2115
  else
716
2116
  symbol_char = STYLES[@style] || "#"
717
2117
  solid_border = colorize(symbol_char, border_color_cfg)
@@ -720,28 +2120,102 @@ class GRmenu
720
2120
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
721
2121
 
722
2122
  unless @title.empty?
723
- 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)
724
2126
  rendered_lines << "#{margin_left}#{solid_border} #{centered_title} #{solid_border}"
725
2127
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
726
2128
  end
727
2129
 
728
- option_names.each_with_index do |option_name, current_index|
729
- avail_w = [total_width - 6, 1].max
730
- if @index == current_index
731
- highlighted_text = colorize("> #{option_name.ljust(avail_w)}", focus_color_cfg)
732
- rendered_lines << "#{margin_left}#{solid_border} #{highlighted_text} #{solid_border}"
733
- else
734
- normal_text = colorize(" #{option_name.ljust(avail_w)}", options_color_cfg)
735
- rendered_lines << "#{margin_left}#{solid_border} #{normal_text} #{solid_border}"
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
+
2138
+ if has_more_above
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 })
2142
+ rendered_lines << "#{margin_left}#{solid_border} #{up_indicator} #{solid_border}"
2143
+ end
2144
+
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}"
736
2174
  end
737
2175
  end
738
2176
 
2177
+ if has_more_below
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 })
2182
+ rendered_lines << "#{margin_left}#{solid_border} #{down_indicator} #{solid_border}"
2183
+ end
2184
+
2185
+ unless active_desc.empty?
2186
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
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 })
2190
+ rendered_lines << "#{margin_left}#{solid_border} #{desc_text} #{solid_border}"
2191
+ end
2192
+
739
2193
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
740
2194
  end
741
2195
 
742
2196
  rendered_lines
743
2197
  end
744
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
+
745
2219
  def draw(size_max: 20, min_width: nil)
746
2220
  target_width = min_width || size_max || 20
747
2221
  action_to_execute = nil
@@ -778,27 +2252,93 @@ class GRmenu
778
2252
  def draw_frame(target_width)
779
2253
  lines = render_lines(target_width)
780
2254
  buffer = String.new(CURSOR_HOME)
781
- lines.each do |line|
782
- buffer << line << CLEAR_TO_EOL << "\r\n"
2255
+ lines.each_with_index do |line, idx|
2256
+ buffer << line << CLEAR_TO_EOL
2257
+ buffer << "\r\n" if idx < lines.length - 1
783
2258
  end
784
2259
  buffer << CLEAR_TO_EOS
785
2260
  Kernel.print(buffer)
786
2261
  end
787
2262
 
788
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
789
2267
  draw_frame(target_width)
790
2268
 
791
- while (key = read_single_key(input_stream))
792
- 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
793
2284
 
794
- if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
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
2298
+
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"
795
2309
  move_up
796
2310
  draw_frame(target_width)
797
2311
  elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
798
2312
  move_down
799
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
800
2334
  elsif key == "\r" || key == "\n"
801
- 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)
802
2342
  end
803
2343
  end
804
2344
 
@@ -806,37 +2346,7 @@ class GRmenu
806
2346
  end
807
2347
 
808
2348
  def read_single_key(input_stream)
809
- unless input_stream.respond_to?(:tty?) && input_stream.tty?
810
- begin
811
- return input_stream.sysread(3) if input_stream.respond_to?(:sysread)
812
- return input_stream.read(1)
813
- rescue EOFError, Errno::EPIPE
814
- return nil
815
- end
816
- end
817
-
818
- first_char = input_stream.getch
819
- return nil if first_char.nil?
820
-
821
- if first_char == "\e"
822
- begin
823
- extra_chars = input_stream.read_nonblock(2)
824
- first_char << extra_chars
825
- rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
826
- end
827
- elsif first_char == "\x00" || first_char == "\xe0"
828
- begin
829
- second_char = input_stream.read_nonblock(1)
830
- first_char << second_char
831
- rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
832
- second_char = input_stream.getch rescue nil
833
- first_char << second_char if second_char
834
- end
835
- end
836
-
837
- first_char
838
- rescue EOFError, Errno::EPIPE, Errno::ENOTTY
839
- nil
2349
+ GRmenu.read_key_raw(input_stream)
840
2350
  end
841
2351
 
842
2352
  def format_auto_name(raw_name)
@@ -848,6 +2358,8 @@ class GRmenu
848
2358
  case action
849
2359
  when Array
850
2360
  action[0].to_s
2361
+ when Hash
2362
+ (action[:name] || action[:title] || action["name"] || action["title"] || "Opcion").to_s
851
2363
  when Method
852
2364
  format_auto_name(action.name)
853
2365
  when Symbol
@@ -869,6 +2381,16 @@ class GRmenu
869
2381
  end
870
2382
  end
871
2383
 
2384
+ def extract_description_from_action(action)
2385
+ if action.is_a?(Array) && action.length >= 3
2386
+ action[2].to_s
2387
+ elsif action.is_a?(Hash)
2388
+ (action[:desc] || action[:description] || action["desc"] || action["description"]).to_s
2389
+ else
2390
+ ""
2391
+ end
2392
+ end
2393
+
872
2394
  def execute_action(action)
873
2395
  case action
874
2396
  when Method, Proc
@@ -881,7 +2403,26 @@ class GRmenu
881
2403
  end
882
2404
  when Array
883
2405
  callable = action[1]
884
- callable.call if callable.respond_to?(:call)
2406
+ if callable.is_a?(Symbol)
2407
+ if Object.respond_to?(callable, true)
2408
+ Object.send(callable)
2409
+ elsif Kernel.respond_to?(callable, true)
2410
+ Kernel.send(callable)
2411
+ end
2412
+ elsif callable.respond_to?(:call)
2413
+ callable.call
2414
+ end
2415
+ when Hash
2416
+ callable = action[:action] || action[:call] || action["action"] || action["call"]
2417
+ if callable.is_a?(Symbol)
2418
+ if Object.respond_to?(callable, true)
2419
+ Object.send(callable)
2420
+ elsif Kernel.respond_to?(callable, true)
2421
+ Kernel.send(callable)
2422
+ end
2423
+ elsif callable.respond_to?(:call)
2424
+ callable.call
2425
+ end
885
2426
  else
886
2427
  action.call if action.respond_to?(:call)
887
2428
  end