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.
- checksums.yaml +4 -4
- data/README.md +186 -49
- data/data/colors.json +224 -44
- data/data/help.txt +223 -90
- data/data/themes/chromatic_red.gr +74 -0
- data/data/themes/cyberpunk.gr +18 -2
- data/data/themes/dracula.gr +18 -2
- data/data/themes/matrix.gr +18 -2
- data/data/themes/monokai.gr +18 -2
- data/data/themes/nord.gr +18 -2
- data/data/themes/sunset.gr +18 -2
- data/grmenu/animation.rb +139 -0
- data/grmenu/banner.rb +99 -0
- data/grmenu/color.rb +265 -0
- data/grmenu/image.rb +339 -0
- data/grmenu/interactive.rb +1002 -0
- data/grmenu/renderer.rb +865 -0
- data/grmenu/set_style.rb +312 -0
- data/grmenu/terminal.rb +189 -0
- data/grmenu/theme.rb +378 -0
- data/grmenu/version.rb +6 -0
- data/grmenu/widgets/checkbox.rb +291 -0
- data/grmenu/widgets/dialogs.rb +251 -0
- data/grmenu/widgets/form.rb +533 -0
- data/grmenu/widgets/input.rb +171 -0
- data/grmenu/widgets/modal.rb +209 -0
- data/grmenu/widgets/progress.rb +210 -0
- data/grmenu/widgets/slider.rb +178 -0
- data/grmenu/widgets/table.rb +291 -0
- data/grmenu/window.rb +187 -0
- data/grmenu.rb +88 -4339
- metadata +23 -3
- data/data/themes/neon_red.gr +0 -51
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,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
|