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,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
|