grmenu 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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