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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class GRmenu
|
|
4
|
+
# Solicita una entrada de texto interactiva en la consola con diseño encapsulado en caja.
|
|
5
|
+
#
|
|
6
|
+
# Soporta modo contraseña (ocultando caracteres con asteriscos), configuración de
|
|
7
|
+
# colores ANSI o modo Chroma/RGB dinámico, y respeta la configuración global de temas
|
|
8
|
+
# definida en la sección "input".
|
|
9
|
+
#
|
|
10
|
+
# @param prompt_or_title [String, nil] Título o prompt inicial por compatibilidad.
|
|
11
|
+
# @param title [String, nil] Título superior de la caja de diálogo.
|
|
12
|
+
# @param label [String, nil] Etiqueta que acompaña al campo de texto.
|
|
13
|
+
# @param default [String] Valor precargado en el campo.
|
|
14
|
+
# @param password [Boolean] Si es true, muestra '*' en lugar de los caracteres reales.
|
|
15
|
+
# @param color [String, Symbol, nil] Color general de la caja.
|
|
16
|
+
# @param border_color [String, Symbol, nil] Color específico para los bordes.
|
|
17
|
+
# @param title_color [String, Symbol, nil] Color para el texto del título.
|
|
18
|
+
# @param label_color [String, Symbol, nil] Color para la etiqueta del campo.
|
|
19
|
+
# @param style [Integer, nil] Estilo de borde (1 al 12).
|
|
20
|
+
# @param width [Integer, nil] Ancho forzado de la caja.
|
|
21
|
+
# @return [String] El texto final ingresado por el usuario.
|
|
22
|
+
def self.input(prompt_or_title = nil, title: nil, label: nil, default: "", password: false, color: nil, border_color: nil, title_color: nil, label_color: nil, style: nil, width: nil)
|
|
23
|
+
# Extraemos la sección de configuración de tema global si está presente
|
|
24
|
+
input_theme = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "input")) || {}
|
|
25
|
+
border_style = (style || input_theme["style"] || 3).to_i
|
|
26
|
+
border_chars = BORDERS[border_style] || BORDERS[3]
|
|
27
|
+
|
|
28
|
+
horizontal_top = border_chars[:ht] || border_chars[:h]
|
|
29
|
+
horizontal_bottom = border_chars[:hb] || border_chars[:h]
|
|
30
|
+
vertical_left = border_chars[:vl] || border_chars[:v]
|
|
31
|
+
vertical_right = border_chars[:vr] || border_chars[:v]
|
|
32
|
+
|
|
33
|
+
# Resolvemos el título de la caja considerando los diferentes parámetros aceptados
|
|
34
|
+
box_title = if title && !title.to_s.empty?
|
|
35
|
+
title.to_s
|
|
36
|
+
elsif prompt_or_title && !label
|
|
37
|
+
prompt_or_title.to_s
|
|
38
|
+
else
|
|
39
|
+
input_theme["title"] || "Entrada de Datos"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Etiqueta que precede al cursor donde el usuario escribe
|
|
43
|
+
input_label = if label && !label.to_s.empty?
|
|
44
|
+
label.to_s
|
|
45
|
+
elsif prompt_or_title && title
|
|
46
|
+
prompt_or_title.to_s
|
|
47
|
+
elsif input_theme["label"]
|
|
48
|
+
input_theme["label"].to_s
|
|
49
|
+
else
|
|
50
|
+
"Valor:"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolución de paleta de colores para bordes, título y texto
|
|
54
|
+
border_color_name = (border_color || color || input_theme["border_color"] || input_theme["color"] || "cyan").to_s
|
|
55
|
+
title_color_name = (title_color || input_theme["title_color"] || "yellow").to_s
|
|
56
|
+
label_color_name = (label_color || input_theme["label_color"] || "white").to_s
|
|
57
|
+
|
|
58
|
+
is_rgb = (border_color_name.downcase == "rgb" || border_color_name.downcase == "rainbow" || border_color_name.downcase == "chroma")
|
|
59
|
+
|
|
60
|
+
# Buffer mutable donde iremos acumulando las pulsaciones del usuario
|
|
61
|
+
buffer_text = String.new(default.to_s)
|
|
62
|
+
term_width = terminal_width
|
|
63
|
+
prompt_len = display_width(box_title) + 6
|
|
64
|
+
content_len = display_width(input_label) + display_width(buffer_text) + 12
|
|
65
|
+
box_width = width ? width.to_i : [prompt_len, content_len, 48].max
|
|
66
|
+
box_width = [box_width, term_width - 4].min
|
|
67
|
+
inner_width = [box_width - 2, 20].max
|
|
68
|
+
|
|
69
|
+
drawn_lines = 0
|
|
70
|
+
|
|
71
|
+
# Dibuja la caja completa y sobreescribe el cuadro anterior subiendo el cursor
|
|
72
|
+
render_input = lambda do
|
|
73
|
+
display_str = password ? ("*" * buffer_text.length) : buffer_text
|
|
74
|
+
available_width = [inner_width - display_width(input_label) - 4, 4].max
|
|
75
|
+
# Si el texto es más largo que la ventana disponible, mostramos el final con puntos suspensivos
|
|
76
|
+
if display_width(display_str) > available_width
|
|
77
|
+
display_str = "..." + display_str[-[available_width - 3, 1].max..-1]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
border_code = is_rgb ? "" : ansi_color(border_color_name, 1)
|
|
81
|
+
title_code = ansi_color(title_color_name, 2)
|
|
82
|
+
label_code = ansi_color(label_color_name, 2)
|
|
83
|
+
reset_code = ansi_reset
|
|
84
|
+
|
|
85
|
+
clean_title = " #{box_title} "
|
|
86
|
+
title_w = display_width(clean_title)
|
|
87
|
+
left_pad = [(inner_width - title_w) / 2, 0].max
|
|
88
|
+
right_pad = [inner_width - title_w - left_pad, 0].max
|
|
89
|
+
|
|
90
|
+
top_fill_left = (horizontal_top * left_pad)[0...left_pad]
|
|
91
|
+
top_fill_right = (horizontal_top * right_pad)[0...right_pad]
|
|
92
|
+
bottom_fill = (horizontal_bottom * inner_width)[0...inner_width]
|
|
93
|
+
|
|
94
|
+
top_line = if is_rgb
|
|
95
|
+
Color.rgb("#{border_chars[:tl]}#{top_fill_left}") + title_code + clean_title + Color.rgb("#{top_fill_right}#{border_chars[:tr]}")
|
|
96
|
+
else
|
|
97
|
+
"#{border_code}#{border_chars[:tl]}#{top_fill_left}#{reset_code}#{title_code}#{clean_title}#{reset_code}#{border_code}#{top_fill_right}#{border_chars[:tr]}#{reset_code}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
empty_line = if is_rgb
|
|
101
|
+
Color.rgb("#{vertical_left}#{' ' * inner_width}#{vertical_right}")
|
|
102
|
+
else
|
|
103
|
+
"#{border_code}#{vertical_left}#{reset_code}#{' ' * inner_width}#{border_code}#{vertical_right}#{reset_code}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
raw_content = " #{input_label} #{display_str}█"
|
|
107
|
+
content_pad = [inner_width - display_width(raw_content), 0].max
|
|
108
|
+
content_line = if is_rgb
|
|
109
|
+
"#{Color.rgb(vertical_left)} #{label_code}#{input_label}#{reset_code} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{Color.rgb(vertical_right)}"
|
|
110
|
+
else
|
|
111
|
+
"#{border_code}#{vertical_left}#{reset_code} #{label_code}#{input_label}#{reset_code} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{border_code}#{vertical_right}#{reset_code}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
bottom_line = if is_rgb
|
|
115
|
+
Color.rgb("#{border_chars[:bl]}#{bottom_fill}#{border_chars[:br]}")
|
|
116
|
+
else
|
|
117
|
+
"#{border_code}#{border_chars[:bl]}#{bottom_fill}#{border_chars[:br]}#{reset_code}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
lines = [top_line, empty_line, content_line, empty_line, bottom_line]
|
|
121
|
+
frame = lines.join("\r\n") + "\r\n"
|
|
122
|
+
|
|
123
|
+
# Rebobinamos el cursor tantas líneas como dibujamos la última vez para no duplicar en pantalla
|
|
124
|
+
Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
|
|
125
|
+
Kernel.print(frame)
|
|
126
|
+
$stdout.flush
|
|
127
|
+
drawn_lines = lines.length
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
Kernel.print(HIDE_CURSOR)
|
|
134
|
+
render_input.call
|
|
135
|
+
|
|
136
|
+
reader = lambda do |stream|
|
|
137
|
+
while (key = GRmenu.read_key_raw(stream))
|
|
138
|
+
# Salir si el usuario presiona Ctrl+C o Escape
|
|
139
|
+
break if key == "\x03" || key == "\e"
|
|
140
|
+
|
|
141
|
+
if key == "\r" || key == "\n"
|
|
142
|
+
# Confirmar y salir con el valor escrito
|
|
143
|
+
break
|
|
144
|
+
elsif key == "\x7f" || key == "\b" || key == "\x08"
|
|
145
|
+
# Backspace: borrar el último caracter
|
|
146
|
+
buffer_text.chop!
|
|
147
|
+
render_input.call
|
|
148
|
+
elsif key == "\x15"
|
|
149
|
+
# Ctrl+U: limpiar todo el campo de entrada
|
|
150
|
+
buffer_text.clear
|
|
151
|
+
render_input.call
|
|
152
|
+
elsif key =~ /^[[:print:]]$/
|
|
153
|
+
# Caracter imprimible: lo agregamos si cabe dentro del ancho interno
|
|
154
|
+
buffer_text << key if display_width(buffer_text) < (inner_width - display_width(input_label) - 6)
|
|
155
|
+
render_input.call
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if is_tty
|
|
161
|
+
$stdin.raw { |s| reader.call(s) }
|
|
162
|
+
else
|
|
163
|
+
reader.call($stdin)
|
|
164
|
+
end
|
|
165
|
+
ensure
|
|
166
|
+
Kernel.print(SHOW_CURSOR)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
buffer_text
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
# GRmenu::Widgets::Modal - Ventana flotante emergente con sombra proyectada 3D.
|
|
5
|
+
# Soporta botones de accion, ajuste de texto, Drop Shadow y temas CSS .gr.
|
|
6
|
+
# ==============================================================================
|
|
7
|
+
|
|
8
|
+
class GRmenu
|
|
9
|
+
# Renderiza una ventana modal flotante interactiva con sombra proyectada 3D.
|
|
10
|
+
# Admite botones interactivos, centrado en pantalla y soporte completo de color.
|
|
11
|
+
def self.modal(title_or_content = nil, content_arg = nil, title: nil, content: nil, buttons: ["Aceptar"], color: nil, border_color: nil, title_color: nil, content_color: nil, style: 3, width: nil, shadow: true, shadow_char: "▒", shadow_color: "gray", center: true)
|
|
12
|
+
modal_theme = (@@global_theme.is_a?(Hash) && (@@global_theme.dig(:sections, "modal") || @@global_theme.dig(:sections, "card"))) || {}
|
|
13
|
+
actual_title = title || (content_arg ? title_or_content : nil) || modal_theme["title"]
|
|
14
|
+
actual_content = (content || (content_arg ? content_arg : title_or_content) || "").to_s
|
|
15
|
+
|
|
16
|
+
style_num = (style || modal_theme["style"] || 3).to_i
|
|
17
|
+
border_cfg = BORDERS[style_num] || BORDERS[3]
|
|
18
|
+
|
|
19
|
+
brd_color_name = (border_color || color || modal_theme["border_color"] || modal_theme["color"] || "cyan").to_s
|
|
20
|
+
tit_color_name = (title_color || modal_theme["title_color"] || "yellow").to_s
|
|
21
|
+
cnt_color_name = (content_color || modal_theme["content_color"] || "white").to_s
|
|
22
|
+
shd_color_name = (shadow_color || "gray").to_s
|
|
23
|
+
|
|
24
|
+
is_rgb = (brd_color_name.downcase == "rgb" || brd_color_name.downcase == "rainbow" || brd_color_name.downcase == "chroma")
|
|
25
|
+
|
|
26
|
+
btn_list = if buttons.is_a?(Array)
|
|
27
|
+
buttons.map(&:to_s)
|
|
28
|
+
elsif buttons
|
|
29
|
+
[buttons.to_s]
|
|
30
|
+
else
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
selected_btn_idx = 0
|
|
34
|
+
|
|
35
|
+
raw_lines = actual_content.split("\n")
|
|
36
|
+
term_w = terminal_width
|
|
37
|
+
content_max_w = raw_lines.map { |l| display_width(l) }.max || 0
|
|
38
|
+
title_w = actual_title ? display_width(actual_title) + 6 : 0
|
|
39
|
+
btns_total_w = btn_list.map { |b| display_width(b) + 8 }.sum + (btn_list.length * 2)
|
|
40
|
+
|
|
41
|
+
box_w = width ? width.to_i : [content_max_w + 6, title_w, btns_total_w, 42].max
|
|
42
|
+
box_w = [box_w, term_w - 6].min
|
|
43
|
+
inner_w = [box_w - 2, 20].max
|
|
44
|
+
|
|
45
|
+
wrapped_lines = []
|
|
46
|
+
raw_lines.each do |raw_l|
|
|
47
|
+
if display_width(raw_l) <= (inner_w - 2)
|
|
48
|
+
wrapped_lines << raw_l
|
|
49
|
+
else
|
|
50
|
+
curr = String.new("")
|
|
51
|
+
raw_l.split(" ").each do |w|
|
|
52
|
+
if curr.empty?
|
|
53
|
+
curr << w
|
|
54
|
+
elsif display_width("#{curr} #{w}") <= (inner_w - 2)
|
|
55
|
+
curr << " " << w
|
|
56
|
+
else
|
|
57
|
+
wrapped_lines << curr
|
|
58
|
+
curr = String.new(w)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
wrapped_lines << curr unless curr.empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
tl = border_cfg[:tl] || "╔"
|
|
66
|
+
tr = border_cfg[:tr] || "╗"
|
|
67
|
+
bl = border_cfg[:bl] || "╚"
|
|
68
|
+
br = border_cfg[:br] || "╝"
|
|
69
|
+
h_char = border_cfg[:ht] || border_cfg[:h] || "═"
|
|
70
|
+
v_char = border_cfg[:vl] || border_cfg[:v] || "║"
|
|
71
|
+
|
|
72
|
+
left_margin_len = center ? [((term_w - (box_w + (shadow ? 2 : 0))) / 2), 0].max : 0
|
|
73
|
+
margin = " " * left_margin_len
|
|
74
|
+
|
|
75
|
+
drawn_lines = 0
|
|
76
|
+
|
|
77
|
+
render_modal = lambda do
|
|
78
|
+
brd_code = is_rgb ? "" : ansi_color(brd_color_name, 1)
|
|
79
|
+
tit_code = ansi_color(tit_color_name, 2)
|
|
80
|
+
cnt_code = ansi_color(cnt_color_name, 1)
|
|
81
|
+
shd_code = ansi_color(shd_color_name, 1)
|
|
82
|
+
rst = ansi_reset
|
|
83
|
+
shd_char = shadow ? (shadow_char || "▒") : ""
|
|
84
|
+
|
|
85
|
+
lines = []
|
|
86
|
+
|
|
87
|
+
top_title_str = if actual_title && !actual_title.empty?
|
|
88
|
+
t_clean = " #{actual_title} "
|
|
89
|
+
t_w = display_width(t_clean)
|
|
90
|
+
if t_w > inner_w
|
|
91
|
+
t_clean = " #{actual_title[0...[inner_w - 6, 1].max]}... "
|
|
92
|
+
t_w = display_width(t_clean)
|
|
93
|
+
end
|
|
94
|
+
l_len = [(inner_w - t_w) / 2, 0].max
|
|
95
|
+
r_len = [inner_w - t_w - l_len, 0].max
|
|
96
|
+
(h_char * l_len) + tit_code + t_clean + brd_code + (h_char * r_len)
|
|
97
|
+
else
|
|
98
|
+
h_char * inner_w
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
top_line = if is_rgb
|
|
102
|
+
Color.rgb("#{tl}#{top_title_str}#{tr}")
|
|
103
|
+
else
|
|
104
|
+
"#{brd_code}#{tl}#{top_title_str}#{tr}#{rst}"
|
|
105
|
+
end
|
|
106
|
+
lines << "#{margin}#{top_line}"
|
|
107
|
+
|
|
108
|
+
wrapped_lines.each do |w_line|
|
|
109
|
+
pad_text = pad_to_width(" " + w_line, inner_w)
|
|
110
|
+
mid_line = if is_rgb
|
|
111
|
+
"#{Color.rgb(v_char)}#{cnt_code}#{pad_text}#{rst}#{Color.rgb(v_char)}"
|
|
112
|
+
else
|
|
113
|
+
"#{brd_code}#{v_char}#{rst}#{cnt_code}#{pad_text}#{rst}#{brd_code}#{v_char}#{rst}"
|
|
114
|
+
end
|
|
115
|
+
r_shadow = shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""
|
|
116
|
+
lines << "#{margin}#{mid_line}#{r_shadow}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if !btn_list.empty?
|
|
120
|
+
lines << "#{margin}#{brd_code}#{v_char}#{rst}#{' ' * inner_w}#{brd_code}#{v_char}#{rst}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
|
|
121
|
+
|
|
122
|
+
rendered_btns = btn_list.each_with_index.map do |btn, idx|
|
|
123
|
+
if idx == selected_btn_idx
|
|
124
|
+
Color.bright_green("> [ #{btn} ] <")
|
|
125
|
+
else
|
|
126
|
+
Color.gray(" [ #{btn} ] ")
|
|
127
|
+
end
|
|
128
|
+
end.join(" ")
|
|
129
|
+
|
|
130
|
+
vis_btn_w = display_width(rendered_btns)
|
|
131
|
+
btn_pad_total = [inner_w - vis_btn_w, 0].max
|
|
132
|
+
btn_l_pad = " " * (btn_pad_total / 2)
|
|
133
|
+
btn_r_pad = " " * (btn_pad_total - (btn_pad_total / 2))
|
|
134
|
+
|
|
135
|
+
btn_row = if is_rgb
|
|
136
|
+
"#{Color.rgb(v_char)}#{btn_l_pad}#{rendered_btns}#{btn_r_pad}#{Color.rgb(v_char)}"
|
|
137
|
+
else
|
|
138
|
+
"#{brd_code}#{v_char}#{rst}#{btn_l_pad}#{rendered_btns}#{btn_r_pad}#{brd_code}#{v_char}#{rst}"
|
|
139
|
+
end
|
|
140
|
+
r_shadow = shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""
|
|
141
|
+
lines << "#{margin}#{btn_row}#{r_shadow}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
bot_line = if is_rgb
|
|
145
|
+
Color.rgb("#{bl}#{h_char * inner_w}#{br}")
|
|
146
|
+
else
|
|
147
|
+
"#{brd_code}#{bl}#{h_char * inner_w}#{br}#{rst}"
|
|
148
|
+
end
|
|
149
|
+
r_shadow = shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""
|
|
150
|
+
lines << "#{margin}#{bot_line}#{r_shadow}"
|
|
151
|
+
|
|
152
|
+
if shadow
|
|
153
|
+
lines << "#{margin} #{shd_code}#{shd_char * box_w}#{rst}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
frame = lines.join("\r\n") + "\r\n"
|
|
157
|
+
Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
|
|
158
|
+
Kernel.print(frame)
|
|
159
|
+
$stdout.flush
|
|
160
|
+
drawn_lines = lines.length
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
|
|
164
|
+
result = nil
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
Kernel.print(HIDE_CURSOR)
|
|
168
|
+
render_modal.call
|
|
169
|
+
|
|
170
|
+
if btn_list.empty?
|
|
171
|
+
continue
|
|
172
|
+
result = true
|
|
173
|
+
else
|
|
174
|
+
reader = lambda do |stream|
|
|
175
|
+
while (key = GRmenu.read_key_raw(stream))
|
|
176
|
+
if key == "\e" || key == "q" || key == "Q" || key == "\x03"
|
|
177
|
+
result = nil
|
|
178
|
+
break
|
|
179
|
+
elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\e[Z"
|
|
180
|
+
selected_btn_idx = (selected_btn_idx - 1) % btn_list.length
|
|
181
|
+
render_modal.call
|
|
182
|
+
elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\t"
|
|
183
|
+
selected_btn_idx = (selected_btn_idx + 1) % btn_list.length
|
|
184
|
+
render_modal.call
|
|
185
|
+
elsif key == "\r" || key == "\n" || key == " "
|
|
186
|
+
result = btn_list.length == 1 ? true : btn_list[selected_btn_idx]
|
|
187
|
+
break
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if is_tty
|
|
193
|
+
$stdin.raw { |s| reader.call(s) }
|
|
194
|
+
else
|
|
195
|
+
reader.call($stdin)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
ensure
|
|
199
|
+
Kernel.print(SHOW_CURSOR)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
result
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class << self
|
|
206
|
+
alias_method :popup, :modal
|
|
207
|
+
alias_method :dialog, :modal
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class GRmenu
|
|
4
|
+
# Barra de progreso visual con marco decorativo, porcentaje numerico y mensaje de estado.
|
|
5
|
+
class ProgressBar
|
|
6
|
+
attr_reader :total, :current, :title, :status
|
|
7
|
+
|
|
8
|
+
# Inicializa una nueva barra de progreso
|
|
9
|
+
def initialize(total = 100, title: nil, color: "cyan", level: 2, style: 3, width: nil)
|
|
10
|
+
@total = [total.to_i, 1].max
|
|
11
|
+
@current = 0
|
|
12
|
+
@title = title
|
|
13
|
+
@status = String.new("")
|
|
14
|
+
@color = color.to_s.downcase
|
|
15
|
+
@level = level.to_i
|
|
16
|
+
@style = style.to_i
|
|
17
|
+
@width = width
|
|
18
|
+
@closed = false
|
|
19
|
+
@drawn_lines_count = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Incrementa el progreso actual en un paso y actualiza opcionalmente el mensaje de estado
|
|
23
|
+
def advance(step = 1, status: nil)
|
|
24
|
+
return if @closed
|
|
25
|
+
@current = [(@current + step), @total].min
|
|
26
|
+
@status = status.to_s if status
|
|
27
|
+
render
|
|
28
|
+
end
|
|
29
|
+
alias_method :increment, :advance
|
|
30
|
+
alias_method :step, :advance
|
|
31
|
+
|
|
32
|
+
# Fija el progreso a un valor numerico absoluto
|
|
33
|
+
def set(value, status: nil)
|
|
34
|
+
return if @closed
|
|
35
|
+
@current = [[value.to_i, 0].max, @total].min
|
|
36
|
+
@status = status.to_s if status
|
|
37
|
+
render
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Renderiza la barra en la terminal reemplazando las lineas anteriores
|
|
41
|
+
def render
|
|
42
|
+
term_w = GRmenu.terminal_width
|
|
43
|
+
box_w = @width || [term_w - 4, 60].min
|
|
44
|
+
box_w = [box_w, 36].max
|
|
45
|
+
|
|
46
|
+
is_rgb = (@color == "rgb" || @color == "rainbow" || @color == "chroma")
|
|
47
|
+
# Angulo en radianes (0 a 2*pi = 6.2831853) para desplazar el color en modo RGB segun el avance
|
|
48
|
+
tick = (@current.to_f / @total) * 6.2831853
|
|
49
|
+
|
|
50
|
+
border_cfg = GRmenu::BORDERS[@style] || GRmenu::BORDERS[3]
|
|
51
|
+
v_l = border_cfg[:vl] || border_cfg[:v]
|
|
52
|
+
v_r = border_cfg[:vr] || border_cfg[:v]
|
|
53
|
+
h_t = border_cfg[:ht] || border_cfg[:h]
|
|
54
|
+
h_b = border_cfg[:hb] || border_cfg[:h]
|
|
55
|
+
|
|
56
|
+
top_fill = (h_t * ((box_w - 2).to_f / h_t.length).ceil)[0...(box_w - 2)]
|
|
57
|
+
bot_fill = (h_b * ((box_w - 2).to_f / h_b.length).ceil)[0...(box_w - 2)]
|
|
58
|
+
|
|
59
|
+
pct = ((@current.to_f / @total) * 100).round
|
|
60
|
+
pct_str = "#{pct}% (#{@current}/#{@total})"
|
|
61
|
+
|
|
62
|
+
inner_w = box_w - 4
|
|
63
|
+
bar_w = [inner_w - pct_str.length - 3, 10].max
|
|
64
|
+
filled_len = ((@current.to_f / @total) * bar_w).round
|
|
65
|
+
empty_len = bar_w - filled_len
|
|
66
|
+
|
|
67
|
+
lines = []
|
|
68
|
+
if is_rgb
|
|
69
|
+
v_r_col = Color.rgb(v_r, tick + (inner_w + 1) * 0.12)
|
|
70
|
+
lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", tick)
|
|
71
|
+
if @title && !@title.empty?
|
|
72
|
+
t_str = @title.to_s
|
|
73
|
+
if GRmenu.display_width(t_str) > inner_w
|
|
74
|
+
t_str = t_str[0...[inner_w - 3, 1].max] + "..."
|
|
75
|
+
end
|
|
76
|
+
pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
|
|
77
|
+
l_p = " " * (pad_t / 2)
|
|
78
|
+
r_p = " " * (pad_t - (pad_t / 2))
|
|
79
|
+
lines << "#{Color.rgb(v_l, tick)} #{l_p}#{Color.rgb(t_str, tick + 2 * 0.12)}#{r_p} #{v_r_col}"
|
|
80
|
+
lines << Color.rgb("#{v_l}#{top_fill}#{v_r}", tick)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
filled_part = Color.rgb("█" * filled_len, tick + 2 * 0.12)
|
|
84
|
+
empty_part = Color.gray("░" * empty_len)
|
|
85
|
+
bar_raw_len = 2 + filled_len + empty_len + 1 + pct_str.length
|
|
86
|
+
pad_bar_len = [inner_w - bar_raw_len, 0].max
|
|
87
|
+
bar_line = "[#{filled_part}#{empty_part}] #{Color.bright_white(pct_str)}" + (" " * pad_bar_len)
|
|
88
|
+
|
|
89
|
+
lines << "#{Color.rgb(v_l, tick)} #{bar_line} #{v_r_col}"
|
|
90
|
+
if @status && !@status.empty?
|
|
91
|
+
st_str = @status.to_s
|
|
92
|
+
if GRmenu.display_width(st_str) > inner_w
|
|
93
|
+
st_str = st_str[0...[inner_w - 3, 1].max] + "..."
|
|
94
|
+
end
|
|
95
|
+
pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
|
|
96
|
+
st_line = st_str + (" " * pad_st)
|
|
97
|
+
lines << "#{Color.rgb(v_l, tick)} #{Color.gray(st_line)} #{v_r_col}"
|
|
98
|
+
end
|
|
99
|
+
lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", tick)
|
|
100
|
+
else
|
|
101
|
+
color_code = GRmenu.ansi_color(@color, @level)
|
|
102
|
+
reset_code = GRmenu.ansi_reset
|
|
103
|
+
|
|
104
|
+
bar_str = "[#{"█" * filled_len}#{"░" * empty_len}] #{pct_str}"
|
|
105
|
+
pad_bar_len = [inner_w - GRmenu.display_width(bar_str), 0].max
|
|
106
|
+
bar_line = bar_str + (" " * pad_bar_len)
|
|
107
|
+
|
|
108
|
+
lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
|
|
109
|
+
if @title && !@title.empty?
|
|
110
|
+
t_str = @title.to_s
|
|
111
|
+
if GRmenu.display_width(t_str) > inner_w
|
|
112
|
+
t_str = t_str[0...[inner_w - 3, 1].max] + "..."
|
|
113
|
+
end
|
|
114
|
+
pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
|
|
115
|
+
l_p = " " * (pad_t / 2)
|
|
116
|
+
r_p = " " * (pad_t - (pad_t / 2))
|
|
117
|
+
lines << "#{color_code}#{v_l}#{reset_code} #{l_p}#{t_str}#{r_p} #{color_code}#{v_r}#{reset_code}"
|
|
118
|
+
lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
|
|
119
|
+
end
|
|
120
|
+
lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
|
|
121
|
+
if @status && !@status.empty?
|
|
122
|
+
st_str = @status.to_s
|
|
123
|
+
if GRmenu.display_width(st_str) > inner_w
|
|
124
|
+
st_str = st_str[0...[inner_w - 3, 1].max] + "..."
|
|
125
|
+
end
|
|
126
|
+
pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
|
|
127
|
+
st_line = st_str + (" " * pad_st)
|
|
128
|
+
lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(st_line)} #{color_code}#{v_r}#{reset_code}"
|
|
129
|
+
end
|
|
130
|
+
lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
frame = lines.join("\r\n") + "\r\n"
|
|
134
|
+
|
|
135
|
+
# Si ya habiamos dibujado lineas, subimos el cursor para sobreescribirlas
|
|
136
|
+
if @drawn_lines_count && @drawn_lines_count > 0
|
|
137
|
+
Kernel.print("\e[#{@drawn_lines_count}A\e[J")
|
|
138
|
+
end
|
|
139
|
+
Kernel.print(frame)
|
|
140
|
+
$stdout.flush
|
|
141
|
+
@drawn_lines_count = lines.length
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Cierra y finaliza la barra de progreso
|
|
145
|
+
def finish(status: "¡Completado!")
|
|
146
|
+
return if @closed
|
|
147
|
+
set(@total, status: status)
|
|
148
|
+
@closed = true
|
|
149
|
+
Kernel.print(GRmenu::SHOW_CURSOR)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Muestra un spinner animado en un hilo de fondo mientras ejecuta el bloque de codigo proporcionado
|
|
154
|
+
def self.spinner(message_arg = nil, message: nil, color: "cyan", level: 2, delay: 0.08, &block)
|
|
155
|
+
actual_message = message || message_arg || "Cargando..."
|
|
156
|
+
# Caracteres Braille giratorios para el spinner
|
|
157
|
+
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
158
|
+
is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
|
|
159
|
+
color_code = is_rgb ? "" : ansi_color(color, level)
|
|
160
|
+
reset_code = ansi_reset
|
|
161
|
+
|
|
162
|
+
stop_spinner = false
|
|
163
|
+
spinner_thread = Thread.new do
|
|
164
|
+
frame_idx = 0
|
|
165
|
+
while !stop_spinner
|
|
166
|
+
f = frames[frame_idx % frames.length]
|
|
167
|
+
f_color = is_rgb ? rgb_color(frame_idx * 0.3) : color_code
|
|
168
|
+
msg_out = is_rgb ? Color.rgb(actual_message, frame_idx * 0.1) : actual_message
|
|
169
|
+
Kernel.print("\r\e[K#{f_color}#{f}#{reset_code} #{msg_out}")
|
|
170
|
+
$stdout.flush
|
|
171
|
+
frame_idx += 1
|
|
172
|
+
sleep(delay)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
begin
|
|
177
|
+
Kernel.print(HIDE_CURSOR)
|
|
178
|
+
result = block ? block.call : nil
|
|
179
|
+
stop_spinner = true
|
|
180
|
+
spinner_thread.join
|
|
181
|
+
success_color = ansi_color("green", 2)
|
|
182
|
+
Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{actual_message} #{Color.gray("Listo!")}\r\n")
|
|
183
|
+
result
|
|
184
|
+
rescue Exception => e
|
|
185
|
+
stop_spinner = true
|
|
186
|
+
spinner_thread.join rescue nil
|
|
187
|
+
error_color = ansi_color("red", 2)
|
|
188
|
+
Kernel.print("\r\e[K#{error_color}[ERROR]#{reset_code} #{actual_message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
|
|
189
|
+
raise e
|
|
190
|
+
ensure
|
|
191
|
+
stop_spinner = true
|
|
192
|
+
Kernel.print(SHOW_CURSOR)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Ejecuta un bloque proporcionandole un objeto ProgressBar para reportar tareas largas
|
|
197
|
+
def self.progress(total_arg = nil, total: nil, title: nil, color: "cyan", level: 2, style: 3, width: nil, &block)
|
|
198
|
+
actual_total = total || total_arg || 100
|
|
199
|
+
bar = ProgressBar.new(actual_total, title: title, color: color, level: level, style: style, width: width)
|
|
200
|
+
Kernel.print(HIDE_CURSOR)
|
|
201
|
+
bar.render
|
|
202
|
+
begin
|
|
203
|
+
result = block ? block.call(bar) : bar
|
|
204
|
+
bar.finish
|
|
205
|
+
result
|
|
206
|
+
ensure
|
|
207
|
+
Kernel.print(SHOW_CURSOR)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|