grmenu 0.1.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/GRmenu.rb +877 -110
  3. data/README.md +268 -126
  4. data/data/borders.json +85 -0
  5. data/data/colors.json +16 -0
  6. data/data/fonts.json +3005 -0
  7. metadata +9 -6
data/GRmenu.rb CHANGED
@@ -1,14 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'io/console'
4
+ require 'json'
4
5
 
5
6
  class GRmenu
6
7
  CLEAR_SCREEN_SEQUENCE = "\e[H\e[2J\e[3J"
7
- HIDE_CURSOR = "\e[?25l"
8
- SHOW_CURSOR = "\e[?25h"
9
- CURSOR_HOME = "\e[H"
10
- CLEAR_TO_EOL = "\e[K"
11
- CLEAR_TO_EOS = "\e[J"
8
+ HIDE_CURSOR = "\e[?25l"
9
+ SHOW_CURSOR = "\e[?25h"
10
+ CURSOR_HOME = "\e[H"
11
+ CLEAR_TO_EOL = "\e[K"
12
+ CLEAR_TO_EOS = "\e[J"
13
+
14
+ module Color
15
+ RESET = "\e[0m"
16
+ BOLD = "\e[1m"
17
+
18
+ CODES = {
19
+ black: { 1 => "\e[30m", 2 => "\e[90m" },
20
+ gray: { 1 => "\e[90m", 2 => "\e[38;5;245m" },
21
+ grey: { 1 => "\e[90m", 2 => "\e[38;5;245m" },
22
+ red: { 1 => "\e[31m", 2 => "\e[91m" },
23
+ green: { 1 => "\e[32m", 2 => "\e[92m" },
24
+ yellow: { 1 => "\e[33m", 2 => "\e[93m" },
25
+ blue: { 1 => "\e[34m", 2 => "\e[94m" },
26
+ magenta: { 1 => "\e[35m", 2 => "\e[95m" },
27
+ purple: { 1 => "\e[38;5;129m", 2 => "\e[38;5;141m" },
28
+ pink: { 1 => "\e[38;5;205m", 2 => "\e[38;5;218m" },
29
+ cyan: { 1 => "\e[36m", 2 => "\e[96m" },
30
+ aqua: { 1 => "\e[38;5;45m", 2 => "\e[38;5;51m" },
31
+ orange: { 1 => "\e[38;5;208m", 2 => "\e[38;5;214m" },
32
+ white: { 1 => "\e[37m", 2 => "\e[97m" }
33
+ }.freeze
34
+
35
+ module_function
36
+
37
+ def paint(text, color_name, level = 1)
38
+ code = CODES.dig(color_name.to_sym, level) || "\e[37m"
39
+ "#{code}#{text}#{RESET}"
40
+ end
41
+
42
+ def red(s); paint(s, :red, 1); end
43
+ def bright_red(s); paint(s, :red, 2); end
44
+ def dark_red(s); paint(s, :red, 1); end
45
+
46
+ def green(s); paint(s, :green, 1); end
47
+ def bright_green(s); paint(s, :green, 2); end
48
+ def dark_green(s); paint(s, :green, 1); end
49
+
50
+ def yellow(s); paint(s, :yellow, 1); end
51
+ def bright_yellow(s); paint(s, :yellow, 2); end
52
+
53
+ def blue(s); paint(s, :blue, 1); end
54
+ def bright_blue(s); paint(s, :blue, 2); end
55
+
56
+ def magenta(s); paint(s, :magenta, 1); end
57
+ def bright_magenta(s); paint(s, :magenta, 2); end
58
+
59
+ def purple(s); paint(s, :purple, 1); end
60
+ def bright_purple(s); paint(s, :purple, 2); end
61
+
62
+ def pink(s); paint(s, :pink, 1); end
63
+ def bright_pink(s); paint(s, :pink, 2); end
64
+
65
+ def cyan(s); paint(s, :cyan, 1); end
66
+ def bright_cyan(s); paint(s, :cyan, 2); end
67
+
68
+ def aqua(s); paint(s, :aqua, 1); end
69
+ def bright_aqua(s); paint(s, :aqua, 2); end
70
+
71
+ def orange(s); paint(s, :orange, 1); end
72
+ def bright_orange(s); paint(s, :orange, 2); end
73
+
74
+ def white(s); paint(s, :white, 1); end
75
+ def bright_white(s); paint(s, :white, 2); end
76
+
77
+ def black(s); paint(s, :black, 1); end
78
+ def gray(s); paint(s, :gray, 1); end
79
+ def bright_gray(s); paint(s, :gray, 2); end
80
+ def grey(s); gray(s); end
81
+
82
+ def r(s); bright_red(s); end
83
+ def dr(s); dark_red(s); end
84
+ def g(s); bright_green(s); end
85
+ def y(s); bright_yellow(s); end
86
+ def w(s); bright_white(s); end
87
+ def gr(s); gray(s); end
88
+ def cy(s); bright_cyan(s); end
89
+ def mg(s); bright_magenta(s); end
90
+ def bl(s); bright_blue(s); end
91
+ end
92
+ C = Color
12
93
 
13
94
  STYLES = {
14
95
  1 => "#", 2 => "┌", 3 => "╔", 4 => "┏", 5 => "╒",
@@ -18,15 +99,21 @@ class GRmenu
18
99
  }.freeze
19
100
 
20
101
  COLORS = {
21
- "black" => { 1 => "\e[30m", 2 => "\e[90m" },
22
- "red" => { 1 => "\e[31m", 2 => "\e[91m" },
23
- "green" => { 1 => "\e[32m", 2 => "\e[92m" },
24
- "yellow" => { 1 => "\e[33m", 2 => "\e[93m" },
25
- "blue" => { 1 => "\e[34m", 2 => "\e[94m" },
26
- "magenta" => { 1 => "\e[35m", 2 => "\e[95m" },
27
- "cyan" => { 1 => "\e[36m", 2 => "\e[96m" },
28
- "white" => { 1 => "\e[37m", 2 => "\e[97m" },
29
- "reset" => "\e[0m"
102
+ "black" => { 1 => "\e[30m", 2 => "\e[90m" },
103
+ "gray" => { 1 => "\e[90m", 2 => "\e[38;5;245m" },
104
+ "grey" => { 1 => "\e[90m", 2 => "\e[38;5;245m" },
105
+ "red" => { 1 => "\e[31m", 2 => "\e[91m" },
106
+ "green" => { 1 => "\e[32m", 2 => "\e[92m" },
107
+ "yellow" => { 1 => "\e[33m", 2 => "\e[93m" },
108
+ "blue" => { 1 => "\e[34m", 2 => "\e[94m" },
109
+ "magenta" => { 1 => "\e[35m", 2 => "\e[95m" },
110
+ "purple" => { 1 => "\e[38;5;129m", 2 => "\e[38;5;141m" },
111
+ "pink" => { 1 => "\e[38;5;205m", 2 => "\e[38;5;218m" },
112
+ "cyan" => { 1 => "\e[36m", 2 => "\e[96m" },
113
+ "aqua" => { 1 => "\e[38;5;45m", 2 => "\e[38;5;51m" },
114
+ "orange" => { 1 => "\e[38;5;208m", 2 => "\e[38;5;214m" },
115
+ "white" => { 1 => "\e[37m", 2 => "\e[97m" },
116
+ "reset" => "\e[0m"
30
117
  }.freeze
31
118
 
32
119
  BORDERS = {
@@ -37,29 +124,210 @@ class GRmenu
37
124
  5 => { h: "═", v: "│", tl: "╒", tr: "╕", bl: "╘", br: "╛" },
38
125
  6 => { h: "─", v: "║", tl: "╓", tr: "╖", bl: "╙", br: "╜" },
39
126
  7 => { h: "─", v: "│", tl: "╭", tr: "╮", bl: "╰", br: "╯" },
40
- 8 => { h: "▀", v: "▌", tl: "▛", tr: "▜", bl: "▙", br: "▟" },
127
+ 8 => { h: "▀", hb: "▄", v: "▌", vl: "▌", vr: "▐", tl: "▛", tr: "▜", bl: "▙", br: "▟" },
41
128
  19 => { h: "●○", v: "●", tl: "●", tr: "●", bl: "●", br: "●" },
42
129
  20 => { h: "★☆", v: "★", tl: "★", tr: "★", bl: "★", br: "★" }
43
130
  }.freeze
44
131
 
132
+ def self._normalize_font(f)
133
+ normalized = {}
134
+ f.each do |key, lines|
135
+ max_w = lines.map(&:length).max
136
+ normalized[key] = lines.map { |line| line.ljust(max_w) }.freeze
137
+ end
138
+ normalized.freeze
139
+ end
140
+
141
+ def self._load_fonts
142
+ possible_paths = [
143
+ File.expand_path("data/fonts.json", __dir__),
144
+ File.expand_path("../data/fonts.json", __dir__),
145
+ File.expand_path("fonts.json", __dir__)
146
+ ]
147
+ path = possible_paths.find { |p| File.file?(p) }
148
+ return {}.freeze unless path
149
+
150
+ raw_fonts = JSON.parse(File.read(path))
151
+ loaded = {}
152
+ raw_fonts.each do |font_key, chars|
153
+ font_id = font_key.to_i
154
+ loaded[font_id] = _normalize_font(chars)
155
+ end
156
+ loaded.freeze
157
+ rescue StandardError
158
+ {}.freeze
159
+ end
160
+
161
+ FONTS = _load_fonts
162
+ FONTS.each { |id, data| const_set("FONT_#{id}", data) }
163
+ FONT = FONTS[1] || {}.freeze
164
+
165
+ class ProgressBar
166
+ attr_reader :total, :current, :title, :status
167
+
168
+ def initialize(total = 100, title: nil, color: "cyan", level: 2, style: 3, width: nil)
169
+ @total = [total.to_i, 1].max
170
+ @current = 0
171
+ @title = title
172
+ @status = ""
173
+ @color = color.to_s.downcase
174
+ @level = level.to_i
175
+ @style = style.to_i
176
+ @width = width
177
+ @closed = false
178
+ @drawn_lines_count = 0
179
+ end
180
+
181
+ def advance(step = 1, status: nil)
182
+ return if @closed
183
+ @current = [(@current + step), @total].min
184
+ @status = status.to_s if status
185
+ render
186
+ end
187
+ alias_method :increment, :advance
188
+ alias_method :step, :advance
189
+
190
+ def set(value, status: nil)
191
+ return if @closed
192
+ @current = [[value.to_i, 0].max, @total].min
193
+ @status = status.to_s if status
194
+ render
195
+ end
196
+
197
+ def render
198
+ term_w = GRmenu.terminal_width
199
+ box_w = @width || [term_w - 4, 60].min
200
+ box_w = [box_w, 36].max
201
+
202
+ color_code = GRmenu::COLORS.dig(@color, @level) || "\e[1;96m"
203
+ reset_code = GRmenu::COLORS["reset"]
204
+ border_cfg = GRmenu::BORDERS[@style] || GRmenu::BORDERS[3]
205
+
206
+ v_l = border_cfg[:vl] || border_cfg[:v]
207
+ v_r = border_cfg[:vr] || border_cfg[:v]
208
+ h_t = border_cfg[:ht] || border_cfg[:h]
209
+ h_b = border_cfg[:hb] || border_cfg[:h]
210
+
211
+ top_fill = (h_t * ((box_w - 2).to_f / h_t.length).ceil)[0...(box_w - 2)]
212
+ bot_fill = (h_b * ((box_w - 2).to_f / h_b.length).ceil)[0...(box_w - 2)]
213
+
214
+ pct = ((@current.to_f / @total) * 100).round
215
+ pct_str = "#{pct}% (#{@current}/#{@total})"
216
+
217
+ inner_w = box_w - 4
218
+ bar_w = [inner_w - pct_str.length - 3, 10].max
219
+ filled_len = ((@current.to_f / @total) * bar_w).round
220
+ empty_len = bar_w - filled_len
221
+
222
+ bar_str = "[#{"█" * filled_len}#{"░" * empty_len}] #{pct_str}"
223
+ bar_line = bar_str.ljust(inner_w)[0...inner_w]
224
+
225
+ lines = []
226
+ lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
227
+ if @title && !@title.empty?
228
+ lines << "#{color_code}#{v_l}#{reset_code} #{@title.center(inner_w)} #{color_code}#{v_r}#{reset_code}"
229
+ lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
230
+ end
231
+ lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
232
+ if @status && !@status.empty?
233
+ stat_line = @status.ljust(inner_w)[0...inner_w]
234
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(stat_line)} #{color_code}#{v_r}#{reset_code}"
235
+ end
236
+ lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
237
+
238
+ frame = lines.join("\r\n") + "\r\n"
239
+
240
+ if @drawn_lines_count && @drawn_lines_count > 0
241
+ Kernel.print("\e[#{@drawn_lines_count}A\e[J")
242
+ end
243
+ Kernel.print(frame)
244
+ $stdout.flush
245
+ @drawn_lines_count = lines.length
246
+ end
247
+
248
+ def finish(status: "¡Completado!")
249
+ return if @closed
250
+ set(@total, status: status)
251
+ @closed = true
252
+ Kernel.print(GRmenu::SHOW_CURSOR)
253
+ end
254
+ end
255
+
256
+ def self.spinner(message = "Cargando...", color: "cyan", level: 2, delay: 0.08, &block)
257
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
258
+ color_name = color.to_s.downcase
259
+ color_code = COLORS.dig(color_name, level) || "\e[1;96m"
260
+ reset_code = COLORS["reset"]
261
+
262
+ stop_spinner = false
263
+ spinner_thread = Thread.new do
264
+ frame_idx = 0
265
+ while !stop_spinner
266
+ f = frames[frame_idx % frames.length]
267
+ Kernel.print("\r\e[K#{color_code}#{f}#{reset_code} #{message}")
268
+ $stdout.flush
269
+ frame_idx += 1
270
+ sleep(delay)
271
+ end
272
+ end
273
+
274
+ begin
275
+ Kernel.print(HIDE_CURSOR)
276
+ result = block ? block.call : nil
277
+ stop_spinner = true
278
+ spinner_thread.join
279
+ success_color = COLORS.dig("green", 2) || "\e[1;92m"
280
+ Kernel.print("\r\e[K#{success_color}✔#{reset_code} #{message} #{Color.gray("¡Listo!")}\r\n")
281
+ result
282
+ rescue Exception => e
283
+ stop_spinner = true
284
+ spinner_thread.join rescue nil
285
+ error_color = COLORS.dig("red", 2) || "\e[1;91m"
286
+ Kernel.print("\r\e[K#{error_color}✖#{reset_code} #{message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
287
+ raise e
288
+ ensure
289
+ stop_spinner = true
290
+ Kernel.print(SHOW_CURSOR)
291
+ end
292
+ end
293
+
294
+ def self.progress(total = 100, title: nil, color: "cyan", level: 2, style: 3, width: nil, &block)
295
+ bar = ProgressBar.new(total, title: title, color: color, level: level, style: style, width: width)
296
+ Kernel.print(HIDE_CURSOR)
297
+ bar.render
298
+ begin
299
+ result = block ? block.call(bar) : bar
300
+ bar.finish
301
+ result
302
+ ensure
303
+ Kernel.print(SHOW_CURSOR)
304
+ end
305
+ end
306
+
45
307
  class SetStyle
46
308
  def initialize(
47
- border: { color: "cyan", level: 1 },
48
- options: { color: "white", level: 1 },
49
- focus: { color: "green", level: 2 }
309
+ border: { color: "cyan", level: 1 },
310
+ options: { color: "white", level: 1 },
311
+ focus: { color: "green", level: 2 },
312
+ title: { color: "yellow", level: 2 },
313
+ banner: { color: "magenta", level: 2 },
314
+ subtitle: { color: "cyan", level: 2 },
315
+ divider: { color: "blue", level: 1 },
316
+ font: 1
50
317
  )
51
- @border = border.dup
52
- @options = options.dup
53
- @focus = focus.dup
318
+ @border = border.dup
319
+ @options = options.dup
320
+ @focus = focus.dup
321
+ @title = title.dup
322
+ @banner = banner.dup
323
+ @subtitle = subtitle.dup
324
+ @divider = divider.dup
325
+ @font = font.to_i
54
326
  end
55
327
 
56
328
  def border(color_name = nil, brightness_level = 1)
57
329
  return @border if color_name.nil?
58
- if color_name.is_a?(Hash)
59
- @border = color_name
60
- else
61
- @border = { color: color_name.to_s, level: brightness_level.to_i }
62
- end
330
+ @border = parse_color(color_name, brightness_level)
63
331
  end
64
332
  alias_method :Border, :border
65
333
  alias_method :set_border, :border
@@ -67,11 +335,7 @@ class GRmenu
67
335
 
68
336
  def options(color_name = nil, brightness_level = 1)
69
337
  return @options if color_name.nil?
70
- if color_name.is_a?(Hash)
71
- @options = color_name
72
- else
73
- @options = { color: color_name.to_s, level: brightness_level.to_i }
74
- end
338
+ @options = parse_color(color_name, brightness_level)
75
339
  end
76
340
  alias_method :Options, :options
77
341
  alias_method :set_options, :options
@@ -79,25 +343,67 @@ class GRmenu
79
343
 
80
344
  def focus(color_name = nil, brightness_level = 2)
81
345
  return @focus if color_name.nil?
82
- if color_name.is_a?(Hash)
83
- @focus = color_name
84
- else
85
- @focus = { color: color_name.to_s, level: brightness_level.to_i }
86
- end
346
+ @focus = parse_color(color_name, brightness_level)
87
347
  end
88
348
  alias_method :Focus, :focus
89
349
  alias_method :set_focus, :focus
90
350
  alias_method :focus=, :focus
91
351
 
352
+ def title(color_name = nil, brightness_level = 2)
353
+ return @title if color_name.nil?
354
+ @title = parse_color(color_name, brightness_level)
355
+ end
356
+ alias_method :Title, :title
357
+ alias_method :set_title, :title
358
+ alias_method :title=, :title
359
+
360
+ def banner(color_name = nil, brightness_level = 2)
361
+ return @banner if color_name.nil?
362
+ @banner = parse_color(color_name, brightness_level)
363
+ end
364
+ alias_method :Banner, :banner
365
+ alias_method :set_banner, :banner
366
+ alias_method :banner=, :banner
367
+
368
+ def subtitle(color_name = nil, brightness_level = 2)
369
+ return @subtitle if color_name.nil?
370
+ @subtitle = parse_color(color_name, brightness_level)
371
+ end
372
+ alias_method :Subtitle, :subtitle
373
+ alias_method :set_subtitle, :subtitle
374
+ alias_method :subtitle=, :subtitle
375
+
376
+ def divider(color_name = nil, brightness_level = 1)
377
+ return @divider if color_name.nil?
378
+ @divider = parse_color(color_name, brightness_level)
379
+ end
380
+ alias_method :Divider, :divider
381
+ alias_method :set_divider, :divider
382
+ alias_method :divider=, :divider
383
+
384
+ def font(font_id = nil)
385
+ return @font if font_id.nil?
386
+ @font = font_id.to_i
387
+ end
388
+ alias_method :Font, :font
389
+ alias_method :set_font, :font
390
+ alias_method :font=, :font
391
+
392
+ private
393
+
394
+ def parse_color(color_val, default_level = 1)
395
+ if color_val.is_a?(Hash)
396
+ { color: (color_val[:color] || color_val["color"]).to_s, level: (color_val[:level] || color_val["level"] || default_level).to_i }
397
+ else
398
+ { color: color_val.to_s, level: default_level.to_i }
399
+ end
400
+ end
401
+
92
402
  class << self
93
403
  def border(color_name = nil, brightness_level = 1)
94
404
  @default_border ||= { color: "cyan", level: 1 }
95
405
  return @default_border if color_name.nil?
96
- if color_name.is_a?(Hash)
97
- @default_border = color_name
98
- else
99
- @default_border = { color: color_name.to_s, level: brightness_level.to_i }
100
- end
406
+ @default_border = { color: color_name.to_s, level: brightness_level.to_i }
101
407
  end
102
408
  alias_method :Border, :border
103
409
  alias_method :border=, :border
@@ -105,11 +411,7 @@ class GRmenu
105
411
  def options(color_name = nil, brightness_level = 1)
106
412
  @default_options ||= { color: "white", level: 1 }
107
413
  return @default_options if color_name.nil?
108
- if color_name.is_a?(Hash)
109
- @default_options = color_name
110
- else
111
- @default_options = { color: color_name.to_s, level: brightness_level.to_i }
112
- end
414
+ @default_options = { color: color_name.to_s, level: brightness_level.to_i }
113
415
  end
114
416
  alias_method :Options, :options
115
417
  alias_method :options=, :options
@@ -117,14 +419,51 @@ class GRmenu
117
419
  def focus(color_name = nil, brightness_level = 2)
118
420
  @default_focus ||= { color: "green", level: 2 }
119
421
  return @default_focus if color_name.nil?
120
- if color_name.is_a?(Hash)
121
- @default_focus = color_name
122
- else
123
- @default_focus = { color: color_name.to_s, level: brightness_level.to_i }
124
- end
422
+ @default_focus = { color: color_name.to_s, level: brightness_level.to_i }
125
423
  end
126
424
  alias_method :Focus, :focus
127
425
  alias_method :focus=, :focus
426
+
427
+ def title(color_name = nil, brightness_level = 2)
428
+ @default_title ||= { color: "yellow", level: 2 }
429
+ return @default_title if color_name.nil?
430
+ @default_title = { color: color_name.to_s, level: brightness_level.to_i }
431
+ end
432
+ alias_method :Title, :title
433
+ alias_method :title=, :title
434
+
435
+ def banner(color_name = nil, brightness_level = 2)
436
+ @default_banner ||= { color: "magenta", level: 2 }
437
+ return @default_banner if color_name.nil?
438
+ @default_banner = { color: color_name.to_s, level: brightness_level.to_i }
439
+ end
440
+ alias_method :Banner, :banner
441
+ alias_method :banner=, :banner
442
+
443
+ def subtitle(color_name = nil, brightness_level = 2)
444
+ @default_subtitle ||= { color: "cyan", level: 2 }
445
+ return @default_subtitle if color_name.nil?
446
+ @default_subtitle = { color: color_name.to_s, level: brightness_level.to_i }
447
+ end
448
+ alias_method :Subtitle, :subtitle
449
+ alias_method :subtitle=, :subtitle
450
+
451
+ def divider(color_name = nil, brightness_level = 1)
452
+ @default_divider ||= { color: "blue", level: 1 }
453
+ return @default_divider if color_name.nil?
454
+ @default_divider = { color: color_name.to_s, level: brightness_level.to_i }
455
+ end
456
+ alias_method :Divider, :divider
457
+ alias_method :divider=, :divider
458
+
459
+ def font(font_id = nil)
460
+ @default_font ||= 1
461
+ return @default_font if font_id.nil?
462
+ @default_font = font_id.to_i
463
+ end
464
+ alias_method :Font, :font
465
+ alias_method :set_font, :font
466
+ alias_method :font=, :font
128
467
  end
129
468
  end
130
469
 
@@ -136,7 +475,7 @@ class GRmenu
136
475
  end
137
476
  end
138
477
 
139
- attr_accessor :functions, :title, :style, :index, :style_config
478
+ attr_accessor :functions, :title, :subtitle, :banner, :banner_style, :divider, :style, :index, :style_config, :center, :page_size
140
479
 
141
480
  alias_method :options, :functions
142
481
  alias_method :options=, :functions=
@@ -144,37 +483,262 @@ class GRmenu
144
483
  alias_method :selected_index=, :index=
145
484
  alias_method :SetStyle, :style_config
146
485
  alias_method :set_style, :style_config
486
+ alias_method :description, :subtitle
487
+ alias_method :description=, :subtitle=
488
+
489
+ def self.STYLES; STYLES; end
490
+ def self.COLORS; COLORS; end
491
+ def self.BORDERS; BORDERS; end
492
+ def self.FONTS; FONTS; end
493
+ def self.FONT; FONT_1; end
494
+
495
+ def self.terminal_width
496
+ cols = $stdout.winsize[1] rescue nil
497
+ cols = $stdin.winsize[1] rescue nil if cols.nil? || cols <= 0
498
+ (cols && cols > 0) ? cols : (ENV['COLUMNS'] ? ENV['COLUMNS'].to_i : 80)
499
+ rescue StandardError
500
+ 80
501
+ end
147
502
 
148
- def self.STYLES
149
- STYLES
503
+ def self.terminal_height
504
+ rows = $stdout.winsize[0] rescue nil
505
+ rows = $stdin.winsize[0] rescue nil if rows.nil? || rows <= 0
506
+ (rows && rows > 0) ? rows : (ENV['LINES'] ? ENV['LINES'].to_i : 24)
507
+ rescue StandardError
508
+ 24
509
+ end
510
+
511
+ def self.clear_screen
512
+ Kernel.print(CLEAR_SCREEN_SEQUENCE)
513
+ end
514
+ class << self
515
+ alias_method :clr, :clear_screen
150
516
  end
151
517
 
152
- def self.COLORS
153
- COLORS
518
+ def self.div(long = nil, color = "blue", level = 1, char = "─")
519
+ width = long || [terminal_width - 2, 64].min
520
+ color_code = COLORS.dig(color.to_s.downcase, level) || "\e[34m"
521
+ Kernel.print("#{color_code}#{char * width}#{COLORS['reset']}\r\n")
154
522
  end
155
523
 
156
- def self.BORDERS
157
- BORDERS
524
+ def self.help(section = :all)
525
+ w = [terminal_width - 4, 70].min
526
+ w = [w, 46].max
527
+ inner_w = w - 2
528
+ h_line = "═" * inner_w
529
+ s_line = "─" * w
530
+
531
+ Kernel.print "\r\n"
532
+ Kernel.print "#{Color.bright_cyan("╔" + h_line + "╗")}\r\n"
533
+ Kernel.print "#{Color.bright_cyan("║")}#{Color.bright_yellow("GRmenu - Guia y Referencia Completa (v2.0)".center(inner_w))}#{Color.bright_cyan("║")}\r\n"
534
+ Kernel.print "#{Color.bright_cyan("║")}#{Color.gray("Menus interactivos, Banners 3D, Barras de Progreso y TTY".center(inner_w))}#{Color.bright_cyan("║")}\r\n"
535
+ Kernel.print "#{Color.bright_cyan("╚" + h_line + "╝")}\r\n\r\n"
536
+
537
+ Kernel.print "#{Color.bright_magenta("[1] HELPERS NATIVOS")}\r\n"
538
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
539
+ Kernel.print " #{Color.bright_green("GRmenu.clear_screen")} #{Color.gray("(o GRmenu.clr)")}\r\n"
540
+ Kernel.print " * Limpia la terminal al instante con secuencias ANSI.\r\n"
541
+ Kernel.print " #{Color.bright_green("GRmenu.continue(mensaje)")}\r\n"
542
+ Kernel.print " * Pausa interactiva: espera una sola tecla en modo TTY crudo.\r\n"
543
+ Kernel.print " #{Color.bright_green("GRmenu.banner(texto, delay, color:, level:, style:, font:)")}\r\n"
544
+ Kernel.print " * Renderiza banner ASCII 3D con marco y animacion opcional.\r\n"
545
+ Kernel.print " #{Color.bright_green("GRmenu.spinner(mensaje, color:, level:, delay:, &bloque)")}\r\n"
546
+ Kernel.print " * Animacion giratoria fluida para tareas de tiempo desconocido.\r\n"
547
+ Kernel.print " * Ejemplo: #{Color.bright_white("GRmenu.spinner(\"Conectando...\") { conectar_db }")}\r\n"
548
+ Kernel.print " #{Color.bright_green("GRmenu.progress(total, title:, color:, level:, style:, width:, &bloque)")}\r\n"
549
+ Kernel.print " * Barra de progreso porcentual dentro de un recuadro estilizado.\r\n"
550
+ Kernel.print " * El bloque recibe 'bar'. Metodos disponibles:\r\n"
551
+ Kernel.print " - #{Color.cyan("bar.advance(n, status: \"...\")")} -> Avanza n pasos (alias: increment, step).\r\n"
552
+ Kernel.print " - #{Color.cyan("bar.set(valor, status: \"...\")")} -> Fija el valor exacto actual.\r\n"
553
+ Kernel.print " - #{Color.cyan("bar.finish(status: \"...\")")} -> Finaliza la barra al 100%.\r\n"
554
+ Kernel.print " * Ejemplo: #{Color.bright_white("GRmenu.progress(10, title: \"Copia\") { |b| 10.times { b.advance(1) } }")}\r\n"
555
+ Kernel.print " #{Color.bright_green("GRmenu.div(longitud, color, level, char)")}\r\n"
556
+ Kernel.print " * Dibuja linea divisoria horizontal adaptable a la consola.\r\n\r\n"
557
+
558
+ Kernel.print "#{Color.bright_magenta("[2] FORMATOS DE OPCIONES Y TOOLTIPS DINAMICOS")}\r\n"
559
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
560
+ Kernel.print " #{Color.cyan("1. Metodo directo:")} #{Color.bright_white("method(:iniciar)")} #{Color.gray("(auto-capitaliza nombre)")}\r\n"
561
+ Kernel.print " #{Color.cyan("2. Simbolo:")} #{Color.bright_white(":iniciar")}\r\n"
562
+ Kernel.print " #{Color.cyan("3. Nombre propio:")} #{Color.bright_white("[\"Mi Accion\", method(:iniciar)]")}\r\n"
563
+ Kernel.print " #{Color.cyan("4. Con Tooltip/Info:")} #{Color.bright_white("[\"Mi Accion\", method(:iniciar), \"Descripcion que sale abajo\"]")}\r\n"
564
+ Kernel.print " #{Color.cyan("5. Lambda / Proc:")} #{Color.bright_white("[\"Test\", -> { puts \"Hola\" }, \"Tooltip opcional\"]")}\r\n"
565
+ Kernel.print " #{Color.cyan("6. Hash:")} #{Color.bright_white("{ name: \"Test\", action: method(:iniciar), desc: \"Info\" }")}\r\n\r\n"
566
+
567
+ Kernel.print "#{Color.bright_magenta("[3] PARAMETROS DE GRmenu.new(functions, ...)")}\r\n"
568
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
569
+ Kernel.print " #{Color.bright_green("functions:")} #{Color.white("Array")} -> Lista de opciones (metodos, simbolos, arreglos, lambdas).\r\n"
570
+ Kernel.print " #{Color.bright_green("banner:")} #{Color.white("String")} -> Texto grande a renderizar en arte ASCII 3D.\r\n"
571
+ Kernel.print " #{Color.bright_green("title:")} #{Color.white("String")} -> Titulo en el encabezado del recuadro.\r\n"
572
+ Kernel.print " #{Color.bright_green("subtitle:")} #{Color.white("String")} -> Subtitulo / descripcion (soporta \\n).\r\n"
573
+ Kernel.print " #{Color.bright_green("page_size:")} #{Color.white("Integer")} -> Limite visible para scroll y paginacion automatica.\r\n"
574
+ Kernel.print " #{Color.bright_green("font:")} #{Color.white("Integer")} -> Fuente ASCII del banner (1 al 10, default 1).\r\n"
575
+ Kernel.print " #{Color.bright_green("style:")} #{Color.white("Integer")} -> Estilo de marco de opciones (1 al 20, default 19).\r\n"
576
+ Kernel.print " #{Color.bright_green("banner_style:")} #{Color.white("Integer")} -> Estilo de marco del banner (1 al 20, default 3).\r\n"
577
+ Kernel.print " #{Color.bright_green("divider:")} #{Color.white("Boolean")} -> Divisores alineados al banner (true/false).\r\n"
578
+ Kernel.print " #{Color.bright_green("center:")} #{Color.white("Boolean")} -> Centrado simetrico de subtitulo y menu (default true).\r\n\r\n"
579
+
580
+ Kernel.print "#{Color.bright_magenta("[4] AUTO-PAGINACION Y SCROLL")}\r\n"
581
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
582
+ Kernel.print " * #{Color.white("100% Automatica:")} Si la lista tiene muchas opciones o la pantalla es pequena,\r\n"
583
+ Kernel.print " GRmenu calcula el espacio disponible y genera una ventana deslizante suave.\r\n"
584
+ Kernel.print " * Indicadores visuales: #{Color.bright_yellow("▲ (+N arriba)")} y #{Color.bright_yellow("▼ (+M abajo)")}.\r\n"
585
+ Kernel.print " * Opcional: fija el limite con #{Color.bright_white("page_size: 8")} al instanciar #{Color.bright_green("GRmenu.new")}.\r\n\r\n"
586
+
587
+ Kernel.print "#{Color.bright_magenta("[5] MODULO DE COLORES (Color / C)")}\r\n"
588
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
589
+ Kernel.print " #{Color.cyan("Uso directo: ")}#{Color.bright_white("puts Color.green(\"Texto\")")} | #{Color.bright_white("puts Color.bright_cyan(\"Texto\")")}\r\n"
590
+ Kernel.print " #{Color.cyan("Paleta: ")}#{Color.red("red")}, #{Color.green("green")}, #{Color.yellow("yellow")}, #{Color.blue("blue")}, #{Color.magenta("magenta")}, #{Color.purple("purple")}, #{Color.pink("pink")}, #{Color.cyan("cyan")}, #{Color.aqua("aqua")}, #{Color.orange("orange")}, #{Color.white("white")}, #{Color.gray("gray")}, #{Color.black("black")}.\r\n"
591
+ Kernel.print " #{Color.cyan("Brillo: ")}#{Color.white("1")} = Normal, #{Color.bright_white("2")} = Brillante / Bold.\r\n\r\n"
592
+
593
+ Kernel.print "#{Color.bright_magenta("[6] FUENTES ASCII 3D DEL BANNER (font: 1 al 10)")}\r\n"
594
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
595
+ Kernel.print " #{Color.yellow("1")} -> #{Color.bright_white("ANSI Shadow 3D (Default)")} #{Color.cyan("[██████╗ ██╗ ██╗]")}\r\n"
596
+ Kernel.print " #{Color.yellow("2")} -> #{Color.bright_white("Slant 3D (FIGlet)")} #{Color.cyan("[ ____ __ __]")}\r\n"
597
+ Kernel.print " #{Color.yellow("3")} -> #{Color.bright_white("Doom / Standard 3D")} #{Color.cyan("[ ____ _ _]")}\r\n"
598
+ Kernel.print " #{Color.yellow("4")} -> #{Color.bright_white("Graffiti Shadow 3D")} #{Color.cyan("[ ,---. ,--. ,--.]")}\r\n"
599
+ Kernel.print " #{Color.yellow("5")} -> #{Color.bright_white("Small Slant / Mini 3D")} #{Color.cyan("[ ___ _ _]")}\r\n"
600
+ Kernel.print " #{Color.yellow("6")} -> #{Color.bright_white("Modular Pipe 3D")} #{Color.cyan("[ _____ _____]")}\r\n"
601
+ Kernel.print " #{Color.yellow("7")} -> #{Color.bright_white("Bubble / Round Gothic")} #{Color.cyan("[ ____ _ _]")}\r\n"
602
+ Kernel.print " #{Color.yellow("8")} -> #{Color.bright_white("Double-Line Wire 3D")} #{Color.cyan("[ ╔═════╗ ║ ║]")}\r\n"
603
+ Kernel.print " #{Color.yellow("9")} -> #{Color.bright_white("Solid Fat 3D Block")} #{Color.cyan("[ ██████▄ ██ ██]")}\r\n"
604
+ Kernel.print " #{Color.yellow("10")}-> #{Color.bright_white("Arcade Stars Matrix")} #{Color.cyan("[ ★★★★ ★ ★]")}\r\n\r\n"
605
+
606
+ Kernel.print "#{Color.bright_magenta("[7] ESTILOS DE MARCO (style / banner_style: 1 al 20)")}\r\n"
607
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
608
+ Kernel.print " #{Color.yellow("3")} -> #{Color.bright_white("Doble linea")} #{Color.cyan("╔═══╗ ║ ║ ╚═══╝")} (Default en Banner)\r\n"
609
+ Kernel.print " #{Color.yellow("7")} -> #{Color.bright_white("Curvas redondeadas")} #{Color.cyan("╭───╮ │ │ ╰───╯")}\r\n"
610
+ Kernel.print " #{Color.yellow("4")} -> #{Color.bright_white("Linea gruesa")} #{Color.cyan("┏━━━┓ ┃ ┃ ┗━━━┛")}\r\n"
611
+ Kernel.print " #{Color.yellow("2")} -> #{Color.bright_white("Linea simple")} #{Color.cyan("┌───┐ │ │ └───┘")}\r\n"
612
+ Kernel.print " #{Color.yellow("8")} -> #{Color.bright_white("Bloques outline")} #{Color.cyan("▛▀▀▀▜ ▌ ▐ ▙▄▄▄▟")}\r\n"
613
+ Kernel.print " #{Color.yellow("19")} -> #{Color.bright_white("Circulos")} #{Color.cyan("●○○○● ● ● ●○○○●")} (Default en Opciones)\r\n"
614
+ Kernel.print " #{Color.yellow("20")} -> #{Color.bright_white("Estrellas")} #{Color.cyan("★☆☆☆★ ★ ★ ★☆☆☆★")}\r\n\r\n"
615
+
616
+ Kernel.print "#{Color.bright_magenta("[8] METODOS DE CONFIGURACION (menu.set_style)")}\r\n"
617
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
618
+ Kernel.print " #{Color.cyan("menu.set_style.font(id)")} -> Cambia fuente ASCII (1..10)\r\n"
619
+ Kernel.print " #{Color.cyan("menu.set_style.banner(color, level)")} -> Color y brillo del banner ASCII\r\n"
620
+ Kernel.print " #{Color.cyan("menu.set_style.title(color, level)")} -> Color y brillo del titulo\r\n"
621
+ Kernel.print " #{Color.cyan("menu.set_style.subtitle(color, level)")} -> Color y brillo del subtitulo\r\n"
622
+ Kernel.print " #{Color.cyan("menu.set_style.divider(color, level)")} -> Color y brillo de las lineas divisorias\r\n"
623
+ Kernel.print " #{Color.cyan("menu.set_style.border(color, level)")} -> Color y brillo del marco de opciones\r\n"
624
+ Kernel.print " #{Color.cyan("menu.set_style.options(color, level)")} -> Color y brillo de opciones no activas\r\n"
625
+ Kernel.print " #{Color.cyan("menu.set_style.focus(color, level)")} -> Color y brillo de la opcion resaltada\r\n\r\n"
626
+
627
+ Kernel.print "#{Color.bright_magenta("[9] EJECUCION (menu.draw)")}\r\n"
628
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n"
629
+ Kernel.print " #{Color.bright_white("menu.draw(size_max: 38)")} -> Inicia el menu interactivo con ancho minimo.\r\n"
630
+ Kernel.print "#{Color.bright_blue(s_line)}\r\n\r\n"
158
631
  end
159
632
 
160
- def initialize(functions, *positional_arguments, title: nil, style: nil, **keyword_arguments)
633
+ def help
634
+ self.class.help
635
+ end
636
+
637
+ def self.continue(text = "Presiona cualquier tecla para continuar...")
638
+ Kernel.print("#{Color.gray(text)} ")
639
+ if $stdin.respond_to?(:raw) && $stdin.respond_to?(:tty?) && $stdin.tty?
640
+ $stdin.raw(&:getch)
641
+ elsif $stdin.respond_to?(:getch)
642
+ $stdin.getch
643
+ else
644
+ $stdin.read(1)
645
+ end
646
+ Kernel.print("\r\n")
647
+ end
648
+
649
+ def self.build_ascii_lines(text, max_cols = terminal_width, font_id = 1)
650
+ target_font = FONTS[font_id.to_i] || FONTS[1]
651
+ clean_chars = text.to_s.upcase.chars.select { |c| target_font.key?(c) }
652
+ return [] if clean_chars.empty?
653
+
654
+ font_height = target_font.values.first.length
655
+
656
+ [2, 1, 0].each do |spacing|
657
+ lines = Array.new(font_height, "")
658
+ clean_chars.each_with_index do |c, idx|
659
+ fig = target_font[c]
660
+ pad = (idx == clean_chars.length - 1) ? "" : (" " * spacing)
661
+ font_height.times { |i| lines[i] += fig[i] + pad }
662
+ end
663
+
664
+ max_len = lines.map(&:length).max
665
+ return lines if (max_len + 6) <= max_cols
666
+ end
667
+
668
+ nil
669
+ end
670
+
671
+ def self.banner(text, delay = 0, color: "magenta", level: 2, style: 3, font: 1)
672
+ cols = terminal_width
673
+ color_code = COLORS.dig(color.to_s.downcase, level) || "\e[1;95m"
674
+ reset_code = COLORS["reset"]
675
+
676
+ ascii_rows = build_ascii_lines(text, cols, font)
677
+ border_cfg = BORDERS[style] || BORDERS[3]
678
+ h_top = border_cfg[:ht] || border_cfg[:h]
679
+ h_bot = border_cfg[:hb] || border_cfg[:h]
680
+ v_l = border_cfg[:vl] || border_cfg[:v]
681
+ v_r = border_cfg[:vr] || border_cfg[:v]
682
+
683
+ if ascii_rows
684
+ max_len = ascii_rows.map(&:length).max
685
+ top_fill = (h_top * ((max_len + 4).to_f / h_top.length).ceil)[0...(max_len + 4)]
686
+ bot_fill = (h_bot * ((max_len + 4).to_f / h_bot.length).ceil)[0...(max_len + 4)]
687
+
688
+ Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
689
+ ascii_rows.each do |line|
690
+ pad = " " * (max_len - line.length)
691
+ Kernel.print("#{color_code}#{v_l} #{line}#{pad} #{v_r}#{reset_code}\r\n")
692
+ sleep(delay) if delay > 0
693
+ end
694
+ Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
695
+ else
696
+ clean_t = text.to_s.strip
697
+ box_w = [clean_t.length + 6, cols - 2].min
698
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
699
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_b.length).ceil)[0...(box_w - 2)]
700
+
701
+ Kernel.print("#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
702
+ Kernel.print("#{color_code}#{v_l} #{clean_t.center(box_w - 4)} #{v_r}#{reset_code}\r\n")
703
+ Kernel.print("#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
704
+ end
705
+ end
706
+
707
+ class << self
708
+ alias_method :message, :banner
709
+ alias_method :logo, :banner
710
+ end
711
+
712
+ def initialize(functions, *positional_arguments, title: nil, banner: nil, subtitle: nil, description: nil, divider: nil, style: nil, banner_style: nil, center: true, font: nil, page_size: nil, **keyword_arguments)
161
713
  @functions = functions.is_a?(Array) ? functions : Array(functions)
162
714
 
163
715
  pos_title = positional_arguments[0]
164
716
  pos_style = positional_arguments[1]
165
717
 
166
- @title = (title || pos_title || keyword_arguments[:title] || "").to_s
167
- @style = (style || pos_style || keyword_arguments[:style] || 19).to_i
168
- @index = 0
169
- @clear_seq = CLEAR_SCREEN_SEQUENCE
718
+ @title = (title || pos_title || keyword_arguments[:title] || "").to_s
719
+ @banner = (banner || keyword_arguments[:banner] || "").to_s
720
+ @subtitle = (subtitle || description || keyword_arguments[:subtitle] || keyword_arguments[:description] || "").to_s
721
+ @divider = divider.nil? ? (!@banner.empty? || !@subtitle.empty?) : divider
722
+ @style = (style || pos_style || keyword_arguments[:style] || 19).to_i
723
+ @banner_style = (banner_style || keyword_arguments[:banner_style] || 3).to_i
724
+ @center = center.nil? ? true : center
725
+ @page_size = (page_size || keyword_arguments[:page_size])&.to_i
726
+ @index = 0
727
+
728
+ init_font = font || keyword_arguments[:font_style] || SetStyle.font || 1
170
729
 
171
730
  @style_config = SetStyle.new(
172
- border: SetStyle.border.dup,
173
- options: SetStyle.options.dup,
174
- focus: SetStyle.focus.dup
731
+ border: SetStyle.border.dup,
732
+ options: SetStyle.options.dup,
733
+ focus: SetStyle.focus.dup,
734
+ title: SetStyle.title.dup,
735
+ banner: SetStyle.banner.dup,
736
+ subtitle: SetStyle.subtitle.dup,
737
+ divider: SetStyle.divider.dup,
738
+ font: init_font
175
739
  )
176
740
  end
177
-
741
+
178
742
  def move_up
179
743
  return @index if @functions.empty?
180
744
  @index = (@index - 1) % @functions.length
@@ -209,73 +773,228 @@ class GRmenu
209
773
  end
210
774
  alias_method :_hline, :build_horizontal_line
211
775
 
212
- def menu
213
- Kernel.print("Press any key to start ...\r\n")
776
+ def render_banner_lines(term_cols)
777
+ return [[], 0] if @banner.nil? || @banner.empty?
778
+
779
+ font_id = @style_config.font || 1
780
+ ascii_rows = self.class.build_ascii_lines(@banner, term_cols, font_id)
781
+ banner_border = BORDERS[@banner_style] || BORDERS[3]
782
+ banner_color_cfg = @style_config.banner
783
+
784
+ h_top = banner_border[:ht] || banner_border[:h]
785
+ h_bot = banner_border[:hb] || banner_border[:h]
786
+ v_l = banner_border[:vl] || banner_border[:v]
787
+ v_r = banner_border[:vr] || banner_border[:v]
788
+
789
+ lines = []
790
+ box_w = 0
791
+ if ascii_rows
792
+ content_w = ascii_rows.map(&:length).max
793
+ box_w = content_w + 6
794
+ top_fill = build_horizontal_line(h_top, content_w + 4)
795
+ bot_fill = build_horizontal_line(h_bot, content_w + 4)
796
+
797
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
798
+ ascii_rows.each do |row|
799
+ pad = " " * (content_w - row.length)
800
+ lines << colorize("#{v_l} #{row}#{pad} #{v_r}", banner_color_cfg)
801
+ end
802
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg)
803
+ else
804
+ clean_b = @banner.strip
805
+ box_w = [clean_b.length + 6, term_cols - 2].min
806
+ top_fill = build_horizontal_line(h_top, box_w - 2)
807
+ bot_fill = build_horizontal_line(h_bot, box_w - 2)
808
+
809
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg)
810
+ lines << colorize("#{v_l} #{clean_b.center(box_w - 4)} #{v_r}", banner_color_cfg)
811
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg)
812
+ end
813
+ [lines, box_w]
214
814
  end
215
815
 
216
816
  def render_lines(size_max = 20)
217
- option_names = @functions.map { |func| extract_name_from_action(func) }
817
+ term_cols = self.class.terminal_width
818
+ term_rows = self.class.terminal_height
819
+ rendered_lines = []
820
+
821
+ banner_box_width = 0
822
+ banner_lines_count = 0
823
+ if @banner && !@banner.empty?
824
+ banner_lines, banner_box_width = render_banner_lines(term_cols)
825
+ rendered_lines.concat(banner_lines)
826
+ rendered_lines << ""
827
+ banner_lines_count = banner_lines.length + 1
828
+ end
829
+
830
+ all_names = @functions.map { |func| extract_name_from_action(func) }
831
+ all_descriptions = @functions.map { |func| extract_description_from_action(func) }
832
+
833
+ active_desc = all_descriptions[@index] || ""
834
+
835
+ calculated_width = [size_max, @title.length + 4].max
836
+ calculated_width = ([calculated_width] + all_names.map { |name| name.length + 6 }).max
837
+ calculated_width = ([calculated_width, active_desc.length + 8].max) unless active_desc.empty?
838
+ total_width = [calculated_width, term_cols - 2].min
218
839
 
219
- total_width = [([size_max] + option_names.map { |name| name.length + 4 }).max, @title.length + 4].max unless @title.empty?
840
+ reference_width = banner_box_width > 0 ? banner_box_width : total_width
841
+ margin_left = (@center && reference_width > total_width) ? " " * ((reference_width - total_width) / 2) : ""
842
+
843
+ subtitle_lines_count = 0
844
+ if @subtitle && !@subtitle.empty?
845
+ subtitle_lines = @subtitle.lines.map(&:chomp)
846
+ div_w = @divider.is_a?(Numeric) ? @divider.to_i : [reference_width, term_cols - 2].min
847
+
848
+ if @divider
849
+ rendered_lines << colorize("─" * div_w, @style_config.divider)
850
+ subtitle_lines_count += 1
851
+ end
852
+
853
+ subtitle_lines.each do |sub_line|
854
+ formatted_sub = @center ? sub_line.center(div_w) : sub_line
855
+ rendered_lines << colorize(formatted_sub, @style_config.subtitle)
856
+ subtitle_lines_count += 1
857
+ end
858
+
859
+ if @divider
860
+ rendered_lines << colorize("─" * div_w, @style_config.divider)
861
+ subtitle_lines_count += 1
862
+ end
863
+ rendered_lines << ""
864
+ subtitle_lines_count += 1
865
+ end
220
866
 
221
867
  border_color_cfg = @style_config.border
222
868
  options_color_cfg = @style_config.options
223
869
  focus_color_cfg = @style_config.focus
870
+ title_color_cfg = @style_config.title
224
871
 
225
872
  box_border = BORDERS[@style]
226
- rendered_lines = []
873
+
874
+ total_items = @functions.length
875
+ overhead = banner_lines_count + subtitle_lines_count + 6
876
+ overhead += 2 unless active_desc.empty?
877
+ available_rows = [term_rows - overhead - 2, 3].max
878
+
879
+ effective_page_size = if @page_size && @page_size > 0
880
+ [@page_size, total_items].min
881
+ elsif total_items > available_rows
882
+ available_rows
883
+ else
884
+ total_items
885
+ end
886
+
887
+ start_idx = 0
888
+ end_idx = total_items - 1
889
+ if total_items > effective_page_size
890
+ half = effective_page_size / 2
891
+ start_idx = [[@index - half, 0].max, total_items - effective_page_size].min
892
+ end_idx = start_idx + effective_page_size - 1
893
+ end
894
+
895
+ visible_indices = (start_idx..end_idx).to_a
896
+ has_more_above = start_idx > 0
897
+ has_more_below = end_idx < (total_items - 1)
898
+
899
+ avail_w = [total_width - 6, 1].max
227
900
 
228
901
  if box_border
229
- horizontal_fill = build_horizontal_line(box_border[:h], total_width - 2)
230
- vertical_char = colorize(box_border[:v], border_color_cfg)
902
+ h_top = box_border[:ht] || box_border[:h]
903
+ h_bot = box_border[:hb] || box_border[:h]
904
+ v_l_raw = box_border[:vl] || box_border[:v]
905
+ v_r_raw = box_border[:vr] || box_border[:v]
906
+
907
+ top_fill = build_horizontal_line(h_top, total_width - 2)
908
+ bot_fill = build_horizontal_line(h_bot, total_width - 2)
909
+ mid_fill = build_horizontal_line(h_top, total_width - 2)
231
910
 
232
- top_border_line = box_border[:tl] + horizontal_fill + box_border[:tr]
233
- rendered_lines << colorize(top_border_line, border_color_cfg)
911
+ v_left = colorize(v_l_raw, border_color_cfg)
912
+ v_right = colorize(v_r_raw, border_color_cfg)
913
+
914
+ top_border_line = box_border[:tl] + top_fill + box_border[:tr]
915
+ rendered_lines << "#{margin_left}#{colorize(top_border_line, border_color_cfg)}"
234
916
 
235
917
  unless @title.empty?
236
- centered_title = @title.center(total_width - 4)
237
- rendered_lines << "#{vertical_char} #{centered_title} #{vertical_char}"
918
+ centered_title = colorize(@title.center(total_width - 4), title_color_cfg)
919
+ rendered_lines << "#{margin_left}#{v_left} #{centered_title} #{v_right}"
920
+
921
+ separator_line = v_l_raw + mid_fill + v_r_raw
922
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg)}"
923
+ end
238
924
 
239
- separator_line = box_border[:v] + horizontal_fill + box_border[:v]
240
- rendered_lines << colorize(separator_line, border_color_cfg)
925
+ if has_more_above
926
+ up_indicator = colorize("▲ (+#{start_idx} arriba)".center(avail_w + 2), { color: "gray", level: 2 })
927
+ rendered_lines << "#{margin_left}#{v_left} #{up_indicator} #{v_right}"
241
928
  end
242
929
 
243
- option_names.each_with_index do |option_name, current_index|
930
+ visible_indices.each do |current_index|
931
+ option_name = all_names[current_index]
244
932
  if @index == current_index
245
- highlighted_text = colorize(">#{option_name.ljust(total_width - 6)}", focus_color_cfg)
246
- rendered_lines << "#{vertical_char} #{highlighted_text} #{vertical_char}"
933
+ highlighted_text = colorize("> #{option_name.ljust(avail_w)}", focus_color_cfg)
934
+ rendered_lines << "#{margin_left}#{v_left} #{highlighted_text} #{v_right}"
247
935
  else
248
- normal_text = colorize("> #{option_name.ljust(total_width - 6)}", options_color_cfg)
249
- rendered_lines << "#{vertical_char} #{normal_text} #{vertical_char}"
936
+ normal_text = colorize(" #{option_name.ljust(avail_w)}", options_color_cfg)
937
+ rendered_lines << "#{margin_left}#{v_left} #{normal_text} #{v_right}"
250
938
  end
251
939
  end
252
940
 
253
- bottom_border_line = box_border[:bl] + horizontal_fill + box_border[:br]
254
- rendered_lines << colorize(bottom_border_line, border_color_cfg)
941
+ if has_more_below
942
+ remaining_below = total_items - 1 - end_idx
943
+ down_indicator = colorize("▼ (+#{remaining_below} abajo)".center(avail_w + 2), { color: "gray", level: 2 })
944
+ rendered_lines << "#{margin_left}#{v_left} #{down_indicator} #{v_right}"
945
+ end
946
+
947
+ unless active_desc.empty?
948
+ separator_line = v_l_raw + mid_fill + v_r_raw
949
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg)}"
950
+ desc_text = colorize("ℹ #{active_desc.ljust(avail_w)}", { color: "cyan", level: 1 })
951
+ rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
952
+ end
953
+
954
+ bottom_border_line = box_border[:bl] + bot_fill + box_border[:br]
955
+ rendered_lines << "#{margin_left}#{colorize(bottom_border_line, border_color_cfg)}"
255
956
  else
256
957
  symbol_char = STYLES[@style] || "#"
257
958
  solid_border = colorize(symbol_char, border_color_cfg)
258
959
  solid_line = symbol_char * total_width
259
960
 
260
- rendered_lines << colorize(solid_line, border_color_cfg)
961
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
261
962
 
262
963
  unless @title.empty?
263
- centered_title = @title.center(total_width - 4)
264
- rendered_lines << "#{solid_border} #{centered_title} #{solid_border}"
265
- rendered_lines << colorize(solid_line, border_color_cfg)
964
+ centered_title = colorize(@title.center(total_width - 4), title_color_cfg)
965
+ rendered_lines << "#{margin_left}#{solid_border} #{centered_title} #{solid_border}"
966
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
967
+ end
968
+
969
+ if has_more_above
970
+ up_indicator = colorize("▲ (+#{start_idx} arriba)".center(avail_w + 2), { color: "gray", level: 2 })
971
+ rendered_lines << "#{margin_left}#{solid_border} #{up_indicator} #{solid_border}"
266
972
  end
267
973
 
268
- option_names.each_with_index do |option_name, current_index|
974
+ visible_indices.each do |current_index|
975
+ option_name = all_names[current_index]
269
976
  if @index == current_index
270
- highlighted_text = colorize(option_name.ljust(total_width - 4), focus_color_cfg)
271
- rendered_lines << "#{solid_border} #{highlighted_text} #{solid_border}"
977
+ highlighted_text = colorize("> #{option_name.ljust(avail_w)}", focus_color_cfg)
978
+ rendered_lines << "#{margin_left}#{solid_border} #{highlighted_text} #{solid_border}"
272
979
  else
273
- normal_text = colorize(option_name.ljust(total_width - 4), options_color_cfg)
274
- rendered_lines << "#{solid_border} #{normal_text} #{solid_border}"
980
+ normal_text = colorize(" #{option_name.ljust(avail_w)}", options_color_cfg)
981
+ rendered_lines << "#{margin_left}#{solid_border} #{normal_text} #{solid_border}"
275
982
  end
276
983
  end
277
984
 
278
- rendered_lines << colorize(solid_line, border_color_cfg)
985
+ if has_more_below
986
+ remaining_below = total_items - 1 - end_idx
987
+ down_indicator = colorize("▼ (+#{remaining_below} abajo)".center(avail_w + 2), { color: "gray", level: 2 })
988
+ rendered_lines << "#{margin_left}#{solid_border} #{down_indicator} #{solid_border}"
989
+ end
990
+
991
+ unless active_desc.empty?
992
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
993
+ desc_text = colorize("ℹ #{active_desc.ljust(avail_w)}", { color: "cyan", level: 1 })
994
+ rendered_lines << "#{margin_left}#{solid_border} #{desc_text} #{solid_border}"
995
+ end
996
+
997
+ rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
279
998
  end
280
999
 
281
1000
  rendered_lines
@@ -317,8 +1036,9 @@ class GRmenu
317
1036
  def draw_frame(target_width)
318
1037
  lines = render_lines(target_width)
319
1038
  buffer = String.new(CURSOR_HOME)
320
- lines.each do |line|
321
- buffer << line << CLEAR_TO_EOL << "\r\n"
1039
+ lines.each_with_index do |line, idx|
1040
+ buffer << line << CLEAR_TO_EOL
1041
+ buffer << "\r\n" if idx < lines.length - 1
322
1042
  end
323
1043
  buffer << CLEAR_TO_EOS
324
1044
  Kernel.print(buffer)
@@ -330,10 +1050,10 @@ class GRmenu
330
1050
  while (key = read_single_key(input_stream))
331
1051
  break if key == "q" || key == "Q" || key == "\x03" || key == "\x04"
332
1052
 
333
- if key == "\e[A" || key == "\eOA"
1053
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
334
1054
  move_up
335
1055
  draw_frame(target_width)
336
- elsif key == "\e[B" || key == "\eOB"
1056
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
337
1057
  move_down
338
1058
  draw_frame(target_width)
339
1059
  elsif key == "\r" || key == "\n"
@@ -363,6 +1083,14 @@ class GRmenu
363
1083
  first_char << extra_chars
364
1084
  rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
365
1085
  end
1086
+ elsif first_char == "\x00" || first_char == "\xe0"
1087
+ begin
1088
+ second_char = input_stream.read_nonblock(1)
1089
+ first_char << second_char
1090
+ rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1091
+ second_char = input_stream.getch rescue nil
1092
+ first_char << second_char if second_char
1093
+ end
366
1094
  end
367
1095
 
368
1096
  first_char
@@ -370,31 +1098,48 @@ class GRmenu
370
1098
  nil
371
1099
  end
372
1100
 
1101
+ def format_auto_name(raw_name)
1102
+ cleaned = raw_name.to_s.gsub(/[_-]+/, ' ').strip
1103
+ cleaned.split(' ').map(&:capitalize).join(' ')
1104
+ end
1105
+
373
1106
  def extract_name_from_action(action)
374
1107
  case action
375
- when Method
376
- action.name.to_s
377
- when Symbol
378
- action.to_s
379
1108
  when Array
380
1109
  action[0].to_s
1110
+ when Hash
1111
+ (action[:name] || action[:title] || action["name"] || action["title"] || "Opcion").to_s
1112
+ when Method
1113
+ format_auto_name(action.name)
1114
+ when Symbol
1115
+ format_auto_name(action)
381
1116
  when Proc
382
1117
  if action.respond_to?(:name) && action.name
383
- action.name.to_s
1118
+ format_auto_name(action.name)
384
1119
  else
385
- "opcion"
1120
+ "Opcion"
386
1121
  end
387
1122
  else
388
1123
  if action.respond_to?(:name)
389
- action.name.to_s
1124
+ format_auto_name(action.name)
390
1125
  elsif action.respond_to?(:title)
391
1126
  action.title.to_s
392
1127
  else
393
- action.to_s
1128
+ format_auto_name(action)
394
1129
  end
395
1130
  end
396
1131
  end
397
1132
 
1133
+ def extract_description_from_action(action)
1134
+ if action.is_a?(Array) && action.length >= 3
1135
+ action[2].to_s
1136
+ elsif action.is_a?(Hash)
1137
+ (action[:desc] || action[:description] || action["desc"] || action["description"]).to_s
1138
+ else
1139
+ ""
1140
+ end
1141
+ end
1142
+
398
1143
  def execute_action(action)
399
1144
  case action
400
1145
  when Method, Proc
@@ -407,11 +1152,33 @@ class GRmenu
407
1152
  end
408
1153
  when Array
409
1154
  callable = action[1]
410
- callable.call if callable.respond_to?(:call)
1155
+ if callable.is_a?(Symbol)
1156
+ if Object.respond_to?(callable, true)
1157
+ Object.send(callable)
1158
+ elsif Kernel.respond_to?(callable, true)
1159
+ Kernel.send(callable)
1160
+ end
1161
+ elsif callable.respond_to?(:call)
1162
+ callable.call
1163
+ end
1164
+ when Hash
1165
+ callable = action[:action] || action[:call] || action["action"] || action["call"]
1166
+ if callable.is_a?(Symbol)
1167
+ if Object.respond_to?(callable, true)
1168
+ Object.send(callable)
1169
+ elsif Kernel.respond_to?(callable, true)
1170
+ Kernel.send(callable)
1171
+ end
1172
+ elsif callable.respond_to?(:call)
1173
+ callable.call
1174
+ end
411
1175
  else
412
1176
  action.call if action.respond_to?(:call)
413
1177
  end
414
1178
  end
415
1179
  end
416
1180
 
1181
+ Color = GRmenu::Color unless defined?(Color)
1182
+ Colors = GRmenu::Color unless defined?(Colors)
1183
+ C = GRmenu::Color unless defined?(C)
417
1184
  Grmenu = GRmenu unless defined?(Grmenu)