rubytui 1.2.4 → 1.2.5
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 +6 -2
- data/lib/rubytui/backends/ansi_backend.rb +51 -23
- data/lib/rubytui/buffer.rb +10 -1
- data/lib/rubytui/gif.rb +290 -0
- data/lib/rubytui/version.rb +1 -1
- data/lib/rubytui/widgets/image.rb +44 -21
- data/lib/rubytui.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3422d29230acde2c0e9da429cf3ddf83a355706183039778574e3e3d9b5875b9
|
|
4
|
+
data.tar.gz: '059ab8b520b2d61ca15a0ba129b211ea4c44a52cb4dd826e5401172bb8ed9c1c'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 388a89d892b0f28d9ebbd9e0dd57f9dea31a65bca3d1d9da2f7af2c446fd9ddaddf05cc1d6cc5e3364860dd80225137573272230bf9dbed727c1edb075f750ae
|
|
7
|
+
data.tar.gz: 3c74612dc4bd6540af8b37cfdbec084ce5b6c292c9e2a64f9256b41ced9bca5855685930840adf199525a45de2b753a8b9bd099cf6ba0b26528db239de3c59e9
|
data/README.md
CHANGED
|
@@ -411,7 +411,8 @@ puts buf.to_ansi
|
|
|
411
411
|
|
|
412
412
|
- PNG is sent to the terminal as is; its dimensions are read from the file header.
|
|
413
413
|
- Raw 8-bit RGBA pixel data requires `format: :rgba` together with `pixel_width:` and `pixel_height:`.
|
|
414
|
-
-
|
|
414
|
+
- GIF is decoded by RubyTUI (no external tools). An animated GIF plays in the terminal using the protocol's animation support, with its own frame timing and loop count; `animate: false` shows only the first frame. A terminal without animation support is expected to show the first frame. Decoding is CPU-bound (roughly 30 ms per 480×270 frame), so load large GIFs with `AsyncImage`.
|
|
415
|
+
- JPEG, WebP and BMP data is detected and raises `RubyTUI::UnsupportedImageFormat`. Convert such images to PNG first, for example with ImageMagick: `system("magick", "input.jpg", "output.png")`.
|
|
415
416
|
- Any other `format:` value raises `ArgumentError`.
|
|
416
417
|
|
|
417
418
|
```ruby
|
|
@@ -419,6 +420,9 @@ puts buf.to_ansi
|
|
|
419
420
|
image = RubyTUI::Widgets::Image.new(path: "photo.png")
|
|
420
421
|
frame.render_widget(image, area)
|
|
421
422
|
|
|
423
|
+
# An animated GIF (plays in the terminal; animate: false shows the first frame)
|
|
424
|
+
image = RubyTUI::Widgets::Image.new(path: "spinner.gif")
|
|
425
|
+
|
|
422
426
|
# With a border and title
|
|
423
427
|
image = RubyTUI::Widgets::Image.new(
|
|
424
428
|
path: "photo.png",
|
|
@@ -467,7 +471,7 @@ Images are tracked by position and content. When an image disappears, moves or c
|
|
|
467
471
|
|
|
468
472
|
### AsyncImage
|
|
469
473
|
|
|
470
|
-
`RubyTUI::Widgets::AsyncImage` runs a loader on a background thread and renders the image once the loader returns PNG data. Create it once and keep it across frames.
|
|
474
|
+
`RubyTUI::Widgets::AsyncImage` runs a loader on a background thread and renders the image once the loader returns PNG or GIF data. Create it once and keep it across frames.
|
|
471
475
|
|
|
472
476
|
```ruby
|
|
473
477
|
image = RubyTUI::Widgets::AsyncImage.new(
|
|
@@ -249,7 +249,16 @@ module RubyTUI
|
|
|
249
249
|
# Transmits the image data as chunked base64 in APC escape sequences.
|
|
250
250
|
# The terminal decodes and renders the image at the cursor position.
|
|
251
251
|
# Each image gets a unique ID for precise deletion.
|
|
252
|
-
#
|
|
252
|
+
#
|
|
253
|
+
# An animated placement (with an +:animation+ entry, see {Buffer#set_image})
|
|
254
|
+
# is sent as the root frame followed by one <tt>a=f</tt> transmission per extra
|
|
255
|
+
# frame, the root frame's gap (<tt>a=a,r=1,z=</tt>), and a play command: with
|
|
256
|
+
# no loop count it runs once and stops on the last frame (<tt>s=2</tt>); a loop
|
|
257
|
+
# count of 0 loops forever (<tt>s=3,v=1</tt>); a loop count of +n+ repeats it
|
|
258
|
+
# +n+ more times (<tt>s=3,v=n+1</tt>). With +:compressed+, the pixel data
|
|
259
|
+
# (and every frame) is already zlib-deflated and is sent with <tt>o=z</tt>.
|
|
260
|
+
# Terminals without animation support show the root frame. Appends to the
|
|
261
|
+
# output buffer; does not flush.
|
|
253
262
|
#
|
|
254
263
|
# @param placement [Hash{Symbol => Object}] image placement from {Buffer#image_placements}
|
|
255
264
|
# @return [void]
|
|
@@ -261,33 +270,39 @@ module RubyTUI
|
|
|
261
270
|
# Position cursor at image origin
|
|
262
271
|
@buffer << "#{CSI}#{placement[:y] + 1};#{placement[:x] + 1}H"
|
|
263
272
|
|
|
264
|
-
encoded = [placement[:data]].pack("m0") # strict base64, no newlines
|
|
265
273
|
format_code = placement[:format] == :png ? 100 : 32
|
|
274
|
+
compressed = placement[:compressed]
|
|
275
|
+
|
|
276
|
+
# C=1: do not move the cursor after displaying — keeps the
|
|
277
|
+
# terminal's cursor position in sync with the backend's model
|
|
278
|
+
params = +"a=T,f=#{format_code},i=#{image_id},C=1"
|
|
279
|
+
params << ",s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}" if format_code == 32
|
|
280
|
+
params << ",o=z" if compressed
|
|
281
|
+
|
|
282
|
+
# Source rectangle for cropping/scrolling (pixel coordinates within image)
|
|
283
|
+
if placement[:src_x] && placement[:src_y]
|
|
284
|
+
params << ",x=#{placement[:src_x]},y=#{placement[:src_y]}"
|
|
285
|
+
params << ",w=#{placement[:src_w]},h=#{placement[:src_h]}" if placement[:src_w] && placement[:src_h]
|
|
286
|
+
end
|
|
266
287
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
chunks.each_with_index do |chunk, i|
|
|
271
|
-
more = i < chunks.length - 1 ? 1 : 0
|
|
272
|
-
|
|
273
|
-
if i == 0
|
|
274
|
-
# C=1: do not move the cursor after displaying — keeps the
|
|
275
|
-
# terminal's cursor position in sync with the backend's model
|
|
276
|
-
params = +"a=T,f=#{format_code},i=#{image_id},C=1"
|
|
277
|
-
params << ",s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}" if format_code == 32
|
|
288
|
+
params << ",c=#{placement[:cols]},r=#{placement[:rows]}"
|
|
289
|
+
send_chunked(params, "", placement[:data])
|
|
278
290
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
params << ",x=#{placement[:src_x]},y=#{placement[:src_y]}"
|
|
282
|
-
params << ",w=#{placement[:src_w]},h=#{placement[:src_h]}" if placement[:src_w] && placement[:src_h]
|
|
283
|
-
end
|
|
291
|
+
anim = placement[:animation]
|
|
292
|
+
return unless anim
|
|
284
293
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
end
|
|
294
|
+
size = +"f=32,s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}"
|
|
295
|
+
size << ",o=z" if compressed
|
|
296
|
+
anim[:frames].each_with_index do |frame, i|
|
|
297
|
+
send_chunked("a=f,i=#{image_id},#{size},z=#{anim[:delays][i + 1]}", "a=f,", frame)
|
|
290
298
|
end
|
|
299
|
+
@buffer << "\e_Ga=a,i=#{image_id},r=1,z=#{anim[:delays][0]}\e\\"
|
|
300
|
+
play = case anim[:loops]
|
|
301
|
+
when nil then "s=2"
|
|
302
|
+
when 0 then "s=3,v=1"
|
|
303
|
+
else "s=3,v=#{anim[:loops] + 1}"
|
|
304
|
+
end
|
|
305
|
+
@buffer << "\e_Ga=a,i=#{image_id},#{play}\e\\"
|
|
291
306
|
end
|
|
292
307
|
|
|
293
308
|
# Delete a Kitty graphics protocol image by ID.
|
|
@@ -339,6 +354,19 @@ module RubyTUI
|
|
|
339
354
|
|
|
340
355
|
private
|
|
341
356
|
|
|
357
|
+
# Base64-encode +payload+ and emit it as Kitty graphics APC sequences in
|
|
358
|
+
# 4096-byte chunks (safe for all Kitty-compatible terminals). The first
|
|
359
|
+
# chunk carries +first+; later chunks carry +cont+ (empty, or <tt>a=f,</tt>
|
|
360
|
+
# for animation frames, which the protocol requires on every chunk).
|
|
361
|
+
def send_chunked(first, cont, payload)
|
|
362
|
+
chunks = [payload].pack("m0").scan(/.{1,4096}/m) # strict base64, no newlines
|
|
363
|
+
chunks.each_with_index do |chunk, i|
|
|
364
|
+
more = i < chunks.length - 1 ? 1 : 0
|
|
365
|
+
params = i.zero? ? "#{first},m=#{more}" : "#{cont}m=#{more}"
|
|
366
|
+
@buffer << "\e_G#{params};#{chunk}\e\\"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
342
370
|
def apply_style(style)
|
|
343
371
|
return if style == @prev_style
|
|
344
372
|
|
data/lib/rubytui/buffer.rb
CHANGED
|
@@ -203,9 +203,15 @@ module RubyTUI
|
|
|
203
203
|
# @param src_y [Integer, nil] source rectangle Y offset in pixels
|
|
204
204
|
# @param src_w [Integer, nil] source rectangle width in pixels
|
|
205
205
|
# @param src_h [Integer, nil] source rectangle height in pixels
|
|
206
|
+
# @param animation [Hash, nil] extra frames for an animated image:
|
|
207
|
+
# <tt>{frames: [rgba, ...], delays: [ms, ...], loops: Integer or nil}</tt>,
|
|
208
|
+
# where +data+ is the first frame and +delays+ covers every frame
|
|
209
|
+
# @param compressed [Boolean] +data+ and the animation frames are already
|
|
210
|
+
# zlib-deflated and are sent with the protocol's <tt>o=z</tt> (default: false)
|
|
206
211
|
# @return [void]
|
|
207
212
|
def set_image(x:, y:, cols:, rows:, data:, pixel_width:, pixel_height:, format:,
|
|
208
|
-
content_hash: nil, src_x: nil, src_y: nil, src_w: nil, src_h: nil
|
|
213
|
+
content_hash: nil, src_x: nil, src_y: nil, src_w: nil, src_h: nil,
|
|
214
|
+
animation: nil, compressed: false)
|
|
209
215
|
# Key by position + content hash to detect when image changes at same position
|
|
210
216
|
key = content_hash ? [x, y, content_hash] : [x, y]
|
|
211
217
|
|
|
@@ -223,6 +229,9 @@ module RubyTUI
|
|
|
223
229
|
placement[:src_h] = src_h
|
|
224
230
|
end
|
|
225
231
|
|
|
232
|
+
placement[:animation] = animation if animation
|
|
233
|
+
placement[:compressed] = true if compressed
|
|
234
|
+
|
|
226
235
|
@image_placements[key] = placement
|
|
227
236
|
end
|
|
228
237
|
|
data/lib/rubytui/gif.rb
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# Pure-Ruby GIF decoder (GIF87a / GIF89a).
|
|
5
|
+
#
|
|
6
|
+
# Decodes every frame and composites it onto the logical screen, following
|
|
7
|
+
# each frame's disposal method, so every returned frame is a complete image.
|
|
8
|
+
# The canvas starts fully transparent (the logical-screen background colour
|
|
9
|
+
# is ignored), "restore to background" clears to transparent, and "restore to
|
|
10
|
+
# previous" restores the canvas as it was before the frame was drawn.
|
|
11
|
+
# Used by {Widgets::Image} to display GIFs through the Kitty graphics
|
|
12
|
+
# protocol, which only accepts RGB, RGBA or PNG data.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# anim = RubyTUI::GIF.decode(File.binread("spinner.gif"))
|
|
16
|
+
# anim.width # => 32
|
|
17
|
+
# anim.frames.size # => 12
|
|
18
|
+
# anim.delays # => [100, 100, ...] (milliseconds)
|
|
19
|
+
# anim.loops # => 0 (loop forever)
|
|
20
|
+
module GIF
|
|
21
|
+
# Decoded GIF.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] width
|
|
24
|
+
# @return [Integer] logical screen width in pixels
|
|
25
|
+
# @!attribute [r] height
|
|
26
|
+
# @return [Integer] logical screen height in pixels
|
|
27
|
+
# @!attribute [r] frames
|
|
28
|
+
# @return [Array<String>] one RGBA string (4 bytes per pixel) per frame
|
|
29
|
+
# @!attribute [r] delays
|
|
30
|
+
# @return [Array<Integer>] display time of each frame in milliseconds
|
|
31
|
+
# @!attribute [r] loops
|
|
32
|
+
# @return [Integer, nil] NETSCAPE2.0 loop count: +0+ loops forever, +n+
|
|
33
|
+
# repeats the animation +n+ more times, +nil+ (no loop extension) plays once
|
|
34
|
+
Animation = Struct.new(:width, :height, :frames, :delays, :loops)
|
|
35
|
+
|
|
36
|
+
# Largest accepted logical screen width or height, in pixels.
|
|
37
|
+
MAX_DIMENSION = 4096
|
|
38
|
+
|
|
39
|
+
# Largest accepted total decoded size (all frames, RGBA), in bytes.
|
|
40
|
+
MAX_DECODED_BYTES = 256 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
# Delay used for frames that declare less than 20 ms (the common browser convention).
|
|
43
|
+
MIN_DELAY_FALLBACK_MS = 100
|
|
44
|
+
|
|
45
|
+
# Rows of an interlaced image, in the order they are stored: every 8th row
|
|
46
|
+
# from 0, every 8th from 4, every 4th from 2, every 2nd from 1.
|
|
47
|
+
INTERLACE_PASSES = [[0, 8], [4, 8], [2, 4], [1, 2]].freeze
|
|
48
|
+
private_constant :INTERLACE_PASSES
|
|
49
|
+
|
|
50
|
+
# Fully transparent RGBA pixel.
|
|
51
|
+
TRANSPARENT = "\x00\x00\x00\x00".b.freeze
|
|
52
|
+
private_constant :TRANSPARENT
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Decode GIF data.
|
|
56
|
+
#
|
|
57
|
+
# @param data [String] raw GIF bytes
|
|
58
|
+
# @return [Animation]
|
|
59
|
+
# @raise [ArgumentError] if the data is not a GIF, is truncated or corrupt,
|
|
60
|
+
# or exceeds {MAX_DIMENSION} / {MAX_DECODED_BYTES}
|
|
61
|
+
def decode(data)
|
|
62
|
+
Decoder.new(data.b).decode
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stateful single-use decoder behind {GIF.decode}.
|
|
67
|
+
# @api private
|
|
68
|
+
class Decoder
|
|
69
|
+
# @param data [String] raw GIF bytes (binary)
|
|
70
|
+
def initialize(data)
|
|
71
|
+
@data = data
|
|
72
|
+
@pos = 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Animation]
|
|
76
|
+
# @raise [ArgumentError] on invalid or oversized data
|
|
77
|
+
def decode
|
|
78
|
+
signature = read(6)
|
|
79
|
+
raise ArgumentError, "Invalid GIF data: bad signature" unless %w[GIF87a GIF89a].include?(signature)
|
|
80
|
+
|
|
81
|
+
@width, @height, packed, _bg, _aspect = read(7).unpack("vvCCC")
|
|
82
|
+
if @width.zero? || @height.zero? || @width > MAX_DIMENSION || @height > MAX_DIMENSION
|
|
83
|
+
raise ArgumentError, "Invalid GIF data: unsupported size #{@width}x#{@height} " \
|
|
84
|
+
"(1..#{MAX_DIMENSION} pixels per side)"
|
|
85
|
+
end
|
|
86
|
+
@global_palette = packed & 0x80 != 0 ? read_palette(packed & 0x07) : nil
|
|
87
|
+
|
|
88
|
+
@canvas = TRANSPARENT * (@width * @height)
|
|
89
|
+
@frames = []
|
|
90
|
+
@delays = []
|
|
91
|
+
@loops = nil
|
|
92
|
+
@gce = nil
|
|
93
|
+
|
|
94
|
+
loop do
|
|
95
|
+
case read_byte
|
|
96
|
+
when 0x21 then read_extension
|
|
97
|
+
when 0x2C then read_image
|
|
98
|
+
when 0x3B then break
|
|
99
|
+
else raise ArgumentError, "Invalid GIF data: unexpected block at byte #{@pos - 1}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "Invalid GIF data: no image frames" if @frames.empty?
|
|
104
|
+
|
|
105
|
+
Animation.new(@width, @height, @frames, @delays, @loops)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def read(n)
|
|
111
|
+
bytes = @data.byteslice(@pos, n)
|
|
112
|
+
raise ArgumentError, "Invalid GIF data: truncated" if bytes.nil? || bytes.bytesize < n
|
|
113
|
+
|
|
114
|
+
@pos += n
|
|
115
|
+
bytes
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_byte
|
|
119
|
+
read(1).getbyte(0)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Concatenated data sub-blocks up to the block terminator.
|
|
123
|
+
def read_sub_blocks
|
|
124
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
125
|
+
while (size = read_byte) != 0
|
|
126
|
+
out << read(size)
|
|
127
|
+
end
|
|
128
|
+
out
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Palette of 2**(bits + 1) entries, each a 4-byte opaque RGBA string.
|
|
132
|
+
def read_palette(bits)
|
|
133
|
+
read(3 * (2 << bits)).unpack("C*").each_slice(3).map { |r, g, b| [r, g, b, 255].pack("C4") }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def read_extension
|
|
137
|
+
label = read_byte
|
|
138
|
+
body = read_sub_blocks
|
|
139
|
+
case label
|
|
140
|
+
when 0xF9 # Graphic Control Extension
|
|
141
|
+
packed, delay, transparent = body.unpack("CvC")
|
|
142
|
+
@gce = {
|
|
143
|
+
disposal: (packed >> 2) & 0x07,
|
|
144
|
+
delay: delay,
|
|
145
|
+
transparent: packed & 0x01 != 0 ? transparent : nil
|
|
146
|
+
}
|
|
147
|
+
when 0xFF # Application Extension: NETSCAPE2.0 loop count
|
|
148
|
+
if body.start_with?("NETSCAPE2.0", "ANIMEXTS1.0") && body.getbyte(11) == 1
|
|
149
|
+
@loops = body.byteslice(12, 2).unpack1("v")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read_image
|
|
155
|
+
left, top, w, h, packed = read(9).unpack("vvvvC")
|
|
156
|
+
palette = packed & 0x80 != 0 ? read_palette(packed & 0x07) : @global_palette
|
|
157
|
+
raise ArgumentError, "Invalid GIF data: frame has no colour table" unless palette
|
|
158
|
+
|
|
159
|
+
interlaced = packed & 0x40 != 0
|
|
160
|
+
min_code_size = read_byte
|
|
161
|
+
raise ArgumentError, "Invalid GIF data: bad LZW code size #{min_code_size}" unless (1..8).cover?(min_code_size)
|
|
162
|
+
|
|
163
|
+
indices = lzw_decode(read_sub_blocks, min_code_size, w * h)
|
|
164
|
+
indices = deinterlace(indices, w, h) if interlaced
|
|
165
|
+
|
|
166
|
+
if (@frames.size + 1) * @canvas.bytesize > MAX_DECODED_BYTES
|
|
167
|
+
raise ArgumentError, "GIF too large: decoded frames exceed #{MAX_DECODED_BYTES / (1024 * 1024)} MB"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
gce = @gce || {}
|
|
171
|
+
@gce = nil
|
|
172
|
+
disposal = gce[:disposal] || 0
|
|
173
|
+
saved = @canvas.dup if disposal == 3
|
|
174
|
+
|
|
175
|
+
draw(indices, palette, gce[:transparent], left, top, w, h)
|
|
176
|
+
@frames << @canvas.dup
|
|
177
|
+
delay_ms = (gce[:delay] || 0) * 10
|
|
178
|
+
@delays << (delay_ms < 20 ? MIN_DELAY_FALLBACK_MS : delay_ms)
|
|
179
|
+
|
|
180
|
+
case disposal
|
|
181
|
+
when 2 then clear_rect(left, top, w, h)
|
|
182
|
+
when 3 then @canvas = saved
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# LZW decompression (variable code size, max 12 bits) into palette indices.
|
|
187
|
+
def lzw_decode(data, min_code_size, pixel_count)
|
|
188
|
+
clear = 1 << min_code_size
|
|
189
|
+
eoi = clear + 1
|
|
190
|
+
base = Array.new(clear) { |i| i.chr(Encoding::BINARY) } + [nil, nil]
|
|
191
|
+
dict = base.dup
|
|
192
|
+
code_size = min_code_size + 1
|
|
193
|
+
out = String.new(capacity: pixel_count, encoding: Encoding::BINARY)
|
|
194
|
+
prev = nil
|
|
195
|
+
bits = 0
|
|
196
|
+
nbits = 0
|
|
197
|
+
|
|
198
|
+
data.each_byte do |byte|
|
|
199
|
+
bits |= byte << nbits
|
|
200
|
+
nbits += 8
|
|
201
|
+
while nbits >= code_size
|
|
202
|
+
code = bits & ((1 << code_size) - 1)
|
|
203
|
+
bits >>= code_size
|
|
204
|
+
nbits -= code_size
|
|
205
|
+
|
|
206
|
+
if code == clear
|
|
207
|
+
dict = base.dup
|
|
208
|
+
code_size = min_code_size + 1
|
|
209
|
+
prev = nil
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
return out if code == eoi
|
|
213
|
+
|
|
214
|
+
if prev.nil?
|
|
215
|
+
entry = dict[code]
|
|
216
|
+
raise ArgumentError, "Invalid GIF data: bad LZW code" unless entry
|
|
217
|
+
elsif code < dict.size
|
|
218
|
+
entry = dict[code]
|
|
219
|
+
dict << prev + entry[0] if dict.size < 4096
|
|
220
|
+
elsif code == dict.size
|
|
221
|
+
entry = prev + prev[0]
|
|
222
|
+
dict << entry if dict.size < 4096
|
|
223
|
+
else
|
|
224
|
+
raise ArgumentError, "Invalid GIF data: bad LZW code"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
out << entry
|
|
228
|
+
return out if out.bytesize >= pixel_count
|
|
229
|
+
|
|
230
|
+
prev = entry
|
|
231
|
+
code_size += 1 if dict.size == (1 << code_size) && code_size < 12
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
out
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Reorder interlaced rows into top-to-bottom order.
|
|
238
|
+
def deinterlace(indices, w, h)
|
|
239
|
+
rows = Array.new(h)
|
|
240
|
+
n = 0
|
|
241
|
+
INTERLACE_PASSES.each do |start, step|
|
|
242
|
+
start.step(h - 1, step) do |y|
|
|
243
|
+
rows[y] = indices.byteslice(n * w, w)
|
|
244
|
+
n += 1
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
rows.join
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Composite one frame's pixels onto the canvas, clipped to the screen.
|
|
251
|
+
def draw(indices, palette, transparent, left, top, w, h)
|
|
252
|
+
x_end = [left + w, @width].min
|
|
253
|
+
return if left >= x_end
|
|
254
|
+
|
|
255
|
+
visible = x_end - left
|
|
256
|
+
h.times do |row|
|
|
257
|
+
y = top + row
|
|
258
|
+
break if y >= @height
|
|
259
|
+
|
|
260
|
+
src = indices.byteslice(row * w, visible) || ""
|
|
261
|
+
offset = (y * @width + left) * 4
|
|
262
|
+
if transparent.nil? || !src.include?(transparent.chr(Encoding::BINARY))
|
|
263
|
+
pixels = src.unpack("C*").map! { |i| palette[i] || TRANSPARENT }.join
|
|
264
|
+
@canvas[offset, pixels.bytesize] = pixels
|
|
265
|
+
else
|
|
266
|
+
src.each_byte.with_index do |i, x|
|
|
267
|
+
next if i == transparent
|
|
268
|
+
|
|
269
|
+
@canvas[offset + x * 4, 4] = palette[i] || TRANSPARENT
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Disposal method 2: restore the frame's area to transparent.
|
|
276
|
+
def clear_rect(left, top, w, h)
|
|
277
|
+
x_end = [left + w, @width].min
|
|
278
|
+
return if left >= x_end
|
|
279
|
+
|
|
280
|
+
blank = TRANSPARENT * (x_end - left)
|
|
281
|
+
h.times do |row|
|
|
282
|
+
y = top + row
|
|
283
|
+
break if y >= @height
|
|
284
|
+
|
|
285
|
+
@canvas[(y * @width + left) * 4, blank.bytesize] = blank
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
data/lib/rubytui/version.rb
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require "zlib"
|
|
4
5
|
|
|
5
6
|
module RubyTUI
|
|
6
7
|
module Widgets
|
|
7
8
|
# Renders an image in the terminal using the Kitty graphics protocol.
|
|
8
9
|
#
|
|
9
|
-
# Supports PNG files natively (sent directly to terminal, no decoding needed)
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# Supports PNG files natively (sent directly to terminal, no decoding needed),
|
|
11
|
+
# raw RGBA pixel data, and GIF (decoded by {GIF}; animated GIFs play in the
|
|
12
|
+
# terminal). Works on terminals that implement the Kitty graphics protocol.
|
|
12
13
|
#
|
|
13
|
-
# JPEG, WebP
|
|
14
|
+
# JPEG, WebP and BMP are NOT supported — convert to PNG first:
|
|
14
15
|
# system("magick", "input.jpg", "-resize", "800x600>", "output.png")
|
|
15
16
|
#
|
|
17
|
+
# @example Animated GIF (plays in the terminal; animate: false shows the first frame)
|
|
18
|
+
# image = RubyTUI::Widgets::Image.new(path: "spinner.gif")
|
|
19
|
+
# frame.render_widget(image, area)
|
|
20
|
+
#
|
|
16
21
|
# @example From a PNG file
|
|
17
22
|
# image = RubyTUI::Widgets::Image.new(path: "photo.png")
|
|
18
23
|
# frame.render_widget(image, area)
|
|
@@ -74,15 +79,17 @@ module RubyTUI
|
|
|
74
79
|
# @return [Integer] image height in pixels
|
|
75
80
|
attr_reader :pixel_height
|
|
76
81
|
|
|
77
|
-
# @return [Symbol] image format (
|
|
82
|
+
# @return [Symbol] image format (+:png+, +:rgba+ or +:gif+)
|
|
78
83
|
attr_reader :format
|
|
79
84
|
|
|
80
|
-
# @param path [String, nil] path to a PNG image file
|
|
81
|
-
# @param data [String, nil] raw image bytes (PNG or RGBA)
|
|
82
|
-
# @param pixel_width [Integer, nil] image width in pixels (required for :rgba, auto-detected for :png)
|
|
83
|
-
# @param pixel_height [Integer, nil] image height in pixels (required for :rgba, auto-detected for :png)
|
|
84
|
-
# @param format [Symbol] image format — +:png+ or +:
|
|
85
|
+
# @param path [String, nil] path to a PNG or GIF image file
|
|
86
|
+
# @param data [String, nil] raw image bytes (PNG, GIF or RGBA)
|
|
87
|
+
# @param pixel_width [Integer, nil] image width in pixels (required for :rgba, auto-detected for :png and :gif)
|
|
88
|
+
# @param pixel_height [Integer, nil] image height in pixels (required for :rgba, auto-detected for :png and :gif)
|
|
89
|
+
# @param format [Symbol] image format — +:png+, +:rgba+ or +:gif+. Auto-detected when +:png+ (default).
|
|
85
90
|
# Ignored when +path+ is given (the format is always detected).
|
|
91
|
+
# @param animate [Boolean] play animated GIFs in the terminal; +false+ shows
|
|
92
|
+
# the first frame only (default: true). Ignored for other formats.
|
|
86
93
|
# @param style [Style] background style for the image area (default: {Style::DEFAULT})
|
|
87
94
|
# @param block [Widgets::Block, nil] optional wrapping block for borders/title
|
|
88
95
|
# @param fit [Symbol, nil] aspect-ratio mode — +:stretch+ (default), +:contain+, +:cover+
|
|
@@ -91,15 +98,16 @@ module RubyTUI
|
|
|
91
98
|
# @param cell_size [Array(Integer, Integer), nil] cell pixel dimensions [width, height] for aspect calculations
|
|
92
99
|
# (default: nil, uses <tt>[9, 18]</tt>)
|
|
93
100
|
# @raise [ArgumentError] if neither +path+ nor +data+ is given, if +data+ is
|
|
94
|
-
# given with a +format+ other than +:png+ or +:
|
|
95
|
-
# or +nil+), if PNG data is truncated
|
|
96
|
-
# missing for +:rgba+
|
|
97
|
-
#
|
|
101
|
+
# given with a +format+ other than +:png+, +:rgba+ or +:gif+ (e.g. +:tiff+, a
|
|
102
|
+
# String or +nil+), if PNG or GIF data is truncated or corrupt (see
|
|
103
|
+
# {GIF.decode}), or if +pixel_width+/+pixel_height+ are missing for +:rgba+
|
|
104
|
+
# data (including data whose format is not recognized)
|
|
105
|
+
# @raise [UnsupportedImageFormat] if the image is JPEG, WebP or BMP (detected,
|
|
98
106
|
# or given as +format+ with +data+)
|
|
99
107
|
# @raise [SystemCallError] if +path+ cannot be read
|
|
100
108
|
def initialize(path: nil, data: nil, pixel_width: nil, pixel_height: nil,
|
|
101
109
|
format: :png, style: Style::DEFAULT, block: nil,
|
|
102
|
-
fit: nil, src_rect: nil, clip: true, cell_size: nil)
|
|
110
|
+
fit: nil, src_rect: nil, clip: true, cell_size: nil, animate: true)
|
|
103
111
|
@style = style
|
|
104
112
|
@block = block
|
|
105
113
|
@fit = fit
|
|
@@ -125,6 +133,16 @@ module RubyTUI
|
|
|
125
133
|
|
|
126
134
|
@pixel_width = pixel_width || dims[0]
|
|
127
135
|
@pixel_height = pixel_height || dims[1]
|
|
136
|
+
elsif @format == :gif
|
|
137
|
+
# Decode and compress once; every (re)transmission reuses the result
|
|
138
|
+
gif = GIF.decode(@image_data)
|
|
139
|
+
@pixel_width = gif.width
|
|
140
|
+
@pixel_height = gif.height
|
|
141
|
+
frames = (animate ? gif.frames : gif.frames.first(1)).map { |f| Zlib::Deflate.deflate(f) }
|
|
142
|
+
@gif_root = frames.first
|
|
143
|
+
if frames.size > 1
|
|
144
|
+
@gif_animation = { frames: frames.drop(1), delays: gif.delays, loops: gif.loops }.freeze
|
|
145
|
+
end
|
|
128
146
|
else
|
|
129
147
|
raise ArgumentError, "pixel_width and pixel_height required for :rgba format" unless pixel_width && pixel_height
|
|
130
148
|
|
|
@@ -187,6 +205,15 @@ module RubyTUI
|
|
|
187
205
|
content_hash: @content_hash
|
|
188
206
|
}
|
|
189
207
|
|
|
208
|
+
# GIF: send the decoded first frame as compressed RGBA, plus the remaining
|
|
209
|
+
# frames when animating
|
|
210
|
+
if @gif_root
|
|
211
|
+
placement[:data] = @gif_root
|
|
212
|
+
placement[:format] = :rgba
|
|
213
|
+
placement[:compressed] = true
|
|
214
|
+
placement[:animation] = @gif_animation if @gif_animation
|
|
215
|
+
end
|
|
216
|
+
|
|
190
217
|
# Add source rectangle if specified (for cropping/scrolling)
|
|
191
218
|
if @src_rect
|
|
192
219
|
placement[:src_x] = @src_rect.x
|
|
@@ -290,7 +317,7 @@ module RubyTUI
|
|
|
290
317
|
|
|
291
318
|
def validate_format!(fmt)
|
|
292
319
|
case fmt
|
|
293
|
-
when :png, :rgba
|
|
320
|
+
when :png, :rgba, :gif
|
|
294
321
|
# OK
|
|
295
322
|
when :jpeg
|
|
296
323
|
raise UnsupportedImageFormat,
|
|
@@ -300,16 +327,12 @@ module RubyTUI
|
|
|
300
327
|
raise UnsupportedImageFormat,
|
|
301
328
|
"WebP format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
|
|
302
329
|
" system(\"magick\", \"input.webp\", \"output.png\")"
|
|
303
|
-
when :gif
|
|
304
|
-
raise UnsupportedImageFormat,
|
|
305
|
-
"GIF format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
|
|
306
|
-
" system(\"magick\", \"input.gif[0]\", \"output.png\") # [0] = first frame"
|
|
307
330
|
when :bmp
|
|
308
331
|
raise UnsupportedImageFormat,
|
|
309
332
|
"BMP format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
|
|
310
333
|
" system(\"magick\", \"input.bmp\", \"output.png\")"
|
|
311
334
|
else
|
|
312
|
-
raise ArgumentError, "Unknown image format #{fmt.inspect}; expected :png or :
|
|
335
|
+
raise ArgumentError, "Unknown image format #{fmt.inspect}; expected :png, :rgba or :gif"
|
|
313
336
|
end
|
|
314
337
|
end
|
|
315
338
|
|
data/lib/rubytui.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubytui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CoCL
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- lib/rubytui/errors.rb
|
|
35
35
|
- lib/rubytui/event.rb
|
|
36
36
|
- lib/rubytui/frame.rb
|
|
37
|
+
- lib/rubytui/gif.rb
|
|
37
38
|
- lib/rubytui/input/key.rb
|
|
38
39
|
- lib/rubytui/input/parser.rb
|
|
39
40
|
- lib/rubytui/input/reader.rb
|