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,865 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Aplica color o animación a un fragmento de texto según la configuración de estilo.
5
+ #
6
+ # Soporta cuatro modos principales:
7
+ # 1. Modo Chroma / Rainbow / RGB: espectro multicolor animado caracter por caracter.
8
+ # 2. Modo Cromático (antes Neón) / Fade: pulso rítmico coordinado sobre el color base.
9
+ # 3. Colores ANSI indexados o nombres estándar resueltos mediante la paleta de la terminal.
10
+ # 4. Texto plano si la configuración es nula o vacía.
11
+ #
12
+ # @param text [String] El texto a colorear.
13
+ # @param color_config [Hash, nil] Diccionario con :color y opcionalmente :level.
14
+ # @param phase_offset [Float] Desfase angular para animaciones sincronizadas entre renglones.
15
+ # @return [String] Cadena con las secuencias de escape ANSI correspondientes.
16
+ def colorize(text, color_config, phase_offset = 0.0)
17
+ return text.to_s if color_config.nil? || color_config.empty?
18
+
19
+ color_name = (color_config[:color] || color_config["color"]).to_s.downcase.strip
20
+ brightness_level = (color_config[:level] || color_config["level"] || 1).to_i
21
+
22
+ # Soporte para sintaxis compacta tipo "color:nivel" (ej. "cyan:2")
23
+ if color_name.include?(":")
24
+ parts = color_name.split(":")
25
+ color_name = parts[0].strip
26
+ brightness_level = parts[1].to_i if parts[1] && !parts[1].empty?
27
+ end
28
+
29
+ is_chromatic_color = color_name.start_with?("chromatic") || color_name.start_with?("cromatico")
30
+ is_anim_active = is_chromatic_color || (@animate && ["chromatic", "cromatico", "fade"].include?(@animate.to_s.downcase))
31
+ is_chroma = color_name == "rgb" || color_name == "rainbow" || color_name == "chroma" || (@animate && ["rgb", "rainbow", "chroma"].include?(@animate.to_s.downcase))
32
+
33
+ # Efecto Chroma / Rainbow: recorremos caracter por caracter aplicando un color del espectro visible
34
+ if is_chroma
35
+ tick = @rgb_tick || 0.0
36
+ out = String.new("")
37
+ char_count = 0
38
+ in_escape = false
39
+ escape_buf = String.new("")
40
+
41
+ text.to_s.each_char do |ch|
42
+ # Si topamos con una secuencia ANSI existente, la conservamos sin romperla
43
+ if ch == "\e"
44
+ in_escape = true
45
+ escape_buf << ch
46
+ next
47
+ end
48
+ if in_escape
49
+ escape_buf << ch
50
+ if ch =~ /[a-zA-Z]/
51
+ in_escape = false
52
+ out << escape_buf
53
+ escape_buf.clear
54
+ end
55
+ next
56
+ end
57
+
58
+ # Espacios y saltos no necesitan color TrueColor pero avanzan la posicion de columna
59
+ w = (self.class.char_width(ch) rescue 1)
60
+ if ch == " " || ch == "\t" || ch == "\r" || ch == "\n"
61
+ out << ch
62
+ char_count += w
63
+ else
64
+ c_code = self.class.rgb_color(tick, char_count * 0.12 + phase_offset)
65
+ out << "#{c_code}#{ch}"
66
+ char_count += w
67
+ end
68
+ end
69
+ out << self.class.ansi_reset
70
+ return out
71
+ end
72
+
73
+ # Efecto Cromático / Fade: respiración senoidal sincronizada sobre el color base
74
+ if is_anim_active
75
+ tick = @rgb_tick || 0.0
76
+ lookup_name = color_name
77
+
78
+ base = if lookup_name =~ /\A#?([0-9a-f]{6})\z/i
79
+ h = $1
80
+ [h[0..1].to_i(16), h[2..3].to_i(16), h[4..5].to_i(16)]
81
+ elsif lookup_name =~ /\A#?([0-9a-f]{3})\z/i
82
+ h = $1
83
+ [(h[0] * 2).to_i(16), (h[1] * 2).to_i(16), (h[2] * 2).to_i(16)]
84
+ else
85
+ BASE_RGB[lookup_name] || [255, 255, 255]
86
+ end
87
+
88
+ factor = (Math.sin(tick + phase_offset) + 1.0) / 2.0
89
+ f = 0.35 + 0.65 * factor
90
+ r = (base[0] * f).clamp(0, 255).to_i
91
+ g = (base[1] * f).clamp(0, 255).to_i
92
+ b = (base[2] * f).clamp(0, 255).to_i
93
+ glow = (factor > 0.85) ? ";1" : ""
94
+ return "\e[38;2;#{r};#{g};#{b}#{glow}m#{text}#{self.class.ansi_reset}"
95
+ end
96
+
97
+ # Colores estáticos ANSI estándar
98
+ color_code = self.class.ansi_color(color_name, brightness_level)
99
+ return text.to_s unless color_code
100
+
101
+ "#{color_code}#{text}#{self.class.ansi_reset}"
102
+ end
103
+ alias_method :_colorize, :colorize
104
+
105
+ # Construye una línea horizontal repitiendo un patrón hasta completar el ancho solicitado.
106
+ #
107
+ # @param pattern [String] Caracter o caracteres que forman la línea (ej: "─" o "=").
108
+ # @param target_width [Integer] Cantidad total de caracteres deseada.
109
+ # @return [String] Cadena recortada al ancho exacto.
110
+ def build_horizontal_line(pattern, target_width)
111
+ return "" if target_width <= 0 || pattern.nil? || pattern.empty?
112
+
113
+ pattern_length = pattern.length
114
+ repetitions_needed = (target_width.to_f / pattern_length).ceil + 1
115
+ (pattern * repetitions_needed)[0...target_width]
116
+ end
117
+ alias_method :_hline, :build_horizontal_line
118
+
119
+ # Genera las líneas que componen el banner de título enmarcado (ASCII Art o texto simple).
120
+ #
121
+ # @param term_cols [Integer] Ancho actual de la consola para limitar el tamaño.
122
+ # @return [Array<Array<String>, Integer>] Arreglo con [líneas_generadas, ancho_del_banner].
123
+ def render_banner_lines(term_cols)
124
+ return [[], 0] if @banner.nil? || @banner.empty?
125
+
126
+ font_id = @style_config.font || 1
127
+ ascii_rows = self.class.build_ascii_lines(@banner, term_cols, font_id)
128
+ banner_border = BORDERS[@banner_style] || BORDERS[3]
129
+ banner_color_cfg = @style_config.banner
130
+
131
+ horizontal_top = banner_border[:ht] || banner_border[:h]
132
+ horizontal_bottom = banner_border[:hb] || banner_border[:h]
133
+ vertical_left = banner_border[:vl] || banner_border[:v]
134
+ vertical_right = banner_border[:vr] || banner_border[:v]
135
+
136
+ lines = []
137
+ box_w = 0
138
+ if ascii_rows
139
+ content_w = ascii_rows.map { |r| GRmenu.display_width(r) }.max
140
+ box_w = content_w + 6
141
+ top_fill = build_horizontal_line(horizontal_top, content_w + 4)
142
+ bot_fill = build_horizontal_line(horizontal_bottom, content_w + 4)
143
+
144
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
145
+ ascii_rows.each do |row|
146
+ pad = " " * (content_w - GRmenu.display_width(row))
147
+ lines << colorize("#{vertical_left} #{row}#{pad} #{vertical_right}", banner_color_cfg, 0.0)
148
+ end
149
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 0.0)
150
+ else
151
+ clean_b = @banner.strip
152
+ b_vis_w = GRmenu.display_width(clean_b)
153
+ box_w = [b_vis_w + 6, term_cols - 2].min
154
+ top_fill = build_horizontal_line(horizontal_top, box_w - 2)
155
+ bot_fill = build_horizontal_line(horizontal_bottom, box_w - 2)
156
+
157
+ pad_b = [box_w - 4 - b_vis_w, 0].max
158
+ l_p = " " * (pad_b / 2)
159
+ r_p = " " * (pad_b - (pad_b / 2))
160
+
161
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
162
+ lines << colorize("#{vertical_left} #{l_p}#{clean_b}#{r_p} #{vertical_right}", banner_color_cfg, 0.0)
163
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 0.0)
164
+ end
165
+ [lines, box_w]
166
+ end
167
+
168
+ # Renderiza una imagen gráfica en la cabecera del menú enmarcada con los bordes de banner.
169
+ #
170
+ # @param term_cols [Integer] Ancho de la terminal.
171
+ # @return [Array<Array<String>, Integer>] [líneas_renderizadas, ancho_de_caja].
172
+ def render_image_lines(term_cols)
173
+ return @cached_image_lines if @cached_image_lines && @cached_image_cols == term_cols
174
+ return [[], 0] unless @image && File.exist?(@image)
175
+
176
+ raw_lines = self.class.load_and_render_image(@image, @image_width || 40, nil, term_cols)
177
+ return [[], 0] if raw_lines.empty?
178
+
179
+ img_w = self.class.display_width(raw_lines.first)
180
+ box_w = img_w + 4
181
+ banner_border = BORDERS[@banner_style] || BORDERS[3]
182
+ banner_color_cfg = @style_config.banner
183
+
184
+ horizontal_top = banner_border[:ht] || banner_border[:h]
185
+ horizontal_bottom = banner_border[:hb] || banner_border[:h]
186
+ vertical_left = banner_border[:vl] || banner_border[:v]
187
+ vertical_right = banner_border[:vr] || banner_border[:v]
188
+
189
+ top_fill = build_horizontal_line(horizontal_top, img_w + 2)
190
+ bot_fill = build_horizontal_line(horizontal_bottom, img_w + 2)
191
+
192
+ b_col_name = (banner_color_cfg[:color] || banner_color_cfg["color"]).to_s.downcase
193
+ is_img_chroma = b_col_name == "rgb" || b_col_name == "rainbow" || b_col_name == "chroma" || (@animate && ["rgb", "rainbow", "chroma"].include?(@animate.to_s.downcase))
194
+ img_r_offset = is_img_chroma ? (box_w - 1) * 0.12 : 0.0
195
+
196
+ lines = []
197
+ lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
198
+ raw_lines.each do |r_line|
199
+ lines << "#{colorize(vertical_left, banner_color_cfg, 0.0)} #{r_line} #{colorize(vertical_right, banner_color_cfg, img_r_offset)}"
200
+ end
201
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 0.0)
202
+
203
+ @cached_image_cols = term_cols
204
+ @cached_image_lines = [lines, box_w]
205
+ @cached_image_lines
206
+ end
207
+
208
+ # Determina si una acción representa un submenú anidado.
209
+ #
210
+ # @param action [Object] Elemento del menú (Array, Hash, GRmenu, etc.).
211
+ # @return [Boolean] true si contiene subopciones.
212
+ def is_submenu_item?(action)
213
+ if action.is_a?(Array)
214
+ target = action[1]
215
+ 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))
216
+ return true if target.is_a?(GRmenu)
217
+ elsif action.is_a?(Hash)
218
+ return true if action[:submenu] || action["submenu"]
219
+ end
220
+ false
221
+ end
222
+
223
+ # Obtiene la lista de acciones hijas de un submenú.
224
+ #
225
+ # @param action [Object] Elemento del menú que contiene el submenú.
226
+ # @return [Array, nil] Las opciones anidadas.
227
+ def get_submenu_actions(action)
228
+ if action.is_a?(Array)
229
+ action[1].is_a?(GRmenu) ? action[1].instance_variable_get(:@functions) : action[1]
230
+ elsif action.is_a?(Hash)
231
+ action[:submenu] || action["submenu"]
232
+ end
233
+ end
234
+
235
+ # Dibuja la caja flotante lateral correspondiente a un nivel de submenú en cascada.
236
+ #
237
+ # @param sub_actions [Array] Lista de opciones dentro del submenú.
238
+ # @param parent_title [String] Título del menú padre que encabeza la cajita.
239
+ # @param is_active [Boolean] Si el foco del teclado se encuentra actualmente en este submenú.
240
+ # @param selected_idx [Integer] Índice de la opción seleccionada dentro del submenú.
241
+ # @return [Array<Array<String>, Integer, Integer>] [líneas, ancho_de_caja, renglón_del_item_activo].
242
+ def render_submenu_box(sub_actions, parent_title = "", is_active: false, selected_idx: 0)
243
+ sub_names = sub_actions.map { |a| extract_name_from_action(a) }
244
+ max_name_len = sub_names.empty? ? 10 : sub_names.map { |n| GRmenu.display_width(n) }.max
245
+ sub_title = parent_title.to_s
246
+ sub_w = [max_name_len + 8, GRmenu.display_width(sub_title) + 6, 20].max
247
+ sub_w = [sub_w, 36].min
248
+ inner_w = [sub_w - 2, 8].max
249
+
250
+ submenu_theme = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "submenu")) || {}
251
+ sub_style = (submenu_theme["style"] || @style || 3).to_i
252
+ sub_border = BORDERS[sub_style] || BORDERS[3]
253
+ horizontal_top = sub_border[:ht] || sub_border[:h]
254
+ horizontal_bottom = sub_border[:hb] || sub_border[:h]
255
+ vertical_left = sub_border[:vl] || sub_border[:v]
256
+ vertical_right = sub_border[:vr] || sub_border[:v]
257
+
258
+ border_color_val = submenu_theme["border"] || submenu_theme["border_color"] || @style_config.border[:color] || "cyan"
259
+ focus_color_val = submenu_theme["focus"] || submenu_theme["focus_color"] || @style_config.focus[:color] || "yellow"
260
+ options_color_val = submenu_theme["options"] || submenu_theme["options_color"] || @style_config.options[:color] || "white"
261
+
262
+ sub_border_cfg = { color: border_color_val, level: 1 }
263
+ sub_focus_cfg = { color: focus_color_val, level: 2 }
264
+ sub_opt_cfg = { color: options_color_val, level: 1 }
265
+ sub_held_cfg = { color: "yellow", level: 1 }
266
+
267
+ sub_border_is_chroma = (border_color_val == "rgb" || border_color_val == "rainbow" || border_color_val == "chroma" || (@animate && ["rgb", "rainbow", "chroma"].include?(@animate.to_s.downcase)))
268
+ sub_r_offset = sub_border_is_chroma ? (sub_w - 1) * 0.12 : 0.0
269
+
270
+ lines = []
271
+ top_fill = build_horizontal_line(horizontal_top, inner_w)
272
+ bot_fill = build_horizontal_line(horizontal_bottom, inner_w)
273
+
274
+ top_border_line = sub_border[:tl] + top_fill + sub_border[:tr]
275
+ lines << colorize(top_border_line, sub_border_cfg, 0.0)
276
+
277
+ unless sub_title.empty?
278
+ pad_t = [inner_w - 2 - GRmenu.display_width(sub_title), 0].max
279
+ t_padded = " " * (pad_t / 2) + sub_title + " " * (pad_t - (pad_t / 2))
280
+ c_title = colorize(t_padded, sub_focus_cfg, sub_border_is_chroma ? 2 * 0.12 : 0.0)
281
+ v_l_col = colorize(vertical_left, sub_border_cfg, 0.0)
282
+ v_r_col = colorize(vertical_right, sub_border_cfg, sub_r_offset)
283
+ lines << "#{v_l_col} #{c_title} #{v_r_col}"
284
+ mid_fill = build_horizontal_line(horizontal_top, inner_w)
285
+ lines << colorize(vertical_left + mid_fill + vertical_right, sub_border_cfg, 0.0)
286
+ end
287
+
288
+ parent_item_row = nil
289
+ sub_names.each_with_index do |s_name, s_idx|
290
+ is_sub = is_submenu_item?(sub_actions[s_idx])
291
+ arrow = is_sub ? "▶" : ""
292
+ is_selected = (s_idx == selected_idx)
293
+ parent_item_row = lines.length if is_selected
294
+
295
+ prefix = is_selected ? "> " : " "
296
+ pad_s = [inner_w - 2 - prefix.length - GRmenu.display_width(s_name) - (is_sub ? 2 : 0), 0].max
297
+ raw_item = if is_sub
298
+ "#{prefix}#{s_name}#{' ' * pad_s} #{arrow}"
299
+ else
300
+ "#{prefix}#{s_name}#{' ' * pad_s}"
301
+ end
302
+
303
+ item_cfg = if is_selected
304
+ is_active ? sub_focus_cfg : sub_held_cfg
305
+ else
306
+ sub_opt_cfg
307
+ end
308
+
309
+ sub_item_phase = sub_border_is_chroma ? 2 * 0.12 : 0.0
310
+ colored_item = colorize(raw_item, item_cfg, sub_item_phase)
311
+ v_l_col = colorize(vertical_left, sub_border_cfg, 0.0)
312
+ v_r_col = colorize(vertical_right, sub_border_cfg, sub_r_offset)
313
+ lines << "#{v_l_col} #{colored_item} #{v_r_col}"
314
+ end
315
+
316
+ bot_border_line = sub_border[:bl] + bot_fill + sub_border[:br]
317
+ lines << colorize(bot_border_line, sub_border_cfg, 0.0)
318
+
319
+ [lines, sub_w, parent_item_row]
320
+ end
321
+
322
+ # Renderiza el menú completo (cabecera, pestañas, marco principal, submenús y descripción).
323
+ #
324
+ # Devuelve una lista de líneas de texto ya coloreadas y formateadas listas para imprimir.
325
+ # Todos los elementos del marco (bordes superior, inferior, laterales y separadores)
326
+ # se encuentran en fase sincronizada (fase 0.0) para garantizar que respiren o brillen en
327
+ # conjunto y ningún borde quede desfasado.
328
+ #
329
+ # @param size_max [Integer] Ancho sugerido para la caja.
330
+ # @return [Array<String>] Líneas finales del frame.
331
+ def render_lines(size_max = 20)
332
+ term_cols = self.class.terminal_width
333
+ term_rows = self.class.terminal_height
334
+ rendered_lines = []
335
+
336
+ # Reinicializamos mapas de impacto del ratón
337
+ @row_hit_map = {}
338
+ @tab_ranges = {}
339
+ @tabs_row = nil
340
+ @up_arrow_row = nil
341
+ @down_arrow_row = nil
342
+ @sub_hit_map = {}
343
+ @sub_1_hit_map = {}
344
+ @sub_2_hit_map = {}
345
+ @sub_1_col_rng = nil
346
+ @sub_2_col_rng = nil
347
+
348
+ header_box_width = 0
349
+ header_lines_count = 0
350
+
351
+ # 1. Imagen superior si existe
352
+ if @image && File.exist?(@image)
353
+ img_lines, img_box_w = render_image_lines(term_cols)
354
+ unless img_lines.empty?
355
+ img_pad = (@center && term_cols > img_box_w) ? " " * ((term_cols - img_box_w) / 2) : ""
356
+ rendered_lines.concat(img_lines.map { |l| "#{img_pad}#{l}" })
357
+ rendered_lines << ""
358
+ header_lines_count += img_lines.length + 1
359
+ header_box_width = [header_box_width, img_box_w].max
360
+ end
361
+ end
362
+
363
+ # 2. Banner ASCII o título superior
364
+ if @banner && !@banner.empty?
365
+ banner_lines, banner_box_w = render_banner_lines(term_cols)
366
+ unless banner_lines.empty?
367
+ b_pad = (@center && term_cols > banner_box_w) ? " " * ((term_cols - banner_box_w) / 2) : ""
368
+ rendered_lines.concat(banner_lines.map { |l| "#{b_pad}#{l}" })
369
+ rendered_lines << ""
370
+ header_lines_count += banner_lines.length + 1
371
+ header_box_width = [header_box_width, banner_box_w].max
372
+ end
373
+ end
374
+
375
+ matching_indices = current_matching_indices
376
+ all_names = @functions.map { |func| extract_name_from_action(func) }
377
+ all_descriptions = @functions.map { |func| extract_description_from_action(func) }
378
+
379
+ active_desc = all_descriptions[@index] || ""
380
+
381
+ cols = @columns
382
+ max_item_len = all_names.empty? ? 10 : all_names.map { |n| GRmenu.display_width(n) }.max
383
+ grid_suggested_w = (max_item_len + 6) * cols + 4
384
+
385
+ calculated_width = [size_max, GRmenu.display_width(@title) + 4, grid_suggested_w].max
386
+ calculated_width = ([calculated_width, GRmenu.display_width(active_desc) + 8].max) unless active_desc.empty?
387
+ calculated_width = ([calculated_width, GRmenu.display_width(@query) + 16].max) if @search
388
+ if @tabs && !@tabs.empty?
389
+ tabs_min_w = @tabs.map { |t| GRmenu.display_width(t) + 4 }.sum + (@tabs.length - 1) * 3
390
+ calculated_width = [calculated_width, tabs_min_w + 4].max
391
+ end
392
+ total_width = [calculated_width, term_cols - 2].min
393
+
394
+ # Cálculo de anchos para submenús laterales (si están abiertos)
395
+ sub_1_preview_w = 0
396
+ sub_2_preview_w = 0
397
+ if @open_level >= 1 && is_submenu_item?(@functions[@index])
398
+ s1_acts = get_submenu_actions(@functions[@index])
399
+ if s1_acts && !s1_acts.empty?
400
+ s1_names = s1_acts.map { |a| extract_name_from_action(a) }
401
+ s1_max = s1_names.empty? ? 10 : s1_names.map { |n| GRmenu.display_width(n) }.max
402
+ s1_title = all_names[@index] || ""
403
+ sub_1_preview_w = [s1_max + 8, GRmenu.display_width(s1_title) + 6, 20].max
404
+ sub_1_preview_w = [sub_1_preview_w, 36].min
405
+
406
+ if @open_level >= 2 && is_submenu_item?(s1_acts[@sub_index_1])
407
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1])
408
+ if s2_acts && !s2_acts.empty?
409
+ s2_names = s2_acts.map { |a| extract_name_from_action(a) }
410
+ s2_max = s2_names.empty? ? 10 : s2_names.map { |n| GRmenu.display_width(n) }.max
411
+ s2_title = extract_name_from_action(s1_acts[@sub_index_1]) || ""
412
+ sub_2_preview_w = [s2_max + 8, GRmenu.display_width(s2_title) + 6, 20].max
413
+ sub_2_preview_w = [sub_2_preview_w, 36].min
414
+ end
415
+ end
416
+ end
417
+ end
418
+
419
+ total_chain_w = total_width
420
+ total_chain_w += 2 + sub_1_preview_w if sub_1_preview_w > 0
421
+ total_chain_w += 2 + sub_2_preview_w if sub_2_preview_w > 0
422
+
423
+ margin_left = if @center && term_cols > total_chain_w
424
+ " " * ((term_cols - total_chain_w) / 2)
425
+ elsif @center && term_cols > total_width
426
+ " " * ((term_cols - total_width) / 2)
427
+ else
428
+ ""
429
+ end
430
+
431
+ # 3. Subtítulo y divisores
432
+ subtitle_lines_count = 0
433
+ if @subtitle && !@subtitle.empty?
434
+ subtitle_lines = @subtitle.lines.map(&:chomp)
435
+ ref_sub_w = header_box_width > 0 ? header_box_width : total_chain_w
436
+ div_w = @divider.is_a?(Numeric) ? @divider.to_i : [ref_sub_w, term_cols - 2].min
437
+ sub_pad = (@center && term_cols > div_w) ? " " * ((term_cols - div_w) / 2) : ""
438
+
439
+ if @divider
440
+ rendered_lines << "#{sub_pad}#{colorize("─" * div_w, @style_config.divider, 0.0)}"
441
+ subtitle_lines_count += 1
442
+ end
443
+
444
+ subtitle_lines.each_with_index do |sub_line, s_i|
445
+ pad_sub = [div_w - GRmenu.display_width(sub_line), 0].max
446
+ formatted_sub = @center ? (" " * (pad_sub / 2) + sub_line + " " * (pad_sub - (pad_sub / 2))) : sub_line
447
+ rendered_lines << "#{sub_pad}#{colorize(formatted_sub, @style_config.subtitle, 0.0)}"
448
+ subtitle_lines_count += 1
449
+ end
450
+
451
+ if @divider
452
+ rendered_lines << "#{sub_pad}#{colorize("─" * div_w, @style_config.divider, 0.0)}"
453
+ subtitle_lines_count += 1
454
+ end
455
+ rendered_lines << ""
456
+ subtitle_lines_count += 1
457
+ end
458
+
459
+ border_color_cfg = @style_config.border
460
+ options_color_cfg = @style_config.options
461
+ focus_color_cfg = @style_config.focus
462
+ title_color_cfg = @style_config.title
463
+
464
+ box_border = BORDERS[@style] || BORDERS[@style.to_i] || BORDERS[3]
465
+
466
+ overhead = header_lines_count + subtitle_lines_count + 6
467
+ overhead += 2 unless active_desc.empty?
468
+ overhead += 2 if @search
469
+ available_rows = [term_rows - overhead - 2, 2].max
470
+
471
+ rows_data = matching_indices.each_slice(cols).to_a
472
+ total_rows = rows_data.length
473
+
474
+ effective_page_rows = if @page_size && @page_size > 0
475
+ [@page_size, total_rows, available_rows].min
476
+ else
477
+ [total_rows, available_rows].min
478
+ end
479
+ effective_page_rows = [effective_page_rows, 1].max
480
+
481
+ curr_matching_pos = matching_indices.index(@index) || 0
482
+ curr_row = total_rows > 0 ? (curr_matching_pos / cols) : 0
483
+
484
+ start_row = 0
485
+ end_row = [total_rows - 1, 0].max
486
+ if total_rows > effective_page_rows
487
+ half_r = effective_page_rows / 2
488
+ start_row = [[curr_row - half_r, 0].max, total_rows - effective_page_rows].min
489
+ end_row = start_row + effective_page_rows - 1
490
+ end
491
+
492
+ visible_rows_data = rows_data[start_row..end_row] || []
493
+ has_more_above = start_row > 0
494
+ has_more_below = end_row < (total_rows - 1)
495
+
496
+ avail_w = [total_width - 4, 1].max
497
+ col_w = [(avail_w - (cols - 1) * 2) / cols, 1].max
498
+
499
+ # 4. Barra de pestañas (Tabs)
500
+ if @tabs && !@tabs.empty?
501
+ tab_strs = []
502
+ raw_tab_strs = []
503
+ @tab_ranges = {}
504
+ @tabs.each_with_index do |t_name, t_i|
505
+ is_active = (t_i == @active_tab_idx)
506
+ raw_t = is_active ? "[ #{t_name} ]" : " #{t_name} "
507
+ colored_t = if is_active
508
+ colorize(raw_t, { color: @active_tab_color, level: 2 }, 0.0)
509
+ else
510
+ colorize(raw_t, { color: @tab_color, level: 1 }, 0.0)
511
+ end
512
+ tab_strs << colored_t
513
+ raw_tab_strs << raw_t
514
+ end
515
+ raw_tabs_joined = raw_tab_strs.join(" ")
516
+ tabs_w = GRmenu.display_width(raw_tabs_joined)
517
+ l_tabs_pad = [(total_width - tabs_w) / 2, 0].max
518
+ formatted_tabs = (" " * l_tabs_pad) + tab_strs.join(" ")
519
+
520
+ start_col = margin_left.length + l_tabs_pad
521
+ cur_x = start_col
522
+ @tabs.each_with_index do |_t_name, t_i|
523
+ t_len = GRmenu.display_width(raw_tab_strs[t_i])
524
+ @tab_ranges[t_i] = (cur_x + 1)..(cur_x + t_len)
525
+ cur_x += t_len + 3
526
+ end
527
+
528
+ @tabs_row = rendered_lines.length + 1
529
+ rendered_lines << "#{margin_left}#{formatted_tabs}"
530
+ rendered_lines << ""
531
+ end
532
+
533
+ # 5. Caja principal de opciones: BORDES 100% SINCRONIZADOS
534
+ if box_border
535
+ horizontal_top = box_border[:ht] || box_border[:h]
536
+ horizontal_bottom = box_border[:hb] || box_border[:h]
537
+ vertical_left_raw = box_border[:vl] || box_border[:v]
538
+ vertical_right_raw = box_border[:vr] || box_border[:v]
539
+
540
+ top_fill = build_horizontal_line(horizontal_top, total_width - 2)
541
+ bot_fill = build_horizontal_line(horizontal_bottom, total_width - 2)
542
+ mid_fill = build_horizontal_line(horizontal_top, total_width - 2)
543
+
544
+ # Determinamos si el borde es Chroma espectral continuo para alinear geométricamente
545
+ # el borde derecho con la columna final, mientras que en modo cromático pulsante
546
+ # ambos lados respiran en fase 0.0 idéntica.
547
+ b_name = (border_color_cfg[:color] || border_color_cfg["color"]).to_s.downcase
548
+ is_border_chroma = (b_name == "rgb" || b_name == "rainbow" || b_name == "chroma" || (@animate && ["rgb", "rainbow", "chroma"].include?(@animate.to_s.downcase)))
549
+ v_r_offset = is_border_chroma ? (total_width - 1) * 0.12 : 0.0
550
+
551
+ v_left = colorize(vertical_left_raw, border_color_cfg, 0.0)
552
+ v_right = colorize(vertical_right_raw, border_color_cfg, v_r_offset)
553
+
554
+ main_box_start = rendered_lines.length
555
+ top_border_line = box_border[:tl] + top_fill + box_border[:tr]
556
+ rendered_lines << "#{margin_left}#{colorize(top_border_line, border_color_cfg, 0.0)}"
557
+
558
+ unless @title.empty?
559
+ pad_t = [total_width - 4 - GRmenu.display_width(@title), 0].max
560
+ title_padded = " " * (pad_t / 2) + @title + " " * (pad_t - (pad_t / 2))
561
+ centered_title = colorize(title_padded, title_color_cfg, is_border_chroma ? 2 * 0.12 : 0.0)
562
+ rendered_lines << "#{margin_left}#{v_left} #{centered_title} #{v_right}"
563
+
564
+ separator_line = vertical_left_raw + mid_fill + vertical_right_raw
565
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.0)}"
566
+ end
567
+
568
+ if @search
569
+ search_prompt = "Buscar: #{@query}█"
570
+ pad_s = [avail_w - GRmenu.display_width(search_prompt), 0].max
571
+ search_padded = search_prompt + (" " * pad_s)
572
+ search_colored = colorize(search_padded, { color: "bright_yellow" }, is_border_chroma ? 2 * 0.12 : 0.0)
573
+ rendered_lines << "#{margin_left}#{v_left} #{search_colored} #{v_right}"
574
+ separator_line = vertical_left_raw + mid_fill + vertical_right_raw
575
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.0)}"
576
+ end
577
+
578
+ if has_more_above
579
+ @up_arrow_row = rendered_lines.length + 1
580
+ up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
581
+ pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
582
+ up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 }, is_border_chroma ? 2 * 0.12 : 0.0)
583
+ rendered_lines << "#{margin_left}#{v_left} #{up_indicator} #{v_right}"
584
+ end
585
+
586
+ parent_row_idx = nil
587
+ if rows_data.empty?
588
+ no_res_txt = "(Sin resultados)"
589
+ pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
590
+ no_res = colorize(" " * (pad_no / 2) + no_res_txt + " " * (pad_no - (pad_no / 2)), { color: "gray", level: 1 }, is_border_chroma ? 2 * 0.12 : 0.0)
591
+ rendered_lines << "#{margin_left}#{v_left} #{no_res} #{v_right}"
592
+ else
593
+ visible_rows_data.each_with_index do |row_indices, r_idx|
594
+ cells = []
595
+ cols.times do |c_idx|
596
+ item_idx = row_indices[c_idx]
597
+ col_offset = 2 + c_idx * (col_w + 2)
598
+ cell_phase = is_border_chroma ? (col_offset * 0.12) : 0.0
599
+ if item_idx
600
+ op_name = all_names[item_idx]
601
+ is_sub = is_submenu_item?(@functions[item_idx])
602
+ arrow = is_sub ? "▶" : ""
603
+ if @index == item_idx
604
+ parent_row_idx = rendered_lines.length
605
+ cell_raw = if is_sub
606
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
607
+ "> #{op_name}#{' ' * pad_sub}#{arrow}"
608
+ else
609
+ "> #{op_name}"
610
+ end
611
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
612
+ cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, cell_phase)
613
+ else
614
+ cell_raw = if is_sub
615
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
616
+ " #{op_name}#{' ' * pad_sub}#{arrow}"
617
+ else
618
+ " #{op_name}"
619
+ end
620
+ pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
621
+ cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, cell_phase)
622
+ end
623
+ else
624
+ cells << (" " * col_w)
625
+ end
626
+ end
627
+ row_str = cells.join(" ")
628
+ pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
629
+ row_padded = row_str + (" " * pad_r)
630
+ cur_line_num = rendered_lines.length + 1
631
+ @row_hit_map[cur_line_num] = { row_indices: row_indices, cols: cols, col_w: col_w, margin_left: margin_left.length }
632
+ rendered_lines << "#{margin_left}#{v_left} #{row_padded} #{v_right}"
633
+ end
634
+ end
635
+
636
+ if has_more_below
637
+ @down_arrow_row = rendered_lines.length + 1
638
+ remaining_below = total_rows - 1 - end_row
639
+ down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
640
+ pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
641
+ down_indicator = colorize(" " * (pad_down / 2) + down_text + " " * (pad_down - (pad_down / 2)), { color: "gray", level: 2 }, is_border_chroma ? 2 * 0.12 : 0.0)
642
+ rendered_lines << "#{margin_left}#{v_left} #{down_indicator} #{v_right}"
643
+ end
644
+
645
+ unless active_desc.empty?
646
+ separator_line = vertical_left_raw + mid_fill + vertical_right_raw
647
+ rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 0.0)}"
648
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
649
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
650
+ raw_desc = "#{pfx}#{active_desc}"
651
+ pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
652
+ desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 }, is_border_chroma ? 2 * 0.12 : 0.0)
653
+ rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
654
+ end
655
+
656
+ bottom_border_line = box_border[:bl] + bot_fill + box_border[:br]
657
+ rendered_lines << "#{margin_left}#{colorize(bottom_border_line, border_color_cfg, 0.0)}"
658
+ end
659
+
660
+ # 6. Costura lateral de submenús flotantes si están abiertos
661
+ if @open_level >= 1 && is_submenu_item?(@functions[@index]) && parent_row_idx
662
+ sub_1_acts = get_submenu_actions(@functions[@index])
663
+ if sub_1_acts && !sub_1_acts.empty?
664
+ sub_1_title = all_names[@index] || "Submenu"
665
+ 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)
666
+
667
+ sub_2_acts = nil
668
+ sub_2_lines = nil
669
+ sub_2_w = 0
670
+ p_row_2 = nil
671
+ if @open_level >= 2 && is_submenu_item?(sub_1_acts[@sub_index_1])
672
+ sub_2_acts = get_submenu_actions(sub_1_acts[@sub_index_1])
673
+ if sub_2_acts && !sub_2_acts.empty?
674
+ sub_2_title = extract_name_from_action(sub_1_acts[@sub_index_1]) || "Submenu"
675
+ 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)
676
+ end
677
+ end
678
+
679
+ # Si caben 3 cajas en la terminal horizontalmente
680
+ if sub_2_lines
681
+ total_3_w = margin_left.length + total_width + 2 + sub_1_w + 2 + sub_2_w
682
+ if total_3_w <= term_cols
683
+ sub_1_start = main_box_start
684
+ p1_abs_row = sub_1_start + (p_row_1 || 1)
685
+ sub_2_start = main_box_start
686
+ max_3_lines = [rendered_lines.length, sub_1_start + sub_1_lines.length, sub_2_start + sub_2_lines.length].max
687
+
688
+ x1_start = margin_left.length + total_width + 2
689
+ x1_end = x1_start + sub_1_w
690
+ x2_start = x1_end + 2
691
+ x2_end = x2_start + sub_2_w
692
+ @sub_1_col_rng = (x1_start..x1_end)
693
+ @sub_2_col_rng = (x2_start..x2_end)
694
+
695
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
696
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
697
+
698
+ stitched_lines = []
699
+ max_3_lines.times do |i|
700
+ l0 = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
701
+
702
+ l1 = " " * sub_1_w
703
+ br1 = " "
704
+ if i >= sub_1_start && i < (sub_1_start + sub_1_lines.length)
705
+ s1_idx = i - sub_1_start
706
+ l1 = sub_1_lines[s1_idx]
707
+ br1 = (i == parent_row_idx) ? "──" : " "
708
+ s1_item = s1_idx - sub_1_offset
709
+ if s1_item >= 0 && s1_item < sub_1_acts.length
710
+ @sub_1_hit_map[i + 1] = s1_item
711
+ @sub_hit_map[i + 1] = s1_item
712
+ end
713
+ end
714
+
715
+ l2 = ""
716
+ br2 = ""
717
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
718
+ s2_idx = i - sub_2_start
719
+ l2 = sub_2_lines[s2_idx]
720
+ br2 = (i == p1_abs_row) ? "──" : " "
721
+ s2_item = s2_idx - sub_2_offset
722
+ if s2_item >= 0 && s2_item < sub_2_acts.length
723
+ @sub_2_hit_map[i + 1] = s2_item
724
+ end
725
+ end
726
+
727
+ stitched_lines << "#{l0}#{br1}#{l1}#{br2}#{l2}".rstrip
728
+ end
729
+ rendered_lines = stitched_lines
730
+ elsif (sub_1_w + 2 + sub_2_w) <= term_cols
731
+ p1_abs_row = (p_row_1 || 1)
732
+ sub_2_start = main_box_start
733
+ max_2_lines = [sub_1_lines.length, sub_2_start + sub_2_lines.length].max
734
+
735
+ x1_start = margin_left.length
736
+ x1_end = x1_start + sub_1_w
737
+ x2_start = x1_end + 2
738
+ x2_end = x2_start + sub_2_w
739
+ @sub_1_col_rng = (x1_start..x1_end)
740
+ @sub_2_col_rng = (x2_start..x2_end)
741
+
742
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
743
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
744
+
745
+ stitched_lines = []
746
+ max_2_lines.times do |i|
747
+ l1 = sub_1_lines[i] || (" " * sub_1_w)
748
+ l2 = ""
749
+ br2 = ""
750
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
751
+ s2_idx = i - sub_2_start
752
+ l2 = sub_2_lines[s2_idx]
753
+ br2 = (i == p1_abs_row) ? "──" : " "
754
+ s2_item = s2_idx - sub_2_offset
755
+ @sub_2_hit_map[i + 1] = s2_item if s2_item >= 0 && s2_item < sub_2_acts.length
756
+ end
757
+ s1_item = i - sub_1_offset
758
+ if s1_item >= 0 && s1_item < sub_1_acts.length
759
+ @sub_1_hit_map[i + 1] = s1_item
760
+ @sub_hit_map[i + 1] = s1_item
761
+ end
762
+ stitched_lines << "#{margin_left}#{l1}#{br2}#{l2}".rstrip
763
+ end
764
+ rendered_lines = stitched_lines
765
+ else
766
+ active_box = (@level == 2) ? sub_2_lines : sub_1_lines
767
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
768
+ end
769
+ else
770
+ # Si solo hay 1 submenú abierto
771
+ total_2_w = margin_left.length + total_width + 2 + sub_1_w
772
+ if total_2_w <= term_cols
773
+ sub_start_line = main_box_start
774
+ max_total_lines = [rendered_lines.length, sub_start_line + sub_1_lines.length].max
775
+
776
+ x1_start = margin_left.length + total_width + 2
777
+ x1_end = x1_start + sub_1_w
778
+ @sub_1_col_rng = (x1_start..x1_end)
779
+
780
+ sub_item_offset = sub_1_title.empty? ? 1 : 3
781
+
782
+ stitched_lines = []
783
+ max_total_lines.times do |i|
784
+ main_line = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
785
+ if i >= sub_start_line && i < (sub_start_line + sub_1_lines.length)
786
+ sub_idx = i - sub_start_line
787
+ sub_l = sub_1_lines[sub_idx]
788
+ bridge = (i == parent_row_idx) ? "──" : " "
789
+ stitched_lines << "#{main_line}#{bridge}#{sub_l}"
790
+
791
+ s_item_idx = sub_idx - sub_item_offset
792
+ if s_item_idx >= 0 && s_item_idx < sub_1_acts.length
793
+ @sub_1_hit_map[i + 1] = s_item_idx
794
+ @sub_hit_map[i + 1] = s_item_idx
795
+ end
796
+ else
797
+ stitched_lines << main_line
798
+ end
799
+ end
800
+ rendered_lines = stitched_lines
801
+ else
802
+ active_box = (@level == 1) ? sub_1_lines : rendered_lines
803
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
804
+ end
805
+ end
806
+ end
807
+ end
808
+
809
+ rendered_lines
810
+ end
811
+
812
+ # Comprueba si alguna de las secciones del menú utiliza el modo dinámico RGB, Rainbow o Chroma.
813
+ #
814
+ # @return [Boolean]
815
+ def has_rgb_animation?
816
+ configs = [
817
+ @style_config.border,
818
+ @style_config.options,
819
+ @style_config.focus,
820
+ @style_config.title,
821
+ @style_config.banner,
822
+ @style_config.subtitle,
823
+ @style_config.divider
824
+ ]
825
+ configs.any? do |c|
826
+ if c.is_a?(Hash)
827
+ val = (c[:color] || c["color"]).to_s.downcase.strip
828
+ val == "rgb" || val == "rainbow" || val == "chroma"
829
+ else
830
+ false
831
+ end
832
+ end
833
+ end
834
+
835
+ # Comprueba si hay alguna animación activa que requiera redibujado continuo en el loop interactivo.
836
+ #
837
+ # @return [Boolean]
838
+ def has_active_animation?
839
+ anim = @animate.to_s.downcase
840
+ return true if ["rgb", "rainbow", "chroma", "chromatic", "cromatico", "fade"].include?(anim)
841
+ return true if has_rgb_animation?
842
+ if @active_tab_color
843
+ atc = @active_tab_color.to_s.downcase
844
+ return true if atc.start_with?("chromatic") || atc.start_with?("cromatico") || ["rgb", "rainbow", "chroma"].include?(atc)
845
+ end
846
+
847
+ configs = [
848
+ @style_config.border,
849
+ @style_config.options,
850
+ @style_config.focus,
851
+ @style_config.title,
852
+ @style_config.banner,
853
+ @style_config.subtitle,
854
+ @style_config.divider
855
+ ]
856
+ configs.any? do |c|
857
+ if c.is_a?(Hash)
858
+ val = (c[:color] || c["color"]).to_s.downcase.strip
859
+ val.start_with?("chromatic") || val.start_with?("cromatico") || val == "rgb" || val == "rainbow" || val == "chroma"
860
+ else
861
+ false
862
+ end
863
+ end
864
+ end
865
+ end