grmenu 2.0.0 → 4.0.1

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.
data/grmenu.rb ADDED
@@ -0,0 +1,4242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'json'
5
+ require 'zlib'
6
+ require 'open3'
7
+
8
+ class GRmenu
9
+ VERSION = "4.0.1"
10
+ CLEAR_SCREEN_SEQUENCE = "\e[H\e[2J\e[3J"
11
+ HIDE_CURSOR = "\e[?25l"
12
+ SHOW_CURSOR = "\e[?25h"
13
+ CURSOR_HOME = "\e[H"
14
+ CLEAR_TO_EOL = "\e[K"
15
+ CLEAR_TO_EOS = "\e[J"
16
+ ENABLE_MOUSE = "\e[?1000h\e[?1006h"
17
+ DISABLE_MOUSE = "\e[?1000l\e[?1006l"
18
+
19
+ def self.find_data_file(filename)
20
+ local_path = File.expand_path("data/#{filename}", __dir__)
21
+ return local_path if File.exist?(local_path)
22
+ parent_path = File.expand_path("../data/#{filename}", __dir__)
23
+ return parent_path if File.exist?(parent_path)
24
+ nil
25
+ end
26
+
27
+ def self.load_json_data(filename)
28
+ path = find_data_file(filename)
29
+ return {} unless path && File.exist?(path)
30
+ JSON.parse(File.read(path))
31
+ rescue StandardError
32
+ {}
33
+ end
34
+
35
+ COLORS = load_json_data('colors.json').freeze
36
+ BORDERS = load_json_data('borders.json').transform_keys(&:to_i).transform_values { |v| v.is_a?(Hash) ? v.transform_keys(&:to_sym) : { h: v.to_s, v: v.to_s, tl: v.to_s, tr: v.to_s, bl: v.to_s, br: v.to_s } }.freeze
37
+ FONTS = load_json_data('fonts.json').transform_keys(&:to_i).freeze
38
+
39
+ BASE_RGB = COLORS.each_with_object({}) do |(name, val), h|
40
+ next if name == "reset"
41
+ code = val.is_a?(Hash) ? (val["2"] || val[2] || val["1"] || val[1]) : val.to_s
42
+ if code =~ /38;2;(\d+);(\d+);(\d+)/
43
+ h[name] = [$1.to_i, $2.to_i, $3.to_i]
44
+ elsif code == "90m" || code == "30m"
45
+ h[name] = [100, 100, 100]
46
+ elsif code == "91m" || code == "31m"
47
+ h[name] = [255, 60, 60]
48
+ elsif code == "92m" || code == "32m"
49
+ h[name] = [60, 255, 60]
50
+ elsif code == "93m" || code == "33m"
51
+ h[name] = [255, 255, 60]
52
+ elsif code == "94m" || code == "34m"
53
+ h[name] = [60, 120, 255]
54
+ elsif code == "95m" || code == "35m"
55
+ h[name] = [255, 60, 255]
56
+ elsif code == "96m" || code == "36m"
57
+ h[name] = [60, 255, 255]
58
+ elsif code == "97m" || code == "37m"
59
+ h[name] = [250, 250, 250]
60
+ elsif code =~ /38;5;(\d+)/
61
+ h[name] = [150, 150, 150]
62
+ else
63
+ h[name] = [220, 220, 220]
64
+ end
65
+ end.freeze
66
+
67
+ FONT_1 = FONTS[1] || {}
68
+ FONT_2 = FONTS[2] || {}
69
+ FONT_3 = FONTS[3] || {}
70
+ FONT_4 = FONTS[4] || {}
71
+ FONT_5 = FONTS[5] || {}
72
+ FONT_6 = FONTS[6] || {}
73
+ FONT_7 = FONTS[7] || {}
74
+ FONT_8 = FONTS[8] || {}
75
+ FONT_9 = FONTS[9] || {}
76
+ FONT_10 = FONTS[10] || {}
77
+
78
+ def self.rgb_color(tick, offset = 0.0)
79
+ t = tick.to_f + offset.to_f
80
+ r = (Math.sin(t) * 127 + 128).clamp(0, 255).to_i
81
+ g = (Math.sin(t + 2.0943951) * 127 + 128).clamp(0, 255).to_i
82
+ b = (Math.sin(t + 4.1887902) * 127 + 128).clamp(0, 255).to_i
83
+ "\e[38;2;#{r};#{g};#{b}m"
84
+ end
85
+
86
+ def self.ansi_color(color_name, level = 1)
87
+ name = color_name.to_s.downcase.strip
88
+ if name.include?(":")
89
+ parts = name.split(":")
90
+ name = parts[0].strip
91
+ level = parts[1].to_i if parts[1] && !parts[1].empty?
92
+ end
93
+ return rgb_color(0.0) if name == "rgb" || name == "rainbow" || name == "chroma"
94
+ if name =~ /\A#?([0-9a-f]{6})\z/i
95
+ hex = $1
96
+ r = hex[0..1].to_i(16)
97
+ g = hex[2..3].to_i(16)
98
+ b = hex[4..5].to_i(16)
99
+ return "\e[38;2;#{r};#{g};#{b}m"
100
+ elsif name =~ /\A#?([0-9a-f]{3})\z/i
101
+ hex = $1
102
+ r = (hex[0] * 2).to_i(16)
103
+ g = (hex[1] * 2).to_i(16)
104
+ b = (hex[2] * 2).to_i(16)
105
+ return "\e[38;2;#{r};#{g};#{b}m"
106
+ end
107
+ lvl_str = level.to_s
108
+ code_raw = COLORS.dig(name, lvl_str) || COLORS.dig(name, level.to_i) || COLORS[name]
109
+ return "\e[#{code_raw}" if code_raw
110
+ "\e[37m"
111
+ end
112
+
113
+ def self.ansi_reset
114
+ "\e[0m"
115
+ end
116
+
117
+ module Color
118
+ RESET = "\e[0m"
119
+ BOLD = "\e[1m"
120
+ module_function
121
+
122
+ def paint(text, color_name, level = 1)
123
+ c_str = color_name.to_s.downcase
124
+ if c_str == "rgb" || c_str == "rainbow" || c_str == "chroma"
125
+ return rgb(text)
126
+ end
127
+ code = GRmenu.ansi_color(color_name, level) || "\e[37m"
128
+ "#{code}#{text}#{RESET}"
129
+ end
130
+
131
+ def rgb(text, offset = 0.0)
132
+ out = String.new("")
133
+ idx = 0
134
+ in_escape = false
135
+ escape_buf = String.new("")
136
+
137
+ text.to_s.each_char do |ch|
138
+ if ch == "\e"
139
+ in_escape = true
140
+ escape_buf << ch
141
+ next
142
+ end
143
+ if in_escape
144
+ escape_buf << ch
145
+ if ch =~ /[a-zA-Z]/
146
+ in_escape = false
147
+ out << escape_buf
148
+ escape_buf.clear
149
+ end
150
+ next
151
+ end
152
+
153
+ if ch == " " || ch == "\n" || ch == "\r" || ch == "\t"
154
+ out << ch
155
+ else
156
+ t = idx * 0.12 + offset
157
+ r = (Math.sin(t) * 127 + 128).clamp(0, 255).to_i
158
+ g = (Math.sin(t + 2.0943951) * 127 + 128).clamp(0, 255).to_i
159
+ b = (Math.sin(t + 4.1887902) * 127 + 128).clamp(0, 255).to_i
160
+ out << "\e[38;2;#{r};#{g};#{b}m#{ch}"
161
+ idx += 1
162
+ end
163
+ end
164
+ out << RESET
165
+ out
166
+ end
167
+
168
+ def red(s); paint(s, :red, 1); end
169
+ def bright_red(s); paint(s, :red, 2); end
170
+ def dark_red(s); paint(s, :red, 1); end
171
+
172
+ def green(s); paint(s, :green, 1); end
173
+ def bright_green(s); paint(s, :green, 2); end
174
+ def dark_green(s); paint(s, :green, 1); end
175
+
176
+ def yellow(s); paint(s, :yellow, 1); end
177
+ def bright_yellow(s); paint(s, :yellow, 2); end
178
+
179
+ def blue(s); paint(s, :blue, 1); end
180
+ def bright_blue(s); paint(s, :blue, 2); end
181
+
182
+ def magenta(s); paint(s, :magenta, 1); end
183
+ def bright_magenta(s); paint(s, :magenta, 2); end
184
+
185
+ def purple(s); paint(s, :purple, 1); end
186
+ def bright_purple(s); paint(s, :purple, 2); end
187
+
188
+ def pink(s); paint(s, :pink, 1); end
189
+ def bright_pink(s); paint(s, :pink, 2); end
190
+
191
+ def cyan(s); paint(s, :cyan, 1); end
192
+ def bright_cyan(s); paint(s, :cyan, 2); end
193
+
194
+ def aqua(s); paint(s, :aqua, 1); end
195
+ def bright_aqua(s); paint(s, :aqua, 2); end
196
+
197
+ def orange(s); paint(s, :orange, 1); end
198
+ def bright_orange(s); paint(s, :orange, 2); end
199
+
200
+ def white(s); paint(s, :white, 1); end
201
+ def bright_white(s); paint(s, :white, 2); end
202
+
203
+ def black(s); paint(s, :black, 1); end
204
+ def gray(s); paint(s, :gray, 1); end
205
+ def bright_gray(s); paint(s, :gray, 2); end
206
+ def grey(s); gray(s); end
207
+
208
+ def neon_red(s); paint(s, :neon_red, 2); end
209
+ def neon_green(s); paint(s, :neon_green, 2); end
210
+ def neon_cyan(s); paint(s, :neon_cyan, 2); end
211
+ def neon_blue(s); paint(s, :neon_blue, 2); end
212
+ def neon_pink(s); paint(s, :neon_pink, 2); end
213
+ def neon_yellow(s); paint(s, :neon_yellow, 2); end
214
+ def neon_orange(s); paint(s, :neon_orange, 2); end
215
+ def neon_purple(s); paint(s, :neon_purple, 2); end
216
+ def neon_magenta(s); paint(s, :neon_magenta, 2); end
217
+ def neon_aqua(s); paint(s, :neon_aqua, 2); end
218
+ def neon_lime(s); paint(s, :neon_lime, 2); end
219
+ def neon_white(s); paint(s, :neon_white, 2); end
220
+
221
+ def r(s); bright_red(s); end
222
+ def dr(s); dark_red(s); end
223
+ def g(s); bright_green(s); end
224
+ def y(s); bright_yellow(s); end
225
+ def w(s); bright_white(s); end
226
+ def gr(s); gray(s); end
227
+ def cy(s); bright_cyan(s); end
228
+ def mg(s); bright_magenta(s); end
229
+ def bl(s); bright_blue(s); end
230
+
231
+ def hex(code, text)
232
+ c = GRmenu.ansi_color(code.to_s)
233
+ "#{c}#{text}#{RESET}"
234
+ end
235
+
236
+ def respond_to_missing?(method_name, include_private = false)
237
+ GRmenu::COLORS.key?(method_name.to_s) || super
238
+ end
239
+
240
+ def method_missing(method_name, *args, &block)
241
+ m_str = method_name.to_s
242
+ if GRmenu::COLORS.key?(m_str)
243
+ text = args[0].to_s
244
+ lvl = args[1] || 2
245
+ paint(text, m_str, lvl)
246
+ else
247
+ super
248
+ end
249
+ end
250
+ end
251
+ C = Color
252
+
253
+ STYLES = {
254
+ 1 => "#", 2 => "┌", 3 => "╔", 4 => "┏", 5 => "╒",
255
+ 6 => "╓", 7 => "╭", 8 => "▛", 9 => "▓", 10 => "▒",
256
+ 11 => "░", 12 => "█", 13 => "*", 14 => "+", 15 => "=",
257
+ 16 => "~", 17 => "-", 18 => "◆", 19 => "●", 20 => "★"
258
+ }.freeze
259
+
260
+ class PNGDecoder
261
+ attr_reader :width, :height, :pixels
262
+
263
+ def self.load(filepath)
264
+ return nil unless filepath && File.exist?(filepath)
265
+ new.parse(File.binread(filepath))
266
+ rescue StandardError
267
+ nil
268
+ end
269
+
270
+ def parse(data)
271
+ return nil unless data && data[0, 8] == "\x89PNG\r\n\x1a\n".b
272
+
273
+ offset = 8
274
+ idat_data = String.new("".b)
275
+ palette = nil
276
+
277
+ while offset < data.bytesize
278
+ len = data[offset, 4].unpack1("N")
279
+ type = data[offset + 4, 4]
280
+ chunk_data = data[offset + 8, len]
281
+ offset += 12 + len
282
+
283
+ case type
284
+ when "IHDR"
285
+ @width, @height, @bit_depth, @color_type = chunk_data.unpack("NNCC")
286
+ when "PLTE"
287
+ palette = chunk_data.bytes.each_slice(3).to_a
288
+ when "IDAT"
289
+ idat_data << chunk_data
290
+ when "IEND"
291
+ break
292
+ end
293
+ end
294
+
295
+ channels = case @color_type
296
+ when 0 then 1
297
+ when 2 then 3
298
+ when 3 then 1
299
+ when 4 then 2
300
+ when 6 then 4
301
+ else return nil
302
+ end
303
+
304
+ bpp = [(@bit_depth * channels + 7) / 8, 1].max
305
+ stride = (@width * channels * @bit_depth + 7) / 8
306
+ scanline_len = stride + 1
307
+
308
+ raw = Zlib::Inflate.inflate(idat_data)
309
+ raw_bytes = raw.bytes
310
+ return nil if raw_bytes.length < (@height * scanline_len)
311
+
312
+ @pixels = Array.new(@height) { Array.new(@width) }
313
+ prev_row = Array.new(stride, 0)
314
+
315
+ @height.times do |y|
316
+ row_start = y * scanline_len
317
+ filter_type = raw_bytes[row_start]
318
+ curr_filtered = raw_bytes[(row_start + 1)...(row_start + scanline_len)]
319
+ curr_recon = Array.new(stride, 0)
320
+
321
+ stride.times do |i|
322
+ a = (i >= bpp) ? curr_recon[i - bpp] : 0
323
+ b = prev_row[i]
324
+ c = (i >= bpp) ? prev_row[i - bpp] : 0
325
+ x = curr_filtered[i]
326
+
327
+ recon_val = case filter_type
328
+ when 0 then x
329
+ when 1 then (x + a) & 0xFF
330
+ when 2 then (x + b) & 0xFF
331
+ when 3 then (x + ((a + b) / 2)) & 0xFF
332
+ when 4
333
+ p_val = a + b - c
334
+ pa = (p_val - a).abs
335
+ pb = (p_val - b).abs
336
+ pc = (p_val - c).abs
337
+ pr = if pa <= pb && pa <= pc
338
+ a
339
+ elsif pb <= pc
340
+ b
341
+ else
342
+ c
343
+ end
344
+ (x + pr) & 0xFF
345
+ else x
346
+ end
347
+ curr_recon[i] = recon_val
348
+ end
349
+
350
+ prev_row = curr_recon
351
+
352
+ if @bit_depth == 16
353
+ @width.times do |x|
354
+ idx = x * channels * 2
355
+ r = curr_recon[idx]
356
+ g = (channels >= 3) ? curr_recon[idx + 2] : r
357
+ b = (channels >= 3) ? curr_recon[idx + 4] : r
358
+ a = (channels == 4) ? curr_recon[idx + 6] : (channels == 2 ? curr_recon[idx + 2] : 255)
359
+ @pixels[y][x] = [r, g, b, a]
360
+ end
361
+ elsif @bit_depth == 8
362
+ @width.times do |x|
363
+ idx = x * channels
364
+ if @color_type == 3
365
+ p_idx = curr_recon[idx]
366
+ rgb_val = palette ? (palette[p_idx] || [0, 0, 0]) : [0, 0, 0]
367
+ @pixels[y][x] = [rgb_val[0], rgb_val[1], rgb_val[2], 255]
368
+ else
369
+ r = curr_recon[idx]
370
+ g = (channels >= 3) ? curr_recon[idx + 1] : r
371
+ b = (channels >= 3) ? curr_recon[idx + 2] : r
372
+ a = (channels == 4 || channels == 2) ? curr_recon[idx + channels - 1] : 255
373
+ @pixels[y][x] = [r, g, b, a]
374
+ end
375
+ end
376
+ end
377
+ end
378
+ self
379
+ end
380
+
381
+ def resample(target_w, target_h)
382
+ resampled = Array.new(target_h) { Array.new(target_w) }
383
+ x_step = @width.to_f / target_w
384
+ y_step = @height.to_f / target_h
385
+
386
+ target_h.times do |ty|
387
+ sy_start = (ty * y_step).to_i
388
+ sy_end = [((ty + 1) * y_step).to_i, @height].min
389
+
390
+ target_w.times do |tx|
391
+ sx_start = (tx * x_step).to_i
392
+ sx_end = [((tx + 1) * x_step).to_i, @width].min
393
+
394
+ r_sum = g_sum = b_sum = a_sum = count = 0
395
+
396
+ (sy_start...sy_end).each do |sy|
397
+ (sx_start...sx_end).each do |sx|
398
+ p = @pixels[sy][sx]
399
+ next unless p
400
+ if p[3] > 10
401
+ r_sum += p[0]
402
+ g_sum += p[1]
403
+ b_sum += p[2]
404
+ a_sum += p[3]
405
+ count += 1
406
+ end
407
+ end
408
+ end
409
+
410
+ if count > 0
411
+ 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)]
412
+ else
413
+ mid_y = (sy_start + sy_end) / 2
414
+ mid_x = (sx_start + sx_end) / 2
415
+ resampled[ty][tx] = @pixels[mid_y][mid_x] || [0, 0, 0, 0]
416
+ end
417
+ end
418
+ end
419
+ resampled
420
+ end
421
+
422
+ def render_ansi_lines(target_w = 40, target_h = nil)
423
+ target_h ||= [((@height.to_f / @width) * target_w).round, 2].max
424
+ target_h += 1 if target_h.odd?
425
+
426
+ grid = resample(target_w, target_h)
427
+ lines = []
428
+
429
+ (0...target_h).step(2) do |y|
430
+ row_top = grid[y]
431
+ row_bot = grid[y + 1] || grid[y]
432
+ line = String.new("")
433
+
434
+ target_w.times do |x|
435
+ r1, g1, b1, a1 = row_top[x]
436
+ r2, g2, b2, a2 = row_bot[x]
437
+
438
+ if a1 < 32 && a2 < 32
439
+ line << "\e[0m "
440
+ elsif a1 < 32
441
+ line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
442
+ elsif a2 < 32
443
+ line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
444
+ else
445
+ line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
446
+ end
447
+ end
448
+ line << "\e[0m"
449
+ lines << line
450
+ end
451
+
452
+ lines
453
+ end
454
+ end
455
+
456
+ def self.char_width(char)
457
+ code = char.ord
458
+ return 0 if code == 0 || code == 0xFE0F || code == 0xFE0E || (code >= 0x0300 && code <= 0x036F) || (code >= 0x200B && code <= 0x200F)
459
+ return 0 if code < 32 || (code >= 0x7F && code < 0xA0)
460
+ return 1 if code == 0x1F5BC || code == 0x1F5B4 || code == 0x1F5B5 || code == 0x1F5C2
461
+ if (code >= 0x1100 && code <= 0x115F) ||
462
+ (code >= 0x2329 && code <= 0x232A) ||
463
+ (code >= 0x2E80 && code <= 0xA4CF && code != 0x303F) ||
464
+ (code >= 0xAC00 && code <= 0xD7A3) ||
465
+ (code >= 0xF900 && code <= 0xFAFF) ||
466
+ (code >= 0xFE10 && code <= 0xFE19) ||
467
+ (code >= 0xFE30 && code <= 0xFE6F) ||
468
+ (code >= 0xFF01 && code <= 0xFF60) ||
469
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
470
+ (code >= 0x1F300 && code <= 0x1F6FF) ||
471
+ (code >= 0x1F900 && code <= 0x1FAFF)
472
+ 2
473
+ else
474
+ 1
475
+ end
476
+ end
477
+
478
+ def self.display_width(str)
479
+ clean = str.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
480
+ clean.chars.map { |c| char_width(c) }.sum
481
+ end
482
+
483
+ def self.pad_to_width(str, target_width, align = :left)
484
+ current_w = display_width(str)
485
+ pad_needed = [target_width - current_w, 0].max
486
+ case align
487
+ when :right
488
+ (" " * pad_needed) + str.to_s
489
+ when :center
490
+ left_pad = " " * (pad_needed / 2)
491
+ right_pad = " " * (pad_needed - (pad_needed / 2))
492
+ left_pad + str.to_s + right_pad
493
+ else
494
+ str.to_s + (" " * pad_needed)
495
+ end
496
+ end
497
+
498
+ def self.load_and_render_image(filepath, width = 40, height = nil, max_cols = terminal_width)
499
+ return [] unless filepath && File.exist?(filepath)
500
+
501
+ req_w = [width.to_i, max_cols - 6].min
502
+ req_w = [req_w, 10].max
503
+
504
+ conv_bin = `which convert 2>/dev/null`.strip
505
+ conv_bin = `which magick 2>/dev/null`.strip if conv_bin.empty?
506
+
507
+ if !conv_bin.empty?
508
+ info, _ = Open3.capture2("identify", "-format", "%w %h", filepath) rescue ["", nil]
509
+ orig_w, orig_h = info.strip.split.map(&:to_f)
510
+ aspect = (orig_w && orig_w > 0) ? (orig_h / orig_w) : 0.6
511
+ scale_h = height || (req_w * aspect).round
512
+ scale_h += 1 if scale_h.odd?
513
+ scale_h = [scale_h, 2].max
514
+
515
+ cmd = [conv_bin, filepath, "-filter", "Lanczos", "-resize", "#{req_w}x#{scale_h}!", "-depth", "8", "rgba:-"]
516
+ stdout, status = Open3.capture2(*cmd) rescue [nil, nil]
517
+ if status && status.success? && stdout.bytesize == (req_w * scale_h * 4)
518
+ raw = stdout.bytes
519
+ lines = []
520
+ (0...scale_h).step(2) do |y|
521
+ line = String.new("")
522
+ req_w.times do |x|
523
+ top_idx = (y * req_w + x) * 4
524
+ bot_idx = ((y + 1) * req_w + x) * 4
525
+ r1, g1, b1, a1 = raw[top_idx, 4]
526
+ r2, g2, b2, a2 = raw[bot_idx, 4]
527
+
528
+ if a1 < 32 && a2 < 32
529
+ line << "\e[0m "
530
+ elsif a1 < 32
531
+ line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
532
+ elsif a2 < 32
533
+ line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
534
+ else
535
+ line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
536
+ end
537
+ end
538
+ line << "\e[0m"
539
+ lines << line
540
+ end
541
+ return lines
542
+ end
543
+ end
544
+
545
+ png = PNGDecoder.load(filepath)
546
+ if png
547
+ return png.render_ansi_lines(req_w, height)
548
+ end
549
+
550
+ []
551
+ rescue StandardError
552
+ []
553
+ end
554
+
555
+ def self.image(filepath, width: 40, height: nil, style: 3, color: "cyan", center: true)
556
+ term_w = terminal_width
557
+ raw_lines = load_and_render_image(filepath, width, height, term_w)
558
+ return nil if raw_lines.empty?
559
+
560
+ img_w = display_width(raw_lines.first)
561
+ box_w = img_w + 4
562
+ margin = (center && term_w > box_w) ? (" " * ((term_w - box_w) / 2)) : ""
563
+
564
+ if style && style > 0
565
+ border_cfg = BORDERS[style] || BORDERS[3]
566
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
567
+ color_code = is_rgb ? "" : ansi_color(color, 2)
568
+ reset_code = ansi_reset
569
+
570
+ h_top = border_cfg[:ht] || border_cfg[:h]
571
+ h_bot = border_cfg[:hb] || border_cfg[:h]
572
+ v_l = border_cfg[:vl] || border_cfg[:v]
573
+ v_r = border_cfg[:vr] || border_cfg[:v]
574
+
575
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
576
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
577
+
578
+ if is_rgb
579
+ Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
580
+ raw_lines.each do |line|
581
+ Kernel.print("#{margin}#{Color.rgb(v_l)} #{line} #{Color.rgb(v_r)}\r\n")
582
+ end
583
+ Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
584
+ else
585
+ Kernel.print("#{margin}#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
586
+ raw_lines.each do |line|
587
+ Kernel.print("#{margin}#{color_code}#{v_l}#{reset_code} #{line} #{color_code}#{v_r}#{reset_code}\r\n")
588
+ end
589
+ Kernel.print("#{margin}#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
590
+ end
591
+ else
592
+ raw_lines.each do |line|
593
+ Kernel.print("#{margin}#{line}\r\n")
594
+ end
595
+ end
596
+
597
+ true
598
+ end
599
+
600
+ class ProgressBar
601
+ attr_reader :total, :current, :title, :status
602
+
603
+ def initialize(total = 100, title: nil, color: "cyan", level: 2, style: 3, width: nil)
604
+ @total = [total.to_i, 1].max
605
+ @current = 0
606
+ @title = title
607
+ @status = String.new("")
608
+ @color = color.to_s.downcase
609
+ @level = level.to_i
610
+ @style = style.to_i
611
+ @width = width
612
+ @closed = false
613
+ @drawn_lines_count = 0
614
+ end
615
+
616
+ def advance(step = 1, status: nil)
617
+ return if @closed
618
+ @current = [(@current + step), @total].min
619
+ @status = status.to_s if status
620
+ render
621
+ end
622
+ alias_method :increment, :advance
623
+ alias_method :step, :advance
624
+
625
+ def set(value, status: nil)
626
+ return if @closed
627
+ @current = [[value.to_i, 0].max, @total].min
628
+ @status = status.to_s if status
629
+ render
630
+ end
631
+
632
+ def render
633
+ term_w = GRmenu.terminal_width
634
+ box_w = @width || [term_w - 4, 60].min
635
+ box_w = [box_w, 36].max
636
+
637
+ is_rgb = (@color == "rgb" || @color == "rainbow" || @color == "chroma")
638
+ tick = (@current.to_f / @total) * 6.2831853
639
+
640
+ border_cfg = GRmenu::BORDERS[@style] || GRmenu::BORDERS[3]
641
+
642
+ v_l = border_cfg[:vl] || border_cfg[:v]
643
+ v_r = border_cfg[:vr] || border_cfg[:v]
644
+ h_t = border_cfg[:ht] || border_cfg[:h]
645
+ h_b = border_cfg[:hb] || border_cfg[:h]
646
+
647
+ top_fill = (h_t * ((box_w - 2).to_f / h_t.length).ceil)[0...(box_w - 2)]
648
+ bot_fill = (h_b * ((box_w - 2).to_f / h_b.length).ceil)[0...(box_w - 2)]
649
+
650
+ pct = ((@current.to_f / @total) * 100).round
651
+ pct_str = "#{pct}% (#{@current}/#{@total})"
652
+
653
+ inner_w = box_w - 4
654
+ bar_w = [inner_w - pct_str.length - 3, 10].max
655
+ filled_len = ((@current.to_f / @total) * bar_w).round
656
+ empty_len = bar_w - filled_len
657
+
658
+ lines = []
659
+ if is_rgb
660
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", tick)
661
+ if @title && !@title.empty?
662
+ t_str = @title.to_s
663
+ if GRmenu.display_width(t_str) > inner_w
664
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
665
+ end
666
+ pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
667
+ l_p = " " * (pad_t / 2)
668
+ r_p = " " * (pad_t - (pad_t / 2))
669
+ lines << "#{Color.rgb(v_l, tick)} #{l_p}#{Color.rgb(t_str, tick + 0.4)}#{r_p} #{Color.rgb(v_r, tick)}"
670
+ lines << Color.rgb("#{v_l}#{top_fill}#{v_r}", tick)
671
+ end
672
+
673
+ filled_part = Color.rgb("█" * filled_len, tick)
674
+ empty_part = Color.gray("░" * empty_len)
675
+ bar_raw_len = 2 + filled_len + empty_len + 1 + pct_str.length
676
+ pad_bar_len = [inner_w - bar_raw_len, 0].max
677
+ bar_line = "[#{filled_part}#{empty_part}] #{Color.bright_white(pct_str)}" + (" " * pad_bar_len)
678
+
679
+ lines << "#{Color.rgb(v_l, tick)} #{bar_line} #{Color.rgb(v_r, tick)}"
680
+ if @status && !@status.empty?
681
+ st_str = @status.to_s
682
+ if GRmenu.display_width(st_str) > inner_w
683
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
684
+ end
685
+ pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
686
+ st_line = st_str + (" " * pad_st)
687
+ lines << "#{Color.rgb(v_l, tick)} #{Color.gray(st_line)} #{Color.rgb(v_r, tick)}"
688
+ end
689
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", tick)
690
+ else
691
+ color_code = GRmenu.ansi_color(@color, @level)
692
+ reset_code = GRmenu.ansi_reset
693
+
694
+ bar_str = "[#{"█" * filled_len}#{"░" * empty_len}] #{pct_str}"
695
+ pad_bar_len = [inner_w - GRmenu.display_width(bar_str), 0].max
696
+ bar_line = bar_str + (" " * pad_bar_len)
697
+
698
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
699
+ if @title && !@title.empty?
700
+ t_str = @title.to_s
701
+ if GRmenu.display_width(t_str) > inner_w
702
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
703
+ end
704
+ pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
705
+ l_p = " " * (pad_t / 2)
706
+ r_p = " " * (pad_t - (pad_t / 2))
707
+ lines << "#{color_code}#{v_l}#{reset_code} #{l_p}#{t_str}#{r_p} #{color_code}#{v_r}#{reset_code}"
708
+ lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
709
+ end
710
+ lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
711
+ if @status && !@status.empty?
712
+ st_str = @status.to_s
713
+ if GRmenu.display_width(st_str) > inner_w
714
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
715
+ end
716
+ pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
717
+ st_line = st_str + (" " * pad_st)
718
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(st_line)} #{color_code}#{v_r}#{reset_code}"
719
+ end
720
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
721
+ end
722
+
723
+ frame = lines.join("\r\n") + "\r\n"
724
+
725
+ if @drawn_lines_count && @drawn_lines_count > 0
726
+ Kernel.print("\e[#{@drawn_lines_count}A\e[J")
727
+ end
728
+ Kernel.print(frame)
729
+ $stdout.flush
730
+ @drawn_lines_count = lines.length
731
+ end
732
+
733
+ def finish(status: "¡Completado!")
734
+ return if @closed
735
+ set(@total, status: status)
736
+ @closed = true
737
+ Kernel.print(GRmenu::SHOW_CURSOR)
738
+ end
739
+ end
740
+
741
+ def self.spinner(message_arg = nil, message: nil, color: "cyan", level: 2, delay: 0.08, &block)
742
+ actual_message = message || message_arg || "Cargando..."
743
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
744
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
745
+ color_code = is_rgb ? "" : ansi_color(color, level)
746
+ reset_code = ansi_reset
747
+
748
+ stop_spinner = false
749
+ spinner_thread = Thread.new do
750
+ frame_idx = 0
751
+ while !stop_spinner
752
+ f = frames[frame_idx % frames.length]
753
+ f_color = is_rgb ? rgb_color(frame_idx * 0.3) : color_code
754
+ msg_out = is_rgb ? Color.rgb(actual_message, frame_idx * 0.1) : actual_message
755
+ Kernel.print("\r\e[K#{f_color}#{f}#{reset_code} #{msg_out}")
756
+ $stdout.flush
757
+ frame_idx += 1
758
+ sleep(delay)
759
+ end
760
+ end
761
+
762
+ begin
763
+ Kernel.print(HIDE_CURSOR)
764
+ result = block ? block.call : nil
765
+ stop_spinner = true
766
+ spinner_thread.join
767
+ success_color = ansi_color("green", 2)
768
+ Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{actual_message} #{Color.gray("Listo!")}\r\n")
769
+ result
770
+ rescue Exception => e
771
+ stop_spinner = true
772
+ spinner_thread.join rescue nil
773
+ error_color = ansi_color("red", 2)
774
+ Kernel.print("\r\e[K#{error_color}[ERROR]#{reset_code} #{actual_message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
775
+ raise e
776
+ ensure
777
+ stop_spinner = true
778
+ Kernel.print(SHOW_CURSOR)
779
+ end
780
+ end
781
+
782
+ def self.progress(total_arg = nil, total: nil, title: nil, color: "cyan", level: 2, style: 3, width: nil, &block)
783
+ actual_total = total || total_arg || 100
784
+ bar = ProgressBar.new(actual_total, title: title, color: color, level: level, style: style, width: width)
785
+ Kernel.print(HIDE_CURSOR)
786
+ bar.render
787
+ begin
788
+ result = block ? block.call(bar) : bar
789
+ bar.finish
790
+ result
791
+ ensure
792
+ Kernel.print(SHOW_CURSOR)
793
+ end
794
+ end
795
+
796
+ def self.confirm(question_arg = nil, question: nil, default: true, color: "cyan", style: 3)
797
+ actual_question = question || question_arg || "¿Confirmar acción?"
798
+ choice = default ? 0 : 1
799
+ term_w = terminal_width
800
+ q_w = display_width(actual_question)
801
+ box_w = [q_w + 8, term_w - 4, 38].max
802
+ box_w = [box_w, 64].min
803
+ inner_w = box_w - 4
804
+
805
+ border_cfg = BORDERS[style] || BORDERS[3]
806
+ h_top = border_cfg[:ht] || border_cfg[:h]
807
+ h_bot = border_cfg[:hb] || border_cfg[:h]
808
+ v_l = border_cfg[:vl] || border_cfg[:v]
809
+ v_r = border_cfg[:vr] || border_cfg[:v]
810
+
811
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
812
+ color_code = is_rgb ? "" : ansi_color(color, 2)
813
+ reset_code = ansi_reset
814
+
815
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
816
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
817
+
818
+ drawn_lines = 0
819
+
820
+ render_confirm = lambda do
821
+ btn_yes = (choice == 0) ? Color.bright_green("> [ Sí ] <") : Color.gray(" [ Sí ] ")
822
+ btn_no = (choice == 1) ? Color.bright_red("> [ No ] <") : Color.gray(" [ No ] ")
823
+ raw_btns = (choice == 0 ? "> [ Sí ] <" : " [ Sí ] ") + " " + (choice == 1 ? "> [ No ] <" : " [ No ] ")
824
+ btns_vis_w = display_width(raw_btns)
825
+ pad_total = [inner_w - btns_vis_w, 0].max
826
+ left_p = " " * (pad_total / 2)
827
+ right_p = " " * (pad_total - (pad_total / 2))
828
+ btn_formatted_line = "#{left_p}#{btn_yes} #{btn_no}#{right_p}"
829
+
830
+ q_clean = actual_question.to_s
831
+ if display_width(q_clean) > inner_w
832
+ q_clean = q_clean[0...[inner_w - 3, 1].max] + "..."
833
+ end
834
+ pad_q = [inner_w - display_width(q_clean), 0].max
835
+ q_left = " " * (pad_q / 2)
836
+ q_right = " " * (pad_q - (pad_q / 2))
837
+
838
+ lines = []
839
+ if is_rgb
840
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")
841
+ lines << "#{Color.rgb(v_l)} #{q_left}#{q_clean}#{q_right} #{Color.rgb(v_r)}"
842
+ lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
843
+ lines << "#{Color.rgb(v_l)} #{btn_formatted_line} #{Color.rgb(v_r)}"
844
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
845
+ else
846
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
847
+ lines << "#{color_code}#{v_l}#{reset_code} #{q_left}#{q_clean}#{q_right} #{color_code}#{v_r}#{reset_code}"
848
+ lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
849
+ lines << "#{color_code}#{v_l}#{reset_code} #{btn_formatted_line} #{color_code}#{v_r}#{reset_code}"
850
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
851
+ end
852
+
853
+ frame = lines.join("\r\n") + "\r\n"
854
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
855
+ Kernel.print(frame)
856
+ $stdout.flush
857
+ drawn_lines = lines.length
858
+ end
859
+
860
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
861
+ result = false
862
+
863
+ begin
864
+ Kernel.print(HIDE_CURSOR)
865
+ render_confirm.call
866
+
867
+ reader = lambda do |stream|
868
+ while (key = GRmenu.read_key_raw(stream))
869
+ break if key == "q" || key == "Q" || key == "\x03" || key == "\e"
870
+ if key == "s" || key == "S" || key == "y" || key == "Y"
871
+ result = true
872
+ break
873
+ elsif key == "n" || key == "N"
874
+ result = false
875
+ break
876
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\t" || key == "\e[C" || key == "\eOC" || key == "\xe0M"
877
+ choice = 1 - choice
878
+ render_confirm.call
879
+ elsif key == "\r" || key == "\n" || key == " "
880
+ result = (choice == 0)
881
+ break
882
+ end
883
+ end
884
+ end
885
+
886
+ if is_tty
887
+ $stdin.raw { |s| reader.call(s) }
888
+ else
889
+ reader.call($stdin)
890
+ end
891
+ ensure
892
+ Kernel.print(SHOW_CURSOR)
893
+ end
894
+
895
+ result
896
+ end
897
+
898
+ def self.input(prompt_or_title = nil, title: nil, label: nil, default: "", password: false, color: nil, border_color: nil, title_color: nil, label_color: nil, style: nil, width: nil)
899
+ c_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "input")) || {}
900
+ style_num = (style || c_sec["style"] || 3).to_i
901
+ border_cfg = BORDERS[style_num] || BORDERS[3]
902
+
903
+ h_top = border_cfg[:ht] || border_cfg[:h]
904
+ h_bot = border_cfg[:hb] || border_cfg[:h]
905
+ v_l = border_cfg[:vl] || border_cfg[:v]
906
+ v_r = border_cfg[:vr] || border_cfg[:v]
907
+
908
+ box_title = if title && !title.to_s.empty?
909
+ title.to_s
910
+ elsif prompt_or_title && !label
911
+ prompt_or_title.to_s
912
+ else
913
+ c_sec["title"] || "Entrada de Datos"
914
+ end
915
+
916
+ input_label = if label && !label.to_s.empty?
917
+ label.to_s
918
+ elsif prompt_or_title && title
919
+ prompt_or_title.to_s
920
+ elsif c_sec["label"]
921
+ c_sec["label"].to_s
922
+ else
923
+ "Valor:"
924
+ end
925
+
926
+ brd_col_name = (border_color || color || c_sec["border_color"] || c_sec["color"] || "cyan").to_s
927
+ tit_col_name = (title_color || c_sec["title_color"] || "yellow").to_s
928
+ lbl_col_name = (label_color || c_sec["label_color"] || "white").to_s
929
+
930
+ is_rgb = (brd_col_name.downcase == "rgb" || brd_col_name.downcase == "rainbow" || brd_col_name.downcase == "chroma")
931
+
932
+ text = String.new(default.to_s)
933
+ term_w = terminal_width
934
+ p_len = display_width(box_title) + 6
935
+ c_len = display_width(input_label) + display_width(text) + 12
936
+ box_w = width ? width.to_i : [p_len, c_len, 48].max
937
+ box_w = [box_w, term_w - 4].min
938
+ inner_w = [box_w - 2, 20].max
939
+
940
+ drawn_lines = 0
941
+
942
+ render_input = lambda do
943
+ display_str = password ? ("*" * text.length) : text
944
+ avail_inp_w = [inner_w - display_width(input_label) - 4, 4].max
945
+ if display_width(display_str) > avail_inp_w
946
+ display_str = "..." + display_str[-[avail_inp_w - 3, 1].max..-1]
947
+ end
948
+
949
+ brd_code = is_rgb ? "" : ansi_color(brd_col_name, 1)
950
+ tit_code = ansi_color(tit_col_name, 2)
951
+ lbl_code = ansi_color(lbl_col_name, 2)
952
+ rst = ansi_reset
953
+
954
+ t_clean = " #{box_title} "
955
+ t_w = display_width(t_clean)
956
+ l_pad = [(inner_w - t_w) / 2, 0].max
957
+ r_pad = [inner_w - t_w - l_pad, 0].max
958
+
959
+ top_fill_l = (h_top * l_pad)[0...l_pad]
960
+ top_fill_r = (h_top * r_pad)[0...r_pad]
961
+ bot_fill = (h_bot * inner_w)[0...inner_w]
962
+
963
+ top_line = if is_rgb
964
+ Color.rgb("#{border_cfg[:tl]}#{top_fill_l}") + tit_code + t_clean + Color.rgb("#{top_fill_r}#{border_cfg[:tr]}")
965
+ else
966
+ "#{brd_code}#{border_cfg[:tl]}#{top_fill_l}#{rst}#{tit_code}#{t_clean}#{rst}#{brd_code}#{top_fill_r}#{border_cfg[:tr]}#{rst}"
967
+ end
968
+
969
+ empty_line = if is_rgb
970
+ Color.rgb("#{v_l}#{' ' * inner_w}#{v_r}")
971
+ else
972
+ "#{brd_code}#{v_l}#{rst}#{' ' * inner_w}#{brd_code}#{v_r}#{rst}"
973
+ end
974
+
975
+ raw_content = " #{input_label} #{display_str}█"
976
+ content_pad = [inner_w - display_width(raw_content), 0].max
977
+ content_line = if is_rgb
978
+ "#{Color.rgb(v_l)} #{lbl_code}#{input_label}#{rst} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{Color.rgb(v_r)}"
979
+ else
980
+ "#{brd_code}#{v_l}#{rst} #{lbl_code}#{input_label}#{rst} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{brd_code}#{v_r}#{rst}"
981
+ end
982
+
983
+ bot_line = if is_rgb
984
+ Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
985
+ else
986
+ "#{brd_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{rst}"
987
+ end
988
+
989
+ lines = [top_line, empty_line, content_line, empty_line, bot_line]
990
+ frame = lines.join("\r\n") + "\r\n"
991
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
992
+ Kernel.print(frame)
993
+ $stdout.flush
994
+ drawn_lines = lines.length
995
+ end
996
+
997
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
998
+
999
+ begin
1000
+ Kernel.print(HIDE_CURSOR)
1001
+ render_input.call
1002
+
1003
+ reader = lambda do |stream|
1004
+ while (key = GRmenu.read_key_raw(stream))
1005
+ break if key == "\x03" || key == "\e"
1006
+ if key == "\r" || key == "\n"
1007
+ break
1008
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
1009
+ text.chop!
1010
+ render_input.call
1011
+ elsif key == "\x15"
1012
+ text.clear
1013
+ render_input.call
1014
+ elsif key =~ /^[[:print:]]$/
1015
+ text << key if display_width(text) < (inner_w - display_width(input_label) - 6)
1016
+ render_input.call
1017
+ end
1018
+ end
1019
+ end
1020
+
1021
+ if is_tty
1022
+ $stdin.raw { |s| reader.call(s) }
1023
+ else
1024
+ reader.call($stdin)
1025
+ end
1026
+ ensure
1027
+ Kernel.print(SHOW_CURSOR)
1028
+ end
1029
+
1030
+ text
1031
+ end
1032
+
1033
+ def self.checkbox(items_arg = nil, items: nil, title: "Selección Múltiple", subtitle: "Espacio: Marcar/Desmarcar | a: Todos | n: Ninguno | i: Invertir | Enter: Confirmar", color: nil, style: nil, page_size: nil, min_width: nil, preselected: [])
1034
+ cb_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "checkbox")) || {}
1035
+ actual_items = items || items_arg || []
1036
+ item_list = actual_items.is_a?(Array) ? actual_items : Array(actual_items)
1037
+ return [] if item_list.empty?
1038
+ chk_mark = cb_sec["checked_mark"] || "[X]"
1039
+ unchk_mark = cb_sec["unchecked_mark"] || "[ ]"
1040
+
1041
+ parsed_items = item_list.map do |it|
1042
+ case it
1043
+ when Array
1044
+ name = it[0].to_s
1045
+ is_chk = it.length > 1 ? !!it[1] : false
1046
+ desc = it.length > 2 ? it[2].to_s : ""
1047
+ { name: name, checked: is_chk, desc: desc, original: it }
1048
+ when Hash
1049
+ name = (it[:name] || it["name"] || it[:title] || it["title"] || "Item").to_s
1050
+ is_chk = !!(it[:checked] || it["checked"] || it[:selected] || it["selected"])
1051
+ desc = (it[:desc] || it["desc"] || it[:description] || it["description"]).to_s
1052
+ { name: name, checked: is_chk, desc: desc, original: it }
1053
+ else
1054
+ { name: it.to_s, checked: false, desc: "", original: it }
1055
+ end
1056
+ end
1057
+
1058
+ preselected.each do |p|
1059
+ if p.is_a?(Integer) && parsed_items[p]
1060
+ parsed_items[p][:checked] = true
1061
+ else
1062
+ it = parsed_items.find { |pi| pi[:name] == p.to_s }
1063
+ it[:checked] = true if it
1064
+ end
1065
+ end
1066
+
1067
+ index = 0
1068
+ rgb_tick = 0.0
1069
+ drawn_lines = 0
1070
+ cb_color = (color || cb_sec["color"] || "cyan").to_s
1071
+ style_num = (style || cb_sec["style"] || 3).to_i
1072
+ is_rgb = (cb_color.downcase == "rgb" || cb_color.downcase == "rainbow" || cb_color.downcase == "chroma")
1073
+ border_cfg = BORDERS[style_num] || BORDERS[3]
1074
+ h_top = border_cfg[:ht] || border_cfg[:h]
1075
+ h_bot = border_cfg[:hb] || border_cfg[:h]
1076
+ v_l = border_cfg[:vl] || border_cfg[:v]
1077
+ v_r = border_cfg[:vr] || border_cfg[:v]
1078
+
1079
+ render_frame = lambda do
1080
+ term_w = terminal_width
1081
+ term_h = terminal_height
1082
+
1083
+ max_name_w = parsed_items.map { |it| display_width(it[:name]) }.max || 10
1084
+ req_w = [max_name_w + 12, display_width(title) + 6, display_width(subtitle) + 4, min_width || 38].max
1085
+ box_w = [req_w, term_w - 4].min
1086
+ inner_w = box_w - 4
1087
+
1088
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
1089
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
1090
+ mid_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
1091
+
1092
+ total_items = parsed_items.length
1093
+ max_visible = page_size ? [page_size, total_items, term_h - 10].min : [total_items, term_h - 10].min
1094
+ max_visible = [max_visible, 1].max
1095
+
1096
+ start_idx = 0
1097
+ end_idx = total_items - 1
1098
+ if total_items > max_visible
1099
+ half = max_visible / 2
1100
+ start_idx = [[index - half, 0].max, total_items - max_visible].min
1101
+ end_idx = start_idx + max_visible - 1
1102
+ end
1103
+
1104
+ lines = []
1105
+ if is_rgb
1106
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", rgb_tick)
1107
+ unless title.to_s.empty?
1108
+ t_clean = title.to_s
1109
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1110
+ pad_t = [inner_w - display_width(t_clean), 0].max
1111
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
1112
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(t_line, rgb_tick + 0.2)} #{Color.rgb(v_r, rgb_tick)}"
1113
+ lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
1114
+ end
1115
+ if start_idx > 0
1116
+ up_t = "▲ (+#{start_idx} arriba)"
1117
+ pad_u = [inner_w - display_width(up_t), 0].max
1118
+ 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)}"
1119
+ end
1120
+ (start_idx..end_idx).each do |i|
1121
+ it = parsed_items[i]
1122
+ mark = it[:checked] ? chk_mark : unchk_mark
1123
+ is_active = (i == index)
1124
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1125
+ name_str = it[:name].to_s
1126
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1127
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1128
+ line_padded = pad_to_width(raw_line, inner_w)
1129
+ if is_active
1130
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(line_padded, rgb_tick + 0.4)} #{Color.rgb(v_r, rgb_tick)}"
1131
+ elsif it[:checked]
1132
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.bright_green(line_padded)} #{Color.rgb(v_r, rgb_tick)}"
1133
+ else
1134
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.white(line_padded)} #{Color.rgb(v_r, rgb_tick)}"
1135
+ end
1136
+ end
1137
+ if end_idx < (total_items - 1)
1138
+ rem = total_items - 1 - end_idx
1139
+ dn_t = "▼ (+#{rem} abajo)"
1140
+ pad_d = [inner_w - display_width(dn_t), 0].max
1141
+ 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)}"
1142
+ end
1143
+ unless subtitle.to_s.empty?
1144
+ lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
1145
+ s_clean = subtitle.to_s
1146
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1147
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1148
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1149
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(sub_padded)} #{Color.rgb(v_r, rgb_tick)}"
1150
+ end
1151
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1152
+ else
1153
+ color_code = ansi_color(cb_color, 2)
1154
+ reset_code = ansi_reset
1155
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1156
+ unless title.to_s.empty?
1157
+ t_clean = title.to_s
1158
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1159
+ pad_t = [inner_w - display_width(t_clean), 0].max
1160
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
1161
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(t_line)} #{color_code}#{v_r}#{reset_code}"
1162
+ lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1163
+ end
1164
+ if start_idx > 0
1165
+ up_t = "▲ (+#{start_idx} arriba)"
1166
+ pad_u = [inner_w - display_width(up_t), 0].max
1167
+ 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}"
1168
+ end
1169
+ (start_idx..end_idx).each do |i|
1170
+ it = parsed_items[i]
1171
+ mark = it[:checked] ? chk_mark : unchk_mark
1172
+ is_active = (i == index)
1173
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1174
+ name_str = it[:name].to_s
1175
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1176
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1177
+ line_padded = pad_to_width(raw_line, inner_w)
1178
+ if is_active
1179
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(line_padded)} #{color_code}#{v_r}#{reset_code}"
1180
+ elsif it[:checked]
1181
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1182
+ else
1183
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.white(line_padded)} #{color_code}#{v_r}#{reset_code}"
1184
+ end
1185
+ end
1186
+ if end_idx < (total_items - 1)
1187
+ rem = total_items - 1 - end_idx
1188
+ dn_t = "▼ (+#{rem} abajo)"
1189
+ pad_d = [inner_w - display_width(dn_t), 0].max
1190
+ 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}"
1191
+ end
1192
+ unless subtitle.to_s.empty?
1193
+ lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1194
+ s_clean = subtitle.to_s
1195
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1196
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1197
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1198
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(sub_padded)} #{color_code}#{v_r}#{reset_code}"
1199
+ end
1200
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
1201
+ end
1202
+
1203
+ frame = lines.join("\r\n") + "\r\n"
1204
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
1205
+ Kernel.print(frame)
1206
+ $stdout.flush
1207
+ drawn_lines = lines.length
1208
+ end
1209
+
1210
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
1211
+ submitted = false
1212
+
1213
+ begin
1214
+ Kernel.print(HIDE_CURSOR)
1215
+ render_frame.call
1216
+
1217
+ reader = lambda do |stream|
1218
+ while true
1219
+ if is_rgb
1220
+ ready = false
1221
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
1222
+ begin
1223
+ sr = IO.select([stream], nil, nil, 0.035)
1224
+ ready = true if sr && sr[0] && !sr[0].empty?
1225
+ rescue StandardError
1226
+ ready = true
1227
+ end
1228
+ else
1229
+ ready = true
1230
+ end
1231
+ unless ready
1232
+ rgb_tick += 0.08
1233
+ render_frame.call
1234
+ next
1235
+ end
1236
+ end
1237
+
1238
+ key = GRmenu.read_key_raw(stream)
1239
+ break if key.nil? || key == "\x03" || key == "\x04" || key == "q" || key == "Q" || key == "\e"
1240
+
1241
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
1242
+ index = (index - 1) % parsed_items.length
1243
+ render_frame.call
1244
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
1245
+ index = (index + 1) % parsed_items.length
1246
+ render_frame.call
1247
+ elsif key == " "
1248
+ parsed_items[index][:checked] = !parsed_items[index][:checked]
1249
+ render_frame.call
1250
+ elsif key == "a" || key == "A"
1251
+ parsed_items.each { |it| it[:checked] = true }
1252
+ render_frame.call
1253
+ elsif key == "n" || key == "N"
1254
+ parsed_items.each { |it| it[:checked] = false }
1255
+ render_frame.call
1256
+ elsif key == "i" || key == "I"
1257
+ parsed_items.each { |it| it[:checked] = !it[:checked] }
1258
+ render_frame.call
1259
+ elsif key == "\r" || key == "\n"
1260
+ submitted = true
1261
+ break
1262
+ end
1263
+ end
1264
+ end
1265
+
1266
+ if is_tty
1267
+ $stdin.raw { |s| reader.call(s) }
1268
+ else
1269
+ reader.call($stdin)
1270
+ end
1271
+ ensure
1272
+ Kernel.print(SHOW_CURSOR)
1273
+ end
1274
+
1275
+ if submitted
1276
+ selected = parsed_items.select { |it| it[:checked] }
1277
+ selected.map { |it| it[:original] }
1278
+ else
1279
+ []
1280
+ end
1281
+ end
1282
+ class << self
1283
+ alias_method :select_multi, :checkbox
1284
+ alias_method :multiselect, :checkbox
1285
+ end
1286
+
1287
+ def self.slider(prompt_arg = nil, prompt: nil, min: 0, max: 100, step: 1, default: nil, unit: "", color: nil, style: nil, width: 46)
1288
+ actual_prompt = prompt || prompt_arg || "Selecciona un valor:"
1289
+ sl_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "slider")) || {}
1290
+ val = (default || min).to_f.clamp(min.to_f, max.to_f)
1291
+ step_val = [step.to_f, 0.001].max
1292
+ drawn_lines = 0
1293
+ rgb_tick = 0.0
1294
+ sl_color = (color || sl_sec["color"] || "cyan").to_s
1295
+ style_num = (style || sl_sec["style"] || 3).to_i
1296
+ is_rgb = (sl_color.downcase == "rgb" || sl_color.downcase == "rainbow" || sl_color.downcase == "chroma")
1297
+
1298
+ border_cfg = BORDERS[style_num] || BORDERS[3]
1299
+ h_top = border_cfg[:ht] || border_cfg[:h]
1300
+ h_bot = border_cfg[:hb] || border_cfg[:h]
1301
+ v_l = border_cfg[:vl] || border_cfg[:v]
1302
+ v_r = border_cfg[:vr] || border_cfg[:v]
1303
+
1304
+ render_slider = lambda do
1305
+ term_w = terminal_width
1306
+ box_w = [width, term_w - 4, display_width(actual_prompt) + 8, 38].max
1307
+ box_w = [box_w, term_w - 2].min
1308
+ inner_w = box_w - 4
1309
+
1310
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
1311
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
1312
+
1313
+ val_display = (val % 1 == 0) ? val.to_i.to_s : val.round(2).to_s
1314
+ val_str = unit.to_s.empty? ? val_display : "#{val_display} #{unit}"
1315
+
1316
+ range_span = (max - min).to_f
1317
+ range_span = 1.0 if range_span <= 0
1318
+ fraction = ((val - min).to_f / range_span).clamp(0.0, 1.0)
1319
+
1320
+ avail_bar_w = [inner_w - display_width(val_str) - 4, 6].max
1321
+ filled_len = (fraction * avail_bar_w).round
1322
+ empty_len = [avail_bar_w - filled_len, 0].max
1323
+
1324
+ p_clean = actual_prompt.to_s
1325
+ if display_width(p_clean) > inner_w
1326
+ p_clean = p_clean[0...[inner_w - 3, 1].max] + "..."
1327
+ end
1328
+ pad_p = [inner_w - display_width(p_clean), 0].max
1329
+ p_line = (" " * (pad_p / 2)) + p_clean + (" " * (pad_p - (pad_p / 2)))
1330
+
1331
+ instr = (inner_w >= 30) ? "← / → Ajustar | Enter Guardar" : "←/→: Ajustar | Enter: Ok"
1332
+ if display_width(instr) > inner_w
1333
+ instr = instr[0...[inner_w - 3, 1].max] + "..."
1334
+ end
1335
+ pad_i = [inner_w - display_width(instr), 0].max
1336
+ i_line = (" " * (pad_i / 2)) + instr + (" " * (pad_i - (pad_i / 2)))
1337
+
1338
+ lines = []
1339
+ if is_rgb
1340
+ lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", rgb_tick)
1341
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(p_line, rgb_tick + 0.3)} #{Color.rgb(v_r, rgb_tick)}"
1342
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{' ' * inner_w} #{Color.rgb(v_r, rgb_tick)}"
1343
+
1344
+ filled_part = Color.rgb("█" * filled_len, rgb_tick + 0.5)
1345
+ empty_part = Color.gray("░" * empty_len)
1346
+ bar_raw = "[#{filled_part}#{empty_part}] #{Color.bright_white(val_str)}"
1347
+ bar_vis_w = 2 + filled_len + empty_len + 1 + display_width(val_str)
1348
+ pad_b = [inner_w - bar_vis_w, 0].max
1349
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{bar_raw}#{' ' * pad_b} #{Color.rgb(v_r, rgb_tick)}"
1350
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{' ' * inner_w} #{Color.rgb(v_r, rgb_tick)}"
1351
+ lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(i_line)} #{Color.rgb(v_r, rgb_tick)}"
1352
+ lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1353
+ else
1354
+ color_code = ansi_color(sl_color, 2)
1355
+ reset_code = ansi_reset
1356
+
1357
+ bar_raw = "[#{"█" * filled_len}#{"░" * empty_len}] #{val_str}"
1358
+ pad_b = [inner_w - display_width(bar_raw), 0].max
1359
+ bar_line = bar_raw + (" " * pad_b)
1360
+
1361
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1362
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_line)} #{color_code}#{v_r}#{reset_code}"
1363
+ lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1364
+ lines << "#{color_code}#{v_l}#{reset_code} #{bar_line} #{color_code}#{v_r}#{reset_code}"
1365
+ lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1366
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(i_line)} #{color_code}#{v_r}#{reset_code}"
1367
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
1368
+ end
1369
+
1370
+ frame = lines.join("\r\n") + "\r\n"
1371
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
1372
+ Kernel.print(frame)
1373
+ $stdout.flush
1374
+ drawn_lines = lines.length
1375
+ end
1376
+
1377
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
1378
+
1379
+ begin
1380
+ Kernel.print(HIDE_CURSOR)
1381
+ render_slider.call
1382
+
1383
+ reader = lambda do |stream|
1384
+ while true
1385
+ if is_rgb
1386
+ ready = false
1387
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
1388
+ begin
1389
+ sr = IO.select([stream], nil, nil, 0.035)
1390
+ ready = true if sr && sr[0] && !sr[0].empty?
1391
+ rescue StandardError
1392
+ ready = true
1393
+ end
1394
+ else
1395
+ ready = true
1396
+ end
1397
+ unless ready
1398
+ rgb_tick += 0.08
1399
+ render_slider.call
1400
+ next
1401
+ end
1402
+ end
1403
+
1404
+ key = GRmenu.read_key_raw(stream)
1405
+ break if key.nil? || key == "\x03" || key == "\x04" || key == "q" || key == "Q" || key == "\e"
1406
+
1407
+ if key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "h" || key == "H"
1408
+ val = (val - step_val).clamp(min.to_f, max.to_f)
1409
+ render_slider.call
1410
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M" || key == "l" || key == "L"
1411
+ val = (val + step_val).clamp(min.to_f, max.to_f)
1412
+ render_slider.call
1413
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
1414
+ val = (val - step_val * 5).clamp(min.to_f, max.to_f)
1415
+ render_slider.call
1416
+ elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
1417
+ val = (val + step_val * 5).clamp(min.to_f, max.to_f)
1418
+ render_slider.call
1419
+ elsif key == "\r" || key == "\n"
1420
+ break
1421
+ end
1422
+ end
1423
+ end
1424
+
1425
+ if is_tty
1426
+ $stdin.raw { |s| reader.call(s) }
1427
+ else
1428
+ reader.call($stdin)
1429
+ end
1430
+ ensure
1431
+ Kernel.print(SHOW_CURSOR)
1432
+ end
1433
+
1434
+ (val % 1 == 0) ? val.to_i : val.round(2)
1435
+ end
1436
+ class << self
1437
+ alias_method :range, :slider
1438
+ end
1439
+
1440
+ def self.read_key_raw(input_stream)
1441
+ is_tty = input_stream.respond_to?(:tty?) && input_stream.tty?
1442
+ first_char = nil
1443
+ begin
1444
+ first_char = is_tty ? input_stream.getch : input_stream.read(1)
1445
+ rescue EOFError, Errno::EPIPE
1446
+ return nil
1447
+ end
1448
+ return nil if first_char.nil?
1449
+
1450
+ if first_char == "\e"
1451
+ begin
1452
+ second = is_tty ? input_stream.read_nonblock(1) : input_stream.read(1)
1453
+ if second
1454
+ first_char << second
1455
+ if second == "[" || second == "O"
1456
+ while true
1457
+ ch = is_tty ? input_stream.read_nonblock(1) : input_stream.read(1)
1458
+ break if ch.nil?
1459
+ first_char << ch
1460
+ break if ch =~ /[a-zA-Z~]/
1461
+ end
1462
+ end
1463
+ end
1464
+ rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1465
+ end
1466
+ elsif first_char == "\x00" || first_char == "\xe0"
1467
+ begin
1468
+ second_char = is_tty ? input_stream.read_nonblock(1) : input_stream.read(1)
1469
+ first_char << second_char if second_char
1470
+ rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1471
+ end
1472
+ end
1473
+
1474
+ first_char
1475
+ rescue EOFError, Errno::EPIPE, Errno::ENOTTY
1476
+ nil
1477
+ end
1478
+
1479
+ class SetStyle
1480
+ def initialize(
1481
+ border: { color: "cyan", level: 1 },
1482
+ options: { color: "white", level: 1 },
1483
+ focus: { color: "green", level: 2 },
1484
+ title: { color: "yellow", level: 2 },
1485
+ banner: { color: "magenta", level: 2 },
1486
+ subtitle: { color: "cyan", level: 2 },
1487
+ divider: { color: "blue", level: 1 },
1488
+ font: 1,
1489
+ desc_prefix: "[i]"
1490
+ )
1491
+ @border = border.dup
1492
+ @options = options.dup
1493
+ @focus = focus.dup
1494
+ @title = title.dup
1495
+ @banner = banner.dup
1496
+ @subtitle = subtitle.dup
1497
+ @divider = divider.dup
1498
+ @font = font.to_i
1499
+ @desc_prefix = desc_prefix.to_s
1500
+ end
1501
+
1502
+ def desc_prefix(prefix_str = nil)
1503
+ return @desc_prefix if prefix_str.nil?
1504
+ @desc_prefix = prefix_str.to_s
1505
+ self
1506
+ end
1507
+ alias_method :description_prefix, :desc_prefix
1508
+ alias_method :desc_prefix=, :desc_prefix
1509
+ alias_method :description_prefix=, :desc_prefix
1510
+
1511
+ def border(color_name = nil, brightness_level = 1)
1512
+ return @border if color_name.nil?
1513
+ @border = parse_color(color_name, brightness_level)
1514
+ self
1515
+ end
1516
+ alias_method :Border, :border
1517
+ alias_method :set_border, :border
1518
+ alias_method :border=, :border
1519
+
1520
+ def options(color_name = nil, brightness_level = 1)
1521
+ return @options if color_name.nil?
1522
+ @options = parse_color(color_name, brightness_level)
1523
+ self
1524
+ end
1525
+ alias_method :Options, :options
1526
+ alias_method :set_options, :options
1527
+ alias_method :options=, :options
1528
+
1529
+ def focus(color_name = nil, brightness_level = 2)
1530
+ return @focus if color_name.nil?
1531
+ @focus = parse_color(color_name, brightness_level)
1532
+ self
1533
+ end
1534
+ alias_method :Focus, :focus
1535
+ alias_method :set_focus, :focus
1536
+ alias_method :focus=, :focus
1537
+
1538
+ def title(color_name = nil, brightness_level = 2)
1539
+ return @title if color_name.nil?
1540
+ @title = parse_color(color_name, brightness_level)
1541
+ self
1542
+ end
1543
+ alias_method :Title, :title
1544
+ alias_method :set_title, :title
1545
+ alias_method :title=, :title
1546
+
1547
+ def banner(color_name = nil, brightness_level = 2)
1548
+ return @banner if color_name.nil?
1549
+ @banner = parse_color(color_name, brightness_level)
1550
+ self
1551
+ end
1552
+ alias_method :Banner, :banner
1553
+ alias_method :set_banner, :banner
1554
+ alias_method :banner=, :banner
1555
+
1556
+ def subtitle(color_name = nil, brightness_level = 2)
1557
+ return @subtitle if color_name.nil?
1558
+ @subtitle = parse_color(color_name, brightness_level)
1559
+ self
1560
+ end
1561
+ alias_method :Subtitle, :subtitle
1562
+ alias_method :set_subtitle, :subtitle
1563
+ alias_method :subtitle=, :subtitle
1564
+
1565
+ def divider(color_name = nil, brightness_level = 1)
1566
+ return @divider if color_name.nil?
1567
+ @divider = parse_color(color_name, brightness_level)
1568
+ self
1569
+ end
1570
+ alias_method :Divider, :divider
1571
+ alias_method :set_divider, :divider
1572
+ alias_method :divider=, :divider
1573
+
1574
+ def font(font_id = nil)
1575
+ return @font if font_id.nil?
1576
+ @font = font_id.to_i
1577
+ self
1578
+ end
1579
+ alias_method :Font, :font
1580
+ alias_method :set_font, :font
1581
+ alias_method :font=, :font
1582
+
1583
+ private
1584
+
1585
+ def parse_color(color_val, default_level = 1)
1586
+ if color_val.is_a?(Hash)
1587
+ { color: (color_val[:color] || color_val["color"]).to_s, level: (color_val[:level] || color_val["level"] || default_level).to_i }
1588
+ else
1589
+ { color: color_val.to_s, level: default_level.to_i }
1590
+ end
1591
+ end
1592
+
1593
+ class << self
1594
+ def border(color_name = nil, brightness_level = 1)
1595
+ @default_border ||= { color: "cyan", level: 1 }
1596
+ return @default_border if color_name.nil?
1597
+ @default_border = { color: color_name.to_s, level: brightness_level.to_i }
1598
+ end
1599
+ alias_method :Border, :border
1600
+ alias_method :border=, :border
1601
+
1602
+ def options(color_name = nil, brightness_level = 1)
1603
+ @default_options ||= { color: "white", level: 1 }
1604
+ return @default_options if color_name.nil?
1605
+ @default_options = { color: color_name.to_s, level: brightness_level.to_i }
1606
+ end
1607
+ alias_method :Options, :options
1608
+ alias_method :options=, :options
1609
+
1610
+ def focus(color_name = nil, brightness_level = 2)
1611
+ @default_focus ||= { color: "green", level: 2 }
1612
+ return @default_focus if color_name.nil?
1613
+ @default_focus = { color: color_name.to_s, level: brightness_level.to_i }
1614
+ end
1615
+ alias_method :Focus, :focus
1616
+ alias_method :focus=, :focus
1617
+
1618
+ def title(color_name = nil, brightness_level = 2)
1619
+ @default_title ||= { color: "yellow", level: 2 }
1620
+ return @default_title if color_name.nil?
1621
+ @default_title = { color: color_name.to_s, level: brightness_level.to_i }
1622
+ end
1623
+ alias_method :Title, :title
1624
+ alias_method :title=, :title
1625
+
1626
+ def banner(color_name = nil, brightness_level = 2)
1627
+ @default_banner ||= { color: "magenta", level: 2 }
1628
+ return @default_banner if color_name.nil?
1629
+ @default_banner = { color: color_name.to_s, level: brightness_level.to_i }
1630
+ end
1631
+ alias_method :Banner, :banner
1632
+ alias_method :banner=, :banner
1633
+
1634
+ def subtitle(color_name = nil, brightness_level = 2)
1635
+ @default_subtitle ||= { color: "cyan", level: 2 }
1636
+ return @default_subtitle if color_name.nil?
1637
+ @default_subtitle = { color: color_name.to_s, level: brightness_level.to_i }
1638
+ end
1639
+ alias_method :Subtitle, :subtitle
1640
+ alias_method :subtitle=, :subtitle
1641
+
1642
+ def divider(color_name = nil, brightness_level = 1)
1643
+ @default_divider ||= { color: "blue", level: 1 }
1644
+ return @default_divider if color_name.nil?
1645
+ @default_divider = { color: color_name.to_s, level: brightness_level.to_i }
1646
+ end
1647
+ alias_method :Divider, :divider
1648
+ alias_method :divider=, :divider
1649
+
1650
+ def font(font_id = nil)
1651
+ @default_font ||= 1
1652
+ return @default_font if font_id.nil?
1653
+ @default_font = font_id.to_i
1654
+ end
1655
+ alias_method :Font, :font
1656
+ alias_method :set_font, :font
1657
+ alias_method :font=, :font
1658
+
1659
+ def desc_prefix(prefix_str = nil)
1660
+ @default_desc_prefix ||= "[i]"
1661
+ return @default_desc_prefix if prefix_str.nil?
1662
+ @default_desc_prefix = prefix_str.to_s
1663
+ end
1664
+ alias_method :description_prefix, :desc_prefix
1665
+ alias_method :desc_prefix=, :desc_prefix
1666
+ alias_method :description_prefix=, :desc_prefix
1667
+ end
1668
+ end
1669
+
1670
+ module GRprint
1671
+ module_function
1672
+
1673
+ def p(text = "", ending = "\r\n")
1674
+ Kernel.print("#{text}#{ending}")
1675
+ end
1676
+ end
1677
+
1678
+ attr_accessor :functions, :title, :subtitle, :banner, :banner_style, :divider, :style, :index, :style_config, :center, :page_size, :search, :columns, :query, :image, :image_width
1679
+
1680
+ alias_method :options, :functions
1681
+ alias_method :options=, :functions=
1682
+ alias_method :selected_index, :index
1683
+ alias_method :selected_index=, :index=
1684
+ alias_method :SetStyle, :style_config
1685
+ alias_method :set_style, :style_config
1686
+ alias_method :description, :subtitle
1687
+ alias_method :description=, :subtitle=
1688
+
1689
+ def self.STYLES; STYLES; end
1690
+ def self.COLORS; COLORS; end
1691
+ def self.BORDERS; BORDERS; end
1692
+ def self.FONTS; FONTS; end
1693
+ def self.FONT; FONT_1; end
1694
+
1695
+ def self.terminal_width
1696
+ cols = $stdout.winsize[1] rescue nil
1697
+ cols = $stdin.winsize[1] rescue nil if cols.nil? || cols <= 0
1698
+ (cols && cols > 0) ? cols : (ENV['COLUMNS'] ? ENV['COLUMNS'].to_i : 80)
1699
+ rescue StandardError
1700
+ 80
1701
+ end
1702
+
1703
+ def self.terminal_height
1704
+ rows = $stdout.winsize[0] rescue nil
1705
+ rows = $stdin.winsize[0] rescue nil if rows.nil? || rows <= 0
1706
+ (rows && rows > 0) ? rows : (ENV['LINES'] ? ENV['LINES'].to_i : 24)
1707
+ rescue StandardError
1708
+ 24
1709
+ end
1710
+
1711
+ def self.clear_screen
1712
+ Kernel.print(CLEAR_SCREEN_SEQUENCE)
1713
+ end
1714
+ class << self
1715
+ alias_method :clr, :clear_screen
1716
+ end
1717
+
1718
+ @@global_theme = {}
1719
+
1720
+ def self.current_theme
1721
+ @@global_theme
1722
+ end
1723
+
1724
+ def self.parse_config_text(text)
1725
+ data = { global: {}, sections: {} }
1726
+ current_sec = nil
1727
+ current_sec_data = {}
1728
+
1729
+ text.to_s.each_line do |line|
1730
+ line = line.strip
1731
+ next if line.empty? || line.start_with?("#")
1732
+ next if line.start_with?("GRmenu::config")
1733
+
1734
+ if line.start_with?("<<")
1735
+ sec_name = line[2..-1].strip.downcase
1736
+ current_sec = sec_name
1737
+ current_sec_data = {}
1738
+ elsif line == ">>"
1739
+ if current_sec
1740
+ data[:sections][current_sec] = current_sec_data
1741
+ current_sec = nil
1742
+ end
1743
+ elsif line.include?("::")
1744
+ key, _, val = line.partition("::")
1745
+ key = key.strip.sub(/^@/, '').downcase
1746
+ val = val.strip.sub(/^["']/, '').sub(/["']$/, '')
1747
+ if current_sec
1748
+ current_sec_data[key] = val
1749
+ else
1750
+ data[:global][key] = val
1751
+ end
1752
+ end
1753
+ end
1754
+ data
1755
+ end
1756
+
1757
+ def self.find_theme_file(path_or_name)
1758
+ name = path_or_name.to_s
1759
+ candidates = [
1760
+ name,
1761
+ "#{name}.gr",
1762
+ find_data_file("themes/#{name}.gr"),
1763
+ find_data_file("themes/#{name}"),
1764
+ File.expand_path("data/themes/#{name}.gr", __dir__),
1765
+ File.expand_path("data/themes/#{name}", __dir__),
1766
+ File.expand_path("../data/themes/#{name}.gr", __dir__),
1767
+ File.expand_path("../data/themes/#{name}", __dir__)
1768
+ ].compact
1769
+ candidates.find { |p| File.exist?(p) }
1770
+ end
1771
+
1772
+ def self.import_config(path_or_name)
1773
+ path = find_theme_file(path_or_name)
1774
+ raise "No se encontro el tema: #{path_or_name}" unless path && File.exist?(path)
1775
+ text = File.read(path)
1776
+ parsed = parse_config_text(text)
1777
+ apply_parsed_theme(parsed)
1778
+ @@global_theme = parsed
1779
+ path
1780
+ end
1781
+
1782
+ def self.theme(name)
1783
+ import_config(name)
1784
+ end
1785
+
1786
+ def self.extract_color_and_level(val, default_level = 1)
1787
+ return ["white", default_level] if val.nil?
1788
+ parts = val.to_s.split(":")
1789
+ c_name = parts[0].to_s.strip
1790
+ lvl = parts[1] ? parts[1].to_i : default_level
1791
+ [c_name, lvl]
1792
+ end
1793
+
1794
+ def self.apply_parsed_theme(parsed)
1795
+ sec = parsed[:sections] || {}
1796
+ glob = parsed[:global] || {}
1797
+ m = (sec["menu"] || {}).merge(glob)
1798
+
1799
+ if m && !m.empty?
1800
+ if m["border"] || m["border_color"]
1801
+ c, l = extract_color_and_level(m["border"] || m["border_color"], 1)
1802
+ SetStyle.border(c, l)
1803
+ end
1804
+ if m["title"] || m["title_color"]
1805
+ c, l = extract_color_and_level(m["title"] || m["title_color"], 2)
1806
+ SetStyle.title(c, l)
1807
+ end
1808
+ if m["focus"] || m["focus_color"]
1809
+ c, l = extract_color_and_level(m["focus"] || m["focus_color"], 2)
1810
+ SetStyle.focus(c, l)
1811
+ end
1812
+ if m["options"] || m["options_color"]
1813
+ c, l = extract_color_and_level(m["options"] || m["options_color"], 1)
1814
+ SetStyle.options(c, l)
1815
+ end
1816
+ if m["banner"] || m["banner_color"]
1817
+ c, l = extract_color_and_level(m["banner"] || m["banner_color"], 2)
1818
+ SetStyle.banner(c, l)
1819
+ end
1820
+ if m["subtitle"] || m["subtitle_color"]
1821
+ c, l = extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
1822
+ SetStyle.subtitle(c, l)
1823
+ end
1824
+ if m["divider"] || m["divider_color"]
1825
+ c, l = extract_color_and_level(m["divider"] || m["divider_color"], 1)
1826
+ SetStyle.divider(c, l)
1827
+ end
1828
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
1829
+ SetStyle.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
1830
+ end
1831
+ SetStyle.font(m["font"].to_i) if m["font"]
1832
+ end
1833
+
1834
+ sec.each do |k, v|
1835
+ next unless v.is_a?(Hash)
1836
+ c_val = v["color"] || v["border"] || v["options"] || v["focus"] || v["title"] || v["banner"] || v["subtitle"] || v["divider"]
1837
+ c, l = extract_color_and_level(c_val, (v["level"] || 1).to_i)
1838
+ case k
1839
+ when "border"
1840
+ SetStyle.border(c, l)
1841
+ when "options"
1842
+ SetStyle.options(c, l)
1843
+ when "focus"
1844
+ SetStyle.focus(c, l)
1845
+ when "title"
1846
+ SetStyle.title(c, l)
1847
+ when "banner"
1848
+ SetStyle.banner(c, l)
1849
+ when "subtitle"
1850
+ SetStyle.subtitle(c, l)
1851
+ when "divider"
1852
+ SetStyle.divider(c, l)
1853
+ end
1854
+ end
1855
+ SetStyle.font(glob["font"].to_i) if glob["font"]
1856
+ end
1857
+
1858
+ def self.style(css_content)
1859
+ parsed = parse_config_text(css_content)
1860
+ apply_parsed_theme(parsed)
1861
+ parsed
1862
+ end
1863
+
1864
+ def self.export_config(path = nil)
1865
+ if path.nil?
1866
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
1867
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
1868
+ path = "#{base}.gr"
1869
+ end
1870
+ lines = ["GRmenu::config<-1->", ""]
1871
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
1872
+ lines << "@author:: \"grcode\""
1873
+ lines << "@version:: \"1.0\""
1874
+ lines << ""
1875
+ lines << "<<menu"
1876
+ lines << " style:: 3"
1877
+ lines << " banner_style:: 3"
1878
+ lines << " font:: #{SetStyle.font}"
1879
+ lines << " animate:: rgb"
1880
+ lines << " center:: true"
1881
+ lines << " border:: #{SetStyle.border[:color]}:#{SetStyle.border[:level]}"
1882
+ lines << " title:: #{SetStyle.title[:color]}:#{SetStyle.title[:level]}"
1883
+ lines << " focus:: #{SetStyle.focus[:color]}:#{SetStyle.focus[:level]}"
1884
+ lines << " options:: #{SetStyle.options[:color]}:#{SetStyle.options[:level]}"
1885
+ lines << " banner:: #{SetStyle.banner[:color]}:#{SetStyle.banner[:level]}"
1886
+ lines << " subtitle:: #{SetStyle.subtitle[:color]}:#{SetStyle.subtitle[:level]}"
1887
+ lines << " divider:: #{SetStyle.divider[:color]}:#{SetStyle.divider[:level]}"
1888
+ lines << ">>"
1889
+ lines << ""
1890
+ lines << "<<table"
1891
+ lines << " style:: 3"
1892
+ lines << " header_color:: yellow:2"
1893
+ lines << " border_color:: rgb:2"
1894
+ lines << " selected_row:: green:2"
1895
+ lines << " row_color:: white:1"
1896
+ lines << " zebra_striping:: true"
1897
+ lines << ">>"
1898
+ lines << ""
1899
+ lines << "<<card"
1900
+ lines << " style:: 7"
1901
+ lines << " border_color:: cyan:2"
1902
+ lines << " title_color:: yellow:2"
1903
+ lines << " content_color:: white:1"
1904
+ lines << ">>"
1905
+ lines << ""
1906
+ lines << "<<slider"
1907
+ lines << " style:: 3"
1908
+ lines << " color:: rgb:2"
1909
+ lines << " fill_char:: █"
1910
+ lines << " empty_char:: ░"
1911
+ lines << ">>"
1912
+ lines << ""
1913
+ lines << "<<checkbox"
1914
+ lines << " style:: 3"
1915
+ lines << " color:: rgb:2"
1916
+ lines << " checked_mark:: [X]"
1917
+ lines << " unchecked_mark:: [ ]"
1918
+ lines << ">>"
1919
+ lines << ""
1920
+ File.write(path, lines.join("\n") + "\n")
1921
+ path
1922
+ end
1923
+
1924
+ def self.export_from_file(source_file, target_path = nil)
1925
+ raise "No existe #{source_file}" unless File.exist?(source_file)
1926
+ orig_draw = instance_method(:draw) rescue nil
1927
+ extracted = nil
1928
+ define_method(:draw) do |*|
1929
+ extracted = {
1930
+ style: @style,
1931
+ banner_style: @banner_style,
1932
+ font: @style_config&.font,
1933
+ animate: @animate,
1934
+ border: @style_config&.border,
1935
+ title: @style_config&.title,
1936
+ focus: @style_config&.focus,
1937
+ options: @style_config&.options,
1938
+ banner: @style_config&.banner,
1939
+ subtitle: @style_config&.subtitle,
1940
+ divider: @style_config&.divider
1941
+ }
1942
+ throw :grmenu_export_completed
1943
+ end
1944
+ begin
1945
+ catch(:grmenu_export_completed) do
1946
+ load(File.expand_path(source_file))
1947
+ end
1948
+ ensure
1949
+ define_method(:draw, orig_draw) if orig_draw
1950
+ end
1951
+ out = target_path || source_file.sub(/\.rb$/, '') + ".gr"
1952
+ if extracted && extracted[:border]
1953
+ lines = ["GRmenu::config<-1->", ""]
1954
+ lines << "@theme:: \"#{File.basename(out, '.gr').capitalize}\""
1955
+ lines << "@author:: \"grcode\""
1956
+ lines << "@version:: \"1.0\""
1957
+ lines << ""
1958
+ lines << "<<menu"
1959
+ lines << " style:: #{extracted[:style] || 3}"
1960
+ lines << " banner_style:: #{extracted[:banner_style] || 3}"
1961
+ lines << " font:: #{extracted[:font] || 1}"
1962
+ lines << " animate:: #{extracted[:animate] || 'rgb'}"
1963
+ lines << " center:: true"
1964
+ lines << " border:: #{extracted[:border][:color]}:#{extracted[:border][:level]}"
1965
+ lines << " title:: #{extracted[:title][:color]}:#{extracted[:title][:level]}"
1966
+ lines << " focus:: #{extracted[:focus][:color]}:#{extracted[:focus][:level]}"
1967
+ lines << " options:: #{extracted[:options][:color]}:#{extracted[:options][:level]}"
1968
+ lines << " banner:: #{extracted[:banner][:color]}:#{extracted[:banner][:level]}"
1969
+ lines << " subtitle:: #{extracted[:subtitle][:color]}:#{extracted[:subtitle][:level]}"
1970
+ lines << " divider:: #{extracted[:divider][:color]}:#{extracted[:divider][:level]}"
1971
+ lines << ">>"
1972
+ lines << ""
1973
+ lines << "<<table"
1974
+ lines << " style:: #{extracted[:style] || 3}"
1975
+ lines << " header_color:: yellow:2"
1976
+ lines << " border_color:: rgb:2"
1977
+ lines << " selected_row:: green:2"
1978
+ lines << " row_color:: white:1"
1979
+ lines << " zebra_striping:: true"
1980
+ lines << ">>"
1981
+ lines << ""
1982
+ lines << "<<card"
1983
+ lines << " style:: 7"
1984
+ lines << " border_color:: cyan:2"
1985
+ lines << " title_color:: yellow:2"
1986
+ lines << " content_color:: white:1"
1987
+ lines << ">>"
1988
+ lines << ""
1989
+ lines << "<<slider"
1990
+ lines << " style:: 3"
1991
+ lines << " color:: rgb:2"
1992
+ lines << " fill_char:: █"
1993
+ lines << " empty_char:: ░"
1994
+ lines << ">>"
1995
+ lines << ""
1996
+ lines << "<<checkbox"
1997
+ lines << " style:: 3"
1998
+ lines << " color:: rgb:2"
1999
+ lines << " checked_mark:: [X]"
2000
+ lines << " unchecked_mark:: [ ]"
2001
+ lines << ">>"
2002
+ lines << ""
2003
+ File.write(out, lines.join("\n") + "\n")
2004
+ out
2005
+ else
2006
+ export_config(out)
2007
+ end
2008
+ end
2009
+
2010
+ def self.split_ansi_chars(str)
2011
+ segments = []
2012
+ current_style = String.new("")
2013
+ in_escape = false
2014
+ escape_buf = String.new("")
2015
+
2016
+ str.to_s.each_char do |ch|
2017
+ if ch == "\e"
2018
+ in_escape = true
2019
+ escape_buf << ch
2020
+ next
2021
+ end
2022
+ if in_escape
2023
+ escape_buf << ch
2024
+ if ch =~ /[a-zA-Z]/
2025
+ in_escape = false
2026
+ current_style = escape_buf.dup
2027
+ escape_buf.clear
2028
+ end
2029
+ next
2030
+ end
2031
+ segments << { char: ch, style: current_style.dup }
2032
+ end
2033
+ segments
2034
+ end
2035
+
2036
+ def self.animate_render(lines, type = :diagonal, delay = 0.012)
2037
+ type_str = type.to_s.downcase
2038
+ return if lines.nil? || lines.empty?
2039
+ rst = ansi_reset
2040
+
2041
+ case type_str
2042
+ when "diagonal"
2043
+ parsed_rows = lines.map { |l| split_ansi_chars(l) }
2044
+ max_len = parsed_rows.map(&:length).max || 0
2045
+ total_steps = max_len + (parsed_rows.length * 2)
2046
+ step = 0
2047
+ while step <= total_steps
2048
+ buffer = String.new(CURSOR_HOME)
2049
+ parsed_rows.each_with_index do |row_segs, y|
2050
+ rendered_row = String.new("")
2051
+ row_segs.each_with_index do |seg, x|
2052
+ if (x + y * 2) <= step
2053
+ rendered_row << seg[:style] << seg[:char] << rst
2054
+ else
2055
+ rendered_row << " "
2056
+ end
2057
+ end
2058
+ buffer << rendered_row << CLEAR_TO_EOL << "\r\n"
2059
+ end
2060
+ buffer << CLEAR_TO_EOS
2061
+ Kernel.print(buffer)
2062
+ $stdout.flush
2063
+ sleep(delay)
2064
+ step += 4
2065
+ end
2066
+ when "linear"
2067
+ buffer = String.new(CURSOR_HOME)
2068
+ lines.each do |line|
2069
+ Kernel.print("#{line}#{CLEAR_TO_EOL}\r\n")
2070
+ $stdout.flush
2071
+ sleep(delay * 3)
2072
+ end
2073
+ when "fade"
2074
+ [1, 2].each do |lvl|
2075
+ buffer = String.new(CURSOR_HOME)
2076
+ lines.each do |line|
2077
+ clean = line.gsub(/\e\[[0-9;]*m/, '')
2078
+ buffer << ansi_color("white", lvl) << clean << rst << CLEAR_TO_EOL << "\r\n"
2079
+ end
2080
+ buffer << CLEAR_TO_EOS
2081
+ Kernel.print(buffer)
2082
+ $stdout.flush
2083
+ sleep(delay * 8)
2084
+ end
2085
+ end
2086
+ end
2087
+
2088
+ def self.alert(type, message, title: nil, style: 3, color: nil, border_color: nil, title_color: nil, pause: true)
2089
+ type_sym = type.to_sym rescue :info
2090
+ tag, def_col, def_title = case type_sym
2091
+ when :success, :ok
2092
+ ["[✔ EXITO]", "green", "Operacion Exitosa"]
2093
+ when :error, :fail, :danger
2094
+ ["[✖ ERROR]", "red", "Error en el Sistema"]
2095
+ when :warning, :warn
2096
+ ["[⚠ AVISO]", "yellow", "Advertencia"]
2097
+ else
2098
+ ["[ℹ INFO]", "cyan", "Informacion"]
2099
+ end
2100
+ card_col = border_color || color || def_col
2101
+ card_title = title || "#{tag} #{def_title}"
2102
+ card(title: card_title, content: message, style: style, color: card_col, title_color: title_color, pause: pause)
2103
+ end
2104
+
2105
+ def self.card(title_or_content = nil, content_arg = nil, title: nil, content: nil, style: nil, color: nil, border_color: nil, title_color: nil, content_color: nil, width: nil, pause: false)
2106
+ c_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "card")) || {}
2107
+ actual_title = title || (content_arg ? title_or_content : nil)
2108
+ actual_content = (content || (content_arg ? content_arg : title_or_content) || "").to_s
2109
+ style_num = (style || c_sec["style"] || 7).to_i
2110
+ border_cfg = BORDERS[style_num] || BORDERS[7]
2111
+ card_color = (border_color || color || c_sec["border_color"] || c_sec["color"] || "cyan").to_s
2112
+ actual_title_color = (title_color || c_sec["title_color"] || "yellow").to_s
2113
+ actual_content_color = (content_color || c_sec["content_color"] || "white").to_s
2114
+ is_rgb = card_color.downcase == "rgb" || card_color.downcase == "rainbow" || card_color.downcase == "chroma"
2115
+
2116
+ lines = actual_content.split("\n")
2117
+ content_max = lines.map { |l| display_width(l) }.max || 0
2118
+ box_w = width || [content_max + 6, actual_title ? display_width(actual_title) + 6 : 0, 46].max
2119
+ box_w = [box_w, terminal_width - 2].min
2120
+ inner_w = box_w - 2
2121
+
2122
+ wrapped_lines = []
2123
+ lines.each do |raw_l|
2124
+ if display_width(raw_l) <= (inner_w - 2)
2125
+ wrapped_lines << raw_l
2126
+ else
2127
+ cur = String.new("")
2128
+ raw_l.split(" ").each do |w|
2129
+ if cur.empty?
2130
+ cur << w
2131
+ elsif display_width("#{cur} #{w}") <= (inner_w - 2)
2132
+ cur << " " << w
2133
+ else
2134
+ wrapped_lines << cur
2135
+ cur = String.new(w)
2136
+ end
2137
+ end
2138
+ wrapped_lines << cur unless cur.empty?
2139
+ end
2140
+ end
2141
+
2142
+ tl = border_cfg[:tl] || "#"
2143
+ tr = border_cfg[:tr] || "#"
2144
+ bl = border_cfg[:bl] || "#"
2145
+ br = border_cfg[:br] || "#"
2146
+ h_char = border_cfg[:h] || "─"
2147
+ v_char = border_cfg[:v] || "│"
2148
+
2149
+ brd_col = is_rgb ? Color.rgb("").sub(/\e\[0m$/, '') : ansi_color(card_color, 1)
2150
+ rst = ansi_reset
2151
+
2152
+ top_str = if actual_title && !actual_title.empty?
2153
+ t_clean = " #{actual_title} "
2154
+ t_len = display_width(t_clean)
2155
+ if t_len > inner_w
2156
+ t_clean = " #{actual_title[0...[inner_w - 6, 1].max]}... "
2157
+ t_len = display_width(t_clean)
2158
+ end
2159
+ l_len = [(inner_w - t_len) / 2, 0].max
2160
+ r_len = [inner_w - t_len - l_len, 0].max
2161
+ h_char * l_len + ansi_color(actual_title_color, 2) + t_clean + brd_col + h_char * r_len
2162
+ else
2163
+ h_char * inner_w
2164
+ end
2165
+
2166
+ out = +""
2167
+ out << "#{brd_col}#{tl}#{top_str}#{tr}#{rst}\r\n"
2168
+ wrapped_lines.each do |line|
2169
+ pad_line = " " + line
2170
+ out << "#{brd_col}#{v_char}#{rst}#{ansi_color(actual_content_color, 1)}#{pad_to_width(pad_line, inner_w)}#{rst}#{brd_col}#{v_char}#{rst}\r\n"
2171
+ end
2172
+ out << "#{brd_col}#{bl}#{h_char * inner_w}#{br}#{rst}\r\n"
2173
+
2174
+ Kernel.print(out)
2175
+ self.continue if pause
2176
+ end
2177
+
2178
+ def self.table(headers_arg = nil, rows_arg = nil, headers: nil, rows: nil, title: nil, style: nil, color: nil, header_color: nil, border_color: nil, selected_row: nil, page_size: nil, search: false, sort: false, animate: nil, width: nil, **kwargs)
2179
+ t_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "table")) || {}
2180
+ input_stream = $stdin
2181
+ output_stream = $stdout
2182
+ style_num = (style || t_sec["style"] || 3).to_i
2183
+ border_cfg = BORDERS[style_num] || BORDERS[3]
2184
+ tbl_color = (border_color || color || t_sec["border_color"] || t_sec["border"] || t_sec["color"] || "cyan").to_s
2185
+ header_color = header_color || t_sec["header_color"] || "yellow"
2186
+ focus_color = selected_row || t_sec["selected_row"] || t_sec["focus"] || "green"
2187
+ page_size ||= (t_sec["page_size"] || 8).to_i
2188
+
2189
+ resolved_headers = (headers || headers_arg || []).map(&:to_s)
2190
+ resolved_raw_rows = (rows || rows_arg || []).map { |r| r.is_a?(Array) ? r.map(&:to_s) : r.values.map(&:to_s) }
2191
+ headers = resolved_headers
2192
+ raw_rows = resolved_raw_rows
2193
+ filtered_rows = raw_rows.dup
2194
+ selected_idx = 0
2195
+ query = String.new("")
2196
+ sort_col = nil
2197
+ sort_asc = true
2198
+ tick = 0.0
2199
+
2200
+ calc_widths = lambda do
2201
+ col_counts = [headers.length, raw_rows.map(&:length).max || 0].max
2202
+ widths = Array.new(col_counts, 0)
2203
+ headers.each_with_index { |h, i| widths[i] = [widths[i], display_width(h)].max }
2204
+ filtered_rows.each do |row|
2205
+ row.each_with_index { |cell, i| widths[i] = [widths[i], display_width(cell)].max }
2206
+ end
2207
+ widths.map { |w| w + 2 }
2208
+ end
2209
+
2210
+ draw_table = lambda do |t_tick|
2211
+ is_rgb = tbl_color.downcase == "rgb" || tbl_color.downcase == "rainbow" || tbl_color.downcase == "chroma"
2212
+ brd_color = is_rgb ? rgb_color(t_tick, 0.0) : ansi_color(tbl_color, 1)
2213
+ hdr_color = is_rgb ? rgb_color(t_tick, 0.8) : ansi_color(header_color, 2)
2214
+ foc_color = is_rgb ? rgb_color(t_tick, 1.4) : ansi_color(focus_color, 2)
2215
+ rst = ansi_reset
2216
+
2217
+ col_w = calc_widths.call
2218
+ help_line = " ↑/↓: Moverse | Enter: Elegir | s: Ordenar | Esc: Salir"
2219
+ tot_w = [col_w.sum + (col_w.length - 1) + 4, title ? display_width(title) + 8 : 0, display_width(help_line) + 4, 46].max
2220
+ tot_w = [tot_w, terminal_width - 2].min
2221
+ inner_w = tot_w - 2
2222
+
2223
+ if inner_w < display_width(help_line)
2224
+ help_line = " ↑/↓: Mover | Enter: Ok | Esc: Salir"
2225
+ end
2226
+
2227
+ tl = border_cfg[:tl] || "#"
2228
+ tr = border_cfg[:tr] || "#"
2229
+ bl = border_cfg[:bl] || "#"
2230
+ br = border_cfg[:br] || "#"
2231
+ h_char = border_cfg[:h] || "─"
2232
+ v_char = border_cfg[:v] || "│"
2233
+
2234
+ top_str = if title && !title.empty?
2235
+ t_clean = " #{title} "
2236
+ t_len = display_width(t_clean)
2237
+ if t_len > inner_w
2238
+ t_clean = " #{title[0...[(inner_w - 6), 1].max]}... "
2239
+ t_len = display_width(t_clean)
2240
+ end
2241
+ left_len = [(inner_w - t_len) / 2, 0].max
2242
+ right_len = [inner_w - t_len - left_len, 0].max
2243
+ h_char * left_len + t_clean + h_char * right_len
2244
+ else
2245
+ h_char * inner_w
2246
+ end
2247
+
2248
+ out = String.new(CURSOR_HOME)
2249
+ out << HIDE_CURSOR
2250
+ out << "#{brd_color}#{tl}#{top_str}#{tr}#{rst}#{CLEAR_TO_EOL}\r\n"
2251
+
2252
+ if search
2253
+ s_line = " Buscar: #{query}█"
2254
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(s_line, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2255
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2256
+ end
2257
+
2258
+ hdr_cells = headers.each_with_index.map do |h, i|
2259
+ w = col_w[i] || 10
2260
+ sort_indicator = sort_col == i ? (sort_asc ? " ▲" : " ▼") : ""
2261
+ h_str = "#{h}#{sort_indicator}"
2262
+ max_c = [w - 2, 2].max
2263
+ h_str = h_str[0...[max_c - 2, 1].max] + ".." if display_width(h_str) > max_c
2264
+ pad_to_width(" #{h_str}", w)
2265
+ end
2266
+ hdr_row_str = " " + hdr_cells.join("│")
2267
+ hdr_row_str = hdr_row_str[0...inner_w] if display_width(hdr_row_str) > inner_w
2268
+ out << "#{brd_color}#{v_char}#{rst}#{hdr_color}#{pad_to_width(hdr_row_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2269
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2270
+
2271
+ max_visible = page_size || 8
2272
+ total_rows = filtered_rows.length
2273
+ if total_rows == 0
2274
+ empty_msg = " (Sin registros que coincidan con '#{query}')"
2275
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(empty_msg, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2276
+ else
2277
+ start_idx = [(selected_idx - max_visible / 2), 0].max
2278
+ start_idx = [start_idx, [total_rows - max_visible, 0].max].min
2279
+ end_idx = [start_idx + max_visible - 1, total_rows - 1].min
2280
+
2281
+ if start_idx > 0
2282
+ up_str = " ▲ (+#{start_idx} arriba)"
2283
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(up_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2284
+ end
2285
+
2286
+ (start_idx..end_idx).each do |r_i|
2287
+ row = filtered_rows[r_i]
2288
+ is_active = (r_i == selected_idx)
2289
+ prefix = is_active ? "> " : " "
2290
+
2291
+ row_cells = row.each_with_index.map do |cell, c_i|
2292
+ w = col_w[c_i] || 10
2293
+ c_str = cell.to_s
2294
+ max_c = [w - 2, 2].max
2295
+ c_str = c_str[0...[max_c - 2, 1].max] + ".." if display_width(c_str) > max_c
2296
+ pad_to_width(" #{c_str}", w)
2297
+ end
2298
+ row_str = prefix + row_cells.join("│")[1..-1].to_s
2299
+ row_str = row_str[0...inner_w] if display_width(row_str) > inner_w
2300
+
2301
+ if is_active
2302
+ out << "#{brd_color}#{v_char}#{rst}#{foc_color}#{pad_to_width(row_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2303
+ else
2304
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(row_str, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2305
+ end
2306
+ end
2307
+
2308
+ remaining_down = total_rows - 1 - end_idx
2309
+ if remaining_down > 0
2310
+ down_str = " ▼ (+#{remaining_down} abajo)"
2311
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(down_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2312
+ end
2313
+ end
2314
+
2315
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2316
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(help_line, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2317
+ out << "#{brd_color}#{bl}#{h_char * inner_w}#{br}#{rst}#{CLEAR_TO_EOL}\r\n"
2318
+ out << CLEAR_TO_EOS
2319
+ output_stream.print(out)
2320
+ output_stream.flush
2321
+ end
2322
+
2323
+ loop_res = nil
2324
+ reader = lambda do |stream|
2325
+ loop do
2326
+ draw_table.call(tick)
2327
+ is_anim = color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
2328
+ if is_anim
2329
+ ready = false
2330
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
2331
+ begin
2332
+ res = IO.select([stream], nil, nil, 0.035)
2333
+ ready = true if res && res[0] && !res[0].empty?
2334
+ rescue StandardError
2335
+ ready = true
2336
+ end
2337
+ else
2338
+ ready = true
2339
+ end
2340
+ unless ready
2341
+ tick += 0.08
2342
+ next
2343
+ end
2344
+ end
2345
+
2346
+ key = read_key_raw(stream)
2347
+ break if key.nil? || key == "\x03" || key == "\x04"
2348
+
2349
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
2350
+ if filtered_rows.length > 0
2351
+ selected_idx = (selected_idx - 1) % filtered_rows.length
2352
+ end
2353
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
2354
+ if filtered_rows.length > 0
2355
+ selected_idx = (selected_idx + 1) % filtered_rows.length
2356
+ end
2357
+ elsif key == "\e[5~" || key == "\e[D"
2358
+ if filtered_rows.length > 0
2359
+ selected_idx = [(selected_idx - (page_size || 8)), 0].max
2360
+ end
2361
+ elsif key == "\e[6~" || key == "\e[C"
2362
+ if filtered_rows.length > 0
2363
+ selected_idx = [(selected_idx + (page_size || 8)), filtered_rows.length - 1].min
2364
+ end
2365
+ elsif key == "\r" || key == "\n"
2366
+ if filtered_rows.length > 0
2367
+ loop_res = filtered_rows[selected_idx]
2368
+ end
2369
+ break
2370
+ elsif key == "\e"
2371
+ if search && !query.empty?
2372
+ query.clear
2373
+ filtered_rows = raw_rows.dup
2374
+ selected_idx = 0
2375
+ else
2376
+ loop_res = nil
2377
+ break
2378
+ end
2379
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
2380
+ if search && !query.empty?
2381
+ query.chop!
2382
+ if query.empty?
2383
+ filtered_rows = raw_rows.dup
2384
+ else
2385
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2386
+ end
2387
+ selected_idx = 0
2388
+ end
2389
+ elsif key == "\x15"
2390
+ if search
2391
+ query.clear
2392
+ filtered_rows = raw_rows.dup
2393
+ selected_idx = 0
2394
+ end
2395
+ elsif (!search || query.empty?) && (key == "s" || key == "S")
2396
+ if sort
2397
+ sort_col = ((sort_col || -1) + 1) % [headers.length, 1].max
2398
+ filtered_rows.sort_by! { |r| r[sort_col] || "" }
2399
+ selected_idx = 0
2400
+ end
2401
+ elsif (!search || query.empty?) && (key == "q" || key == "Q")
2402
+ loop_res = nil
2403
+ break
2404
+ elsif search && key =~ /^[[:print:]]$/
2405
+ query << key
2406
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2407
+ selected_idx = 0
2408
+ end
2409
+ end
2410
+ end
2411
+
2412
+ begin
2413
+ output_stream.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2414
+ if input_stream.respond_to?(:raw) && input_stream.respond_to?(:tty?) && input_stream.tty?
2415
+ input_stream.raw { |s| reader.call(s) }
2416
+ else
2417
+ reader.call(input_stream)
2418
+ end
2419
+ ensure
2420
+ output_stream.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2421
+ end
2422
+ loop_res
2423
+ end
2424
+
2425
+ def self.div(long = nil, color = "blue", level = 1, char = "─")
2426
+ width = long || [terminal_width - 2, 64].min
2427
+ if color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
2428
+ Kernel.print("#{Color.rgb(char * width)}\r\n")
2429
+ else
2430
+ color_code = ansi_color(color, level)
2431
+ reset_code = ansi_reset
2432
+ Kernel.print("#{color_code}#{char * width}#{reset_code}\r\n")
2433
+ end
2434
+ end
2435
+
2436
+ def self.help(section = :all)
2437
+ path = find_data_file("help.txt")
2438
+ return unless path && File.exist?(path)
2439
+ content = File.read(path)
2440
+ COLORS.each do |c_name, lvls|
2441
+ if lvls.is_a?(Hash)
2442
+ content.gsub!("{#{c_name}}", ansi_color(c_name, 1))
2443
+ content.gsub!("{bright_#{c_name}}", ansi_color(c_name, 2))
2444
+ end
2445
+ end
2446
+ content.gsub!("{reset}", ansi_reset)
2447
+ Kernel.print("\r\n#{content}\r\n")
2448
+ end
2449
+
2450
+ def help
2451
+ self.class.help
2452
+ end
2453
+
2454
+ def self.continue(text = "Presiona cualquier tecla para continuar...")
2455
+ Kernel.print("#{Color.gray(text)} ")
2456
+ if $stdin.respond_to?(:raw) && $stdin.respond_to?(:tty?) && $stdin.tty?
2457
+ $stdin.raw(&:getch)
2458
+ elsif $stdin.respond_to?(:getch)
2459
+ $stdin.getch
2460
+ else
2461
+ $stdin.read(1)
2462
+ end
2463
+ Kernel.print("\r\n")
2464
+ end
2465
+
2466
+ def self.build_ascii_lines(text, max_cols = terminal_width, font_id = 1)
2467
+ target_font = FONTS[font_id.to_i] || FONTS[1]
2468
+ clean_chars = text.to_s.upcase.chars.select { |c| target_font.key?(c) }
2469
+ return [] if clean_chars.empty?
2470
+
2471
+ font_height = target_font.values.first.length
2472
+
2473
+ [2, 1, 0].each do |spacing|
2474
+ lines = Array.new(font_height, "")
2475
+ clean_chars.each_with_index do |c, idx|
2476
+ fig = target_font[c]
2477
+ pad = (idx == clean_chars.length - 1) ? "" : (" " * spacing)
2478
+ font_height.times { |i| lines[i] += fig[i] + pad }
2479
+ end
2480
+
2481
+ max_len = lines.map { |l| display_width(l) }.max
2482
+ return lines if (max_len + 6) <= max_cols
2483
+ end
2484
+
2485
+ nil
2486
+ end
2487
+
2488
+ def self.banner(text, delay = 0, color: "magenta", level: 2, style: 3, font: 1)
2489
+ cols = terminal_width
2490
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
2491
+ color_code = is_rgb ? "" : ansi_color(color, level)
2492
+ reset_code = ansi_reset
2493
+
2494
+ ascii_rows = build_ascii_lines(text, cols, font)
2495
+ border_cfg = BORDERS[style] || BORDERS[3]
2496
+ h_top = border_cfg[:ht] || border_cfg[:h]
2497
+ h_bot = border_cfg[:hb] || border_cfg[:h]
2498
+ v_l = border_cfg[:vl] || border_cfg[:v]
2499
+ v_r = border_cfg[:vr] || border_cfg[:v]
2500
+
2501
+ if ascii_rows
2502
+ max_len = ascii_rows.map { |r| display_width(r) }.max
2503
+ top_fill = (h_top * ((max_len + 4).to_f / h_top.length).ceil)[0...(max_len + 4)]
2504
+ bot_fill = (h_bot * ((max_len + 4).to_f / h_bot.length).ceil)[0...(max_len + 4)]
2505
+
2506
+ if is_rgb
2507
+ Kernel.print("#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
2508
+ ascii_rows.each_with_index do |line, idx|
2509
+ pad = " " * (max_len - display_width(line))
2510
+ row_content = " #{line}#{pad} "
2511
+ Kernel.print("#{Color.rgb(v_l)}#{Color.rgb(row_content, idx * 0.2)}#{Color.rgb(v_r)}\r\n")
2512
+ sleep(delay) if delay > 0
2513
+ end
2514
+ Kernel.print("#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
2515
+ else
2516
+ Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
2517
+ ascii_rows.each do |line|
2518
+ pad = " " * (max_len - display_width(line))
2519
+ Kernel.print("#{color_code}#{v_l} #{line}#{pad} #{v_r}#{reset_code}\r\n")
2520
+ sleep(delay) if delay > 0
2521
+ end
2522
+ Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
2523
+ end
2524
+ else
2525
+ clean_t = text.to_s.strip
2526
+ box_w = [display_width(clean_t) + 6, cols - 2].min
2527
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
2528
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
2529
+
2530
+ pad_t = [box_w - 4 - display_width(clean_t), 0].max
2531
+ l_p = " " * (pad_t / 2)
2532
+ r_p = " " * (pad_t - (pad_t / 2))
2533
+
2534
+ if is_rgb
2535
+ Kernel.print("#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
2536
+ Kernel.print("#{Color.rgb(v_l)} #{Color.rgb(l_p + clean_t + r_p)} #{Color.rgb(v_r)}\r\n")
2537
+ Kernel.print("#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
2538
+ else
2539
+ Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
2540
+ Kernel.print("#{color_code}#{v_l} #{l_p}#{clean_t}#{r_p} #{v_r}#{reset_code}\r\n")
2541
+ Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
2542
+ end
2543
+ end
2544
+ end
2545
+
2546
+ class << self
2547
+ alias_method :message, :banner
2548
+ alias_method :logo, :banner
2549
+
2550
+ def tabs(tabs_hash, *args, **kwargs)
2551
+ new([], *args, tabs: tabs_hash, **kwargs)
2552
+ end
2553
+ end
2554
+
2555
+ 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, mouse: nil, tabs: nil, active_tab_color: nil, tab_color: nil, **keyword_arguments)
2556
+ tabs_data = tabs || keyword_arguments[:tabs] || (functions.is_a?(Hash) ? functions : nil)
2557
+ if tabs_data.is_a?(Hash) && !tabs_data.empty?
2558
+ @tabs = tabs_data.keys.map(&:to_s)
2559
+ @tab_contents = tabs_data.transform_keys(&:to_s)
2560
+ @active_tab_idx = 0
2561
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
2562
+ else
2563
+ @tabs = nil
2564
+ @tab_contents = nil
2565
+ @active_tab_idx = nil
2566
+ @functions = functions.is_a?(Array) ? functions : Array(functions)
2567
+ end
2568
+
2569
+ pos_title = positional_arguments[0]
2570
+ pos_style = positional_arguments[1]
2571
+
2572
+ @title = (title || pos_title || keyword_arguments[:title] || "").to_s
2573
+ @banner = (banner || keyword_arguments[:banner] || "").to_s
2574
+ @subtitle = (subtitle || description || keyword_arguments[:subtitle] || keyword_arguments[:description] || "").to_s
2575
+ @divider = divider.nil? ? (!@banner.empty? || !@subtitle.empty?) : divider
2576
+
2577
+ theme_menu_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu')) || {}
2578
+ th_style = theme_menu_sec['style']&.to_i
2579
+ th_bstyle = theme_menu_sec['banner_style']&.to_i
2580
+
2581
+ @style = (style || pos_style || keyword_arguments[:style] || th_style || 19).to_i
2582
+ @banner_style = (banner_style || keyword_arguments[:banner_style] || th_bstyle || 3).to_i
2583
+ @center = center.nil? ? (theme_menu_sec.key?('center') ? (theme_menu_sec['center'].to_s != 'false') : true) : center
2584
+ @page_size = (page_size || keyword_arguments[:page_size])&.to_i
2585
+ @search = search || keyword_arguments[:search] || false
2586
+ @columns = [(columns || keyword_arguments[:columns] || 1).to_i, 1].max
2587
+ @image = image || keyword_arguments[:image]
2588
+ @image_width = (image_width || keyword_arguments[:image_width])&.to_i
2589
+ @animate = (keyword_arguments[:animate] || (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu', 'animate')) || false).to_s
2590
+ @desc_prefix = keyword_arguments[:desc_prefix] || keyword_arguments[:description_prefix]
2591
+ @mouse = (mouse == true || keyword_arguments[:mouse] == true || mouse.to_s == 'true' || keyword_arguments[:mouse].to_s == 'true' || (theme_menu_sec['mouse'].to_s == 'true'))
2592
+
2593
+ t_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "tabs")) || {}
2594
+ @active_tab_color = (active_tab_color || keyword_arguments[:active_tab_color] || t_sec["active_tab"] || t_sec["active_tab_color"] || "yellow").to_s
2595
+ @tab_color = (tab_color || keyword_arguments[:tab_color] || t_sec["tab_color"] || t_sec["inactive_tab"] || t_sec["color"] || "gray").to_s
2596
+
2597
+ @query = String.new("")
2598
+ @index = 0
2599
+ @rgb_tick = 0.0
2600
+
2601
+ @level = 0
2602
+ @open_level = 0
2603
+ @sub_index_1 = 0
2604
+ @sub_index_2 = 0
2605
+ @sub_1_hit_map = {}
2606
+ @sub_2_hit_map = {}
2607
+ @sub_1_col_rng = nil
2608
+ @sub_2_col_rng = nil
2609
+
2610
+ @active_panel = :main
2611
+ @sub_index = 0
2612
+ @submenu_open = false
2613
+
2614
+ @row_hit_map = {}
2615
+ @tab_ranges = {}
2616
+ @tabs_row = nil
2617
+ @up_arrow_row = nil
2618
+ @down_arrow_row = nil
2619
+ @sub_hit_map = {}
2620
+
2621
+ @cached_image_lines = nil
2622
+ @cached_image_cols = nil
2623
+
2624
+ init_font = font || keyword_arguments[:font_style] || SetStyle.font || 1
2625
+ init_pfx = @desc_prefix || (@@global_theme.is_a?(Hash) && (@@global_theme.dig(:sections, 'menu', 'desc_prefix') || @@global_theme.dig(:sections, 'menu', 'prefix'))) || SetStyle.desc_prefix || "[i]"
2626
+
2627
+ @style_config = SetStyle.new(
2628
+ border: SetStyle.border.dup,
2629
+ options: SetStyle.options.dup,
2630
+ focus: SetStyle.focus.dup,
2631
+ title: SetStyle.title.dup,
2632
+ banner: SetStyle.banner.dup,
2633
+ subtitle: SetStyle.subtitle.dup,
2634
+ divider: SetStyle.divider.dup,
2635
+ font: init_font,
2636
+ desc_prefix: init_pfx
2637
+ )
2638
+ end
2639
+
2640
+ def current_matching_indices
2641
+ if @search && !@query.empty?
2642
+ indices = []
2643
+ @functions.each_with_index do |func, idx|
2644
+ name = extract_name_from_action(func)
2645
+ indices << idx if name.downcase.include?(@query.downcase)
2646
+ end
2647
+ indices
2648
+ else
2649
+ (0...@functions.length).to_a
2650
+ end
2651
+ end
2652
+
2653
+ def move_up
2654
+ matching = current_matching_indices
2655
+ return @index if matching.empty?
2656
+ cols = @columns
2657
+ pos = matching.index(@index) || 0
2658
+ if cols <= 1
2659
+ new_pos = (pos - 1) % matching.length
2660
+ else
2661
+ new_pos = pos - cols
2662
+ if new_pos < 0
2663
+ new_pos = pos
2664
+ while (new_pos + cols) < matching.length
2665
+ new_pos += cols
2666
+ end
2667
+ end
2668
+ end
2669
+ @index = matching[new_pos]
2670
+ end
2671
+ alias_method :_up, :move_up
2672
+
2673
+ def move_down
2674
+ matching = current_matching_indices
2675
+ return @index if matching.empty?
2676
+ cols = @columns
2677
+ pos = matching.index(@index) || 0
2678
+ if cols <= 1
2679
+ new_pos = (pos + 1) % matching.length
2680
+ else
2681
+ new_pos = pos + cols
2682
+ if new_pos >= matching.length
2683
+ new_pos = pos % cols
2684
+ end
2685
+ end
2686
+ @index = matching[new_pos]
2687
+ end
2688
+ alias_method :_down, :move_down
2689
+
2690
+ def move_left
2691
+ matching = current_matching_indices
2692
+ return @index if matching.empty?
2693
+ cols = @columns
2694
+ pos = matching.index(@index) || 0
2695
+ if cols <= 1
2696
+ new_pos = (pos - 1) % matching.length
2697
+ else
2698
+ if (pos % cols) == 0
2699
+ new_pos = [pos + (cols - 1), matching.length - 1].min
2700
+ else
2701
+ new_pos = pos - 1
2702
+ end
2703
+ end
2704
+ @index = matching[new_pos]
2705
+ end
2706
+
2707
+ def move_right
2708
+ matching = current_matching_indices
2709
+ return @index if matching.empty?
2710
+ cols = @columns
2711
+ pos = matching.index(@index) || 0
2712
+ if cols <= 1
2713
+ new_pos = (pos + 1) % matching.length
2714
+ else
2715
+ if (pos % cols) == (cols - 1) || pos == (matching.length - 1)
2716
+ new_pos = pos - (pos % cols)
2717
+ else
2718
+ new_pos = pos + 1
2719
+ end
2720
+ end
2721
+ @index = matching[new_pos]
2722
+ end
2723
+
2724
+ def colorize(text, color_config, phase_offset = 0.0)
2725
+ return text.to_s if color_config.nil? || color_config.empty?
2726
+
2727
+ color_name = (color_config[:color] || color_config["color"]).to_s.downcase.strip
2728
+ brightness_level = (color_config[:level] || color_config["level"] || 1).to_i
2729
+
2730
+ if color_name.include?(":")
2731
+ parts = color_name.split(":")
2732
+ color_name = parts[0].strip
2733
+ brightness_level = parts[1].to_i if parts[1] && !parts[1].empty?
2734
+ end
2735
+
2736
+ is_neon_color = color_name.start_with?("neon")
2737
+ is_anim_active = is_neon_color || (@animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase))
2738
+ is_chroma = color_name == "rgb" || color_name == "rainbow" || color_name == "chroma" || @animate.to_s.downcase == "rgb"
2739
+
2740
+ if is_chroma
2741
+ tick = @rgb_tick || 0.0
2742
+ out = String.new("")
2743
+ char_count = 0
2744
+ in_escape = false
2745
+ escape_buf = String.new("")
2746
+
2747
+ text.to_s.each_char do |ch|
2748
+ if ch == "\e"
2749
+ in_escape = true
2750
+ escape_buf << ch
2751
+ next
2752
+ end
2753
+ if in_escape
2754
+ escape_buf << ch
2755
+ if ch =~ /[a-zA-Z]/
2756
+ in_escape = false
2757
+ out << escape_buf
2758
+ escape_buf.clear
2759
+ end
2760
+ next
2761
+ end
2762
+
2763
+ if ch == " " || ch == "\t" || ch == "\r" || ch == "\n"
2764
+ out << ch
2765
+ else
2766
+ c_code = self.class.rgb_color(tick, char_count * 0.12 + phase_offset)
2767
+ out << "#{c_code}#{ch}"
2768
+ char_count += 1
2769
+ end
2770
+ end
2771
+ out << self.class.ansi_reset
2772
+ return out
2773
+ end
2774
+
2775
+ if is_anim_active
2776
+ tick = @rgb_tick || 0.0
2777
+ base = if color_name =~ /\A#?([0-9a-f]{6})\z/i
2778
+ h = $1
2779
+ [h[0..1].to_i(16), h[2..3].to_i(16), h[4..5].to_i(16)]
2780
+ elsif color_name =~ /\A#?([0-9a-f]{3})\z/i
2781
+ h = $1
2782
+ [(h[0] * 2).to_i(16), (h[1] * 2).to_i(16), (h[2] * 2).to_i(16)]
2783
+ else
2784
+ BASE_RGB[color_name] || [255, 255, 255]
2785
+ end
2786
+
2787
+ if @animate.to_s.downcase == "fade"
2788
+ factor = (Math.sin(tick + phase_offset) + 1.0) / 2.0
2789
+ f = 0.35 + 0.65 * factor
2790
+ r = (base[0] * f).clamp(0, 255).to_i
2791
+ g = (base[1] * f).clamp(0, 255).to_i
2792
+ b = (base[2] * f).clamp(0, 255).to_i
2793
+ glow = (factor > 0.85) ? ";1" : ""
2794
+ return "\e[38;2;#{r};#{g};#{b}#{glow}m#{text}#{self.class.ansi_reset}"
2795
+ else
2796
+ out = String.new("")
2797
+ char_count = 0
2798
+ in_escape = false
2799
+ escape_buf = String.new("")
2800
+
2801
+ text.to_s.each_char do |ch|
2802
+ if ch == "\e"
2803
+ in_escape = true
2804
+ escape_buf << ch
2805
+ next
2806
+ end
2807
+ if in_escape
2808
+ escape_buf << ch
2809
+ if ch =~ /[a-zA-Z]/
2810
+ in_escape = false
2811
+ out << escape_buf
2812
+ escape_buf.clear
2813
+ end
2814
+ next
2815
+ end
2816
+
2817
+ if ch == " " || ch == "\t" || ch == "\r" || ch == "\n"
2818
+ out << ch
2819
+ else
2820
+ phase = char_count * 0.22 + phase_offset
2821
+ factor = (Math.sin(tick + phase) + 1.0) / 2.0
2822
+ f = 0.35 + 0.65 * factor
2823
+ r = (base[0] * f).clamp(0, 255).to_i
2824
+ g = (base[1] * f).clamp(0, 255).to_i
2825
+ b = (base[2] * f).clamp(0, 255).to_i
2826
+ glow = (factor > 0.85) ? ";1" : ""
2827
+ out << "\e[38;2;#{r};#{g};#{b}#{glow}m#{ch}"
2828
+ char_count += 1
2829
+ end
2830
+ end
2831
+ out << self.class.ansi_reset
2832
+ return out
2833
+ end
2834
+ end
2835
+
2836
+ color_code = self.class.ansi_color(color_name, brightness_level)
2837
+ return text.to_s unless color_code
2838
+
2839
+ "#{color_code}#{text}#{self.class.ansi_reset}"
2840
+ end
2841
+ alias_method :_colorize, :colorize
2842
+
2843
+ def build_horizontal_line(pattern, target_width)
2844
+ return "" if target_width <= 0 || pattern.nil? || pattern.empty?
2845
+
2846
+ pattern_length = pattern.length
2847
+ repetitions_needed = (target_width.to_f / pattern_length).ceil + 1
2848
+ (pattern * repetitions_needed)[0...target_width]
2849
+ end
2850
+ alias_method :_hline, :build_horizontal_line
2851
+
2852
+ def render_banner_lines(term_cols)
2853
+ return [[], 0] if @banner.nil? || @banner.empty?
2854
+
2855
+ font_id = @style_config.font || 1
2856
+ ascii_rows = self.class.build_ascii_lines(@banner, term_cols, font_id)
2857
+ banner_border = BORDERS[@banner_style] || BORDERS[3]
2858
+ banner_color_cfg = @style_config.banner
2859
+
2860
+ h_top = banner_border[:ht] || banner_border[:h]
2861
+ h_bot = banner_border[:hb] || banner_border[:h]
2862
+ v_l = banner_border[:vl] || banner_border[:v]
2863
+ v_r = banner_border[:vr] || banner_border[:v]
2864
+
2865
+ lines = []
2866
+ box_w = 0
2867
+ if ascii_rows
2868
+ content_w = ascii_rows.map { |r| GRmenu.display_width(r) }.max
2869
+ box_w = content_w + 6
2870
+ top_fill = build_horizontal_line(h_top, content_w + 4)
2871
+ bot_fill = build_horizontal_line(h_bot, content_w + 4)
2872
+
2873
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
2874
+ ascii_rows.each_with_index do |row, r_i|
2875
+ pad = " " * (content_w - GRmenu.display_width(row))
2876
+ lines << colorize("#{v_l} #{row}#{pad} #{v_r}", banner_color_cfg, r_i * 0.2)
2877
+ end
2878
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 1.2)
2879
+ else
2880
+ clean_b = @banner.strip
2881
+ b_vis_w = GRmenu.display_width(clean_b)
2882
+ box_w = [b_vis_w + 6, term_cols - 2].min
2883
+ top_fill = build_horizontal_line(h_top, box_w - 2)
2884
+ bot_fill = build_horizontal_line(h_bot, box_w - 2)
2885
+
2886
+ pad_b = [box_w - 4 - b_vis_w, 0].max
2887
+ l_p = " " * (pad_b / 2)
2888
+ r_p = " " * (pad_b - (pad_b / 2))
2889
+
2890
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
2891
+ lines << colorize("#{v_l} #{l_p}#{clean_b}#{r_p} #{v_r}", banner_color_cfg, 0.4)
2892
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 0.8)
2893
+ end
2894
+ [lines, box_w]
2895
+ end
2896
+
2897
+ def render_image_lines(term_cols)
2898
+ return @cached_image_lines if @cached_image_lines && @cached_image_cols == term_cols
2899
+
2900
+ return [[], 0] unless @image && File.exist?(@image)
2901
+
2902
+ raw_lines = self.class.load_and_render_image(@image, @image_width || 40, nil, term_cols)
2903
+ return [[], 0] if raw_lines.empty?
2904
+
2905
+ img_w = self.class.display_width(raw_lines.first)
2906
+ box_w = img_w + 4
2907
+ banner_border = BORDERS[@banner_style] || BORDERS[3]
2908
+ banner_color_cfg = @style_config.banner
2909
+
2910
+ h_top = banner_border[:ht] || banner_border[:h]
2911
+ h_bot = banner_border[:hb] || banner_border[:h]
2912
+ v_l = banner_border[:vl] || banner_border[:v]
2913
+ v_r = banner_border[:vr] || banner_border[:v]
2914
+
2915
+ top_fill = build_horizontal_line(h_top, img_w + 2)
2916
+ bot_fill = build_horizontal_line(h_bot, img_w + 2)
2917
+
2918
+ lines = []
2919
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
2920
+ raw_lines.each do |r_line|
2921
+ lines << "#{colorize(v_l, banner_color_cfg)} #{r_line} #{colorize(v_r, banner_color_cfg)}"
2922
+ end
2923
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg)
2924
+
2925
+ @cached_image_cols = term_cols
2926
+ @cached_image_lines = [lines, box_w]
2927
+ @cached_image_lines
2928
+ end
2929
+
2930
+ def is_submenu_item?(action)
2931
+ if action.is_a?(Array)
2932
+ target = action[1]
2933
+ return true if target.is_a?(Array) && (target.empty? || target.first.is_a?(Array) || target.first.is_a?(Proc) || target.first.is_a?(Method) || target.first.is_a?(Symbol))
2934
+ return true if target.is_a?(GRmenu)
2935
+ elsif action.is_a?(Hash)
2936
+ return true if action[:submenu] || action["submenu"]
2937
+ end
2938
+ false
2939
+ end
2940
+
2941
+ def get_submenu_actions(action)
2942
+ if action.is_a?(Array)
2943
+ action[1].is_a?(GRmenu) ? action[1].instance_variable_get(:@functions) : action[1]
2944
+ elsif action.is_a?(Hash)
2945
+ action[:submenu] || action["submenu"]
2946
+ end
2947
+ end
2948
+
2949
+ def render_submenu_box(sub_actions, parent_title = "", is_active: false, selected_idx: 0)
2950
+ sub_names = sub_actions.map { |a| extract_name_from_action(a) }
2951
+ max_name_len = sub_names.empty? ? 10 : sub_names.map { |n| GRmenu.display_width(n) }.max
2952
+ sub_title = parent_title.to_s
2953
+ sub_w = [max_name_len + 8, GRmenu.display_width(sub_title) + 6, 20].max
2954
+ sub_w = [sub_w, 36].min
2955
+ inner_w = [sub_w - 2, 8].max
2956
+
2957
+ s_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "submenu")) || {}
2958
+ sub_style = (s_sec["style"] || @style || 3).to_i
2959
+ sub_border = BORDERS[sub_style] || BORDERS[3]
2960
+ h_top = sub_border[:ht] || sub_border[:h]
2961
+ h_bot = sub_border[:hb] || sub_border[:h]
2962
+ v_l = sub_border[:vl] || sub_border[:v]
2963
+ v_r = sub_border[:vr] || sub_border[:v]
2964
+
2965
+ s_brd_col = s_sec["border"] || s_sec["border_color"] || @style_config.border[:color] || "cyan"
2966
+ s_foc_col = s_sec["focus"] || s_sec["focus_color"] || @style_config.focus[:color] || "yellow"
2967
+ s_opt_col = s_sec["options"] || s_sec["options_color"] || @style_config.options[:color] || "white"
2968
+
2969
+ s_brd_cfg = { color: s_brd_col, level: 1 }
2970
+ s_foc_cfg = { color: s_foc_col, level: 2 }
2971
+ s_opt_cfg = { color: s_opt_col, level: 1 }
2972
+ s_held_cfg = { color: "yellow", level: 1 }
2973
+
2974
+ lines = []
2975
+ top_fill = build_horizontal_line(h_top, inner_w)
2976
+ bot_fill = build_horizontal_line(h_bot, inner_w)
2977
+
2978
+ top_border_line = sub_border[:tl] + top_fill + sub_border[:tr]
2979
+ lines << colorize(top_border_line, s_brd_cfg, 0.0)
2980
+
2981
+ unless sub_title.empty?
2982
+ pad_t = [inner_w - 2 - GRmenu.display_width(sub_title), 0].max
2983
+ t_padded = " " * (pad_t / 2) + sub_title + " " * (pad_t - (pad_t / 2))
2984
+ c_title = colorize(t_padded, s_foc_cfg, 0.2)
2985
+ v_l_col = colorize(v_l, s_brd_cfg, 0.2)
2986
+ v_r_col = colorize(v_r, s_brd_cfg, 0.2)
2987
+ lines << "#{v_l_col} #{c_title} #{v_r_col}"
2988
+ mid_fill = build_horizontal_line(h_top, inner_w)
2989
+ lines << colorize(v_l + mid_fill + v_r, s_brd_cfg, 0.4)
2990
+ end
2991
+
2992
+ parent_item_row = nil
2993
+ sub_names.each_with_index do |s_name, s_idx|
2994
+ is_sub = is_submenu_item?(sub_actions[s_idx])
2995
+ arrow = is_sub ? "▶" : ""
2996
+ is_selected = (s_idx == selected_idx)
2997
+ parent_item_row = lines.length if is_selected
2998
+
2999
+ prefix = is_selected ? "> " : " "
3000
+ pad_s = [inner_w - 2 - prefix.length - GRmenu.display_width(s_name) - (is_sub ? 2 : 0), 0].max
3001
+ raw_item = if is_sub
3002
+ "#{prefix}#{s_name}#{' ' * pad_s} #{arrow}"
3003
+ else
3004
+ "#{prefix}#{s_name}#{' ' * pad_s}"
3005
+ end
3006
+
3007
+ item_cfg = if is_selected
3008
+ is_active ? s_foc_cfg : s_held_cfg
3009
+ else
3010
+ s_opt_cfg
3011
+ end
3012
+
3013
+ colored_item = colorize(raw_item, item_cfg, s_idx * 0.2)
3014
+ v_l_col = colorize(v_l, s_brd_cfg, 0.2)
3015
+ v_r_col = colorize(v_r, s_brd_cfg, 0.8)
3016
+ lines << "#{v_l_col} #{colored_item} #{v_r_col}"
3017
+ end
3018
+
3019
+ bot_border_line = sub_border[:bl] + bot_fill + sub_border[:br]
3020
+ lines << colorize(bot_border_line, s_brd_cfg, 0.6)
3021
+
3022
+ [lines, sub_w, parent_item_row]
3023
+ end
3024
+
3025
+ def render_lines(size_max = 20)
3026
+ term_cols = self.class.terminal_width
3027
+ term_rows = self.class.terminal_height
3028
+ rendered_lines = []
3029
+ @row_hit_map = {}
3030
+ @tab_ranges = {}
3031
+ @tabs_row = nil
3032
+ @up_arrow_row = nil
3033
+ @down_arrow_row = nil
3034
+ @sub_hit_map = {}
3035
+ @sub_1_hit_map = {}
3036
+ @sub_2_hit_map = {}
3037
+ @sub_1_col_rng = nil
3038
+ @sub_2_col_rng = nil
3039
+
3040
+ header_box_width = 0
3041
+ header_lines_count = 0
3042
+
3043
+ if @image && File.exist?(@image)
3044
+ img_lines, img_box_w = render_image_lines(term_cols)
3045
+ unless img_lines.empty?
3046
+ rendered_lines.concat(img_lines)
3047
+ rendered_lines << ""
3048
+ header_lines_count += img_lines.length + 1
3049
+ header_box_width = [header_box_width, img_box_w].max
3050
+ end
3051
+ end
3052
+
3053
+ if @banner && !@banner.empty?
3054
+ banner_lines, banner_box_w = render_banner_lines(term_cols)
3055
+ unless banner_lines.empty?
3056
+ rendered_lines.concat(banner_lines)
3057
+ rendered_lines << ""
3058
+ header_lines_count += banner_lines.length + 1
3059
+ header_box_width = [header_box_width, banner_box_w].max
3060
+ end
3061
+ end
3062
+
3063
+ matching_indices = current_matching_indices
3064
+ all_names = @functions.map { |func| extract_name_from_action(func) }
3065
+ all_descriptions = @functions.map { |func| extract_description_from_action(func) }
3066
+
3067
+ active_desc = all_descriptions[@index] || ""
3068
+
3069
+ cols = @columns
3070
+ max_item_len = all_names.empty? ? 10 : all_names.map { |n| GRmenu.display_width(n) }.max
3071
+ grid_suggested_w = (max_item_len + 6) * cols + 4
3072
+
3073
+ calculated_width = [size_max, GRmenu.display_width(@title) + 4, grid_suggested_w].max
3074
+ calculated_width = ([calculated_width, GRmenu.display_width(active_desc) + 8].max) unless active_desc.empty?
3075
+ calculated_width = ([calculated_width, GRmenu.display_width(@query) + 16].max) if @search
3076
+ total_width = [calculated_width, term_cols - 2].min
3077
+
3078
+ reference_width = header_box_width > 0 ? header_box_width : total_width
3079
+ margin_left = (@center && reference_width > total_width) ? " " * ((reference_width - total_width) / 2) : ""
3080
+
3081
+ subtitle_lines_count = 0
3082
+ if @subtitle && !@subtitle.empty?
3083
+ subtitle_lines = @subtitle.lines.map(&:chomp)
3084
+ div_w = @divider.is_a?(Numeric) ? @divider.to_i : [reference_width, term_cols - 2].min
3085
+
3086
+ if @divider
3087
+ rendered_lines << colorize("─" * div_w, @style_config.divider, 0.0)
3088
+ subtitle_lines_count += 1
3089
+ end
3090
+
3091
+ subtitle_lines.each_with_index do |sub_line, s_i|
3092
+ pad_sub = [div_w - GRmenu.display_width(sub_line), 0].max
3093
+ formatted_sub = @center ? (" " * (pad_sub / 2) + sub_line + " " * (pad_sub - (pad_sub / 2))) : sub_line
3094
+ rendered_lines << colorize(formatted_sub, @style_config.subtitle, s_i * 0.3)
3095
+ subtitle_lines_count += 1
3096
+ end
3097
+
3098
+ if @divider
3099
+ rendered_lines << colorize("─" * div_w, @style_config.divider, 0.6)
3100
+ subtitle_lines_count += 1
3101
+ end
3102
+ rendered_lines << ""
3103
+ subtitle_lines_count += 1
3104
+ end
3105
+
3106
+ border_color_cfg = @style_config.border
3107
+ options_color_cfg = @style_config.options
3108
+ focus_color_cfg = @style_config.focus
3109
+ title_color_cfg = @style_config.title
3110
+
3111
+ box_border = BORDERS[@style]
3112
+
3113
+ overhead = header_lines_count + subtitle_lines_count + 6
3114
+ overhead += 2 unless active_desc.empty?
3115
+ overhead += 2 if @search
3116
+ available_rows = [term_rows - overhead - 2, 2].max
3117
+
3118
+ rows_data = matching_indices.each_slice(cols).to_a
3119
+ total_rows = rows_data.length
3120
+
3121
+ effective_page_rows = if @page_size && @page_size > 0
3122
+ [@page_size, total_rows, available_rows].min
3123
+ else
3124
+ [total_rows, available_rows].min
3125
+ end
3126
+ effective_page_rows = [effective_page_rows, 1].max
3127
+
3128
+ curr_matching_pos = matching_indices.index(@index) || 0
3129
+ curr_row = total_rows > 0 ? (curr_matching_pos / cols) : 0
3130
+
3131
+ start_row = 0
3132
+ end_row = [total_rows - 1, 0].max
3133
+ if total_rows > effective_page_rows
3134
+ half_r = effective_page_rows / 2
3135
+ start_row = [[curr_row - half_r, 0].max, total_rows - effective_page_rows].min
3136
+ end_row = start_row + effective_page_rows - 1
3137
+ end
3138
+
3139
+ visible_rows_data = rows_data[start_row..end_row] || []
3140
+ has_more_above = start_row > 0
3141
+ has_more_below = end_row < (total_rows - 1)
3142
+
3143
+ avail_w = [total_width - 4, 1].max
3144
+ col_w = [(avail_w - (cols - 1) * 2) / cols, 1].max
3145
+
3146
+ if @tabs && !@tabs.empty?
3147
+ tab_strs = []
3148
+ raw_tab_strs = []
3149
+ @tab_ranges = {}
3150
+ @tabs.each_with_index do |t_name, t_i|
3151
+ is_active = (t_i == @active_tab_idx)
3152
+ raw_t = is_active ? "[ #{t_name} ]" : " #{t_name} "
3153
+ colored_t = if is_active
3154
+ colorize(raw_t, { color: @active_tab_color, level: 2 }, 0.0)
3155
+ else
3156
+ colorize(raw_t, { color: @tab_color, level: 1 }, 0.0)
3157
+ end
3158
+ tab_strs << colored_t
3159
+ raw_tab_strs << raw_t
3160
+ end
3161
+ raw_tabs_joined = raw_tab_strs.join(" ")
3162
+ tabs_w = GRmenu.display_width(raw_tabs_joined)
3163
+ l_tabs_pad = [(total_width - tabs_w) / 2, 0].max
3164
+ formatted_tabs = (" " * l_tabs_pad) + tab_strs.join(" ")
3165
+
3166
+ start_col = margin_left.length + l_tabs_pad
3167
+ cur_x = start_col
3168
+ @tabs.each_with_index do |_t_name, t_i|
3169
+ t_len = GRmenu.display_width(raw_tab_strs[t_i])
3170
+ @tab_ranges[t_i] = (cur_x + 1)..(cur_x + t_len)
3171
+ cur_x += t_len + 3
3172
+ end
3173
+
3174
+ @tabs_row = rendered_lines.length + 1
3175
+ rendered_lines << "#{margin_left}#{formatted_tabs}"
3176
+ rendered_lines << ""
3177
+ end
3178
+
3179
+ if box_border
3180
+ h_top = box_border[:ht] || box_border[:h]
3181
+ h_bot = box_border[:hb] || box_border[:h]
3182
+ v_l_raw = box_border[:vl] || box_border[:v]
3183
+ v_r_raw = box_border[:vr] || box_border[:v]
3184
+
3185
+ top_fill = build_horizontal_line(h_top, total_width - 2)
3186
+ bot_fill = build_horizontal_line(h_bot, total_width - 2)
3187
+ mid_fill = build_horizontal_line(h_top, total_width - 2)
3188
+
3189
+ v_left = colorize(v_l_raw, border_color_cfg, 0.2)
3190
+ v_right = colorize(v_r_raw, border_color_cfg, 0.8)
3191
+
3192
+ top_border_line = box_border[:tl] + top_fill + box_border[:tr]
3193
+ rendered_lines << "#{margin_left}#{colorize(top_border_line, border_color_cfg, 0.0)}"
3194
+
3195
+ unless @title.empty?
3196
+ pad_t = [total_width - 4 - GRmenu.display_width(@title), 0].max
3197
+ title_padded = " " * (pad_t / 2) + @title + " " * (pad_t - (pad_t / 2))
3198
+ centered_title = colorize(title_padded, title_color_cfg, 0.4)
3199
+ rendered_lines << "#{margin_left}#{v_left} #{centered_title} #{v_right}"
3200
+
3201
+ separator_line = v_l_raw + mid_fill + v_r_raw
3202
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.6)}"
3203
+ end
3204
+
3205
+ if @search
3206
+ search_prompt = "Buscar: #{@query}█"
3207
+ pad_s = [avail_w - GRmenu.display_width(search_prompt), 0].max
3208
+ search_padded = search_prompt + (" " * pad_s)
3209
+ rendered_lines << "#{margin_left}#{v_left} #{Color.bright_yellow(search_padded)} #{v_right}"
3210
+ separator_line = v_l_raw + mid_fill + v_r_raw
3211
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.8)}"
3212
+ end
3213
+
3214
+ if has_more_above
3215
+ @up_arrow_row = rendered_lines.length + 1
3216
+ up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
3217
+ pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
3218
+ up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
3219
+ rendered_lines << "#{margin_left}#{v_left} #{up_indicator} #{v_right}"
3220
+ end
3221
+
3222
+ parent_row_idx = nil
3223
+ if rows_data.empty?
3224
+ no_res_txt = "(Sin resultados)"
3225
+ pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
3226
+ no_res = colorize(" " * (pad_no / 2) + no_res_txt + " " * (pad_no - (pad_no / 2)), { color: "gray", level: 1 })
3227
+ rendered_lines << "#{margin_left}#{v_left} #{no_res} #{v_right}"
3228
+ else
3229
+ visible_rows_data.each_with_index do |row_indices, r_idx|
3230
+ cells = []
3231
+ cols.times do |c_idx|
3232
+ item_idx = row_indices[c_idx]
3233
+ if item_idx
3234
+ op_name = all_names[item_idx]
3235
+ is_sub = is_submenu_item?(@functions[item_idx])
3236
+ arrow = is_sub ? "▶" : ""
3237
+ if @index == item_idx
3238
+ parent_row_idx = rendered_lines.length
3239
+ cell_raw = if is_sub
3240
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3241
+ "> #{op_name}#{' ' * pad_sub}#{arrow}"
3242
+ else
3243
+ "> #{op_name}"
3244
+ end
3245
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
3246
+ cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
3247
+ else
3248
+ cell_raw = if is_sub
3249
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3250
+ " #{op_name}#{' ' * pad_sub}#{arrow}"
3251
+ else
3252
+ " #{op_name}"
3253
+ end
3254
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
3255
+ cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
3256
+ end
3257
+ else
3258
+ cells << (" " * col_w)
3259
+ end
3260
+ end
3261
+ row_str = cells.join(" ")
3262
+ pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
3263
+ row_padded = row_str + (" " * pad_r)
3264
+ cur_line_num = rendered_lines.length + 1
3265
+ @row_hit_map[cur_line_num] = { row_indices: row_indices, cols: cols, col_w: col_w, margin_left: margin_left.length }
3266
+ rendered_lines << "#{margin_left}#{v_left} #{row_padded} #{v_right}"
3267
+ end
3268
+ end
3269
+
3270
+ if has_more_below
3271
+ @down_arrow_row = rendered_lines.length + 1
3272
+ remaining_below = total_rows - 1 - end_row
3273
+ down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
3274
+ pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
3275
+ down_indicator = colorize(" " * (pad_down / 2) + down_text + " " * (pad_down - (pad_down / 2)), { color: "gray", level: 2 })
3276
+ rendered_lines << "#{margin_left}#{v_left} #{down_indicator} #{v_right}"
3277
+ end
3278
+
3279
+ unless active_desc.empty?
3280
+ separator_line = v_l_raw + mid_fill + v_r_raw
3281
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 1.0)}"
3282
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3283
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3284
+ raw_desc = "#{pfx}#{active_desc}"
3285
+ pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
3286
+ desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
3287
+ rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
3288
+ end
3289
+
3290
+ bottom_border_line = box_border[:bl] + bot_fill + box_border[:br]
3291
+ rendered_lines << "#{margin_left}#{colorize(bottom_border_line, border_color_cfg, 1.4)}"
3292
+ else
3293
+ symbol_char = STYLES[@style] || "#"
3294
+ solid_border = colorize(symbol_char, border_color_cfg)
3295
+ solid_line = symbol_char * total_width
3296
+
3297
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
3298
+
3299
+ unless @title.empty?
3300
+ pad_t = [total_width - 4 - GRmenu.display_width(@title), 0].max
3301
+ title_padded = " " * (pad_t / 2) + @title + " " * (pad_t - (pad_t / 2))
3302
+ centered_title = colorize(title_padded, title_color_cfg)
3303
+ rendered_lines << "#{margin_left}#{solid_border} #{centered_title} #{solid_border}"
3304
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
3305
+ end
3306
+
3307
+ if @search
3308
+ search_prompt = "Buscar: #{@query}█"
3309
+ pad_s = [avail_w - GRmenu.display_width(search_prompt), 0].max
3310
+ search_padded = search_prompt + (" " * pad_s)
3311
+ rendered_lines << "#{margin_left}#{solid_border} #{Color.bright_yellow(search_padded)} #{solid_border}"
3312
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
3313
+ end
3314
+
3315
+ if has_more_above
3316
+ @up_arrow_row = rendered_lines.length + 1
3317
+ up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
3318
+ pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
3319
+ up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
3320
+ rendered_lines << "#{margin_left}#{solid_border} #{up_indicator} #{solid_border}"
3321
+ end
3322
+
3323
+ parent_row_idx = nil
3324
+ if rows_data.empty?
3325
+ no_res_txt = "(Sin resultados)"
3326
+ pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
3327
+ no_res = colorize(" " * (pad_no / 2) + no_res_txt + " " * (pad_no - (pad_no / 2)), { color: "gray", level: 1 })
3328
+ rendered_lines << "#{margin_left}#{solid_border} #{no_res} #{solid_border}"
3329
+ else
3330
+ visible_rows_data.each_with_index do |row_indices, r_idx|
3331
+ cells = []
3332
+ cols.times do |c_idx|
3333
+ item_idx = row_indices[c_idx]
3334
+ if item_idx
3335
+ op_name = all_names[item_idx]
3336
+ is_sub = is_submenu_item?(@functions[item_idx])
3337
+ arrow = is_sub ? "▶" : ""
3338
+ if @index == item_idx
3339
+ parent_row_idx = rendered_lines.length
3340
+ cell_raw = if is_sub
3341
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3342
+ "> #{op_name}#{' ' * pad_sub}#{arrow}"
3343
+ else
3344
+ "> #{op_name}"
3345
+ end
3346
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
3347
+ cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
3348
+ else
3349
+ cell_raw = if is_sub
3350
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3351
+ " #{op_name}#{' ' * pad_sub}#{arrow}"
3352
+ else
3353
+ " #{op_name}"
3354
+ end
3355
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
3356
+ cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
3357
+ end
3358
+ else
3359
+ cells << (" " * col_w)
3360
+ end
3361
+ end
3362
+ row_str = cells.join(" ")
3363
+ pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
3364
+ row_padded = row_str + (" " * pad_r)
3365
+ cur_line_num = rendered_lines.length + 1
3366
+ @row_hit_map[cur_line_num] = { row_indices: row_indices, cols: cols, col_w: col_w, margin_left: margin_left.length }
3367
+ rendered_lines << "#{margin_left}#{solid_border} #{row_padded} #{solid_border}"
3368
+ end
3369
+ end
3370
+
3371
+ if has_more_below
3372
+ @down_arrow_row = rendered_lines.length + 1
3373
+ remaining_below = total_rows - 1 - end_row
3374
+ down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
3375
+ pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
3376
+ down_indicator = colorize(" " * (pad_down / 2) + down_text + " " * (pad_down - (pad_down / 2)), { color: "gray", level: 2 })
3377
+ rendered_lines << "#{margin_left}#{solid_border} #{down_indicator} #{solid_border}"
3378
+ end
3379
+
3380
+ unless active_desc.empty?
3381
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
3382
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3383
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3384
+ raw_desc = "#{pfx}#{active_desc}"
3385
+ pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
3386
+ desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
3387
+ rendered_lines << "#{margin_left}#{solid_border} #{desc_text} #{solid_border}"
3388
+ end
3389
+
3390
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
3391
+ end
3392
+
3393
+ if @open_level >= 1 && is_submenu_item?(@functions[@index]) && parent_row_idx
3394
+ sub_1_acts = get_submenu_actions(@functions[@index])
3395
+ if sub_1_acts && !sub_1_acts.empty?
3396
+ sub_1_title = all_names[@index] || "Submenu"
3397
+ sub_1_lines, sub_1_w, p_row_1 = render_submenu_box(sub_1_acts, sub_1_title, is_active: (@level == 1), selected_idx: @sub_index_1)
3398
+
3399
+ sub_2_acts = nil
3400
+ sub_2_lines = nil
3401
+ sub_2_w = 0
3402
+ p_row_2 = nil
3403
+ if @open_level >= 2 && is_submenu_item?(sub_1_acts[@sub_index_1])
3404
+ sub_2_acts = get_submenu_actions(sub_1_acts[@sub_index_1])
3405
+ if sub_2_acts && !sub_2_acts.empty?
3406
+ sub_2_title = extract_name_from_action(sub_1_acts[@sub_index_1]) || "Submenu"
3407
+ sub_2_lines, sub_2_w, p_row_2 = render_submenu_box(sub_2_acts, sub_2_title, is_active: (@level == 2), selected_idx: @sub_index_2)
3408
+ end
3409
+ end
3410
+
3411
+ if sub_2_lines
3412
+ total_3_w = margin_left.length + total_width + 2 + sub_1_w + 2 + sub_2_w
3413
+ if total_3_w <= term_cols
3414
+ sub_1_start = [[parent_row_idx - 1, 0].max, [rendered_lines.length - sub_1_lines.length, 0].max].min
3415
+ p1_abs_row = sub_1_start + (p_row_1 || 1)
3416
+ sub_2_start = [[p1_abs_row - 1, 0].max, [rendered_lines.length - sub_2_lines.length, 0].max].min
3417
+ max_3_lines = [rendered_lines.length, sub_1_start + sub_1_lines.length, sub_2_start + sub_2_lines.length].max
3418
+
3419
+ x1_start = margin_left.length + total_width + 2
3420
+ x1_end = x1_start + sub_1_w
3421
+ x2_start = x1_end + 2
3422
+ x2_end = x2_start + sub_2_w
3423
+ @sub_1_col_rng = (x1_start..x1_end)
3424
+ @sub_2_col_rng = (x2_start..x2_end)
3425
+
3426
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
3427
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
3428
+
3429
+ stitched_lines = []
3430
+ max_3_lines.times do |i|
3431
+ l0 = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
3432
+
3433
+ l1 = " " * sub_1_w
3434
+ br1 = " "
3435
+ if i >= sub_1_start && i < (sub_1_start + sub_1_lines.length)
3436
+ s1_idx = i - sub_1_start
3437
+ l1 = sub_1_lines[s1_idx]
3438
+ br1 = (i == parent_row_idx) ? "──" : " "
3439
+ s1_item = s1_idx - sub_1_offset
3440
+ if s1_item >= 0 && s1_item < sub_1_acts.length
3441
+ @sub_1_hit_map[i + 1] = s1_item
3442
+ @sub_hit_map[i + 1] = s1_item
3443
+ end
3444
+ end
3445
+
3446
+ l2 = ""
3447
+ br2 = ""
3448
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
3449
+ s2_idx = i - sub_2_start
3450
+ l2 = sub_2_lines[s2_idx]
3451
+ br2 = (i == p1_abs_row) ? "──" : " "
3452
+ s2_item = s2_idx - sub_2_offset
3453
+ if s2_item >= 0 && s2_item < sub_2_acts.length
3454
+ @sub_2_hit_map[i + 1] = s2_item
3455
+ end
3456
+ end
3457
+
3458
+ stitched_lines << "#{l0}#{br1}#{l1}#{br2}#{l2}".rstrip
3459
+ end
3460
+ rendered_lines = stitched_lines
3461
+ elsif (sub_1_w + 2 + sub_2_w) <= term_cols
3462
+ p1_abs_row = (p_row_1 || 1)
3463
+ sub_2_start = [[p1_abs_row - 1, 0].max, [sub_1_lines.length - sub_2_lines.length, 0].max].min
3464
+ max_2_lines = [sub_1_lines.length, sub_2_start + sub_2_lines.length].max
3465
+
3466
+ x1_start = margin_left.length
3467
+ x1_end = x1_start + sub_1_w
3468
+ x2_start = x1_end + 2
3469
+ x2_end = x2_start + sub_2_w
3470
+ @sub_1_col_rng = (x1_start..x1_end)
3471
+ @sub_2_col_rng = (x2_start..x2_end)
3472
+
3473
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
3474
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
3475
+
3476
+ stitched_lines = []
3477
+ max_2_lines.times do |i|
3478
+ l1 = sub_1_lines[i] || (" " * sub_1_w)
3479
+ l2 = ""
3480
+ br2 = ""
3481
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
3482
+ s2_idx = i - sub_2_start
3483
+ l2 = sub_2_lines[s2_idx]
3484
+ br2 = (i == p1_abs_row) ? "──" : " "
3485
+ s2_item = s2_idx - sub_2_offset
3486
+ @sub_2_hit_map[i + 1] = s2_item if s2_item >= 0 && s2_item < sub_2_acts.length
3487
+ end
3488
+ s1_item = i - sub_1_offset
3489
+ if s1_item >= 0 && s1_item < sub_1_acts.length
3490
+ @sub_1_hit_map[i + 1] = s1_item
3491
+ @sub_hit_map[i + 1] = s1_item
3492
+ end
3493
+ stitched_lines << "#{margin_left}#{l1}#{br2}#{l2}".rstrip
3494
+ end
3495
+ rendered_lines = stitched_lines
3496
+ else
3497
+ active_box = (@level == 2) ? sub_2_lines : sub_1_lines
3498
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
3499
+ end
3500
+ else
3501
+ total_2_w = margin_left.length + total_width + 2 + sub_1_w
3502
+ if total_2_w <= term_cols
3503
+ sub_start_line = [[parent_row_idx - 1, 0].max, [rendered_lines.length - sub_1_lines.length, 0].max].min
3504
+ max_total_lines = [rendered_lines.length, sub_start_line + sub_1_lines.length].max
3505
+
3506
+ x1_start = margin_left.length + total_width + 2
3507
+ x1_end = x1_start + sub_1_w
3508
+ @sub_1_col_rng = (x1_start..x1_end)
3509
+
3510
+ sub_item_offset = sub_1_title.empty? ? 1 : 3
3511
+
3512
+ stitched_lines = []
3513
+ max_total_lines.times do |i|
3514
+ main_line = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
3515
+ if i >= sub_start_line && i < (sub_start_line + sub_1_lines.length)
3516
+ sub_idx = i - sub_start_line
3517
+ sub_l = sub_1_lines[sub_idx]
3518
+ bridge = (i == parent_row_idx) ? "──" : " "
3519
+ stitched_lines << "#{main_line}#{bridge}#{sub_l}"
3520
+
3521
+ s_item_idx = sub_idx - sub_item_offset
3522
+ if s_item_idx >= 0 && s_item_idx < sub_1_acts.length
3523
+ @sub_1_hit_map[i + 1] = s_item_idx
3524
+ @sub_hit_map[i + 1] = s_item_idx
3525
+ end
3526
+ else
3527
+ stitched_lines << main_line
3528
+ end
3529
+ end
3530
+ rendered_lines = stitched_lines
3531
+ else
3532
+ active_box = (@level == 1) ? sub_1_lines : rendered_lines
3533
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
3534
+ end
3535
+ end
3536
+ end
3537
+ end
3538
+
3539
+ rendered_lines
3540
+ end
3541
+
3542
+ def has_rgb_animation?
3543
+ configs = [
3544
+ @style_config.border,
3545
+ @style_config.options,
3546
+ @style_config.focus,
3547
+ @style_config.title,
3548
+ @style_config.banner,
3549
+ @style_config.subtitle,
3550
+ @style_config.divider
3551
+ ]
3552
+ configs.any? do |c|
3553
+ if c.is_a?(Hash)
3554
+ val = (c[:color] || c["color"]).to_s.downcase
3555
+ val == "rgb" || val == "rainbow" || val == "chroma"
3556
+ else
3557
+ false
3558
+ end
3559
+ end
3560
+ end
3561
+
3562
+ def has_active_animation?
3563
+ return true if @animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase)
3564
+ return true if has_rgb_animation?
3565
+ return true if @active_tab_color && @active_tab_color.to_s.downcase.start_with?("neon")
3566
+ configs = [
3567
+ @style_config.border,
3568
+ @style_config.options,
3569
+ @style_config.focus,
3570
+ @style_config.title,
3571
+ @style_config.banner,
3572
+ @style_config.subtitle,
3573
+ @style_config.divider
3574
+ ]
3575
+ configs.any? do |c|
3576
+ if c.is_a?(Hash)
3577
+ val = (c[:color] || c["color"]).to_s.downcase.strip
3578
+ val.start_with?("neon")
3579
+ else
3580
+ false
3581
+ end
3582
+ end
3583
+ end
3584
+
3585
+ def style(css_content)
3586
+ parsed = self.class.parse_config_text(css_content)
3587
+ m = ((parsed[:sections] && parsed[:sections]["menu"]) || {}).merge(parsed[:global] || {})
3588
+ if m["style"]
3589
+ @style = m["style"].to_i
3590
+ @border_config = BORDERS[@style] || BORDERS[3]
3591
+ end
3592
+ @banner_style = m["banner_style"].to_i if m["banner_style"]
3593
+ @animate = m["animate"].to_s if m["animate"]
3594
+ @center = (m["center"].to_s != "false") if m.key?("center")
3595
+ if m["border"] || m["border_color"]
3596
+ c, l = self.class.extract_color_and_level(m["border"] || m["border_color"], 1)
3597
+ @style_config.border(c, l)
3598
+ end
3599
+ if m["options"] || m["options_color"]
3600
+ c, l = self.class.extract_color_and_level(m["options"] || m["options_color"], 1)
3601
+ @style_config.options(c, l)
3602
+ end
3603
+ if m["focus"] || m["focus_color"]
3604
+ c, l = self.class.extract_color_and_level(m["focus"] || m["focus_color"], 2)
3605
+ @style_config.focus(c, l)
3606
+ end
3607
+ if m["title"] || m["title_color"]
3608
+ c, l = self.class.extract_color_and_level(m["title"] || m["title_color"], 2)
3609
+ @style_config.title(c, l)
3610
+ end
3611
+ if m["banner"] || m["banner_color"]
3612
+ c, l = self.class.extract_color_and_level(m["banner"] || m["banner_color"], 2)
3613
+ @style_config.banner(c, l)
3614
+ end
3615
+ if m["subtitle"] || m["subtitle_color"]
3616
+ c, l = self.class.extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
3617
+ @style_config.subtitle(c, l)
3618
+ end
3619
+ if m["divider"] || m["divider_color"]
3620
+ c, l = self.class.extract_color_and_level(m["divider"] || m["divider_color"], 1)
3621
+ @style_config.divider(c, l)
3622
+ end
3623
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
3624
+ @style_config.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
3625
+ end
3626
+ @style_config.font(m["font"].to_i) if m["font"]
3627
+ @mouse = (m["mouse"].to_s == "true") if m.key?("mouse")
3628
+ if parsed[:sections] && parsed[:sections]["tabs"]
3629
+ t_sec = parsed[:sections]["tabs"]
3630
+ @active_tab_color = t_sec["active_tab"] || t_sec["active_tab_color"] || @active_tab_color if (t_sec["active_tab"] || t_sec["active_tab_color"])
3631
+ @tab_color = t_sec["tab_color"] || t_sec["inactive_tab"] || t_sec["color"] || @tab_color if (t_sec["tab_color"] || t_sec["inactive_tab"] || t_sec["color"])
3632
+ end
3633
+ self
3634
+ end
3635
+
3636
+ def export_config(path = nil)
3637
+ if path.nil?
3638
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
3639
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
3640
+ path = "#{base}.gr"
3641
+ end
3642
+ b_cfg = @style_config&.border || SetStyle.border
3643
+ t_cfg = @style_config&.title || SetStyle.title
3644
+ f_cfg = @style_config&.focus || SetStyle.focus
3645
+ o_cfg = @style_config&.options || SetStyle.options
3646
+ bn_cfg = @style_config&.banner || SetStyle.banner
3647
+ s_cfg = @style_config&.subtitle || SetStyle.subtitle
3648
+ d_cfg = @style_config&.divider || SetStyle.divider
3649
+ dp_val = @style_config&.desc_prefix || SetStyle.desc_prefix
3650
+
3651
+ lines = ["GRmenu::config<-1->", ""]
3652
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
3653
+ lines << "@author:: \"grcode\""
3654
+ lines << "@version:: \"1.0\""
3655
+ lines << ""
3656
+ lines << "<<menu"
3657
+ lines << " style:: #{@style || 3}"
3658
+ lines << " banner_style:: #{@banner_style || 3}"
3659
+ lines << " font:: #{@style_config&.font || SetStyle.font}"
3660
+ lines << " animate:: #{@animate || 'rgb'}"
3661
+ lines << " center:: #{@center.nil? ? true : @center}"
3662
+ lines << " desc_prefix:: #{dp_val}"
3663
+ lines << " mouse:: #{@mouse}" if @mouse
3664
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3665
+ lines << " title:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3666
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3667
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
3668
+ lines << " banner:: #{bn_cfg[:color]}:#{bn_cfg[:level]}"
3669
+ lines << " subtitle:: #{s_cfg[:color]}:#{s_cfg[:level]}"
3670
+ lines << " divider:: #{d_cfg[:color]}:#{d_cfg[:level]}"
3671
+ lines << ">>"
3672
+ lines << ""
3673
+ lines << "<<submenu"
3674
+ lines << " style:: #{@style || 3}"
3675
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3676
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3677
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
3678
+ lines << ">>"
3679
+ lines << ""
3680
+ lines << "<<tabs"
3681
+ lines << " active_tab:: #{@active_tab_color || 'yellow'}:2"
3682
+ lines << " tab_color:: #{@tab_color || 'gray'}:1"
3683
+ lines << ">>"
3684
+ lines << ""
3685
+ lines << "<<input"
3686
+ lines << " style:: 3"
3687
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3688
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3689
+ lines << " label_color:: white:1"
3690
+ lines << ">>"
3691
+ lines << ""
3692
+ lines << "<<table"
3693
+ lines << " style:: #{@style || 3}"
3694
+ lines << " header_color:: yellow:2"
3695
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3696
+ lines << " selected_row:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3697
+ lines << " row_color:: white:1"
3698
+ lines << " zebra_striping:: true"
3699
+ lines << ">>"
3700
+ lines << ""
3701
+ lines << "<<card"
3702
+ lines << " style:: 7"
3703
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3704
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3705
+ lines << " content_color:: white:1"
3706
+ lines << ">>"
3707
+ lines << ""
3708
+ lines << "<<slider"
3709
+ lines << " style:: #{@style || 3}"
3710
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3711
+ lines << " fill_char:: █"
3712
+ lines << " empty_char:: ░"
3713
+ lines << ">>"
3714
+ lines << ""
3715
+ lines << "<<checkbox"
3716
+ lines << " style:: #{@style || 3}"
3717
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3718
+ lines << " checked_mark:: [X]"
3719
+ lines << " unchecked_mark:: [ ]"
3720
+ lines << ">>"
3721
+ lines << ""
3722
+ File.write(path, lines.join("\n") + "\n")
3723
+ path
3724
+ end
3725
+ alias_method :export_theme, :export_config
3726
+
3727
+ def draw(size_max: 20, min_width: nil)
3728
+ if ARGV.any? { |a| ["-theme", "--theme", "-ex", "--export-theme"].include?(a.to_s.downcase) }
3729
+ out_idx = ARGV.index { |a| ["-o", "--out", "--output"].include?(a.to_s.downcase) }
3730
+ target_file = out_idx ? ARGV[out_idx + 1] : "tema_exportado.gr"
3731
+ export_config(target_file)
3732
+ Kernel.puts Color.bright_green("[OK] Tema exportado exitosamente a: #{target_file}")
3733
+ exit(0)
3734
+ end
3735
+
3736
+ target_width = min_width || size_max || 20
3737
+ action_to_execute = nil
3738
+
3739
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
3740
+
3741
+ begin
3742
+ Kernel.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
3743
+ Kernel.print(ENABLE_MOUSE) if @mouse
3744
+
3745
+ if @animate && !["false", "rgb", "", "nil"].include?(@animate.downcase)
3746
+ intro_lines = render_lines(target_width)
3747
+ self.class.animate_render(intro_lines, @animate)
3748
+ end
3749
+
3750
+ if is_tty
3751
+ $stdin.raw do |raw_input_stream|
3752
+ action_to_execute = run_interactive_loop(raw_input_stream, target_width)
3753
+ end
3754
+ else
3755
+ action_to_execute = run_interactive_loop($stdin, target_width)
3756
+ end
3757
+ ensure
3758
+ Kernel.print(DISABLE_MOUSE) if @mouse
3759
+ Kernel.print(SHOW_CURSOR)
3760
+ end
3761
+
3762
+ if action_to_execute
3763
+ Kernel.print(CLEAR_SCREEN_SEQUENCE)
3764
+ execute_action(action_to_execute)
3765
+ else
3766
+ Kernel.print(CLEAR_SCREEN_SEQUENCE)
3767
+ end
3768
+ rescue Interrupt
3769
+ Kernel.print(DISABLE_MOUSE) if @mouse
3770
+ Kernel.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
3771
+ nil
3772
+ end
3773
+
3774
+ private
3775
+
3776
+ def draw_frame(target_width)
3777
+ lines = render_lines(target_width)
3778
+ buffer = String.new(CURSOR_HOME)
3779
+ lines.each_with_index do |line, idx|
3780
+ buffer << line << CLEAR_TO_EOL
3781
+ buffer << "\r\n" if idx < lines.length - 1
3782
+ end
3783
+ buffer << CLEAR_TO_EOS
3784
+ Kernel.print(buffer)
3785
+ end
3786
+
3787
+ def run_interactive_loop(input_stream, target_width)
3788
+ matching = current_matching_indices
3789
+ @index = matching.first || 0 unless matching.include?(@index)
3790
+ @rgb_tick = 0.0
3791
+ draw_frame(target_width)
3792
+
3793
+ animating = has_active_animation?
3794
+
3795
+ while true
3796
+ if animating
3797
+ ready = false
3798
+ if input_stream.respond_to?(:to_io) || input_stream.is_a?(IO)
3799
+ begin
3800
+ select_res = IO.select([input_stream], nil, nil, 0.035)
3801
+ ready = true if select_res && select_res[0] && !select_res[0].empty?
3802
+ rescue StandardError
3803
+ ready = true
3804
+ end
3805
+ else
3806
+ ready = true
3807
+ end
3808
+
3809
+ unless ready
3810
+ @rgb_tick += 0.08
3811
+ draw_frame(target_width)
3812
+ next
3813
+ end
3814
+ end
3815
+
3816
+ key = read_single_key(input_stream)
3817
+ break if key.nil? || key == "\x03" || key == "\x04"
3818
+
3819
+ if key =~ /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
3820
+ btn = $1.to_i
3821
+ col = $2.to_i
3822
+ row = $3.to_i
3823
+ act = $4
3824
+
3825
+ if btn == 64
3826
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
3827
+ s1_acts = get_submenu_actions(@functions[@index])
3828
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3829
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
3830
+ elsif @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
3831
+ s1_acts = get_submenu_actions(@functions[@index])
3832
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
3833
+ @sub_index_2 = 0
3834
+ else
3835
+ move_up
3836
+ @sub_index_1 = 0
3837
+ @sub_index_2 = 0
3838
+ end
3839
+ draw_frame(target_width)
3840
+ next
3841
+ elsif btn == 65
3842
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
3843
+ s1_acts = get_submenu_actions(@functions[@index])
3844
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3845
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
3846
+ elsif @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
3847
+ s1_acts = get_submenu_actions(@functions[@index])
3848
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
3849
+ @sub_index_2 = 0
3850
+ else
3851
+ move_down
3852
+ @sub_index_1 = 0
3853
+ @sub_index_2 = 0
3854
+ end
3855
+ draw_frame(target_width)
3856
+ next
3857
+ elsif btn == 0 && act == "M"
3858
+ if @tabs && !@tabs.empty? && @tabs_row && row == @tabs_row
3859
+ clicked_tab = @tab_ranges.find { |_idx, rng| rng.cover?(col) }
3860
+ if clicked_tab
3861
+ @active_tab_idx = clicked_tab[0]
3862
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
3863
+ @index = 0
3864
+ @level = 0
3865
+ @open_level = 0
3866
+ @sub_index_1 = 0
3867
+ @sub_index_2 = 0
3868
+ @active_panel = :main
3869
+ @submenu_open = false
3870
+ draw_frame(target_width)
3871
+ next
3872
+ end
3873
+ end
3874
+
3875
+ if @up_arrow_row && row == @up_arrow_row
3876
+ move_up
3877
+ draw_frame(target_width)
3878
+ next
3879
+ end
3880
+
3881
+ if @down_arrow_row && row == @down_arrow_row
3882
+ move_down
3883
+ draw_frame(target_width)
3884
+ next
3885
+ end
3886
+
3887
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && @sub_2_hit_map[row]
3888
+ s2_clicked = @sub_2_hit_map[row]
3889
+ s1_acts = get_submenu_actions(@functions[@index])
3890
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3891
+ if s2_acts && s2_clicked < s2_acts.length
3892
+ if @level == 2 && @sub_index_2 == s2_clicked
3893
+ return s2_acts[s2_clicked]
3894
+ else
3895
+ @level = 2
3896
+ @sub_index_2 = s2_clicked
3897
+ draw_frame(target_width)
3898
+ next
3899
+ end
3900
+ end
3901
+ end
3902
+
3903
+ if @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && @sub_1_hit_map[row]
3904
+ s1_clicked = @sub_1_hit_map[row]
3905
+ s1_acts = get_submenu_actions(@functions[@index])
3906
+ if s1_acts && s1_clicked < s1_acts.length
3907
+ t_act = s1_acts[s1_clicked]
3908
+ if @level == 1 && @sub_index_1 == s1_clicked
3909
+ if is_submenu_item?(t_act)
3910
+ @open_level = 2
3911
+ @level = 2
3912
+ @sub_index_2 = 0
3913
+ draw_frame(target_width)
3914
+ next
3915
+ else
3916
+ return t_act
3917
+ end
3918
+ else
3919
+ @level = 1
3920
+ @sub_index_1 = s1_clicked
3921
+ if is_submenu_item?(t_act)
3922
+ @open_level = 2
3923
+ @sub_index_2 = 0
3924
+ else
3925
+ @open_level = 1
3926
+ end
3927
+ draw_frame(target_width)
3928
+ next
3929
+ end
3930
+ end
3931
+ end
3932
+
3933
+ if @row_hit_map && @row_hit_map[row]
3934
+ hit = @row_hit_map[row]
3935
+ inner_x = col - hit[:margin_left] - 3
3936
+ if inner_x >= 0
3937
+ c_idx = (inner_x / (hit[:col_w] + 2)).to_i
3938
+ c_idx = [[c_idx, 0].max, hit[:cols] - 1].min
3939
+ clicked_item = hit[:row_indices][c_idx]
3940
+ if clicked_item
3941
+ if @index == clicked_item
3942
+ if is_submenu_item?(@functions[clicked_item])
3943
+ @open_level = 1
3944
+ @level = 1
3945
+ @sub_index_1 = 0
3946
+ @sub_index_2 = 0
3947
+ @active_panel = :sub
3948
+ @submenu_open = true
3949
+ draw_frame(target_width)
3950
+ next
3951
+ else
3952
+ return @functions[clicked_item]
3953
+ end
3954
+ else
3955
+ @index = clicked_item
3956
+ @level = 0
3957
+ @active_panel = :main
3958
+ if is_submenu_item?(@functions[clicked_item])
3959
+ @open_level = 1
3960
+ @sub_index_1 = 0
3961
+ @sub_index_2 = 0
3962
+ @submenu_open = true
3963
+ else
3964
+ @open_level = 0
3965
+ @submenu_open = false
3966
+ end
3967
+ draw_frame(target_width)
3968
+ next
3969
+ end
3970
+ end
3971
+ end
3972
+ end
3973
+ end
3974
+ next
3975
+ end
3976
+
3977
+ if @tabs && !@tabs.empty?
3978
+ if key == "\t"
3979
+ @active_tab_idx = (@active_tab_idx + 1) % @tabs.length
3980
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
3981
+ @index = 0
3982
+ @level = 0
3983
+ @open_level = 0
3984
+ @sub_index_1 = 0
3985
+ @sub_index_2 = 0
3986
+ @active_panel = :main
3987
+ @submenu_open = false
3988
+ draw_frame(target_width)
3989
+ next
3990
+ elsif key == "\e[Z"
3991
+ @active_tab_idx = (@active_tab_idx - 1) % @tabs.length
3992
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
3993
+ @index = 0
3994
+ @level = 0
3995
+ @open_level = 0
3996
+ @sub_index_1 = 0
3997
+ @sub_index_2 = 0
3998
+ @active_panel = :main
3999
+ @submenu_open = false
4000
+ draw_frame(target_width)
4001
+ next
4002
+ end
4003
+ end
4004
+
4005
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
4006
+ s1_acts = get_submenu_actions(@functions[@index])
4007
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
4008
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
4009
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
4010
+ draw_frame(target_width)
4011
+ next
4012
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
4013
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
4014
+ draw_frame(target_width)
4015
+ next
4016
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
4017
+ @open_level = 1
4018
+ @level = 1
4019
+ draw_frame(target_width)
4020
+ next
4021
+ elsif key == "\r" || key == "\n"
4022
+ return s2_acts[@sub_index_2] if s2_acts && @sub_index_2 < s2_acts.length
4023
+ end
4024
+ end
4025
+
4026
+ if @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
4027
+ s1_acts = get_submenu_actions(@functions[@index])
4028
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
4029
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
4030
+ @sub_index_2 = 0
4031
+ draw_frame(target_width)
4032
+ next
4033
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
4034
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
4035
+ @sub_index_2 = 0
4036
+ draw_frame(target_width)
4037
+ next
4038
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
4039
+ @open_level = 0
4040
+ @level = 0
4041
+ @active_panel = :main
4042
+ @submenu_open = false
4043
+ draw_frame(target_width)
4044
+ next
4045
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
4046
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
4047
+ if is_submenu_item?(cur_l1)
4048
+ @open_level = 2
4049
+ @level = 2
4050
+ @sub_index_2 = 0
4051
+ draw_frame(target_width)
4052
+ next
4053
+ end
4054
+ elsif key == "\r" || key == "\n"
4055
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
4056
+ if is_submenu_item?(cur_l1)
4057
+ @open_level = 2
4058
+ @level = 2
4059
+ @sub_index_2 = 0
4060
+ draw_frame(target_width)
4061
+ next
4062
+ else
4063
+ return cur_l1
4064
+ end
4065
+ end
4066
+ end
4067
+
4068
+ if !@search && (key == "q" || key == "Q")
4069
+ break
4070
+ end
4071
+
4072
+ if key == "\e"
4073
+ if @search && !@query.empty?
4074
+ @query.clear
4075
+ matching = current_matching_indices
4076
+ @index = matching.first || 0
4077
+ draw_frame(target_width)
4078
+ else
4079
+ break
4080
+ end
4081
+ elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
4082
+ move_up
4083
+ @sub_index_1 = 0
4084
+ @sub_index_2 = 0
4085
+ draw_frame(target_width)
4086
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
4087
+ move_down
4088
+ @sub_index_1 = 0
4089
+ @sub_index_2 = 0
4090
+ draw_frame(target_width)
4091
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K"
4092
+ if @open_level > 0
4093
+ @open_level = 0
4094
+ @level = 0
4095
+ @active_panel = :main
4096
+ @submenu_open = false
4097
+ draw_frame(target_width)
4098
+ else
4099
+ move_left
4100
+ draw_frame(target_width)
4101
+ end
4102
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
4103
+ if is_submenu_item?(@functions[@index])
4104
+ @open_level = 1
4105
+ @level = 1
4106
+ @sub_index_1 = 0
4107
+ @sub_index_2 = 0
4108
+ @active_panel = :sub
4109
+ @submenu_open = true
4110
+ draw_frame(target_width)
4111
+ else
4112
+ move_right
4113
+ draw_frame(target_width)
4114
+ end
4115
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
4116
+ if @search && !@query.empty?
4117
+ @query.chop!
4118
+ matching = current_matching_indices
4119
+ @index = matching.first || 0
4120
+ draw_frame(target_width)
4121
+ end
4122
+ elsif key == "\x15"
4123
+ if @search
4124
+ @query.clear
4125
+ matching = current_matching_indices
4126
+ @index = matching.first || 0
4127
+ draw_frame(target_width)
4128
+ end
4129
+ elsif key == "\r" || key == "\n"
4130
+ matching = current_matching_indices
4131
+ if matching.include?(@index)
4132
+ if is_submenu_item?(@functions[@index])
4133
+ @open_level = 1
4134
+ @level = 1
4135
+ @sub_index_1 = 0
4136
+ @sub_index_2 = 0
4137
+ @active_panel = :sub
4138
+ @submenu_open = true
4139
+ draw_frame(target_width)
4140
+ else
4141
+ return @functions[@index]
4142
+ end
4143
+ end
4144
+ elsif @search && key =~ /^[[:print:]]$/
4145
+ @query << key
4146
+ matching = current_matching_indices
4147
+ @index = matching.first || 0
4148
+ draw_frame(target_width)
4149
+ end
4150
+ end
4151
+
4152
+ nil
4153
+ end
4154
+
4155
+ def read_single_key(input_stream)
4156
+ GRmenu.read_key_raw(input_stream)
4157
+ end
4158
+
4159
+ def format_auto_name(raw_name)
4160
+ cleaned = raw_name.to_s.gsub(/[_-]+/, ' ').strip
4161
+ cleaned.split(' ').map(&:capitalize).join(' ')
4162
+ end
4163
+
4164
+ def extract_name_from_action(action)
4165
+ case action
4166
+ when Array
4167
+ action[0].to_s
4168
+ when Hash
4169
+ (action[:name] || action[:title] || action["name"] || action["title"] || "Opcion").to_s
4170
+ when Method
4171
+ format_auto_name(action.name)
4172
+ when Symbol
4173
+ format_auto_name(action)
4174
+ when Proc
4175
+ if action.respond_to?(:name) && action.name
4176
+ format_auto_name(action.name)
4177
+ else
4178
+ "Opcion"
4179
+ end
4180
+ else
4181
+ if action.respond_to?(:name)
4182
+ format_auto_name(action.name)
4183
+ elsif action.respond_to?(:title)
4184
+ action.title.to_s
4185
+ else
4186
+ format_auto_name(action)
4187
+ end
4188
+ end
4189
+ end
4190
+
4191
+ def extract_description_from_action(action)
4192
+ if action.is_a?(Array) && action.length >= 3
4193
+ action[2].to_s
4194
+ elsif action.is_a?(Hash)
4195
+ (action[:desc] || action[:description] || action["desc"] || action["description"]).to_s
4196
+ else
4197
+ ""
4198
+ end
4199
+ end
4200
+
4201
+ def execute_action(action)
4202
+ case action
4203
+ when Method, Proc
4204
+ action.call
4205
+ when Symbol
4206
+ if Object.respond_to?(action, true)
4207
+ Object.send(action)
4208
+ elsif Kernel.respond_to?(action, true)
4209
+ Kernel.send(action)
4210
+ end
4211
+ when Array
4212
+ callable = action[1]
4213
+ if callable.is_a?(Symbol)
4214
+ if Object.respond_to?(callable, true)
4215
+ Object.send(callable)
4216
+ elsif Kernel.respond_to?(callable, true)
4217
+ Kernel.send(callable)
4218
+ end
4219
+ elsif callable.respond_to?(:call)
4220
+ callable.call
4221
+ end
4222
+ when Hash
4223
+ callable = action[:action] || action[:call] || action["action"] || action["call"]
4224
+ if callable.is_a?(Symbol)
4225
+ if Object.respond_to?(callable, true)
4226
+ Object.send(callable)
4227
+ elsif Kernel.respond_to?(callable, true)
4228
+ Kernel.send(callable)
4229
+ end
4230
+ elsif callable.respond_to?(:call)
4231
+ callable.call
4232
+ end
4233
+ else
4234
+ action.call if action.respond_to?(:call)
4235
+ end
4236
+ end
4237
+ end
4238
+
4239
+ Color = GRmenu::Color unless defined?(Color)
4240
+ Colors = GRmenu::Color unless defined?(Colors)
4241
+ C = GRmenu::Color unless defined?(C)
4242
+ Grmenu = GRmenu unless defined?(Grmenu)