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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac917512f05261231e65601213ab71f6205cd68818e3da3adb1f867c66759719
4
- data.tar.gz: bbad14e5928bde95d26c6cfcf2143f68f228f51559519b68f1c83eae5dec1ced
3
+ metadata.gz: 3422d29230acde2c0e9da429cf3ddf83a355706183039778574e3e3d9b5875b9
4
+ data.tar.gz: '059ab8b520b2d61ca15a0ba129b211ea4c44a52cb4dd826e5401172bb8ed9c1c'
5
5
  SHA512:
6
- metadata.gz: 11827373b3850a674cf837353c4a60c8927abe4da423e0dfff1c2b3ee39b1c733f211f209151446140b3a257a5033d9bfe50e69b9daa8eeb211db34a4d6496e1
7
- data.tar.gz: a887bd14dbb30d02e9fd0801b2acbe56acb543dcf25e216a4cb7f23e580ae69dabc6d5d15e420b708c107d7b28ec00683a6efeeaef9a266cd57f005f8a317f24
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
- - JPEG, WebP, GIF 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")`.
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
- # Appends to the output buffer; does not flush.
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
- # Chunk into 4096-byte pieces (safe for all Kitty-compatible terminals)
268
- chunks = encoded.scan(/.{1,4096}/m)
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
- # Source rectangle for cropping/scrolling (pixel coordinates within image)
280
- if placement[:src_x] && placement[:src_y]
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
- params << ",c=#{placement[:cols]},r=#{placement[:rows]},m=#{more}"
286
- @buffer << "\e_G#{params};#{chunk}\e\\"
287
- else
288
- @buffer << "\e_Gm=#{more};#{chunk}\e\\"
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
 
@@ -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
 
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module RubyTUI
4
4
  # Gem version string.
5
- VERSION = "1.2.4"
5
+ VERSION = "1.2.5"
6
6
  end
@@ -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
- # and raw RGBA pixel data. Works on terminals that support the Kitty graphics
11
- # protocol: Kitty, Ghostty, WezTerm. Note: iTerm2 uses a different protocol.
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, GIF, and BMP are NOT supported — convert to PNG first:
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 (:png or :rgba)
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 +:rgba+. Auto-detected when +:png+ (default).
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 +:rgba+ (e.g. +:tiff+, a String
95
- # or +nil+), if PNG data is truncated, or if +pixel_width+/+pixel_height+ are
96
- # missing for +:rgba+ data (including data whose format is not recognized)
97
- # @raise [UnsupportedImageFormat] if the image is JPEG, WebP, GIF or BMP (detected,
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 :rgba"
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
@@ -10,6 +10,7 @@ require_relative "rubytui/rect"
10
10
  require_relative "rubytui/style"
11
11
  require_relative "rubytui/cell"
12
12
  require_relative "rubytui/unicode"
13
+ require_relative "rubytui/gif"
13
14
  require_relative "rubytui/buffer"
14
15
  require_relative "rubytui/symbols"
15
16
 
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
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