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.
data/grmenu/theme.rb ADDED
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Almacena la configuracion del tema global cargado actualmente
5
+ @@global_theme = {}
6
+
7
+ # Devuelve el tema global activo
8
+ def self.current_theme
9
+ @@global_theme
10
+ end
11
+
12
+ # Parsea el contenido de un archivo de tema .gr o de un bloque heredoc <<-GR.
13
+ # La sintaxis admite secciones delimitadas por <<nombre ... >> y pares clave:: valor.
14
+ def self.parse_config_text(text)
15
+ data = { global: {}, sections: {} }
16
+ current_section = nil
17
+ current_section_data = {}
18
+
19
+ text.to_s.each_line do |line|
20
+ line = line.strip
21
+ next if line.empty? || line.start_with?("#")
22
+ next if line.start_with?("GRmenu::config") # Encabezado de version del formato .gr
23
+
24
+ # Apertura de bloque de seccion (ej. <<menu, <<tabs, <<input)
25
+ if line.start_with?("<<")
26
+ sec_name = line[2..-1].strip.downcase
27
+ current_section = sec_name
28
+ current_section_data = {}
29
+ # Cierre de bloque de seccion
30
+ elsif line == ">>"
31
+ if current_section
32
+ data[:sections][current_section] = current_section_data
33
+ current_section = nil
34
+ end
35
+ # Definicion de propiedad clave:: valor
36
+ elsif line.include?("::")
37
+ key, _, val = line.partition("::")
38
+ key = key.strip.sub(/^@/, '').downcase
39
+ val = val.strip.sub(/^["']/, '').sub(/["']$/, '')
40
+ if current_section
41
+ current_section_data[key] = val
42
+ else
43
+ data[:global][key] = val
44
+ end
45
+ end
46
+ end
47
+ data
48
+ end
49
+
50
+ # Localiza un archivo de tema .gr buscando en rutas relativas y en la carpeta data/themes/
51
+ def self.find_theme_file(path_or_name)
52
+ raw_name = path_or_name.to_s
53
+ names = [raw_name]
54
+ if raw_name.start_with?("cromatico_")
55
+ names << raw_name.sub(/\Acromatico_/, "chromatic_")
56
+ elsif raw_name.start_with?("chromatic_")
57
+ names << raw_name.sub(/\Achromatic_/, "cromatico_")
58
+ end
59
+
60
+ names.each do |name|
61
+ candidate_paths = [
62
+ name,
63
+ "#{name}.gr",
64
+ find_data_file("themes/#{name}.gr"),
65
+ find_data_file("themes/#{name}"),
66
+ File.expand_path("data/themes/#{name}.gr", __dir__),
67
+ File.expand_path("data/themes/#{name}", __dir__),
68
+ File.expand_path("../data/themes/#{name}.gr", __dir__),
69
+ File.expand_path("../data/themes/#{name}", __dir__)
70
+ ].compact
71
+ found = candidate_paths.find { |path| File.exist?(path) }
72
+ return found if found
73
+ end
74
+ nil
75
+ end
76
+
77
+ # Importa y aplica un tema .gr desde un archivo en disco
78
+ def self.import_config(path_or_name)
79
+ path = find_theme_file(path_or_name)
80
+ raise "No se encontro el tema: #{path_or_name}" unless path && File.exist?(path)
81
+ text = File.read(path)
82
+ parsed = parse_config_text(text)
83
+ apply_parsed_theme(parsed)
84
+ @@global_theme = parsed
85
+ path
86
+ end
87
+
88
+ # Alias amigable para importar un tema: GRmenu.theme(:cyberpunk) o GRmenu.theme("matrix")
89
+ def self.theme(name)
90
+ import_config(name)
91
+ end
92
+
93
+ # Descompone una cadena de especificacion de color "nombre:nivel" (ej. "cyan:2") en [nombre, nivel]
94
+ def self.extract_color_and_level(val, default_level = 1)
95
+ return ["white", default_level] if val.nil?
96
+ parts = val.to_s.split(":")
97
+ color_name = parts[0].to_s.strip
98
+ level = parts[1] ? parts[1].to_i : default_level
99
+ [color_name, level]
100
+ end
101
+
102
+ # Aplica los estilos parseados a SetStyle respetando estrictamente los valores
103
+ # fijados explicitamente por el programador (from_css: true).
104
+ def self.apply_parsed_theme(parsed)
105
+ sections = parsed[:sections] || {}
106
+ global_settings = parsed[:global] || {}
107
+ menu_settings = (sections["menu"] || {}).merge(global_settings)
108
+
109
+ if menu_settings && !menu_settings.empty?
110
+ if (menu_settings["border"] || menu_settings["border_color"]) && !SetStyle.explicitly_set?(:border)
111
+ c, l = extract_color_and_level(menu_settings["border"] || menu_settings["border_color"], 1)
112
+ SetStyle.border(c, l, from_css: true)
113
+ end
114
+ if (menu_settings["title"] || menu_settings["title_color"]) && !SetStyle.explicitly_set?(:title)
115
+ c, l = extract_color_and_level(menu_settings["title"] || menu_settings["title_color"], 2)
116
+ SetStyle.title(c, l, from_css: true)
117
+ end
118
+ if (menu_settings["focus"] || menu_settings["focus_color"]) && !SetStyle.explicitly_set?(:focus)
119
+ c, l = extract_color_and_level(menu_settings["focus"] || menu_settings["focus_color"], 2)
120
+ SetStyle.focus(c, l, from_css: true)
121
+ end
122
+ if (menu_settings["options"] || menu_settings["options_color"]) && !SetStyle.explicitly_set?(:options)
123
+ c, l = extract_color_and_level(menu_settings["options"] || menu_settings["options_color"], 1)
124
+ SetStyle.options(c, l, from_css: true)
125
+ end
126
+ if (menu_settings["banner"] || menu_settings["banner_color"]) && !SetStyle.explicitly_set?(:banner)
127
+ c, l = extract_color_and_level(menu_settings["banner"] || menu_settings["banner_color"], 2)
128
+ SetStyle.banner(c, l, from_css: true)
129
+ end
130
+ if (menu_settings["subtitle"] || menu_settings["subtitle_color"]) && !SetStyle.explicitly_set?(:subtitle)
131
+ c, l = extract_color_and_level(menu_settings["subtitle"] || menu_settings["subtitle_color"], 1)
132
+ SetStyle.subtitle(c, l, from_css: true)
133
+ end
134
+ if (menu_settings["divider"] || menu_settings["divider_color"]) && !SetStyle.explicitly_set?(:divider)
135
+ c, l = extract_color_and_level(menu_settings["divider"] || menu_settings["divider_color"], 1)
136
+ SetStyle.divider(c, l, from_css: true)
137
+ end
138
+ if (menu_settings["desc_prefix"] || menu_settings["description_prefix"] || menu_settings["prefix"]) && !SetStyle.explicitly_set?(:desc_prefix)
139
+ SetStyle.desc_prefix(menu_settings["desc_prefix"] || menu_settings["description_prefix"] || menu_settings["prefix"], from_css: true)
140
+ end
141
+ if menu_settings["font"] && !SetStyle.explicitly_set?(:font)
142
+ SetStyle.font(menu_settings["font"].to_i, from_css: true)
143
+ end
144
+ end
145
+
146
+ # Aplica estilos a secciones especificas (border, options, focus, etc.)
147
+ sections.each do |section_key, section_data|
148
+ next unless section_data.is_a?(Hash)
149
+ color_spec = section_data["color"] || section_data["border"] || section_data["options"] || section_data["focus"] || section_data["title"] || section_data["banner"] || section_data["subtitle"] || section_data["divider"]
150
+ c, l = extract_color_and_level(color_spec, (section_data["level"] || 1).to_i)
151
+ case section_key
152
+ when "border"
153
+ SetStyle.border(c, l, from_css: true) unless SetStyle.explicitly_set?(:border)
154
+ when "options"
155
+ SetStyle.options(c, l, from_css: true) unless SetStyle.explicitly_set?(:options)
156
+ when "focus"
157
+ SetStyle.focus(c, l, from_css: true) unless SetStyle.explicitly_set?(:focus)
158
+ when "title"
159
+ SetStyle.title(c, l, from_css: true) unless SetStyle.explicitly_set?(:title)
160
+ when "banner"
161
+ SetStyle.banner(c, l, from_css: true) unless SetStyle.explicitly_set?(:banner)
162
+ when "subtitle"
163
+ SetStyle.subtitle(c, l, from_css: true) unless SetStyle.explicitly_set?(:subtitle)
164
+ when "divider"
165
+ SetStyle.divider(c, l, from_css: true) unless SetStyle.explicitly_set?(:divider)
166
+ end
167
+ end
168
+ SetStyle.font(global_settings["font"].to_i, from_css: true) if global_settings["font"] && !SetStyle.explicitly_set?(:font)
169
+ end
170
+
171
+ # Inyecta estilos CSS directamente a traves de una cadena heredoc en la clase
172
+ def self.style(css_content)
173
+ parsed = parse_config_text(css_content)
174
+ apply_parsed_theme(parsed)
175
+ parsed
176
+ end
177
+
178
+ # Exporta la configuracion actual de SetStyle a un archivo .gr valido
179
+ def self.export_config(path = nil)
180
+ if path.nil?
181
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
182
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
183
+ path = "#{base}.gr"
184
+ end
185
+ lines = ["GRmenu::config<-1->", ""]
186
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
187
+ lines << "@author:: \"grcode\""
188
+ lines << "@version:: \"1.0\""
189
+ lines << ""
190
+ lines << "<<menu"
191
+ lines << " style:: 3"
192
+ lines << " banner_style:: 3"
193
+ lines << " font:: #{SetStyle.font}"
194
+ lines << " animate:: rgb"
195
+ lines << " center:: true"
196
+ lines << " border:: #{SetStyle.border[:color]}:#{SetStyle.border[:level]}"
197
+ lines << " title:: #{SetStyle.title[:color]}:#{SetStyle.title[:level]}"
198
+ lines << " focus:: #{SetStyle.focus[:color]}:#{SetStyle.focus[:level]}"
199
+ lines << " options:: #{SetStyle.options[:color]}:#{SetStyle.options[:level]}"
200
+ lines << " banner:: #{SetStyle.banner[:color]}:#{SetStyle.banner[:level]}"
201
+ lines << " subtitle:: #{SetStyle.subtitle[:color]}:#{SetStyle.subtitle[:level]}"
202
+ lines << " divider:: #{SetStyle.divider[:color]}:#{SetStyle.divider[:level]}"
203
+ lines << ">>"
204
+ lines << ""
205
+ lines << "<<table"
206
+ lines << " style:: 3"
207
+ lines << " header_color:: yellow:2"
208
+ lines << " border_color:: rgb:2"
209
+ lines << " selected_row:: green:2"
210
+ lines << " row_color:: white:1"
211
+ lines << " zebra_striping:: true"
212
+ lines << ">>"
213
+ lines << ""
214
+ lines << "<<card"
215
+ lines << " style:: 7"
216
+ lines << " border_color:: cyan:2"
217
+ lines << " title_color:: yellow:2"
218
+ lines << " content_color:: white:1"
219
+ lines << ">>"
220
+ lines << ""
221
+ lines << "<<slider"
222
+ lines << " style:: 3"
223
+ lines << " color:: rgb:2"
224
+ lines << " fill_char:: █"
225
+ lines << " empty_char:: ░"
226
+ lines << ">>"
227
+ lines << ""
228
+ lines << "<<checkbox"
229
+ lines << " style:: 3"
230
+ lines << " color:: rgb:2"
231
+ lines << " checked_mark:: [X]"
232
+ lines << " unchecked_mark:: [ ]"
233
+ lines << ">>"
234
+ lines << ""
235
+ lines << "<<input"
236
+ lines << " style:: 3"
237
+ lines << " border_color:: cyan:2"
238
+ lines << " title_color:: yellow:2"
239
+ lines << " label_color:: white:2"
240
+ lines << ">>"
241
+ lines << ""
242
+ lines << "<<modal"
243
+ lines << " style:: 3"
244
+ lines << " border_color:: cyan:2"
245
+ lines << " title_color:: yellow:2"
246
+ lines << " content_color:: white:1"
247
+ lines << " shadow:: true"
248
+ lines << ">>"
249
+ lines << ""
250
+ lines << "<<form"
251
+ lines << " style:: 3"
252
+ lines << " border_color:: cyan:2"
253
+ lines << " title_color:: yellow:2"
254
+ lines << " focus_color:: green:2"
255
+ lines << " shadow:: true"
256
+ lines << ">>"
257
+ lines << ""
258
+ File.write(path, lines.join("\n") + "\n")
259
+ path
260
+ end
261
+
262
+ # Inspecciona un script Ruby de usuario interceptando la llamada a `draw`
263
+ # para extraer sus configuraciones y guardarlas como tema .gr sin ejecutar la TUI.
264
+ def self.export_from_file(source_file, target_path = nil)
265
+ raise "No existe #{source_file}" unless File.exist?(source_file)
266
+ original_draw_method = instance_method(:draw) rescue nil
267
+ extracted = nil
268
+
269
+ # Sobrescribe draw temporalmente para interceptar los atributos antes de renderizar
270
+ define_method(:draw) do |*|
271
+ extracted = {
272
+ style: @style,
273
+ banner_style: @banner_style,
274
+ font: @style_config&.font,
275
+ animate: @animate,
276
+ border: @style_config&.border,
277
+ title: @style_config&.title,
278
+ focus: @style_config&.focus,
279
+ options: @style_config&.options,
280
+ banner: @style_config&.banner,
281
+ subtitle: @style_config&.subtitle,
282
+ divider: @style_config&.divider
283
+ }
284
+ # Lanza un salto no local para abortar la ejecucion del script tras capturar datos
285
+ throw :grmenu_export_completed
286
+ end
287
+
288
+ begin
289
+ catch(:grmenu_export_completed) do
290
+ load(File.expand_path(source_file))
291
+ end
292
+ ensure
293
+ # Restaura siempre el metodo draw original
294
+ define_method(:draw, original_draw_method) if original_draw_method
295
+ end
296
+
297
+ output_path = target_path || source_file.sub(/\.rb$/, '') + ".gr"
298
+ if extracted && extracted[:border]
299
+ lines = ["GRmenu::config<-1->", ""]
300
+ lines << "@theme:: \"#{File.basename(output_path, '.gr').capitalize}\""
301
+ lines << "@author:: \"grcode\""
302
+ lines << "@version:: \"1.0\""
303
+ lines << ""
304
+ lines << "<<menu"
305
+ lines << " style:: #{extracted[:style] || 3}"
306
+ lines << " banner_style:: #{extracted[:banner_style] || 3}"
307
+ lines << " font:: #{extracted[:font] || 1}"
308
+ lines << " animate:: #{extracted[:animate] || 'rgb'}"
309
+ lines << " center:: true"
310
+ lines << " border:: #{extracted[:border][:color]}:#{extracted[:border][:level]}"
311
+ lines << " title:: #{extracted[:title][:color]}:#{extracted[:title][:level]}"
312
+ lines << " focus:: #{extracted[:focus][:color]}:#{extracted[:focus][:level]}"
313
+ lines << " options:: #{extracted[:options][:color]}:#{extracted[:options][:level]}"
314
+ lines << " banner:: #{extracted[:banner][:color]}:#{extracted[:banner][:level]}"
315
+ lines << " subtitle:: #{extracted[:subtitle][:color]}:#{extracted[:subtitle][:level]}"
316
+ lines << " divider:: #{extracted[:divider][:color]}:#{extracted[:divider][:level]}"
317
+ lines << ">>"
318
+ lines << ""
319
+ lines << "<<table"
320
+ lines << " style:: #{extracted[:style] || 3}"
321
+ lines << " header_color:: yellow:2"
322
+ lines << " border_color:: rgb:2"
323
+ lines << " selected_row:: green:2"
324
+ lines << " row_color:: white:1"
325
+ lines << " zebra_striping:: true"
326
+ lines << ">>"
327
+ lines << ""
328
+ lines << "<<card"
329
+ lines << " style:: 7"
330
+ lines << " border_color:: cyan:2"
331
+ lines << " title_color:: yellow:2"
332
+ lines << " content_color:: white:1"
333
+ lines << ">>"
334
+ lines << ""
335
+ lines << "<<slider"
336
+ lines << " style:: 3"
337
+ lines << " color:: rgb:2"
338
+ lines << " fill_char:: █"
339
+ lines << " empty_char:: ░"
340
+ lines << ">>"
341
+ lines << ""
342
+ lines << "<<checkbox"
343
+ lines << " style:: 3"
344
+ lines << " color:: rgb:2"
345
+ lines << " checked_mark:: [X]"
346
+ lines << " unchecked_mark:: [ ]"
347
+ lines << ">>"
348
+ lines << ""
349
+ lines << "<<input"
350
+ lines << " style:: #{extracted[:style] || 3}"
351
+ lines << " border_color:: #{extracted[:border] ? "#{extracted[:border][:color]}:#{extracted[:border][:level]}" : 'cyan:2'}"
352
+ lines << " title_color:: #{extracted[:title] ? "#{extracted[:title][:color]}:#{extracted[:title][:level]}" : 'yellow:2'}"
353
+ lines << " label_color:: white:2"
354
+ lines << ">>"
355
+ lines << ""
356
+ lines << "<<modal"
357
+ lines << " style:: #{extracted[:style] || 3}"
358
+ lines << " border_color:: #{extracted[:border] ? "#{extracted[:border][:color]}:#{extracted[:border][:level]}" : 'cyan:2'}"
359
+ lines << " title_color:: #{extracted[:title] ? "#{extracted[:title][:color]}:#{extracted[:title][:level]}" : 'yellow:2'}"
360
+ lines << " content_color:: white:1"
361
+ lines << " shadow:: true"
362
+ lines << ">>"
363
+ lines << ""
364
+ lines << "<<form"
365
+ lines << " style:: #{extracted[:style] || 3}"
366
+ lines << " border_color:: #{extracted[:border] ? "#{extracted[:border][:color]}:#{extracted[:border][:level]}" : 'cyan:2'}"
367
+ lines << " title_color:: #{extracted[:title] ? "#{extracted[:title][:color]}:#{extracted[:title][:level]}" : 'yellow:2'}"
368
+ lines << " focus_color:: #{extracted[:focus] ? "#{extracted[:focus][:color]}:#{extracted[:focus][:level]}" : 'green:2'}"
369
+ lines << " shadow:: true"
370
+ lines << ">>"
371
+ lines << ""
372
+ File.write(output_path, lines.join("\n") + "\n")
373
+ output_path
374
+ else
375
+ export_config(output_path)
376
+ end
377
+ end
378
+ end
data/grmenu/version.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Versión oficial de la suite GRmenu en Ruby
5
+ VERSION = "5.0.0"
6
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Widget de selección múltiple (checkbox) para la terminal.
5
+ #
6
+ # Permite al usuario navegar con las flechas y alternar la selección de varios
7
+ # elementos con la barra espaciadora. También soporta atajos rápidos para marcar
8
+ # todos ('a'), desmarcar todos ('n') o invertir la selección ('i').
9
+ #
10
+ # @param items_arg [Array, nil] Lista de opciones si se pasa como primer argumento posicional.
11
+ # @param items [Array, nil] Lista de opciones si se pasa como argumento nominal.
12
+ # @param title [String] Título superior en el marco.
13
+ # @param subtitle [String] Instrucciones o atajos mostrados en la parte inferior.
14
+ # @param color [String, Symbol, nil] Color del marco y elementos activos.
15
+ # @param style [Integer, nil] Estilo de borde del marco (1 a 12).
16
+ # @param page_size [Integer, nil] Cantidad máxima de elementos visibles a la vez.
17
+ # @param min_width [Integer, nil] Ancho mínimo forzado para la caja.
18
+ # @param preselected [Array] Índices o nombres de los elementos que inician marcados.
19
+ # @return [Array] Lista de los elementos seleccionados (en su formato u objeto original).
20
+ def self.checkbox(items_arg = nil, items: nil, title: "Selección Múltiple", subtitle: "Espacio: Marcar/Desmarcar | a: Todos | n: Ninguno | i: Invertir | Enter: Confirmar", color: nil, style: nil, page_size: nil, min_width: nil, preselected: [])
21
+ checkbox_theme = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "checkbox")) || {}
22
+ actual_items = items || items_arg || []
23
+ item_list = actual_items.is_a?(Array) ? actual_items : Array(actual_items)
24
+ return [] if item_list.empty?
25
+
26
+ checked_mark = checkbox_theme["checked_mark"] || "[X]"
27
+ unchecked_mark = checkbox_theme["unchecked_mark"] || "[ ]"
28
+
29
+ # Normalizamos los elementos de entrada a una estructura uniforme:
30
+ # { name: String, checked: Boolean, desc: String, original: Object }
31
+ parsed_items = item_list.map do |it|
32
+ case it
33
+ when Array
34
+ name = it[0].to_s
35
+ is_chk = it.length > 1 ? !!it[1] : false
36
+ desc = it.length > 2 ? it[2].to_s : ""
37
+ { name: name, checked: is_chk, desc: desc, original: it }
38
+ when Hash
39
+ name = (it[:name] || it["name"] || it[:title] || it["title"] || "Item").to_s
40
+ is_chk = !!(it[:checked] || it["checked"] || it[:selected] || it["selected"])
41
+ desc = (it[:desc] || it["desc"] || it[:description] || it["description"]).to_s
42
+ { name: name, checked: is_chk, desc: desc, original: it }
43
+ else
44
+ { name: it.to_s, checked: false, desc: "", original: it }
45
+ end
46
+ end
47
+
48
+ # Aplicar selecciones previas ya sea por índice entero o coincidencia exacta de nombre
49
+ preselected.each do |p|
50
+ if p.is_a?(Integer) && parsed_items[p]
51
+ parsed_items[p][:checked] = true
52
+ else
53
+ found = parsed_items.find { |item| item[:name] == p.to_s }
54
+ found[:checked] = true if found
55
+ end
56
+ end
57
+
58
+ current_cursor_idx = 0
59
+ rgb_tick = 0.0
60
+ drawn_lines = 0
61
+ box_color_name = (color || checkbox_theme["color"] || "cyan").to_s
62
+ border_style = (style || checkbox_theme["style"] || 3).to_i
63
+ is_rgb = (box_color_name.downcase == "rgb" || box_color_name.downcase == "rainbow" || box_color_name.downcase == "chroma")
64
+
65
+ border_chars = BORDERS[border_style] || BORDERS[3]
66
+ horizontal_top = border_chars[:ht] || border_chars[:h]
67
+ horizontal_bottom = border_chars[:hb] || border_chars[:h]
68
+ vertical_left = border_chars[:vl] || border_chars[:v]
69
+ vertical_right = border_chars[:vr] || border_chars[:v]
70
+
71
+ render_frame = lambda do
72
+ term_w = terminal_width
73
+ term_h = terminal_height
74
+
75
+ # Calculamos anchos necesarios basados en el texto más largo
76
+ max_name_w = parsed_items.map { |it| display_width(it[:name]) }.max || 10
77
+ req_w = [max_name_w + 12, display_width(title) + 6, display_width(subtitle) + 4, min_width || 38].max
78
+ box_w = [req_w, term_w - 4].min
79
+ inner_w = box_w - 4
80
+
81
+ top_fill = (horizontal_top * ((box_w - 2).to_f / horizontal_top.length).ceil)[0...(box_w - 2)]
82
+ bot_fill = (horizontal_bottom * ((box_w - 2).to_f / horizontal_bottom.length).ceil)[0...(box_w - 2)]
83
+ mid_fill = (horizontal_top * ((box_w - 2).to_f / horizontal_top.length).ceil)[0...(box_w - 2)]
84
+
85
+ total_items = parsed_items.length
86
+ max_visible = page_size ? [page_size, total_items, term_h - 10].min : [total_items, term_h - 10].min
87
+ max_visible = [max_visible, 1].max
88
+
89
+ # Ventana deslizante para paginar la lista si no cabe en pantalla
90
+ start_idx = 0
91
+ end_idx = total_items - 1
92
+ if total_items > max_visible
93
+ half = max_visible / 2
94
+ start_idx = [[current_cursor_idx - half, 0].max, total_items - max_visible].min
95
+ end_idx = start_idx + max_visible - 1
96
+ end
97
+
98
+ lines = []
99
+ if is_rgb
100
+ vr_col = Color.rgb(vertical_right, rgb_tick + (req_w - 1) * 0.12)
101
+ lines << Color.rgb("#{border_chars[:tl]}#{top_fill}#{border_chars[:tr]}", rgb_tick)
102
+ unless title.to_s.empty?
103
+ t_clean = title.to_s
104
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
105
+ pad_t = [inner_w - display_width(t_clean), 0].max
106
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
107
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.rgb(t_line, rgb_tick + 2 * 0.12)} #{vr_col}"
108
+ lines << Color.rgb("#{vertical_left}#{mid_fill}#{vertical_right}", rgb_tick)
109
+ end
110
+ if start_idx > 0
111
+ up_t = "▲ (+#{start_idx} arriba)"
112
+ pad_u = [inner_w - display_width(up_t), 0].max
113
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.gray(" " * (pad_u / 2) + up_t + " " * (pad_u - (pad_u / 2)))} #{vr_col}"
114
+ end
115
+ (start_idx..end_idx).each do |i|
116
+ it = parsed_items[i]
117
+ mark = it[:checked] ? checked_mark : unchecked_mark
118
+ is_active = (i == current_cursor_idx)
119
+ max_w = [inner_w - display_width(mark) - 4, 4].max
120
+ name_str = it[:name].to_s
121
+ name_str = name_str[0...[max_w - 3, 1].max] + "..." if display_width(name_str) > max_w
122
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
123
+ line_padded = pad_to_width(raw_line, inner_w)
124
+ if is_active
125
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.rgb(line_padded, rgb_tick + 2 * 0.12)} #{vr_col}"
126
+ elsif it[:checked]
127
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.bright_green(line_padded)} #{vr_col}"
128
+ else
129
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.white(line_padded)} #{vr_col}"
130
+ end
131
+ end
132
+ if end_idx < (total_items - 1)
133
+ rem = total_items - 1 - end_idx
134
+ dn_t = "▼ (+#{rem} abajo)"
135
+ pad_d = [inner_w - display_width(dn_t), 0].max
136
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.gray(" " * (pad_d / 2) + dn_t + " " * (pad_d - (pad_d / 2)))} #{vr_col}"
137
+ end
138
+ unless subtitle.to_s.empty?
139
+ lines << Color.rgb("#{vertical_left}#{mid_fill}#{vertical_right}", rgb_tick)
140
+ s_clean = subtitle.to_s
141
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
142
+ pad_sub = [inner_w - display_width(s_clean), 0].max
143
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
144
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.gray(sub_padded)} #{vr_col}"
145
+ end
146
+ lines << Color.rgb("#{border_chars[:bl]}#{bot_fill}#{border_chars[:br]}", rgb_tick)
147
+ else
148
+ color_code = ansi_color(box_color_name, 2)
149
+ reset_code = ansi_reset
150
+ lines << "#{color_code}#{border_chars[:tl]}#{top_fill}#{border_chars[:tr]}#{reset_code}"
151
+ unless title.to_s.empty?
152
+ t_clean = title.to_s
153
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
154
+ pad_t = [inner_w - display_width(t_clean), 0].max
155
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
156
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.bright_yellow(t_line)} #{color_code}#{vertical_right}#{reset_code}"
157
+ lines << "#{color_code}#{vertical_left}#{top_fill}#{vertical_right}#{reset_code}"
158
+ end
159
+ if start_idx > 0
160
+ up_t = "▲ (+#{start_idx} arriba)"
161
+ pad_u = [inner_w - display_width(up_t), 0].max
162
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.gray(" " * (pad_u / 2) + up_t + " " * (pad_u - (pad_u / 2)))} #{color_code}#{vertical_right}#{reset_code}"
163
+ end
164
+ (start_idx..end_idx).each do |i|
165
+ it = parsed_items[i]
166
+ mark = it[:checked] ? checked_mark : unchecked_mark
167
+ is_active = (i == current_cursor_idx)
168
+ max_w = [inner_w - display_width(mark) - 4, 4].max
169
+ name_str = it[:name].to_s
170
+ name_str = name_str[0...[max_w - 3, 1].max] + "..." if display_width(name_str) > max_w
171
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
172
+ line_padded = pad_to_width(raw_line, inner_w)
173
+ if is_active
174
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.bright_yellow(line_padded)} #{color_code}#{vertical_right}#{reset_code}"
175
+ elsif it[:checked]
176
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{vertical_right}#{reset_code}"
177
+ else
178
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.white(line_padded)} #{color_code}#{vertical_right}#{reset_code}"
179
+ end
180
+ end
181
+ if end_idx < (total_items - 1)
182
+ rem = total_items - 1 - end_idx
183
+ dn_t = "▼ (+#{rem} abajo)"
184
+ pad_d = [inner_w - display_width(dn_t), 0].max
185
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.gray(" " * (pad_d / 2) + dn_t + " " * (pad_d - (pad_d / 2)))} #{color_code}#{vertical_right}#{reset_code}"
186
+ end
187
+ unless subtitle.to_s.empty?
188
+ lines << "#{color_code}#{vertical_left}#{top_fill}#{vertical_right}#{reset_code}"
189
+ s_clean = subtitle.to_s
190
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
191
+ pad_sub = [inner_w - display_width(s_clean), 0].max
192
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
193
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.gray(sub_padded)} #{color_code}#{vertical_right}#{reset_code}"
194
+ end
195
+ lines << "#{color_code}#{border_chars[:bl]}#{bot_fill}#{border_chars[:br]}#{reset_code}"
196
+ end
197
+
198
+ frame = lines.join("\r\n") + "\r\n"
199
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
200
+ Kernel.print(frame)
201
+ $stdout.flush
202
+ drawn_lines = lines.length
203
+ end
204
+
205
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
206
+ submitted = false
207
+
208
+ begin
209
+ Kernel.print(HIDE_CURSOR)
210
+ render_frame.call
211
+
212
+ reader = lambda do |stream|
213
+ while true
214
+ # Para efecto Chroma/RGB, usamos un polling select de 35ms para avanzar el arcoíris
215
+ if is_rgb
216
+ ready = false
217
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
218
+ begin
219
+ sr = IO.select([stream], nil, nil, 0.035)
220
+ ready = true if sr && sr[0] && !sr[0].empty?
221
+ rescue StandardError
222
+ ready = true
223
+ end
224
+ else
225
+ ready = true
226
+ end
227
+ unless ready
228
+ rgb_tick += 0.08
229
+ render_frame.call
230
+ next
231
+ end
232
+ end
233
+
234
+ key = GRmenu.read_key_raw(stream)
235
+ break if key.nil? || key == "\x03" || key == "\x04" || key == "q" || key == "Q" || key == "\e"
236
+
237
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
238
+ # Flecha Arriba
239
+ current_cursor_idx = (current_cursor_idx - 1) % parsed_items.length
240
+ render_frame.call
241
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
242
+ # Flecha Abajo
243
+ current_cursor_idx = (current_cursor_idx + 1) % parsed_items.length
244
+ render_frame.call
245
+ elsif key == " "
246
+ # Alternar estado del elemento actual
247
+ parsed_items[current_cursor_idx][:checked] = !parsed_items[current_cursor_idx][:checked]
248
+ render_frame.call
249
+ elsif key == "a" || key == "A"
250
+ # Marcar todos
251
+ parsed_items.each { |item| item[:checked] = true }
252
+ render_frame.call
253
+ elsif key == "n" || key == "N"
254
+ # Desmarcar todos
255
+ parsed_items.each { |item| item[:checked] = false }
256
+ render_frame.call
257
+ elsif key == "i" || key == "I"
258
+ # Invertir selecciones
259
+ parsed_items.each { |item| item[:checked] = !item[:checked] }
260
+ render_frame.call
261
+ elsif key == "\r" || key == "\n"
262
+ # Enter: confirmar la selección
263
+ submitted = true
264
+ break
265
+ end
266
+ end
267
+ end
268
+
269
+ if is_tty
270
+ $stdin.raw { |s| reader.call(s) }
271
+ else
272
+ reader.call($stdin)
273
+ end
274
+ ensure
275
+ Kernel.print(SHOW_CURSOR)
276
+ end
277
+
278
+ if submitted
279
+ # Retornar los objetos o estructuras originales que quedaron seleccionados
280
+ selected_items = parsed_items.select { |item| item[:checked] }
281
+ selected_items.map { |item| item[:original] }
282
+ else
283
+ []
284
+ end
285
+ end
286
+
287
+ class << self
288
+ alias_method :select_multi, :checkbox
289
+ alias_method :multiselect, :checkbox
290
+ end
291
+ end