grmenu 4.1.0 → 5.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.
@@ -0,0 +1,1002 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Atributos de configuración y estado del menú interactivo
5
+ attr_accessor :functions, :title, :subtitle, :banner, :banner_style, :divider,
6
+ :index, :style_config, :center, :page_size, :search, :columns,
7
+ :query, :image, :image_width
8
+
9
+ # Alias de compatibilidad hacia atrás
10
+ alias_method :options, :functions
11
+ alias_method :options=, :functions=
12
+ alias_method :selected_index, :index
13
+ alias_method :selected_index=, :index=
14
+ alias_method :SetStyle, :style_config
15
+ alias_method :set_style, :style_config
16
+ alias_method :description, :subtitle
17
+ alias_method :description=, :subtitle=
18
+
19
+ # Retorna el prefijo configurado para las descripciones de opciones (por defecto '[i]')
20
+ def desc_prefix
21
+ @desc_prefix || @style_config&.desc_prefix || SetStyle.desc_prefix
22
+ end
23
+
24
+ # Modifica dinámicamente el prefijo de las descripciones
25
+ def desc_prefix=(val)
26
+ @desc_prefix = val.to_s
27
+ @style_config&.desc_prefix(val)
28
+ end
29
+ alias_method :prefix, :desc_prefix
30
+ alias_method :prefix=, :desc_prefix=
31
+ alias_method :description_prefix, :desc_prefix
32
+ alias_method :description_prefix=, :desc_prefix=
33
+
34
+ # Accesores directos a los catálogos y datasets cargados desde JSON
35
+ def self.STYLES; STYLES; end
36
+ def self.COLORS; COLORS; end
37
+ def self.BORDERS; BORDERS; end
38
+ def self.FONTS; FONTS; end
39
+ def self.FONT; FONT_1; end
40
+
41
+ class << self
42
+ # Constructor conveniente para instanciar un menú organizado por pestañas.
43
+ #
44
+ # @param tabs_hash [Hash] Diccionario donde las claves son los nombres de pestañas y los valores son listas de acciones.
45
+ # @return [GRmenu] Instancia del menú configurada con pestañas.
46
+ def tabs(tabs_hash, *args, **kwargs)
47
+ new([], *args, tabs: tabs_hash, **kwargs)
48
+ end
49
+ end
50
+
51
+ # Inicializa una nueva instancia de menú interactivo.
52
+ #
53
+ # Puede configurarse mediante argumentos nominales o posicionales, así como
54
+ # heredar estilos globales o valores predeterminados de SetStyle.
55
+ #
56
+ # @param functions [Array, Hash] Lista de opciones ejecutables o Hash de pestañas.
57
+ # @param positional_arguments [Array] Argumentos posicionales opcionales: [title, style].
58
+ # @param title [String, nil] Título del menú mostrado en la parte superior.
59
+ # @param banner [String, nil] Texto para banner o logo en ASCII 3D.
60
+ # @param subtitle [String, nil] Subtítulo o texto descriptivo general.
61
+ # @param description [String, nil] Alias de subtítulo.
62
+ # @param divider [Boolean, Integer, nil] Si se dibuja línea divisoria tras el banner/subtítulo.
63
+ # @param style [Integer, nil] Estilo de borde del menú (1 al 19).
64
+ # @param banner_style [Integer, nil] Estilo de borde del banner (1 al 12).
65
+ # @param center [Boolean] Centra horizontalmente el menú en la ventana de la terminal.
66
+ # @param font [Integer, nil] Fuente tipográfica ASCII para el banner (1 a 11).
67
+ # @param page_size [Integer, nil] Número máximo de opciones visibles en pantalla.
68
+ # @param search [Boolean] Habilita búsqueda y filtrado en tiempo real.
69
+ # @param columns [Integer] Número de columnas de opciones para mostrar en cuadrícula.
70
+ # @param image [String, nil] Ruta a un archivo PNG para renderizarlo con caracteres de bloques.
71
+ # @param image_width [Integer, nil] Ancho deseado de la imagen renderizada.
72
+ # @param mouse [Boolean, nil] Habilita captura de eventos de clic y rueda del ratón.
73
+ # @param tabs [Hash, nil] Diccionario de pestañas si no se pasó en functions.
74
+ # @param desc_prefix [String, nil] Prefijo para la descripción inferior (ej: "[i]").
75
+ # @param animate [String, Symbol, Boolean, nil] Animación de entrada o continua (:rainbow, :chroma, :rgb, :fade, :diagonal, :linear, :chromatic).
76
+ 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, desc_prefix: nil, prefix: nil, border: nil, border_color: nil, options_color: nil, focus_color: nil, title_color: nil, banner_color: nil, subtitle_color: nil, divider_color: nil, animate: nil, **keyword_arguments)
77
+ # Detectamos si se pasaron pestañas en el argumento tabs o functions
78
+ tabs_data = tabs || keyword_arguments[:tabs] || (functions.is_a?(Hash) ? functions : nil)
79
+ if tabs_data.is_a?(Hash) && !tabs_data.empty?
80
+ @tabs = tabs_data.keys.map(&:to_s)
81
+ @tab_contents = tabs_data.transform_keys(&:to_s)
82
+ @active_tab_idx = 0
83
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
84
+ else
85
+ @tabs = nil
86
+ @tab_contents = nil
87
+ @active_tab_idx = nil
88
+ @functions = functions.is_a?(Array) ? functions : Array(functions)
89
+ end
90
+
91
+ pos_title = positional_arguments[0]
92
+ pos_style = positional_arguments[1]
93
+
94
+ @title = (title || pos_title || keyword_arguments[:title] || "").to_s
95
+ @banner = (banner || keyword_arguments[:banner] || "").to_s
96
+ @subtitle = (subtitle || description || keyword_arguments[:subtitle] || keyword_arguments[:description] || "").to_s
97
+ @divider = divider.nil? ? (!@banner.empty? || !@subtitle.empty?) : divider
98
+
99
+ theme_menu_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu')) || {}
100
+ th_style = theme_menu_sec['style']&.to_i
101
+ th_bstyle = theme_menu_sec['banner_style']&.to_i
102
+
103
+ @style = (style || pos_style || keyword_arguments[:style] || th_style || 19).to_i
104
+ @banner_style = (banner_style || keyword_arguments[:banner_style] || th_bstyle || 3).to_i
105
+ @center = center.nil? ? (theme_menu_sec.key?('center') ? (theme_menu_sec['center'].to_s != 'false') : true) : center
106
+ @page_size = (page_size || keyword_arguments[:page_size])&.to_i
107
+ @search = search || keyword_arguments[:search] || false
108
+ @columns = [(columns || keyword_arguments[:columns] || 1).to_i, 1].max
109
+ @image = image || keyword_arguments[:image]
110
+ @image_width = (image_width || keyword_arguments[:image_width])&.to_i
111
+ @animate = (animate || keyword_arguments[:animate] || (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu', 'animate')) || false).to_s
112
+
113
+ # Configuración de soporte para ratón
114
+ m_val = mouse.nil? ? keyword_arguments[:mouse] : mouse
115
+ if m_val.nil?
116
+ @mouse = theme_menu_sec.key?('mouse') ? (theme_menu_sec['mouse'].to_s != 'false') : true
117
+ else
118
+ @mouse = (m_val == true || m_val.to_s == 'true')
119
+ end
120
+
121
+ tabs_theme_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "tabs")) || {}
122
+ @active_tab_color = (active_tab_color || keyword_arguments[:active_tab_color] || tabs_theme_sec["active_tab"] || tabs_theme_sec["active_tab_color"] || "yellow").to_s
123
+ @tab_color = (tab_color || keyword_arguments[:tab_color] || tabs_theme_sec["tab_color"] || tabs_theme_sec["inactive_tab"] || tabs_theme_sec["color"] || "gray").to_s
124
+
125
+ @query = String.new("")
126
+ @index = 0
127
+ @rgb_tick = 0.0
128
+
129
+ # Estado de navegación en submenús anidados
130
+ @level = 0
131
+ @open_level = 0
132
+ @sub_index_1 = 0
133
+ @sub_index_2 = 0
134
+ @sub_1_hit_map = {}
135
+ @sub_2_hit_map = {}
136
+ @sub_1_col_rng = nil
137
+ @sub_2_col_rng = nil
138
+
139
+ @active_panel = :main
140
+ @sub_index = 0
141
+ @submenu_open = false
142
+
143
+ # Registro de áreas activas para eventos de ratón
144
+ @row_hit_map = {}
145
+ @tab_ranges = {}
146
+ @tabs_row = nil
147
+ @up_arrow_row = nil
148
+ @down_arrow_row = nil
149
+ @sub_hit_map = {}
150
+
151
+ @cached_image_lines = nil
152
+ @cached_image_cols = nil
153
+
154
+ # Resolución del prefijo y fuente con prioridad estricta: SetStyle explícito > argumento directo > tema > por defecto
155
+ init_font = font || keyword_arguments[:font] || keyword_arguments[:font_style] || SetStyle.font || 1
156
+ raw_pfx = desc_prefix || prefix || keyword_arguments[:desc_prefix] || keyword_arguments[:prefix] || keyword_arguments[:description_prefix]
157
+ @desc_prefix = raw_pfx.to_s unless raw_pfx.nil?
158
+ 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]"
159
+
160
+ @style_config = SetStyle.new(
161
+ border: SetStyle.border.dup,
162
+ options: SetStyle.options.dup,
163
+ focus: SetStyle.focus.dup,
164
+ title: SetStyle.title.dup,
165
+ banner: SetStyle.banner.dup,
166
+ subtitle: SetStyle.subtitle.dup,
167
+ divider: SetStyle.divider.dup,
168
+ font: init_font,
169
+ desc_prefix: init_pfx
170
+ )
171
+ @style_config.mark_explicit(:font) if font || keyword_arguments[:font] || keyword_arguments[:font_style]
172
+ @style_config.mark_explicit(:desc_prefix) unless raw_pfx.nil?
173
+
174
+ # Aplicamos estilos directos si fueron provistos en la llamada new()
175
+ if border || border_color || keyword_arguments[:border] || keyword_arguments[:border_color]
176
+ b_c, b_l = self.class.extract_color_and_level(border || border_color || keyword_arguments[:border] || keyword_arguments[:border_color], 1)
177
+ @style_config.border(b_c, b_l)
178
+ end
179
+ if options_color || keyword_arguments[:options_color] || keyword_arguments[:options]
180
+ o_c, o_l = self.class.extract_color_and_level(options_color || keyword_arguments[:options_color] || keyword_arguments[:options], 1)
181
+ @style_config.options(o_c, o_l)
182
+ end
183
+ if focus_color || keyword_arguments[:focus_color] || keyword_arguments[:focus]
184
+ f_c, f_l = self.class.extract_color_and_level(focus_color || keyword_arguments[:focus_color] || keyword_arguments[:focus], 2)
185
+ @style_config.focus(f_c, f_l)
186
+ end
187
+ if title_color || keyword_arguments[:title_color]
188
+ t_c, t_l = self.class.extract_color_and_level(title_color || keyword_arguments[:title_color], 2)
189
+ @style_config.title(t_c, t_l)
190
+ end
191
+ if banner_color || keyword_arguments[:banner_color]
192
+ bn_c, bn_l = self.class.extract_color_and_level(banner_color || keyword_arguments[:banner_color], 2)
193
+ @style_config.banner(bn_c, bn_l)
194
+ end
195
+ if subtitle_color || keyword_arguments[:subtitle_color]
196
+ s_c, s_l = self.class.extract_color_and_level(subtitle_color || keyword_arguments[:subtitle_color], 1)
197
+ @style_config.subtitle(s_c, s_l)
198
+ end
199
+ if divider_color || keyword_arguments[:divider_color]
200
+ d_c, d_l = self.class.extract_color_and_level(divider_color || keyword_arguments[:divider_color], 1)
201
+ @style_config.divider(d_c, d_l)
202
+ end
203
+ end
204
+
205
+ # Devuelve la lista de índices de @functions que coinciden con el término de búsqueda actual.
206
+ #
207
+ # @return [Array<Integer>]
208
+ def current_matching_indices
209
+ if @search && !@query.empty?
210
+ indices = []
211
+ @functions.each_with_index do |func, idx|
212
+ name = extract_name_from_action(func)
213
+ indices << idx if name.downcase.include?(@query.downcase)
214
+ end
215
+ indices
216
+ else
217
+ (0...@functions.length).to_a
218
+ end
219
+ end
220
+
221
+ # Desplaza el cursor de selección una fila hacia arriba.
222
+ # En menús multi-columna salta exactamente el ancho de la columna hacia arriba.
223
+ def move_up
224
+ matching = current_matching_indices
225
+ return @index if matching.empty?
226
+ cols = @columns
227
+ pos = matching.index(@index) || 0
228
+ if cols <= 1
229
+ new_pos = (pos - 1) % matching.length
230
+ else
231
+ new_pos = pos - cols
232
+ if new_pos < 0
233
+ new_pos = pos
234
+ while (new_pos + cols) < matching.length
235
+ new_pos += cols
236
+ end
237
+ end
238
+ end
239
+ @index = matching[new_pos]
240
+ end
241
+ alias_method :_up, :move_up
242
+
243
+ # Desplaza el cursor de selección una fila hacia abajo.
244
+ def move_down
245
+ matching = current_matching_indices
246
+ return @index if matching.empty?
247
+ cols = @columns
248
+ pos = matching.index(@index) || 0
249
+ if cols <= 1
250
+ new_pos = (pos + 1) % matching.length
251
+ else
252
+ new_pos = pos + cols
253
+ if new_pos >= matching.length
254
+ new_pos = pos % cols
255
+ end
256
+ end
257
+ @index = matching[new_pos]
258
+ end
259
+ alias_method :_down, :move_down
260
+
261
+ # Desplaza el cursor de selección una columna hacia la izquierda.
262
+ def move_left
263
+ matching = current_matching_indices
264
+ return @index if matching.empty?
265
+ cols = @columns
266
+ pos = matching.index(@index) || 0
267
+ if cols <= 1
268
+ new_pos = (pos - 1) % matching.length
269
+ else
270
+ if (pos % cols) == 0
271
+ new_pos = [pos + (cols - 1), matching.length - 1].min
272
+ else
273
+ new_pos = pos - 1
274
+ end
275
+ end
276
+ @index = matching[new_pos]
277
+ end
278
+
279
+ # Desplaza el cursor de selección una columna hacia la derecha.
280
+ def move_right
281
+ matching = current_matching_indices
282
+ return @index if matching.empty?
283
+ cols = @columns
284
+ pos = matching.index(@index) || 0
285
+ if cols <= 1
286
+ new_pos = (pos + 1) % matching.length
287
+ else
288
+ if (pos % cols) == (cols - 1) || pos == (matching.length - 1)
289
+ new_pos = pos - (pos % cols)
290
+ else
291
+ new_pos = pos + 1
292
+ end
293
+ end
294
+ @index = matching[new_pos]
295
+ end
296
+
297
+ # Configura estilos a partir de una cadena con sintaxis de hoja de estilos GR.
298
+ #
299
+ # Respeta las propiedades que hayan sido configuradas explícitamente mediante SetStyle,
300
+ # garantizando que la configuración programática siempre tiene la máxima prioridad.
301
+ #
302
+ # @param css_content [String, nil] Bloque de texto con directivas <<menu ... >>
303
+ # @return [self, Integer] La instancia para encadenar llamadas, o el estilo actual si no se pasan argumentos.
304
+ def style(css_content = nil)
305
+ return @style if css_content.nil?
306
+ parsed = self.class.parse_config_text(css_content)
307
+ menu_cfg = ((parsed[:sections] && parsed[:sections]["menu"]) || {}).merge(parsed[:global] || {})
308
+
309
+ if menu_cfg["style"]
310
+ @style = menu_cfg["style"].to_i
311
+ @border_config = BORDERS[@style] || BORDERS[3]
312
+ end
313
+ @banner_style = menu_cfg["banner_style"].to_i if menu_cfg["banner_style"]
314
+ @animate = menu_cfg["animate"].to_s if menu_cfg["animate"]
315
+ @center = (menu_cfg["center"].to_s != "false") if menu_cfg.key?("center")
316
+
317
+ # Los colores de CSS solo se aplican si la propiedad no fue definida explícitamente en SetStyle
318
+ if (menu_cfg["border"] || menu_cfg["border_color"]) && !@style_config.explicitly_set?(:border)
319
+ c, l = self.class.extract_color_and_level(menu_cfg["border"] || menu_cfg["border_color"], 1)
320
+ @style_config.border(c, l, from_css: true)
321
+ end
322
+ if (menu_cfg["options"] || menu_cfg["options_color"]) && !@style_config.explicitly_set?(:options)
323
+ c, l = self.class.extract_color_and_level(menu_cfg["options"] || menu_cfg["options_color"], 1)
324
+ @style_config.options(c, l, from_css: true)
325
+ end
326
+ if (menu_cfg["focus"] || menu_cfg["focus_color"]) && !@style_config.explicitly_set?(:focus)
327
+ c, l = self.class.extract_color_and_level(menu_cfg["focus"] || menu_cfg["focus_color"], 2)
328
+ @style_config.focus(c, l, from_css: true)
329
+ end
330
+ if (menu_cfg["title"] || menu_cfg["title_color"]) && !@style_config.explicitly_set?(:title)
331
+ c, l = self.class.extract_color_and_level(menu_cfg["title"] || menu_cfg["title_color"], 2)
332
+ @style_config.title(c, l, from_css: true)
333
+ end
334
+ if (menu_cfg["banner"] || menu_cfg["banner_color"]) && !@style_config.explicitly_set?(:banner)
335
+ c, l = self.class.extract_color_and_level(menu_cfg["banner"] || menu_cfg["banner_color"], 2)
336
+ @style_config.banner(c, l, from_css: true)
337
+ end
338
+ if (menu_cfg["subtitle"] || menu_cfg["subtitle_color"]) && !@style_config.explicitly_set?(:subtitle)
339
+ c, l = self.class.extract_color_and_level(menu_cfg["subtitle"] || menu_cfg["subtitle_color"], 1)
340
+ @style_config.subtitle(c, l, from_css: true)
341
+ end
342
+ if (menu_cfg["divider"] || menu_cfg["divider_color"]) && !@style_config.explicitly_set?(:divider)
343
+ c, l = self.class.extract_color_and_level(menu_cfg["divider"] || menu_cfg["divider_color"], 1)
344
+ @style_config.divider(c, l, from_css: true)
345
+ end
346
+ if (menu_cfg["desc_prefix"] || menu_cfg["description_prefix"] || menu_cfg["prefix"]) && !@style_config.explicitly_set?(:desc_prefix)
347
+ @style_config.desc_prefix(menu_cfg["desc_prefix"] || menu_cfg["description_prefix"] || menu_cfg["prefix"], from_css: true)
348
+ end
349
+ if menu_cfg["font"] && !@style_config.explicitly_set?(:font)
350
+ @style_config.font(menu_cfg["font"].to_i, from_css: true)
351
+ end
352
+
353
+ @mouse = (menu_cfg["mouse"].to_s == "true") if menu_cfg.key?("mouse")
354
+
355
+ if parsed[:sections] && parsed[:sections]["tabs"]
356
+ tabs_sec = parsed[:sections]["tabs"]
357
+ @active_tab_color = tabs_sec["active_tab"] || tabs_sec["active_tab_color"] || @active_tab_color if (tabs_sec["active_tab"] || tabs_sec["active_tab_color"])
358
+ @tab_color = tabs_sec["tab_color"] || tabs_sec["inactive_tab"] || tabs_sec["color"] || @tab_color if (tabs_sec["tab_color"] || tabs_sec["inactive_tab"] || tabs_sec["color"])
359
+ end
360
+
361
+ self
362
+ end
363
+
364
+ # Setter para style que acepta tanto un entero de estilo como una hoja de estilos completa.
365
+ def style=(val)
366
+ if val.is_a?(String) && (val.include?("::") || val.include?("<<") || val.include?("@theme"))
367
+ style(val)
368
+ else
369
+ @style = val.to_i
370
+ @border_config = BORDERS[@style] || BORDERS[3]
371
+ end
372
+ end
373
+
374
+ # Exporta la configuración visual y colores actuales del menú a un archivo .gr.
375
+ #
376
+ # @param path [String, nil] Ruta de salida. Si es nil, infiere el nombre del archivo llamador.
377
+ # @return [String] Ruta final del archivo generado.
378
+ def export_config(path = nil)
379
+ if path.nil?
380
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
381
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
382
+ path = "#{base}.gr"
383
+ end
384
+
385
+ b_cfg = @style_config&.border || SetStyle.border
386
+ t_cfg = @style_config&.title || SetStyle.title
387
+ f_cfg = @style_config&.focus || SetStyle.focus
388
+ o_cfg = @style_config&.options || SetStyle.options
389
+ bn_cfg = @style_config&.banner || SetStyle.banner
390
+ s_cfg = @style_config&.subtitle || SetStyle.subtitle
391
+ d_cfg = @style_config&.divider || SetStyle.divider
392
+ dp_val = @style_config&.desc_prefix || SetStyle.desc_prefix
393
+
394
+ lines = ["GRmenu::config<-1->", ""]
395
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
396
+ lines << "@author:: \"grcode\""
397
+ lines << "@version:: \"1.0\""
398
+ lines << ""
399
+ lines << "<<menu"
400
+ lines << " style:: #{@style || 3}"
401
+ lines << " banner_style:: #{@banner_style || 3}"
402
+ lines << " font:: #{@style_config&.font || SetStyle.font}"
403
+ lines << " animate:: #{@animate || 'rgb'}"
404
+ lines << " center:: #{@center.nil? ? true : @center}"
405
+ lines << " desc_prefix:: #{dp_val}"
406
+ lines << " mouse:: #{@mouse}" if @mouse
407
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
408
+ lines << " title:: #{t_cfg[:color]}:#{t_cfg[:level]}"
409
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
410
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
411
+ lines << " banner:: #{bn_cfg[:color]}:#{bn_cfg[:level]}"
412
+ lines << " subtitle:: #{s_cfg[:color]}:#{s_cfg[:level]}"
413
+ lines << " divider:: #{d_cfg[:color]}:#{d_cfg[:level]}"
414
+ lines << ">>"
415
+ lines << ""
416
+ lines << "<<submenu"
417
+ lines << " style:: #{@style || 3}"
418
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
419
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
420
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
421
+ lines << ">>"
422
+ lines << ""
423
+ lines << "<<tabs"
424
+ lines << " active_tab:: #{@active_tab_color || 'yellow'}:2"
425
+ lines << " tab_color:: #{@tab_color || 'gray'}:1"
426
+ lines << ">>"
427
+ lines << ""
428
+ lines << "<<input"
429
+ lines << " style:: 3"
430
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
431
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
432
+ lines << " label_color:: white:1"
433
+ lines << ">>"
434
+ lines << ""
435
+ lines << "<<table"
436
+ lines << " style:: #{@style || 3}"
437
+ lines << " header_color:: yellow:2"
438
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
439
+ lines << " selected_row:: #{f_cfg[:color]}:#{f_cfg[:level]}"
440
+ lines << " row_color:: white:1"
441
+ lines << " zebra_striping:: true"
442
+ lines << ">>"
443
+ lines << ""
444
+ lines << "<<card"
445
+ lines << " style:: 7"
446
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
447
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
448
+ lines << " content_color:: white:1"
449
+ lines << ">>"
450
+ lines << ""
451
+ lines << "<<slider"
452
+ lines << " style:: #{@style || 3}"
453
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
454
+ lines << " fill_char:: █"
455
+ lines << " empty_char:: ░"
456
+ lines << ">>"
457
+ lines << ""
458
+ lines << "<<checkbox"
459
+ lines << " style:: #{@style || 3}"
460
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
461
+ lines << " checked_mark:: [X]"
462
+ lines << " unchecked_mark:: [ ]"
463
+ lines << ">>"
464
+ lines << ""
465
+ File.write(path, lines.join("\n") + "\n")
466
+ path
467
+ end
468
+ alias_method :export_theme, :export_config
469
+
470
+ # Inicia el ciclo interactivo del menú en la terminal.
471
+ #
472
+ # Muestra las opciones, procesa pulsaciones de teclado y clics del ratón,
473
+ # redibuja animaciones si están activas y ejecuta la acción elegida.
474
+ #
475
+ # @param size_max [Integer] Ancho mínimo o sugerido de la caja.
476
+ # @param min_width [Integer, nil] Alias de ancho mínimo.
477
+ # @return [Object, nil] El resultado de la acción ejecutada o nil si se canceló.
478
+ def draw(size_max: 20, min_width: nil)
479
+ # Soporte para exportación rápida vía argumento en línea de comandos (-theme o -ex)
480
+ if ARGV.any? { |a| ["-theme", "--theme", "-ex", "--export-theme"].include?(a.to_s.downcase) }
481
+ out_idx = ARGV.index { |a| ["-o", "--out", "--output"].include?(a.to_s.downcase) }
482
+ target_file = out_idx ? ARGV[out_idx + 1] : "tema_exportado.gr"
483
+ export_config(target_file)
484
+ Kernel.puts Color.bright_green("[OK] Tema exportado exitosamente a: #{target_file}")
485
+ exit(0)
486
+ end
487
+
488
+ target_width = min_width || size_max || 20
489
+ action_to_execute = nil
490
+
491
+ self.class.enable_windows_vt
492
+ $stdout.sync = true
493
+
494
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
495
+
496
+ begin
497
+ Kernel.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
498
+ Kernel.print(ENABLE_MOUSE) if @mouse
499
+ $stdout.flush
500
+
501
+ # Si hay una transición de entrada configurada (:diagonal, :linear, :fade, :rainbow, :chroma, :rgb, :chromatic, :cromatico)
502
+ intro_anim = @animate.to_s.downcase
503
+ if ["diagonal", "linear", "fade", "rainbow", "chroma", "rgb", "chromatic", "cromatico"].include?(intro_anim)
504
+ intro_lines = render_lines(target_width)
505
+ self.class.animate_render(intro_lines, @animate)
506
+ end
507
+
508
+ if is_tty
509
+ $stdin.raw do |raw_input_stream|
510
+ action_to_execute = run_interactive_loop(raw_input_stream, target_width)
511
+ end
512
+ else
513
+ action_to_execute = run_interactive_loop($stdin, target_width)
514
+ end
515
+ ensure
516
+ # Siempre restaurar el ratón y el cursor para no dejar la terminal corrupta
517
+ Kernel.print(DISABLE_MOUSE) if @mouse
518
+ Kernel.print(SHOW_CURSOR)
519
+ $stdout.flush
520
+ end
521
+
522
+ if action_to_execute
523
+ Kernel.print(CLEAR_SCREEN_SEQUENCE)
524
+ $stdout.flush
525
+ execute_action(action_to_execute)
526
+ else
527
+ Kernel.print(CLEAR_SCREEN_SEQUENCE)
528
+ $stdout.flush
529
+ end
530
+ rescue Interrupt
531
+ # Manejo limpio al interrumpir con Ctrl+C
532
+ Kernel.print(DISABLE_MOUSE) if @mouse
533
+ Kernel.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
534
+ $stdout.flush
535
+ nil
536
+ end
537
+
538
+ private
539
+
540
+ # Dibuja un solo cuadro del menú en la posición de inicio del cursor (CURSOR_HOME)
541
+ def draw_frame(target_width)
542
+ lines = render_lines(target_width)
543
+ buffer = String.new(CURSOR_HOME)
544
+ lines.each_with_index do |line, idx|
545
+ buffer << line << CLEAR_TO_EOL
546
+ buffer << "\r\n" if idx < lines.length - 1
547
+ end
548
+ buffer << CLEAR_TO_EOS
549
+ Kernel.print(buffer)
550
+ $stdout.flush
551
+ end
552
+
553
+ # Bucle interactivo principal: escucha teclas, eventos de ratón y actualiza animación
554
+ def run_interactive_loop(input_stream, target_width)
555
+ matching = current_matching_indices
556
+ @index = matching.first || 0 unless matching.include?(@index)
557
+ @rgb_tick = 0.0
558
+ draw_frame(target_width)
559
+
560
+ animating = has_active_animation?
561
+
562
+ while true
563
+ # Si hay animación continua (RGB, Chroma, Cromático), esperamos con select no bloqueante
564
+ if animating
565
+ ready = false
566
+ if input_stream.respond_to?(:to_io) || input_stream.is_a?(IO)
567
+ begin
568
+ select_res = IO.select([input_stream], nil, nil, 0.035)
569
+ ready = true if select_res && select_res[0] && !select_res[0].empty?
570
+ rescue StandardError
571
+ ready = true
572
+ end
573
+ else
574
+ ready = true
575
+ end
576
+
577
+ unless ready
578
+ @rgb_tick += 0.08
579
+ draw_frame(target_width)
580
+ next
581
+ end
582
+ end
583
+
584
+ key = read_single_key(input_stream)
585
+ break if key.nil? || key == "\x03" || key == "\x04"
586
+
587
+ # Evento de ratón en formato SGR: \e[<Btn;Col;Row(M|m)
588
+ if key =~ /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
589
+ btn = $1.to_i
590
+ col = $2.to_i
591
+ row = $3.to_i
592
+ act = $4
593
+
594
+ # Rueda del ratón hacia arriba (btn 64)
595
+ if btn == 64
596
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
597
+ s1_acts = get_submenu_actions(@functions[@index])
598
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
599
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
600
+ @level = 2
601
+ elsif @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
602
+ s1_acts = get_submenu_actions(@functions[@index])
603
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
604
+ @sub_index_2 = 0
605
+ @level = 1
606
+ else
607
+ move_up
608
+ @sub_index_1 = 0
609
+ @sub_index_2 = 0
610
+ @level = 0
611
+ end
612
+ draw_frame(target_width)
613
+ next
614
+ # Rueda del ratón hacia abajo (btn 65)
615
+ elsif btn == 65
616
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
617
+ s1_acts = get_submenu_actions(@functions[@index])
618
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
619
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
620
+ @level = 2
621
+ elsif @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
622
+ s1_acts = get_submenu_actions(@functions[@index])
623
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
624
+ @sub_index_2 = 0
625
+ @level = 1
626
+ else
627
+ move_down
628
+ @sub_index_1 = 0
629
+ @sub_index_2 = 0
630
+ @level = 0
631
+ end
632
+ draw_frame(target_width)
633
+ next
634
+ # Clic izquierdo del ratón (btn 0, act 'M')
635
+ elsif btn == 0 && act == "M"
636
+ # Clic sobre la barra de pestañas
637
+ if @tabs && !@tabs.empty? && @tabs_row && row == @tabs_row
638
+ clicked_tab = @tab_ranges.find { |_idx, rng| rng.cover?(col) }
639
+ if clicked_tab
640
+ @active_tab_idx = clicked_tab[0]
641
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
642
+ @index = 0
643
+ @level = 0
644
+ @open_level = 0
645
+ @sub_index_1 = 0
646
+ @sub_index_2 = 0
647
+ @active_panel = :main
648
+ @submenu_open = false
649
+ draw_frame(target_width)
650
+ next
651
+ end
652
+ end
653
+
654
+ # Clic en flecha superior de scroll
655
+ if @up_arrow_row && row == @up_arrow_row
656
+ move_up
657
+ draw_frame(target_width)
658
+ next
659
+ end
660
+
661
+ # Clic en flecha inferior de scroll
662
+ if @down_arrow_row && row == @down_arrow_row
663
+ move_down
664
+ draw_frame(target_width)
665
+ next
666
+ end
667
+
668
+ # Clic dentro de submenú nivel 2
669
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && @sub_2_hit_map[row]
670
+ s2_clicked = @sub_2_hit_map[row]
671
+ s1_acts = get_submenu_actions(@functions[@index])
672
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
673
+ if s2_acts && s2_clicked < s2_acts.length
674
+ @level = 2
675
+ @sub_index_2 = s2_clicked
676
+ return s2_acts[s2_clicked]
677
+ end
678
+ end
679
+
680
+ # Clic dentro de submenú nivel 1
681
+ if @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && @sub_1_hit_map[row]
682
+ s1_clicked = @sub_1_hit_map[row]
683
+ s1_acts = get_submenu_actions(@functions[@index])
684
+ if s1_acts && s1_clicked < s1_acts.length
685
+ target_action = s1_acts[s1_clicked]
686
+ @level = 1
687
+ @sub_index_1 = s1_clicked
688
+ if is_submenu_item?(target_action)
689
+ @open_level = 2
690
+ @level = 2
691
+ @sub_index_2 = 0
692
+ draw_frame(target_width)
693
+ next
694
+ else
695
+ return target_action
696
+ end
697
+ end
698
+ end
699
+
700
+ # Clic sobre una opción de la lista principal
701
+ if @row_hit_map && @row_hit_map[row]
702
+ hit = @row_hit_map[row]
703
+ x_in_box = col - hit[:margin_left]
704
+ if x_in_box > 0 && (hit[:total_w].nil? || x_in_box <= hit[:total_w])
705
+ c_idx = [[((x_in_box - 2) / ([hit[:col_w], 1].max + 2)).to_i, 0].max, hit[:cols] - 1].min
706
+ clicked_item = hit[:row_indices][c_idx]
707
+ if clicked_item
708
+ @index = clicked_item
709
+ if is_submenu_item?(@functions[clicked_item])
710
+ @open_level = 1
711
+ @level = 1
712
+ @sub_index_1 = 0
713
+ @sub_index_2 = 0
714
+ @active_panel = :sub
715
+ @submenu_open = true
716
+ draw_frame(target_width)
717
+ next
718
+ else
719
+ return @functions[clicked_item]
720
+ end
721
+ end
722
+ end
723
+ end
724
+ end
725
+ next
726
+ end
727
+
728
+ # Navegación entre pestañas con tecla Tab y Shift+Tab (\e[Z)
729
+ if @tabs && !@tabs.empty?
730
+ if key == "\t"
731
+ @active_tab_idx = (@active_tab_idx + 1) % @tabs.length
732
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
733
+ @index = 0
734
+ @level = 0
735
+ @open_level = 0
736
+ @sub_index_1 = 0
737
+ @sub_index_2 = 0
738
+ @active_panel = :main
739
+ @submenu_open = false
740
+ draw_frame(target_width)
741
+ next
742
+ elsif key == "\e[Z"
743
+ @active_tab_idx = (@active_tab_idx - 1) % @tabs.length
744
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
745
+ @index = 0
746
+ @level = 0
747
+ @open_level = 0
748
+ @sub_index_1 = 0
749
+ @sub_index_2 = 0
750
+ @active_panel = :main
751
+ @submenu_open = false
752
+ draw_frame(target_width)
753
+ next
754
+ end
755
+ end
756
+
757
+ # Control de teclado enfocado en submenú nivel 2
758
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
759
+ s1_acts = get_submenu_actions(@functions[@index])
760
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
761
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
762
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
763
+ draw_frame(target_width)
764
+ next
765
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
766
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
767
+ draw_frame(target_width)
768
+ next
769
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
770
+ @open_level = 1
771
+ @level = 1
772
+ draw_frame(target_width)
773
+ next
774
+ elsif key == "\r" || key == "\n"
775
+ return s2_acts[@sub_index_2] if s2_acts && @sub_index_2 < s2_acts.length
776
+ end
777
+ end
778
+
779
+ # Control de teclado enfocado en submenú nivel 1
780
+ if @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
781
+ s1_acts = get_submenu_actions(@functions[@index])
782
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
783
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
784
+ @sub_index_2 = 0
785
+ draw_frame(target_width)
786
+ next
787
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
788
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
789
+ @sub_index_2 = 0
790
+ draw_frame(target_width)
791
+ next
792
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
793
+ @open_level = 0
794
+ @level = 0
795
+ @active_panel = :main
796
+ @submenu_open = false
797
+ draw_frame(target_width)
798
+ next
799
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
800
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
801
+ if is_submenu_item?(cur_l1)
802
+ @open_level = 2
803
+ @level = 2
804
+ @sub_index_2 = 0
805
+ draw_frame(target_width)
806
+ next
807
+ end
808
+ elsif key == "\r" || key == "\n"
809
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
810
+ if is_submenu_item?(cur_l1)
811
+ @open_level = 2
812
+ @level = 2
813
+ @sub_index_2 = 0
814
+ draw_frame(target_width)
815
+ next
816
+ else
817
+ return cur_l1
818
+ end
819
+ end
820
+ end
821
+
822
+ # Salir con 'q' si no estamos escribiendo en la barra de búsqueda
823
+ if !@search && (key == "q" || key == "Q")
824
+ break
825
+ end
826
+
827
+ # Tecla Escape: limpia búsqueda si hay texto, de lo contrario cierra el menú
828
+ if key == "\e"
829
+ if @search && !@query.empty?
830
+ @query.clear
831
+ matching = current_matching_indices
832
+ @index = matching.first || 0
833
+ draw_frame(target_width)
834
+ else
835
+ break
836
+ end
837
+ elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
838
+ move_up
839
+ @sub_index_1 = 0
840
+ @sub_index_2 = 0
841
+ draw_frame(target_width)
842
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
843
+ move_down
844
+ @sub_index_1 = 0
845
+ @sub_index_2 = 0
846
+ draw_frame(target_width)
847
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K"
848
+ if @open_level > 0
849
+ @open_level = 0
850
+ @level = 0
851
+ @active_panel = :main
852
+ @submenu_open = false
853
+ draw_frame(target_width)
854
+ else
855
+ move_left
856
+ draw_frame(target_width)
857
+ end
858
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
859
+ if is_submenu_item?(@functions[@index])
860
+ @open_level = 1
861
+ @level = 1
862
+ @sub_index_1 = 0
863
+ @sub_index_2 = 0
864
+ @active_panel = :sub
865
+ @submenu_open = true
866
+ draw_frame(target_width)
867
+ else
868
+ move_right
869
+ draw_frame(target_width)
870
+ end
871
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
872
+ # Backspace en el campo de búsqueda
873
+ if @search && !@query.empty?
874
+ @query.chop!
875
+ matching = current_matching_indices
876
+ @index = matching.first || 0
877
+ draw_frame(target_width)
878
+ end
879
+ elsif key == "\x15"
880
+ # Ctrl+U: limpiar búsqueda
881
+ if @search
882
+ @query.clear
883
+ matching = current_matching_indices
884
+ @index = matching.first || 0
885
+ draw_frame(target_width)
886
+ end
887
+ elsif key == "\r" || key == "\n"
888
+ # Enter: abrir submenú o ejecutar la opción actual
889
+ matching = current_matching_indices
890
+ if matching.include?(@index)
891
+ if is_submenu_item?(@functions[@index])
892
+ @open_level = 1
893
+ @level = 1
894
+ @sub_index_1 = 0
895
+ @sub_index_2 = 0
896
+ @active_panel = :sub
897
+ @submenu_open = true
898
+ draw_frame(target_width)
899
+ else
900
+ return @functions[@index]
901
+ end
902
+ end
903
+ elsif @search && key =~ /^[[:print:]]$/
904
+ # Agregar letra a la búsqueda y filtrar opciones
905
+ @query << key
906
+ matching = current_matching_indices
907
+ @index = matching.first || 0
908
+ draw_frame(target_width)
909
+ end
910
+ end
911
+
912
+ nil
913
+ end
914
+
915
+ # Lee una tecla individual o secuencia de escape desde el stream
916
+ def read_single_key(input_stream)
917
+ GRmenu.read_key_raw(input_stream)
918
+ end
919
+
920
+ # Transforma nombres en snake_case o kebab-case a palabras capitalizadas legibles
921
+ def format_auto_name(raw_name)
922
+ cleaned = raw_name.to_s.gsub(/[_-]+/, ' ').strip
923
+ cleaned.split(' ').map(&:capitalize).join(' ')
924
+ end
925
+
926
+ # Extrae la etiqueta visible para un elemento del menú según su tipo
927
+ def extract_name_from_action(action)
928
+ case action
929
+ when Array
930
+ action[0].to_s
931
+ when Hash
932
+ (action[:name] || action[:title] || action["name"] || action["title"] || "Opcion").to_s
933
+ when Method
934
+ format_auto_name(action.name)
935
+ when Symbol
936
+ format_auto_name(action)
937
+ when Proc
938
+ if action.respond_to?(:name) && action.name
939
+ format_auto_name(action.name)
940
+ else
941
+ "Opcion"
942
+ end
943
+ else
944
+ if action.respond_to?(:name)
945
+ format_auto_name(action.name)
946
+ elsif action.respond_to?(:title)
947
+ action.title.to_s
948
+ else
949
+ format_auto_name(action)
950
+ end
951
+ end
952
+ end
953
+
954
+ # Extrae la descripción secundaria asociada a una opción
955
+ def extract_description_from_action(action)
956
+ if action.is_a?(Array) && action.length >= 3
957
+ action[2].to_s
958
+ elsif action.is_a?(Hash)
959
+ (action[:desc] || action[:description] || action["desc"] || action["description"]).to_s
960
+ else
961
+ ""
962
+ end
963
+ end
964
+
965
+ # Ejecuta la acción seleccionada por el usuario (Proc, Method, Symbol o bloque invocable)
966
+ def execute_action(action)
967
+ case action
968
+ when Method, Proc
969
+ action.call
970
+ when Symbol
971
+ if Object.respond_to?(action, true)
972
+ Object.send(action)
973
+ elsif Kernel.respond_to?(action, true)
974
+ Kernel.send(action)
975
+ end
976
+ when Array
977
+ callable = action[1]
978
+ if callable.is_a?(Symbol)
979
+ if Object.respond_to?(callable, true)
980
+ Object.send(callable)
981
+ elsif Kernel.respond_to?(callable, true)
982
+ Kernel.send(callable)
983
+ end
984
+ elsif callable.respond_to?(:call)
985
+ callable.call
986
+ end
987
+ when Hash
988
+ callable = action[:action] || action[:call] || action["action"] || action["call"]
989
+ if callable.is_a?(Symbol)
990
+ if Object.respond_to?(callable, true)
991
+ Object.send(callable)
992
+ elsif Kernel.respond_to?(callable, true)
993
+ Kernel.send(callable)
994
+ end
995
+ elsif callable.respond_to?(:call)
996
+ callable.call
997
+ end
998
+ else
999
+ action.call if action.respond_to?(:call)
1000
+ end
1001
+ end
1002
+ end