grmenu 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +186 -49
- data/data/colors.json +224 -44
- data/data/help.txt +223 -90
- data/data/themes/chromatic_red.gr +74 -0
- data/data/themes/cyberpunk.gr +18 -2
- data/data/themes/dracula.gr +18 -2
- data/data/themes/matrix.gr +18 -2
- data/data/themes/monokai.gr +18 -2
- data/data/themes/nord.gr +18 -2
- data/data/themes/sunset.gr +18 -2
- data/grmenu/animation.rb +139 -0
- data/grmenu/banner.rb +99 -0
- data/grmenu/color.rb +265 -0
- data/grmenu/image.rb +339 -0
- data/grmenu/interactive.rb +1002 -0
- data/grmenu/renderer.rb +865 -0
- data/grmenu/set_style.rb +312 -0
- data/grmenu/terminal.rb +189 -0
- data/grmenu/theme.rb +378 -0
- data/grmenu/version.rb +6 -0
- data/grmenu/widgets/checkbox.rb +291 -0
- data/grmenu/widgets/dialogs.rb +251 -0
- data/grmenu/widgets/form.rb +533 -0
- data/grmenu/widgets/input.rb +171 -0
- data/grmenu/widgets/modal.rb +209 -0
- data/grmenu/widgets/progress.rb +210 -0
- data/grmenu/widgets/slider.rb +178 -0
- data/grmenu/widgets/table.rb +291 -0
- data/grmenu/window.rb +187 -0
- data/grmenu.rb +88 -4339
- metadata +23 -3
- data/data/themes/neon_red.gr +0 -51
data/grmenu/image.rb
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class GRmenu
|
|
4
|
+
# Decodificador PNG nativo en Ruby puro sin dependencias de gemas externas.
|
|
5
|
+
# Procesa las cabeceras IHDR, descomprime los chunks IDAT con Zlib,
|
|
6
|
+
# reconstruye las lineas de escaneo (scanlines) segun los 5 filtros estandar de PNG
|
|
7
|
+
# y escala la imagen para convertirla a celdas de texto TrueColor (24 bits).
|
|
8
|
+
class PNGDecoder
|
|
9
|
+
attr_reader :width, :height, :pixels
|
|
10
|
+
|
|
11
|
+
# Carga y parsea un archivo PNG desde disco
|
|
12
|
+
def self.load(filepath)
|
|
13
|
+
return nil unless filepath && File.exist?(filepath)
|
|
14
|
+
new.parse(File.binread(filepath))
|
|
15
|
+
rescue StandardError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parsea los bytes en binario del archivo PNG
|
|
20
|
+
def parse(data)
|
|
21
|
+
# Valida la firma magica de 8 bytes de un archivo PNG
|
|
22
|
+
return nil unless data && data[0, 8] == "\x89PNG\r\n\x1a\n".b
|
|
23
|
+
|
|
24
|
+
offset = 8
|
|
25
|
+
idat_data = String.new("".b)
|
|
26
|
+
palette = nil
|
|
27
|
+
|
|
28
|
+
# Itera sobre los chunks PNG: [4 bytes longitud][4 bytes tipo][datos][4 bytes CRC]
|
|
29
|
+
while offset < data.bytesize
|
|
30
|
+
len = data[offset, 4].unpack1("N")
|
|
31
|
+
type = data[offset + 4, 4]
|
|
32
|
+
chunk_data = data[offset + 8, len]
|
|
33
|
+
offset += 12 + len # Avanza los 4 de len + 4 de tipo + longitud de datos + 4 de CRC
|
|
34
|
+
|
|
35
|
+
case type
|
|
36
|
+
when "IHDR"
|
|
37
|
+
# Cabecera principal: ancho, alto, profundidad de bits y tipo de color
|
|
38
|
+
@width, @height, @bit_depth, @color_type = chunk_data.unpack("NNCC")
|
|
39
|
+
when "PLTE"
|
|
40
|
+
# Paleta de colores para imagenes indexadas (tipo 3)
|
|
41
|
+
palette = chunk_data.bytes.each_slice(3).to_a
|
|
42
|
+
when "IDAT"
|
|
43
|
+
# Concatena bloques de datos comprimidos de la imagen
|
|
44
|
+
idat_data << chunk_data
|
|
45
|
+
when "IEND"
|
|
46
|
+
break
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Canales segun tipo de color PNG:
|
|
51
|
+
# 0: escala de grises (1), 2: RGB (3), 3: indexado (1), 4: gris+alfa (2), 6: RGBA (4)
|
|
52
|
+
channels = case @color_type
|
|
53
|
+
when 0 then 1
|
|
54
|
+
when 2 then 3
|
|
55
|
+
when 3 then 1
|
|
56
|
+
when 4 then 2
|
|
57
|
+
when 6 then 4
|
|
58
|
+
else return nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
bytes_per_pixel = [(@bit_depth * channels + 7) / 8, 1].max
|
|
62
|
+
row_stride = (@width * channels * @bit_depth + 7) / 8
|
|
63
|
+
scanline_length = row_stride + 1 # +1 byte inicial por cada scanline que define el tipo de filtro
|
|
64
|
+
|
|
65
|
+
# Descomprime el flujo de datos IDAT con zlib
|
|
66
|
+
raw_uncompressed = Zlib::Inflate.inflate(idat_data)
|
|
67
|
+
raw_bytes = raw_uncompressed.bytes
|
|
68
|
+
return nil if raw_bytes.length < (@height * scanline_length)
|
|
69
|
+
|
|
70
|
+
@pixels = Array.new(@height) { Array.new(@width) }
|
|
71
|
+
prev_reconstructed_row = Array.new(row_stride, 0)
|
|
72
|
+
|
|
73
|
+
# Reconstruccion de filtros PNG por cada linea de escaneo (RFC 2083):
|
|
74
|
+
# Filtro 0: None
|
|
75
|
+
# Filtro 1: Sub (reconstruye con el byte de la izquierda: a)
|
|
76
|
+
# Filtro 2: Up (reconstruye con el byte de arriba: b)
|
|
77
|
+
# Filtro 3: Average (promedio entre izquierda y arriba: (a + b) / 2)
|
|
78
|
+
# Filtro 4: Paeth (predictor lineal basado en izquierda, arriba y diagonal)
|
|
79
|
+
@height.times do |y|
|
|
80
|
+
row_start_index = y * scanline_length
|
|
81
|
+
filter_type = raw_bytes[row_start_index]
|
|
82
|
+
curr_filtered = raw_bytes[(row_start_index + 1)...(row_start_index + scanline_length)]
|
|
83
|
+
curr_reconstructed = Array.new(row_stride, 0)
|
|
84
|
+
|
|
85
|
+
row_stride.times do |i|
|
|
86
|
+
a = (i >= bytes_per_pixel) ? curr_reconstructed[i - bytes_per_pixel] : 0
|
|
87
|
+
b = prev_reconstructed_row[i]
|
|
88
|
+
c = (i >= bytes_per_pixel) ? prev_reconstructed_row[i - bytes_per_pixel] : 0
|
|
89
|
+
x = curr_filtered[i]
|
|
90
|
+
|
|
91
|
+
recon_val = case filter_type
|
|
92
|
+
when 0 then x
|
|
93
|
+
when 1 then (x + a) & 0xFF
|
|
94
|
+
when 2 then (x + b) & 0xFF
|
|
95
|
+
when 3 then (x + ((a + b) / 2)) & 0xFF
|
|
96
|
+
when 4
|
|
97
|
+
# Algoritmo predictor Paeth
|
|
98
|
+
p_val = a + b - c
|
|
99
|
+
pa = (p_val - a).abs
|
|
100
|
+
pb = (p_val - b).abs
|
|
101
|
+
pc = (p_val - c).abs
|
|
102
|
+
pr = if pa <= pb && pa <= pc
|
|
103
|
+
a
|
|
104
|
+
elsif pb <= pc
|
|
105
|
+
b
|
|
106
|
+
else
|
|
107
|
+
c
|
|
108
|
+
end
|
|
109
|
+
(x + pr) & 0xFF
|
|
110
|
+
else x
|
|
111
|
+
end
|
|
112
|
+
curr_reconstructed[i] = recon_val
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
prev_reconstructed_row = curr_reconstructed
|
|
116
|
+
|
|
117
|
+
# Mapea los bytes reconstruidos a la matriz de pixeles [R, G, B, A]
|
|
118
|
+
if @bit_depth == 16
|
|
119
|
+
@width.times do |x|
|
|
120
|
+
idx = x * channels * 2
|
|
121
|
+
r = curr_reconstructed[idx]
|
|
122
|
+
g = (channels >= 3) ? curr_reconstructed[idx + 2] : r
|
|
123
|
+
b = (channels >= 3) ? curr_reconstructed[idx + 4] : r
|
|
124
|
+
a = (channels == 4) ? curr_reconstructed[idx + 6] : (channels == 2 ? curr_reconstructed[idx + 2] : 255)
|
|
125
|
+
@pixels[y][x] = [r, g, b, a]
|
|
126
|
+
end
|
|
127
|
+
elsif @bit_depth == 8
|
|
128
|
+
@width.times do |x|
|
|
129
|
+
idx = x * channels
|
|
130
|
+
if @color_type == 3
|
|
131
|
+
p_idx = curr_reconstructed[idx]
|
|
132
|
+
rgb_val = palette ? (palette[p_idx] || [0, 0, 0]) : [0, 0, 0]
|
|
133
|
+
@pixels[y][x] = [rgb_val[0], rgb_val[1], rgb_val[2], 255]
|
|
134
|
+
else
|
|
135
|
+
r = curr_reconstructed[idx]
|
|
136
|
+
g = (channels >= 3) ? curr_reconstructed[idx + 1] : r
|
|
137
|
+
b = (channels >= 3) ? curr_reconstructed[idx + 2] : r
|
|
138
|
+
a = (channels == 4 || channels == 2) ? curr_reconstructed[idx + channels - 1] : 255
|
|
139
|
+
@pixels[y][x] = [r, g, b, a]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Realiza un reescalado (resampling) de la matriz de pixeles por promedio de area (box average)
|
|
148
|
+
def resample(target_w, target_h)
|
|
149
|
+
resampled = Array.new(target_h) { Array.new(target_w) }
|
|
150
|
+
x_step = @width.to_f / target_w
|
|
151
|
+
y_step = @height.to_f / target_h
|
|
152
|
+
|
|
153
|
+
target_h.times do |ty|
|
|
154
|
+
sy_start = (ty * y_step).to_i
|
|
155
|
+
sy_end = [((ty + 1) * y_step).to_i, @height].min
|
|
156
|
+
|
|
157
|
+
target_w.times do |tx|
|
|
158
|
+
sx_start = (tx * x_step).to_i
|
|
159
|
+
sx_end = [((tx + 1) * x_step).to_i, @width].min
|
|
160
|
+
|
|
161
|
+
r_sum = g_sum = b_sum = a_sum = count = 0
|
|
162
|
+
|
|
163
|
+
(sy_start...sy_end).each do |sy|
|
|
164
|
+
(sx_start...sx_end).each do |sx|
|
|
165
|
+
pixel = @pixels[sy][sx]
|
|
166
|
+
next unless pixel
|
|
167
|
+
# Desprecia pixeles casi completamente transparentes (alfa <= 10)
|
|
168
|
+
if pixel[3] > 10
|
|
169
|
+
r_sum += pixel[0]
|
|
170
|
+
g_sum += pixel[1]
|
|
171
|
+
b_sum += pixel[2]
|
|
172
|
+
a_sum += pixel[3]
|
|
173
|
+
count += 1
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if count > 0
|
|
179
|
+
resampled[ty][tx] = [
|
|
180
|
+
(r_sum / count).clamp(0, 255),
|
|
181
|
+
(g_sum / count).clamp(0, 255),
|
|
182
|
+
(b_sum / count).clamp(0, 255),
|
|
183
|
+
(a_sum / count).clamp(0, 255)
|
|
184
|
+
]
|
|
185
|
+
else
|
|
186
|
+
mid_y = (sy_start + sy_end) / 2
|
|
187
|
+
mid_x = (sx_start + sx_end) / 2
|
|
188
|
+
resampled[ty][tx] = @pixels[mid_y][mid_x] || [0, 0, 0, 0]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
resampled
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Convierte la imagen a lineas de texto ANSI TrueColor.
|
|
196
|
+
# Emplea el caracter de medio bloque superior '▀':
|
|
197
|
+
# El color frontal pinta el pixel superior y el color de fondo pinta el pixel inferior,
|
|
198
|
+
# permitiendo duplicar la resolucion vertical de la terminal en una sola linea de texto.
|
|
199
|
+
def render_ansi_lines(target_w = 40, target_h = nil)
|
|
200
|
+
target_h ||= [((@height.to_f / @width) * target_w).round, 2].max
|
|
201
|
+
target_h += 1 if target_h.odd? # Requiere altura par porque cada fila de texto empaqueta 2 pixeles verticales
|
|
202
|
+
|
|
203
|
+
grid = resample(target_w, target_h)
|
|
204
|
+
lines = []
|
|
205
|
+
|
|
206
|
+
(0...target_h).step(2) do |y|
|
|
207
|
+
row_top = grid[y]
|
|
208
|
+
row_bot = grid[y + 1] || grid[y]
|
|
209
|
+
line = String.new("")
|
|
210
|
+
|
|
211
|
+
target_w.times do |x|
|
|
212
|
+
r1, g1, b1, a1 = row_top[x]
|
|
213
|
+
r2, g2, b2, a2 = row_bot[x]
|
|
214
|
+
|
|
215
|
+
if a1 < 32 && a2 < 32
|
|
216
|
+
line << "\e[0m "
|
|
217
|
+
elsif a1 < 32
|
|
218
|
+
line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
|
|
219
|
+
elsif a2 < 32
|
|
220
|
+
line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
|
|
221
|
+
else
|
|
222
|
+
line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
line << "\e[0m"
|
|
226
|
+
lines << line
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
lines
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Carga una imagen y la renderiza en lineas ANSI TrueColor.
|
|
234
|
+
# Intenta usar ImageMagick (convert o magick) si esta disponible en el sistema operativo
|
|
235
|
+
# para aplicar escalado Lanczos de alta definicion; de lo contrario utiliza PNGDecoder nativo.
|
|
236
|
+
def self.load_and_render_image(filepath, width = 40, height = nil, max_cols = terminal_width)
|
|
237
|
+
return [] unless filepath && File.exist?(filepath)
|
|
238
|
+
|
|
239
|
+
req_w = [width.to_i, max_cols - 6].min
|
|
240
|
+
req_w = [req_w, 10].max
|
|
241
|
+
|
|
242
|
+
conv_bin = `which convert 2>/dev/null`.strip
|
|
243
|
+
conv_bin = `which magick 2>/dev/null`.strip if conv_bin.empty?
|
|
244
|
+
|
|
245
|
+
# Ruta con ImageMagick disponible en el sistema
|
|
246
|
+
if !conv_bin.empty?
|
|
247
|
+
info, _ = Open3.capture2("identify", "-format", "%w %h", filepath) rescue ["", nil]
|
|
248
|
+
orig_w, orig_h = info.strip.split.map(&:to_f)
|
|
249
|
+
aspect = (orig_w && orig_w > 0) ? (orig_h / orig_w) : 0.6
|
|
250
|
+
scale_h = height || (req_w * aspect).round
|
|
251
|
+
scale_h += 1 if scale_h.odd?
|
|
252
|
+
scale_h = [scale_h, 2].max
|
|
253
|
+
|
|
254
|
+
# Convierte la imagen a un flujo plano de bytes RGBA
|
|
255
|
+
cmd = [conv_bin, filepath, "-filter", "Lanczos", "-resize", "#{req_w}x#{scale_h}!", "-depth", "8", "rgba:-"]
|
|
256
|
+
stdout, status = Open3.capture2(*cmd) rescue [nil, nil]
|
|
257
|
+
if status && status.success? && stdout.bytesize == (req_w * scale_h * 4)
|
|
258
|
+
raw = stdout.bytes
|
|
259
|
+
lines = []
|
|
260
|
+
(0...scale_h).step(2) do |y|
|
|
261
|
+
line = String.new("")
|
|
262
|
+
req_w.times do |x|
|
|
263
|
+
top_idx = (y * req_w + x) * 4
|
|
264
|
+
bot_idx = ((y + 1) * req_w + x) * 4
|
|
265
|
+
r1, g1, b1, a1 = raw[top_idx, 4]
|
|
266
|
+
r2, g2, b2, a2 = raw[bot_idx, 4]
|
|
267
|
+
|
|
268
|
+
if a1 < 32 && a2 < 32
|
|
269
|
+
line << "\e[0m "
|
|
270
|
+
elsif a1 < 32
|
|
271
|
+
line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
|
|
272
|
+
elsif a2 < 32
|
|
273
|
+
line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
|
|
274
|
+
else
|
|
275
|
+
line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
line << "\e[0m"
|
|
279
|
+
lines << line
|
|
280
|
+
end
|
|
281
|
+
return lines
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Ruta de fallback: PNGDecoder nativo en Ruby puro
|
|
286
|
+
png = PNGDecoder.load(filepath)
|
|
287
|
+
return png.render_ansi_lines(req_w, height) if png
|
|
288
|
+
|
|
289
|
+
[]
|
|
290
|
+
rescue StandardError
|
|
291
|
+
[]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Dibuja una imagen centrada en la terminal con marco decorativo opcional
|
|
295
|
+
def self.image(filepath, width: 40, height: nil, style: 3, color: "cyan", center: true)
|
|
296
|
+
term_w = terminal_width
|
|
297
|
+
raw_lines = load_and_render_image(filepath, width, height, term_w)
|
|
298
|
+
return nil if raw_lines.empty?
|
|
299
|
+
|
|
300
|
+
img_w = display_width(raw_lines.first)
|
|
301
|
+
box_w = img_w + 4
|
|
302
|
+
margin = (center && term_w > box_w) ? (" " * ((term_w - box_w) / 2)) : ""
|
|
303
|
+
|
|
304
|
+
if style && style > 0
|
|
305
|
+
border_cfg = BORDERS[style] || BORDERS[3]
|
|
306
|
+
is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
|
|
307
|
+
color_code = is_rgb ? "" : ansi_color(color, 2)
|
|
308
|
+
reset_code = ansi_reset
|
|
309
|
+
|
|
310
|
+
h_top = border_cfg[:ht] || border_cfg[:h]
|
|
311
|
+
h_bot = border_cfg[:hb] || border_cfg[:h]
|
|
312
|
+
v_l = border_cfg[:vl] || border_cfg[:v]
|
|
313
|
+
v_r = border_cfg[:vr] || border_cfg[:v]
|
|
314
|
+
|
|
315
|
+
top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
|
|
316
|
+
bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
|
|
317
|
+
|
|
318
|
+
if is_rgb
|
|
319
|
+
Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
|
|
320
|
+
raw_lines.each do |line|
|
|
321
|
+
Kernel.print("#{margin}#{Color.rgb(v_l)} #{line} #{Color.rgb(v_r)}\r\n")
|
|
322
|
+
end
|
|
323
|
+
Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
|
|
324
|
+
else
|
|
325
|
+
Kernel.print("#{margin}#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
|
|
326
|
+
raw_lines.each do |line|
|
|
327
|
+
Kernel.print("#{margin}#{color_code}#{v_l}#{reset_code} #{line} #{color_code}#{v_r}#{reset_code}\r\n")
|
|
328
|
+
end
|
|
329
|
+
Kernel.print("#{margin}#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
|
|
330
|
+
end
|
|
331
|
+
else
|
|
332
|
+
raw_lines.each do |line|
|
|
333
|
+
Kernel.print("#{margin}#{line}\r\n")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
true
|
|
338
|
+
end
|
|
339
|
+
end
|