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,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Widget de barra deslizante (slider) interactiva para la terminal.
5
+ #
6
+ # Permite al usuario seleccionar valores numéricos continuos o discretos dentro
7
+ # de un rango mínimo y máximo, con teclas de dirección izquierda/derecha (paso simple)
8
+ # y arriba/abajo (paso rápido de x5).
9
+ #
10
+ # @param prompt_arg [String, nil] Texto explicativo posicional.
11
+ # @param prompt [String, nil] Texto explicativo nominal.
12
+ # @param min [Numeric] Valor mínimo del rango.
13
+ # @param max [Numeric] Valor máximo del rango.
14
+ # @param step [Numeric] Incremento/decremento por pulsación de tecla.
15
+ # @param default [Numeric, nil] Valor inicial predeterminado.
16
+ # @param unit [String] Unidad de medida mostrada junto al número (ej: "%", "px", "MB").
17
+ # @param color [String, Symbol, nil] Color del marco o tema.
18
+ # @param style [Integer, nil] Estilo de borde (1 al 12).
19
+ # @param width [Integer] Ancho deseado de la caja.
20
+ # @return [Integer, Float] El valor final seleccionado.
21
+ def self.slider(prompt_arg = nil, prompt: nil, min: 0, max: 100, step: 1, default: nil, unit: "", color: nil, style: nil, width: 46)
22
+ actual_prompt = prompt || prompt_arg || "Selecciona un valor:"
23
+ slider_theme = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "slider")) || {}
24
+ current_value = (default || min).to_f.clamp(min.to_f, max.to_f)
25
+ step_val = [step.to_f, 0.001].max
26
+ drawn_lines = 0
27
+ rgb_tick = 0.0
28
+ slider_color = (color || slider_theme["color"] || "cyan").to_s
29
+ border_style = (style || slider_theme["style"] || 3).to_i
30
+ is_rgb = (slider_color.downcase == "rgb" || slider_color.downcase == "rainbow" || slider_color.downcase == "chroma")
31
+
32
+ border_chars = BORDERS[border_style] || BORDERS[3]
33
+ horizontal_top = border_chars[:ht] || border_chars[:h]
34
+ horizontal_bottom = border_chars[:hb] || border_chars[:h]
35
+ vertical_left = border_chars[:vl] || border_chars[:v]
36
+ vertical_right = border_chars[:vr] || border_chars[:v]
37
+
38
+ render_slider = lambda do
39
+ term_w = terminal_width
40
+ box_w = [width, term_w - 4, display_width(actual_prompt) + 8, 38].max
41
+ box_w = [box_w, term_w - 2].min
42
+ inner_w = box_w - 4
43
+
44
+ top_fill = (horizontal_top * ((box_w - 2).to_f / horizontal_top.length).ceil)[0...(box_w - 2)]
45
+ bot_fill = (horizontal_bottom * ((box_w - 2).to_f / horizontal_bottom.length).ceil)[0...(box_w - 2)]
46
+
47
+ # Mostramos números enteros limpios si no hay decimales
48
+ val_display = (current_value % 1 == 0) ? current_value.to_i.to_s : current_value.round(2).to_s
49
+ val_str = unit.to_s.empty? ? val_display : "#{val_display} #{unit}"
50
+
51
+ range_span = (max - min).to_f
52
+ range_span = 1.0 if range_span <= 0
53
+ fraction = ((current_value - min).to_f / range_span).clamp(0.0, 1.0)
54
+
55
+ # Determinamos cuántos bloques llenos (█) y vacíos (░) corresponden al porcentaje actual
56
+ avail_bar_w = [inner_w - display_width(val_str) - 4, 6].max
57
+ filled_len = (fraction * avail_bar_w).round
58
+ empty_len = [avail_bar_w - filled_len, 0].max
59
+
60
+ p_clean = actual_prompt.to_s
61
+ p_clean = p_clean[0...[inner_w - 3, 1].max] + "..." if display_width(p_clean) > inner_w
62
+ pad_p = [inner_w - display_width(p_clean), 0].max
63
+ prompt_line = (" " * (pad_p / 2)) + p_clean + (" " * (pad_p - (pad_p / 2)))
64
+
65
+ instruction_text = (inner_w >= 30) ? "← / → Ajustar | Enter Guardar" : "←/→: Ajustar | Enter: Ok"
66
+ instruction_text = instruction_text[0...[inner_w - 3, 1].max] + "..." if display_width(instruction_text) > inner_w
67
+ pad_i = [inner_w - display_width(instruction_text), 0].max
68
+ instr_line = (" " * (pad_i / 2)) + instruction_text + (" " * (pad_i - (pad_i / 2)))
69
+
70
+ lines = []
71
+ if is_rgb
72
+ vr_col = Color.rgb(vertical_right, rgb_tick + (inner_w + 1) * 0.12)
73
+ lines << Color.rgb("#{border_chars[:tl]}#{top_fill}#{border_chars[:tr]}", rgb_tick)
74
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.rgb(prompt_line, rgb_tick + 2 * 0.12)} #{vr_col}"
75
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{' ' * inner_w} #{vr_col}"
76
+
77
+ filled_part = Color.rgb("█" * filled_len, rgb_tick + 0.5)
78
+ empty_part = Color.gray("░" * empty_len)
79
+ bar_raw = "[#{filled_part}#{empty_part}] #{Color.bright_white(val_str)}"
80
+ bar_vis_w = 2 + filled_len + empty_len + 1 + display_width(val_str)
81
+ pad_b = [inner_w - bar_vis_w, 0].max
82
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{bar_raw}#{' ' * pad_b} #{vr_col}"
83
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{' ' * inner_w} #{vr_col}"
84
+ lines << "#{Color.rgb(vertical_left, rgb_tick)} #{Color.gray(instr_line)} #{vr_col}"
85
+ lines << Color.rgb("#{border_chars[:bl]}#{bot_fill}#{border_chars[:br]}", rgb_tick)
86
+ else
87
+ color_code = ansi_color(slider_color, 2)
88
+ reset_code = ansi_reset
89
+
90
+ bar_raw = "[#{"█" * filled_len}#{"░" * empty_len}] #{val_str}"
91
+ pad_b = [inner_w - display_width(bar_raw), 0].max
92
+ bar_line = bar_raw + (" " * pad_b)
93
+
94
+ lines << "#{color_code}#{border_chars[:tl]}#{top_fill}#{border_chars[:tr]}#{reset_code}"
95
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.bright_yellow(prompt_line)} #{color_code}#{vertical_right}#{reset_code}"
96
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{' ' * inner_w} #{color_code}#{vertical_right}#{reset_code}"
97
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{bar_line} #{color_code}#{vertical_right}#{reset_code}"
98
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{' ' * inner_w} #{color_code}#{vertical_right}#{reset_code}"
99
+ lines << "#{color_code}#{vertical_left}#{reset_code} #{Color.gray(instr_line)} #{color_code}#{vertical_right}#{reset_code}"
100
+ lines << "#{color_code}#{border_chars[:bl]}#{bot_fill}#{border_chars[:br]}#{reset_code}"
101
+ end
102
+
103
+ frame = lines.join("\r\n") + "\r\n"
104
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
105
+ Kernel.print(frame)
106
+ $stdout.flush
107
+ drawn_lines = lines.length
108
+ end
109
+
110
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
111
+
112
+ begin
113
+ Kernel.print(HIDE_CURSOR)
114
+ render_slider.call
115
+
116
+ reader = lambda do |stream|
117
+ while true
118
+ if is_rgb
119
+ ready = false
120
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
121
+ begin
122
+ sr = IO.select([stream], nil, nil, 0.035)
123
+ ready = true if sr && sr[0] && !sr[0].empty?
124
+ rescue StandardError
125
+ ready = true
126
+ end
127
+ else
128
+ ready = true
129
+ end
130
+ unless ready
131
+ rgb_tick += 0.08
132
+ render_slider.call
133
+ next
134
+ end
135
+ end
136
+
137
+ key = GRmenu.read_key_raw(stream)
138
+ break if key.nil? || key == "\x03" || key == "\x04" || key == "q" || key == "Q" || key == "\e"
139
+
140
+ if key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "h" || key == "H"
141
+ # Izquierda / 'h': restar 1 paso
142
+ current_value = (current_value - step_val).clamp(min.to_f, max.to_f)
143
+ render_slider.call
144
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M" || key == "l" || key == "L"
145
+ # Derecha / 'l': sumar 1 paso
146
+ current_value = (current_value + step_val).clamp(min.to_f, max.to_f)
147
+ render_slider.call
148
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
149
+ # Abajo: paso rápido hacia atrás (x5)
150
+ current_value = (current_value - step_val * 5).clamp(min.to_f, max.to_f)
151
+ render_slider.call
152
+ elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
153
+ # Arriba: paso rápido hacia adelante (x5)
154
+ current_value = (current_value + step_val * 5).clamp(min.to_f, max.to_f)
155
+ render_slider.call
156
+ elsif key == "\r" || key == "\n"
157
+ # Enter: confirmar
158
+ break
159
+ end
160
+ end
161
+ end
162
+
163
+ if is_tty
164
+ $stdin.raw { |s| reader.call(s) }
165
+ else
166
+ reader.call($stdin)
167
+ end
168
+ ensure
169
+ Kernel.print(SHOW_CURSOR)
170
+ end
171
+
172
+ (current_value % 1 == 0) ? current_value.to_i : current_value.round(2)
173
+ end
174
+
175
+ class << self
176
+ alias_method :range, :slider
177
+ end
178
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Tabla interactiva para la terminal con paginación, búsqueda en vivo y ordenamiento.
5
+ #
6
+ # Permite al usuario desplazarse entre filas de datos con las teclas direccionales,
7
+ # filtrar en tiempo real si `search: true`, ordenar columnas con la tecla 's' si `sort: true`,
8
+ # y seleccionar una fila pulsando Enter.
9
+ #
10
+ # @param headers_arg [Array, nil] Encabezados de columnas (posicional).
11
+ # @param rows_arg [Array, nil] Filas de datos (posicional).
12
+ # @param headers [Array, nil] Encabezados de columnas (nominal).
13
+ # @param rows [Array, nil] Filas de datos (nominal).
14
+ # @param title [String, nil] Título superior enmarcado en la tabla.
15
+ # @param style [Integer, nil] Estilo de borde (1 al 12).
16
+ # @param color [String, Symbol, nil] Color general del marco.
17
+ # @param header_color [String, Symbol, nil] Color de los textos de encabezado.
18
+ # @param border_color [String, Symbol, nil] Color específico de las líneas divisorias.
19
+ # @param selected_row [String, Symbol, nil] Color de resalto para la fila seleccionada.
20
+ # @param page_size [Integer, nil] Número máximo de filas mostradas a la vez.
21
+ # @param search [Boolean] Habilita barra de búsqueda interactiva en tiempo real.
22
+ # @param sort [Boolean] Permite ordenar las columnas ciclando con la tecla 's'.
23
+ # @param animate [Symbol, nil] Efecto de animación opcional.
24
+ # @param width [Integer, nil] Ancho forzado de la tabla.
25
+ # @return [Array, nil] La fila seleccionada por el usuario, o nil si canceló.
26
+ def self.table(headers_arg = nil, rows_arg = nil, headers: nil, rows: nil, title: nil, style: nil, color: nil, header_color: nil, border_color: nil, selected_row: nil, page_size: nil, search: false, sort: false, animate: nil, width: nil, **kwargs)
27
+ table_theme = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "table")) || {}
28
+ input_stream = $stdin
29
+ output_stream = $stdout
30
+
31
+ border_style = (style || table_theme["style"] || 3).to_i
32
+ border_chars = BORDERS[border_style] || BORDERS[3]
33
+
34
+ table_color_name = (border_color || color || table_theme["border_color"] || table_theme["border"] || table_theme["color"] || "cyan").to_s
35
+ header_color_val = header_color || table_theme["header_color"] || "yellow"
36
+ focus_color_val = selected_row || table_theme["selected_row"] || table_theme["focus"] || "green"
37
+ page_size ||= (table_theme["page_size"] || 8).to_i
38
+
39
+ resolved_headers = (headers || headers_arg || []).map(&:to_s)
40
+ resolved_raw_rows = (rows || rows_arg || []).map { |r| r.is_a?(Array) ? r.map(&:to_s) : r.values.map(&:to_s) }
41
+ headers = resolved_headers
42
+ raw_rows = resolved_raw_rows
43
+ filtered_rows = raw_rows.dup
44
+
45
+ selected_idx = 0
46
+ query = String.new("")
47
+ sort_column_idx = nil
48
+ sort_ascending = true
49
+ animation_tick = 0.0
50
+
51
+ # Calcula el ancho ideal para cada columna según el contenido más largo
52
+ calc_widths = lambda do
53
+ col_counts = [headers.length, raw_rows.map(&:length).max || 0].max
54
+ widths = Array.new(col_counts, 0)
55
+ headers.each_with_index { |h, i| widths[i] = [widths[i], display_width(h)].max }
56
+ filtered_rows.each do |row|
57
+ row.each_with_index { |cell, i| widths[i] = [widths[i], display_width(cell)].max }
58
+ end
59
+ widths.map { |w| w + 2 }
60
+ end
61
+
62
+ draw_table = lambda do |current_tick|
63
+ is_rgb = ["rgb", "rainbow", "chroma"].include?(table_color_name.downcase) || (animate && ["rgb", "rainbow", "chroma"].include?(animate.to_s.downcase))
64
+ resolved_border_color = is_rgb ? rgb_color(current_tick, 0.0) : ansi_color(table_color_name, 1)
65
+ resolved_header_color = is_rgb ? rgb_color(current_tick, 0.8) : ansi_color(header_color_val, 2)
66
+ resolved_focus_color = is_rgb ? rgb_color(current_tick, 1.4) : ansi_color(focus_color_val, 2)
67
+ reset_code = ansi_reset
68
+
69
+ col_widths = calc_widths.call
70
+ help_line = " ↑/↓: Moverse | Enter: Elegir | s: Ordenar | Esc: Salir"
71
+ total_width = [col_widths.sum + (col_widths.length - 1) + 4, title ? display_width(title) + 8 : 0, display_width(help_line) + 4, 46].max
72
+ total_width = [total_width, terminal_width - 2].min
73
+ inner_w = total_width - 2
74
+
75
+ # Acortamos el texto de ayuda si el ancho disponible en la terminal es muy pequeño
76
+ if inner_w < display_width(help_line)
77
+ help_line = " ↑/↓: Mover | Enter: Ok | Esc: Salir"
78
+ end
79
+
80
+ top_left = border_chars[:tl] || "#"
81
+ top_right = border_chars[:tr] || "#"
82
+ bot_left = border_chars[:bl] || "#"
83
+ bot_right = border_chars[:br] || "#"
84
+ horizontal_c = border_chars[:h] || "─"
85
+ vertical_c = border_chars[:v] || "│"
86
+
87
+ top_str = if title && !title.empty?
88
+ clean_title = " #{title} "
89
+ title_len = display_width(clean_title)
90
+ if title_len > inner_w
91
+ clean_title = " #{title[0...[(inner_w - 6), 1].max]}... "
92
+ title_len = display_width(clean_title)
93
+ end
94
+ left_len = [(inner_w - title_len) / 2, 0].max
95
+ right_len = [inner_w - title_len - left_len, 0].max
96
+ horizontal_c * left_len + clean_title + horizontal_c * right_len
97
+ else
98
+ horizontal_c * inner_w
99
+ end
100
+
101
+ out = String.new(CURSOR_HOME)
102
+ out << HIDE_CURSOR
103
+ out << "#{resolved_border_color}#{top_left}#{top_str}#{top_right}#{reset_code}#{CLEAR_TO_EOL}\r\n"
104
+
105
+ # Si está habilitada la búsqueda, mostramos la barra con el query actual
106
+ if search
107
+ search_line = " Buscar: #{query}█"
108
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{pad_to_width(search_line, inner_w)}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
109
+ out << "#{resolved_border_color}#{vertical_c}#{horizontal_c * inner_w}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
110
+ end
111
+
112
+ # Fila de encabezados de columnas
113
+ header_cells = headers.each_with_index.map do |h, i|
114
+ w = col_widths[i] || 10
115
+ sort_indicator = sort_column_idx == i ? (sort_ascending ? " ▲" : " ▼") : ""
116
+ h_str = "#{h}#{sort_indicator}"
117
+ max_c = [w - 2, 2].max
118
+ h_str = h_str[0...[max_c - 2, 1].max] + ".." if display_width(h_str) > max_c
119
+ pad_to_width(" #{h_str}", w)
120
+ end
121
+ hdr_row_str = " " + header_cells.join("│")
122
+ hdr_row_str = hdr_row_str[0...inner_w] if display_width(hdr_row_str) > inner_w
123
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{resolved_header_color}#{pad_to_width(hdr_row_str, inner_w)}#{reset_code}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
124
+ out << "#{resolved_border_color}#{vertical_c}#{horizontal_c * inner_w}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
125
+
126
+ max_visible = page_size || 8
127
+ total_rows = filtered_rows.length
128
+ if total_rows == 0
129
+ empty_msg = " (Sin registros que coincidan con '#{query}')"
130
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{pad_to_width(empty_msg, inner_w)}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
131
+ else
132
+ # Paginación inteligente manteniendo visible la fila seleccionada
133
+ start_idx = [(selected_idx - max_visible / 2), 0].max
134
+ start_idx = [start_idx, [total_rows - max_visible, 0].max].min
135
+ end_idx = [start_idx + max_visible - 1, total_rows - 1].min
136
+
137
+ if start_idx > 0
138
+ up_str = " ▲ (+#{start_idx} arriba)"
139
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{ansi_color('gray', 1)}#{pad_to_width(up_str, inner_w)}#{reset_code}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
140
+ end
141
+
142
+ (start_idx..end_idx).each do |r_i|
143
+ row = filtered_rows[r_i]
144
+ is_active = (r_i == selected_idx)
145
+ prefix = is_active ? "> " : " "
146
+
147
+ row_cells = row.each_with_index.map do |cell, c_i|
148
+ w = col_widths[c_i] || 10
149
+ c_str = cell.to_s
150
+ max_c = [w - 2, 2].max
151
+ c_str = c_str[0...[max_c - 2, 1].max] + ".." if display_width(c_str) > max_c
152
+ pad_to_width(" #{c_str}", w)
153
+ end
154
+ row_str = prefix + row_cells.join("│")[1..-1].to_s
155
+ row_str = row_str[0...inner_w] if display_width(row_str) > inner_w
156
+
157
+ if is_active
158
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{resolved_focus_color}#{pad_to_width(row_str, inner_w)}#{reset_code}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
159
+ else
160
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{pad_to_width(row_str, inner_w)}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
161
+ end
162
+ end
163
+
164
+ remaining_down = total_rows - 1 - end_idx
165
+ if remaining_down > 0
166
+ down_str = " ▼ (+#{remaining_down} abajo)"
167
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{ansi_color('gray', 1)}#{pad_to_width(down_str, inner_w)}#{reset_code}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
168
+ end
169
+ end
170
+
171
+ out << "#{resolved_border_color}#{vertical_c}#{horizontal_c * inner_w}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
172
+ out << "#{resolved_border_color}#{vertical_c}#{reset_code}#{ansi_color('gray', 1)}#{pad_to_width(help_line, inner_w)}#{reset_code}#{resolved_border_color}#{vertical_c}#{reset_code}#{CLEAR_TO_EOL}\r\n"
173
+ out << "#{resolved_border_color}#{bot_left}#{horizontal_c * inner_w}#{bot_right}#{reset_code}#{CLEAR_TO_EOL}\r\n"
174
+ out << CLEAR_TO_EOS
175
+ output_stream.print(out)
176
+ output_stream.flush
177
+ end
178
+
179
+ loop_res = nil
180
+ reader = lambda do |stream|
181
+ loop do
182
+ draw_table.call(animation_tick)
183
+ is_anim = is_rgb
184
+ if is_anim
185
+ ready = false
186
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
187
+ begin
188
+ res = IO.select([stream], nil, nil, 0.035)
189
+ ready = true if res && res[0] && !res[0].empty?
190
+ rescue StandardError
191
+ ready = true
192
+ end
193
+ else
194
+ ready = true
195
+ end
196
+ unless ready
197
+ animation_tick += 0.08
198
+ next
199
+ end
200
+ end
201
+
202
+ key = read_key_raw(stream)
203
+ break if key.nil? || key == "\x03" || key == "\x04"
204
+
205
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
206
+ # Flecha Arriba
207
+ if filtered_rows.length > 0
208
+ selected_idx = (selected_idx - 1) % filtered_rows.length
209
+ end
210
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
211
+ # Flecha Abajo
212
+ if filtered_rows.length > 0
213
+ selected_idx = (selected_idx + 1) % filtered_rows.length
214
+ end
215
+ elsif key == "\e[5~" || key == "\e[D"
216
+ # RePág o Flecha Izquierda: saltar una página hacia arriba
217
+ if filtered_rows.length > 0
218
+ selected_idx = [(selected_idx - (page_size || 8)), 0].max
219
+ end
220
+ elsif key == "\e[6~" || key == "\e[C"
221
+ # AvPág o Flecha Derecha: saltar una página hacia abajo
222
+ if filtered_rows.length > 0
223
+ selected_idx = [(selected_idx + (page_size || 8)), filtered_rows.length - 1].min
224
+ end
225
+ elsif key == "\r" || key == "\n"
226
+ # Confirmar fila seleccionada
227
+ if filtered_rows.length > 0
228
+ loop_res = filtered_rows[selected_idx]
229
+ end
230
+ break
231
+ elsif key == "\e"
232
+ # Escape: si había búsqueda activa la limpia, de lo contrario sale
233
+ if search && !query.empty?
234
+ query.clear
235
+ filtered_rows = raw_rows.dup
236
+ selected_idx = 0
237
+ else
238
+ loop_res = nil
239
+ break
240
+ end
241
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
242
+ # Borrar caracter en el filtro de búsqueda
243
+ if search && !query.empty?
244
+ query.chop!
245
+ if query.empty?
246
+ filtered_rows = raw_rows.dup
247
+ else
248
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
249
+ end
250
+ selected_idx = 0
251
+ end
252
+ elsif key == "\x15"
253
+ # Ctrl+U: limpiar búsqueda por completo
254
+ if search
255
+ query.clear
256
+ filtered_rows = raw_rows.dup
257
+ selected_idx = 0
258
+ end
259
+ elsif (!search || query.empty?) && (key == "s" || key == "S")
260
+ # Ciclar por la siguiente columna para ordenar
261
+ if sort
262
+ sort_column_idx = ((sort_column_idx || -1) + 1) % [headers.length, 1].max
263
+ filtered_rows.sort_by! { |r| r[sort_column_idx] || "" }
264
+ selected_idx = 0
265
+ end
266
+ elsif (!search || query.empty?) && (key == "q" || key == "Q")
267
+ loop_res = nil
268
+ break
269
+ elsif search && key =~ /^[[:print:]]$/
270
+ # Agregar letra al filtro de búsqueda y actualizar filas visibles al vuelo
271
+ query << key
272
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
273
+ selected_idx = 0
274
+ end
275
+ end
276
+ end
277
+
278
+ begin
279
+ output_stream.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
280
+ if input_stream.respond_to?(:raw) && input_stream.respond_to?(:tty?) && input_stream.tty?
281
+ input_stream.raw { |s| reader.call(s) }
282
+ else
283
+ reader.call(input_stream)
284
+ end
285
+ ensure
286
+ output_stream.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
287
+ end
288
+
289
+ loop_res
290
+ end
291
+ end
data/grmenu/window.rb ADDED
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ==============================================================================
4
+ # GRmenu::Window - Control del lienzo y color de fondo nativo de la terminal.
5
+ # Permite alterar el color de fondo de toda la ventana usando OSC 11 y ANSI BCE,
6
+ # garantizando la restauración automática al salir o ante errores inesperados.
7
+ # ==============================================================================
8
+
9
+ class GRmenu
10
+ module Window
11
+ @active = false
12
+ @cleanup_hook_registered = false
13
+ @current_bg = nil
14
+ @current_fg = nil
15
+
16
+ class << self
17
+ # Indica si hay un color de ventana personalizado activo actualmente
18
+ def active?
19
+ @active == true
20
+ end
21
+
22
+ # Resuelve cualquier especificación de color (nombre, español, hex, array, rgb()) a [R, G, B]
23
+ def resolve_rgb(color, *rest, **kwargs)
24
+ target = kwargs[:bg] || color
25
+ # Formato numérico posicional: color(255, 0, 50)
26
+ if target.is_a?(Numeric) && rest.length >= 2
27
+ return [target.to_i.clamp(0, 255), rest[0].to_i.clamp(0, 255), rest[1].to_i.clamp(0, 255)]
28
+ end
29
+
30
+ # Formato array: color([255, 0, 50])
31
+ if target.is_a?(Array) && target.length >= 3
32
+ return [target[0].to_i.clamp(0, 255), target[1].to_i.clamp(0, 255), target[2].to_i.clamp(0, 255)]
33
+ end
34
+
35
+ str = target.to_s.strip.downcase
36
+
37
+ # Formato funcional rgb(r, g, b)
38
+ if str =~ /\Argb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\z/i
39
+ return [$1.to_i.clamp(0, 255), $2.to_i.clamp(0, 255), $3.to_i.clamp(0, 255)]
40
+ end
41
+
42
+ # Hexadecimal de 6 dígitos (#RRGGBB o RRGGBB)
43
+ if str =~ /\A#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\z/i
44
+ return [$1.to_i(16), $2.to_i(16), $3.to_i(16)]
45
+ end
46
+
47
+ # Hexadecimal shorthand de 3 dígitos (#RGB o RGB)
48
+ if str =~ /\A#?([0-9a-f])([0-9a-f])([0-9a-f])\z/i
49
+ return [($1 * 2).to_i(16), ($2 * 2).to_i(16), ($3 * 2).to_i(16)]
50
+ end
51
+
52
+ # Normalización y búsqueda por nombre en los catálogos del sistema
53
+ clean = str.split(":").first.strip
54
+ return GRmenu::BASE_RGB[clean].dup if GRmenu::BASE_RGB.key?(clean)
55
+
56
+ code_val = GRmenu::COLORS[clean] || GRmenu::COLORS.dig(clean, "2") || GRmenu::COLORS.dig(clean, "1")
57
+ if code_val.to_s =~ /38;2;(\d+);(\d+);(\d+)/
58
+ return [$1.to_i, $2.to_i, $3.to_i]
59
+ end
60
+
61
+ # Fallback predeterminado a negro
62
+ [0, 0, 0]
63
+ end
64
+
65
+ # Cambia el color de fondo de toda la ventana de la terminal.
66
+ # Admite nombres ("red", "azul", "negro"), hex ("#1a1b26"), rgb([r,g,b]), o kwargs.
67
+ # Se restaura automáticamente sin que el programador tenga que escribir código de limpieza.
68
+ def color(bg_color = nil, *rest, fg: nil, clear: true, **kwargs)
69
+ ensure_cleanup_hook
70
+
71
+ target_bg = kwargs[:bg] || bg_color
72
+ return reset if target_bg.nil? || target_bg.to_s.downcase == "reset"
73
+
74
+ r, g, b = resolve_rgb(target_bg, *rest, **kwargs)
75
+ hex = sprintf("#%02x%02x%02x", r, g, b)
76
+ @current_bg = hex
77
+
78
+ # 1. Comando OSC 11 para la ventana nativa (XTerm / GNOME / Kitty / Alacritty / Windows Terminal)
79
+ # 2. Secuencia ANSI BCE (Background Color Erase) para pintar todas las celdas de la pantalla
80
+ buffer = String.new("")
81
+ buffer << "\e]11;#{hex}\a\e]11;#{hex}\e\\"
82
+ buffer << "\e[48;2;#{r};#{g};#{b}m"
83
+
84
+ # Manejo opcional de color de texto (foreground) de la ventana
85
+ if fg
86
+ fg_r, fg_g, fg_b = resolve_rgb(fg)
87
+ fg_hex = sprintf("#%02x%02x%02x", fg_r, fg_g, fg_b)
88
+ @current_fg = fg_hex
89
+ buffer << "\e]10;#{fg_hex}\a\e]10;#{fg_hex}\e\\"
90
+ buffer << "\e[38;2;#{fg_r};#{fg_g};#{fg_b}m"
91
+ end
92
+
93
+ # Limpiar y rellenar pantalla completa con el color activo
94
+ buffer << "\e[2J\e[H" if clear
95
+
96
+ $stdout.print(buffer)
97
+ $stdout.flush
98
+ @active = true
99
+ self
100
+ end
101
+
102
+ alias_method :bg, :color
103
+ alias_method :background, :color
104
+ alias_method :set, :color
105
+
106
+ # Cambia el color de texto por defecto de la ventana
107
+ def fg(fg_color)
108
+ ensure_cleanup_hook
109
+ fg_r, fg_g, fg_b = resolve_rgb(fg_color)
110
+ fg_hex = sprintf("#%02x%02x%02x", fg_r, fg_g, fg_b)
111
+ @current_fg = fg_hex
112
+ $stdout.print("\e]10;#{fg_hex}\a\e]10;#{fg_hex}\e\\\e[38;2;#{fg_r};#{fg_g};#{fg_b}m")
113
+ $stdout.flush
114
+ @active = true
115
+ self
116
+ end
117
+
118
+ # Restaura inmediatamente el color de fondo y texto original de la terminal
119
+ def reset
120
+ return unless @active
121
+ @active = false
122
+ @current_bg = nil
123
+ @current_fg = nil
124
+
125
+ # OSC 111 (reset fondo), OSC 110 (reset texto), \e[0m (ANSI reset), y limpia pantalla
126
+ $stdout.print("\e]111\a\e]111\e\\\e]110\a\e]110\e\\\e[0m\e[2J\e[H")
127
+ $stdout.flush
128
+ self
129
+ end
130
+
131
+ # Asigna el título a la barra de la ventana de la terminal
132
+ def title(window_title)
133
+ $stdout.print("\e]0;#{window_title}\a")
134
+ $stdout.flush
135
+ self
136
+ end
137
+
138
+ # Limpia la pantalla usando el color de fondo actual
139
+ def clear
140
+ $stdout.print("\e[2J\e[H")
141
+ $stdout.flush
142
+ self
143
+ end
144
+
145
+ private
146
+
147
+ # Registra los manejadores de salida (at_exit) y señales del sistema una única vez
148
+ def ensure_cleanup_hook
149
+ return if @cleanup_hook_registered
150
+ @cleanup_hook_registered = true
151
+
152
+ # Se ejecuta ante salida normal o errores/excepciones no capturadas de Ruby
153
+ at_exit do
154
+ reset if active?
155
+ end
156
+
157
+ # Intercepta interrupción por teclado (Ctrl+C), kill normal y desconexión de terminal
158
+ [:INT, :TERM, :HUP].each do |sig|
159
+ old_handler = Signal.trap(sig) do
160
+ reset if active?
161
+ if old_handler.respond_to?(:call)
162
+ old_handler.call
163
+ elsif old_handler == "DEFAULT"
164
+ exit(1)
165
+ else
166
+ exit(0)
167
+ end
168
+ end rescue nil
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ # Punto de entrada público en GRmenu:
175
+ # Permite invocar como GRmenu.window.bg("red"), GRmenu.window.color("red") o directamente GRmenu.window("red")
176
+ def self.window(*args, **kwargs)
177
+ if args.empty? && kwargs.empty?
178
+ GRmenu::Window
179
+ else
180
+ GRmenu::Window.color(*args, **kwargs)
181
+ end
182
+ end
183
+
184
+ class << self
185
+ alias_method :window_bg, :window
186
+ end
187
+ end