Class: RubyTUI::Widgets::Image

Inherits:
Object
  • Object
show all
Includes:
RubyTUI::Widget
Defined in:
lib/rubytui/widgets/image.rb

Overview

Renders an image in the terminal using the Kitty graphics protocol.

Supports PNG files natively (sent directly to terminal, no decoding needed) and raw RGBA pixel data. Works on terminals that support the Kitty graphics protocol: Kitty, Ghostty, WezTerm. Note: iTerm2 uses a different protocol.

JPEG, WebP, GIF, and BMP are NOT supported — convert to PNG first:

system("magick", "input.jpg", "-resize", "800x600>", "output.png")

Examples:

From a PNG file

image = RubyTUI::Widgets::Image.new(path: "photo.png")
frame.render_widget(image, area)

From a PNG file with border

block = RubyTUI::Widgets::Block.new(title: " Photo ", borders: :all)
image = RubyTUI::Widgets::Image.new(path: "photo.png", block: block)
frame.render_widget(image, area)

From raw RGBA pixel data

image = RubyTUI::Widgets::Image.new(
  data: rgba_bytes,
  pixel_width: 800,
  pixel_height: 600,
  format: :rgba
)

Aspect-ratio-preserving fit

image = RubyTUI::Widgets::Image.new(path: "photo.png", fit: :contain)
frame.render_widget(image, area)  # Image computes sub-rect

Source-rect cropping (scrollable image)

image = RubyTUI::Widgets::Image.new(
  path: "tall_image.png",
  src_rect: Rect.new(0, scroll_offset, pixel_width, visible_height)
)

Constant Summary collapse

PNG_SIGNATURE =

PNG file signature (first 8 bytes)

"\x89PNG\r\n\x1a\n".b.freeze
JPEG_SIGNATURE =

JPEG signatures (SOI marker)

"\xFF\xD8\xFF".b.freeze
WEBP_RIFF =

RIFF container tag at byte offset 0 of a WebP file.

"RIFF".b.freeze
WEBP_MARKER =

WebP form type at byte offset 8 of a WebP file.

"WEBP".b.freeze
GIF87A_SIGNATURE =

GIF87a signature.

"GIF87a".b.freeze
GIF89A_SIGNATURE =

GIF89a signature.

"GIF89a".b.freeze
BMP_SIGNATURE =

BMP signature

"BM".b.freeze
FIT_MODES =

Fit modes for aspect-ratio-preserving sizing

%i[stretch contain cover].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path: nil, data: nil, pixel_width: nil, pixel_height: nil, format: :png, style: Style::DEFAULT, block: nil, fit: nil, src_rect: nil, clip: true, cell_size: nil) ⇒ Image

Returns a new instance of Image.

Parameters:

  • path (String, nil) (defaults to: nil) —

    path to a PNG image file

  • data (String, nil) (defaults to: nil) —

    raw image bytes (PNG or RGBA)

  • pixel_width (Integer, nil) (defaults to: nil) —

    image width in pixels (required for :rgba, auto-detected for :png)

  • pixel_height (Integer, nil) (defaults to: nil) —

    image height in pixels (required for :rgba, auto-detected for :png)

  • format (Symbol) (defaults to: :png) —

    image format — :png or :rgba. Auto-detected when :png (default). Ignored when path is given (the format is always detected).

  • style (Style) (defaults to: Style::DEFAULT) —

    background style for the image area (default: Style::DEFAULT)

  • block (Widgets::Block, nil) (defaults to: nil) —

    optional wrapping block for borders/title

  • fit (Symbol, nil) (defaults to: nil) —

    aspect-ratio mode — :stretch (default), :contain, :cover

  • src_rect (Rect, nil) (defaults to: nil) —

    source rectangle for cropping (pixel coordinates within the image)

  • clip (Boolean) (defaults to: true) —

    if true, clamp rendering to the available area (default: true)

  • cell_size (Array(Integer, Integer), nil) (defaults to: nil) —

    cell pixel dimensions [width, height] for aspect calculations (default: nil, uses [9, 18])

Raises:

  • (ArgumentError) —

    if neither path nor data is given, if PNG data is truncated, or if +pixel_width+/+pixel_height+ are missing for :rgba data (including data whose format is not recognized)

  • (UnsupportedImageFormat) —

    if the image is JPEG, WebP, GIF or BMP

  • (SystemCallError) —

    if path cannot be read



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/rubytui/widgets/image.rb', line 98

def initialize(path: nil, data: nil, pixel_width: nil, pixel_height: nil,
               format: :png, style: Style::DEFAULT, block: nil,
               fit: nil, src_rect: nil, clip: true, cell_size: nil)
  @style = style
  @block = block
  @fit = fit
  @src_rect = src_rect
  @clip = clip
  @cell_size = cell_size

  if path
    @image_data = File.binread(path)
    @format = self.class.detect_format(@image_data)
  elsif data
    @image_data = data
    @format = format == :png ? self.class.detect_format(data) : format
  else
    raise ArgumentError, "Image requires either path: or data:"
  end

  validate_format!(@format)

  if @format == :png
    dims = self.class.png_dimensions(@image_data)
    raise ArgumentError, "Invalid PNG data" unless dims

    @pixel_width = pixel_width || dims[0]
    @pixel_height = pixel_height || dims[1]
  else
    raise ArgumentError, "pixel_width and pixel_height required for :rgba format" unless pixel_width && pixel_height

    @pixel_width = pixel_width
    @pixel_height = pixel_height
  end

  @content_hash = Digest::MD5.hexdigest(@image_data)[0, 8]
end

Instance Attribute Details

#format ⇒ Symbol (readonly)

Returns image format (:png or :rgba).

Returns:

  • (Symbol) —

    image format (:png or :rgba)



78
79
80
# File 'lib/rubytui/widgets/image.rb', line 78

def format
  @format
end

#pixel_height ⇒ Integer (readonly)

Returns image height in pixels.

Returns:

  • (Integer) —

    image height in pixels



75
76
77
# File 'lib/rubytui/widgets/image.rb', line 75

def pixel_height
  @pixel_height
end

#pixel_width ⇒ Integer (readonly)

Returns image width in pixels.

Returns:

  • (Integer) —

    image width in pixels



72
73
74
# File 'lib/rubytui/widgets/image.rb', line 72

def pixel_width
  @pixel_width
end

Class Method Details

.detect_format(data) ⇒ Symbol

Detect image format from magic bytes.

Parameters:

  • data (String) —

    raw image bytes

Returns:

  • (Symbol) —

    detected format — :png, :jpeg, :webp, :gif, :bmp, or :rgba (unknown)



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/rubytui/widgets/image.rb', line 217

def self.detect_format(data)
  return :rgba if data.nil? || data.bytesize < 8

  # Force binary encoding for comparison
  header = data.byteslice(0, 12).b

  if header.start_with?(PNG_SIGNATURE)
    :png
  elsif header.start_with?(JPEG_SIGNATURE)
    :jpeg
  elsif header.start_with?(WEBP_RIFF) && data.byteslice(8, 4).b == WEBP_MARKER
    :webp
  elsif header.start_with?(GIF87A_SIGNATURE) || header.start_with?(GIF89A_SIGNATURE)
    :gif
  elsif header.start_with?(BMP_SIGNATURE)
    :bmp
  else
    :rgba
  end
end

.fit_area(area, pixel_width, pixel_height, cell_size: [9, 18], mode: :contain) ⇒ Rect

Compute area that preserves aspect ratio.

Parameters:

  • area (Rect) —

    available area in terminal cells

  • pixel_width (Integer) —

    image width in pixels

  • pixel_height (Integer) —

    image height in pixels

  • cell_size (Array(Integer, Integer)) (defaults to: [9, 18]) —

    cell dimensions [width_px, height_px]

  • mode (Symbol) (defaults to: :contain) —

    :contain (fit inside, preserving aspect ratio) or :cover (computes an overflowing size, then clamps it to area; nothing is cropped, so the result is the whole area, as with :stretch)

Returns:

  • (Rect) —

    computed placement within area



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/rubytui/widgets/image.rb', line 248

def self.fit_area(area, pixel_width, pixel_height, cell_size: [9, 18], mode: :contain)
  cell_w, cell_h = cell_size
  cell_ratio = cell_h.to_f / cell_w

  image_aspect = pixel_width.to_f / pixel_height
  area_aspect = (area.width * cell_w).to_f / (area.height * cell_h)

  if mode == :contain
    if image_aspect > area_aspect
      # Image is wider — constrain by width
      cols = area.width
      rows = (cols / image_aspect / cell_ratio).round
    else
      # Image is taller — constrain by height
      rows = area.height
      cols = (rows * image_aspect * cell_ratio).round
    end
  else # :cover
    if image_aspect > area_aspect
      # Image is wider — constrain by height (will overflow width)
      rows = area.height
      cols = (rows * image_aspect * cell_ratio).round
    else
      # Image is taller — constrain by width (will overflow height)
      cols = area.width
      rows = (cols / image_aspect / cell_ratio).round
    end
  end

  cols = [[cols, 1].max, area.width].min
  rows = [[rows, 1].max, area.height].min

  # Center within area
  x = area.x + (area.width - cols) / 2
  y = area.y + (area.height - rows) / 2

  Rect.new(x, y, cols, rows)
end

.png_dimensions(data) ⇒ Array(Integer, Integer)?

Read width and height from a PNG file's IHDR chunk.

Parameters:

  • data (String) —

    raw PNG file bytes

Returns:

  • (Array(Integer, Integer), nil) —

    [width, height] or nil if not valid PNG



204
205
206
207
208
209
210
211
# File 'lib/rubytui/widgets/image.rb', line 204

def self.png_dimensions(data)
  return nil unless data.bytesize >= 24
  return nil unless data.byteslice(0, 8) == PNG_SIGNATURE

  width = data.byteslice(16, 4).unpack1("N")
  height = data.byteslice(20, 4).unpack1("N")
  [width, height]
end

Instance Method Details

#render(area, buf) ⇒ void

This method returns an undefined value.

Render this image into the buffer.

Fills the area with placeholder cells and registers an image placement via Buffer#set_image. The backend emits the Kitty graphics protocol sequences during Terminal#draw.

Parameters:

  • area (Rect) —

    the rectangular area to render into

  • buf (Buffer) —

    the buffer to write cells into



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/rubytui/widgets/image.rb', line 145

def render(area, buf)
  return if area.empty?

  render_area = area
  if @block
    @block.render(area, buf)
    render_area = @block.inner(area)
  end

  return if render_area.empty?

  # Apply fit mode to compute actual image placement
  if @fit && @fit != :stretch
    render_area = compute_fit_area(render_area)
  end

  # Clip to buffer bounds if requested
  if @clip
    render_area = clip_to_buffer(render_area, buf.area)
  end

  return if render_area.empty?

  # Fill the area with spaces to claim it in the buffer
  render_area.height.times do |row|
    render_area.width.times do |col|
      buf[render_area.x + col, render_area.y + row]&.set(" ", @style)
    end
  end

  # Build placement hash
  placement = {
    x: render_area.x,
    y: render_area.y,
    cols: render_area.width,
    rows: render_area.height,
    data: @image_data,
    pixel_width: @pixel_width,
    pixel_height: @pixel_height,
    format: @format,
    content_hash: @content_hash
  }

  # Add source rectangle if specified (for cropping/scrolling)
  if @src_rect
    placement[:src_x] = @src_rect.x
    placement[:src_y] = @src_rect.y
    placement[:src_w] = @src_rect.width
    placement[:src_h] = @src_rect.height
  end

  # Register the image placement on the buffer
  buf.set_image(**placement)
end