rubytui 1.2.3

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +574 -0
  4. data/lib/rubytui/app.rb +174 -0
  5. data/lib/rubytui/backend.rb +158 -0
  6. data/lib/rubytui/backends/ansi_backend.rb +391 -0
  7. data/lib/rubytui/backends/test_backend.rb +223 -0
  8. data/lib/rubytui/buffer.rb +400 -0
  9. data/lib/rubytui/cell.rb +55 -0
  10. data/lib/rubytui/color.rb +153 -0
  11. data/lib/rubytui/color_mode.rb +203 -0
  12. data/lib/rubytui/errors.rb +13 -0
  13. data/lib/rubytui/event.rb +161 -0
  14. data/lib/rubytui/frame.rb +70 -0
  15. data/lib/rubytui/input/key.rb +93 -0
  16. data/lib/rubytui/input/parser.rb +231 -0
  17. data/lib/rubytui/input/reader.rb +119 -0
  18. data/lib/rubytui/layout/constraint.rb +83 -0
  19. data/lib/rubytui/layout/flex.rb +16 -0
  20. data/lib/rubytui/layout/layout.rb +205 -0
  21. data/lib/rubytui/modifier.rb +67 -0
  22. data/lib/rubytui/rect.rb +126 -0
  23. data/lib/rubytui/stateful_widget.rb +20 -0
  24. data/lib/rubytui/style.rb +143 -0
  25. data/lib/rubytui/symbols.rb +88 -0
  26. data/lib/rubytui/terminal.rb +218 -0
  27. data/lib/rubytui/text/line.rb +67 -0
  28. data/lib/rubytui/text/span.rb +34 -0
  29. data/lib/rubytui/text/text.rb +82 -0
  30. data/lib/rubytui/unicode.rb +162 -0
  31. data/lib/rubytui/version.rb +6 -0
  32. data/lib/rubytui/widget.rb +20 -0
  33. data/lib/rubytui/widgets/async_image.rb +248 -0
  34. data/lib/rubytui/widgets/block.rb +260 -0
  35. data/lib/rubytui/widgets/canvas.rb +248 -0
  36. data/lib/rubytui/widgets/chart.rb +224 -0
  37. data/lib/rubytui/widgets/gauge.rb +139 -0
  38. data/lib/rubytui/widgets/image.rb +330 -0
  39. data/lib/rubytui/widgets/input_field.rb +245 -0
  40. data/lib/rubytui/widgets/list.rb +186 -0
  41. data/lib/rubytui/widgets/paragraph.rb +181 -0
  42. data/lib/rubytui/widgets/popup.rb +140 -0
  43. data/lib/rubytui/widgets/scrollbar.rb +175 -0
  44. data/lib/rubytui/widgets/sparkline.rb +86 -0
  45. data/lib/rubytui/widgets/table.rb +231 -0
  46. data/lib/rubytui/widgets/tabs.rb +90 -0
  47. data/lib/rubytui.rb +389 -0
  48. metadata +89 -0
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # Dataset represents a series of data points for the Chart widget.
6
+ Dataset = Data.define(:name, :data, :style) do
7
+ # @param name [String] dataset name; not rendered, as {Chart} draws no legend
8
+ # (default: "")
9
+ # @param data [Array<Array(Numeric, Numeric)>] <tt>[x, y]</tt> points, joined by
10
+ # lines in the given order (default: [])
11
+ # @param style [Style] line style (default: {Style::DEFAULT})
12
+ def initialize(name: "", data: [], style: Style::DEFAULT)
13
+ super
14
+ end
15
+ end
16
+
17
+ # Chart renders line charts using Canvas (Braille) for high-resolution plotting.
18
+ # Supports multiple datasets, labeled axes, and automatic scaling.
19
+ #
20
+ # @example
21
+ # ds = RubyTUI::Widgets::Dataset.new(name: "load", data: [[0, 1], [1, 3], [2, 2]])
22
+ # chart = RubyTUI::Widgets::Chart.new(datasets: [ds], x_axis: ["t", 0, 2], y_axis: ["y", 0, 4])
23
+ # frame.render_widget(chart, area)
24
+ #
25
+ # @see Dataset
26
+ class Chart
27
+ include Widget
28
+
29
+ # @param datasets [Array<Dataset>] series to plot; datasets with fewer than
30
+ # 2 points are skipped (default: [])
31
+ # @param x_axis [Array(String, Numeric, Numeric), nil] <tt>[label, min, max]</tt>;
32
+ # +min+/+max+ may be nil for bounds computed from the data. When set, one row
33
+ # is reserved for the axis and the label is drawn centered on it (default: nil,
34
+ # bounds computed from the data and no row reserved)
35
+ # @param y_axis [Array(String, Numeric, Numeric), nil] <tt>[label, min, max]</tt>;
36
+ # +min+/+max+ may be nil for bounds computed from the data. When set, the
37
+ # min/max values are drawn left of a vertical axis line and the label's first
38
+ # character at mid-height (default: nil, no y axis; bounds computed from the data)
39
+ # @param style [Style] currently unused by rendering (default: {Style::DEFAULT})
40
+ # @param axis_style [Style, nil] axis line style (default: bright black foreground)
41
+ # @param label_style [Style, nil] style of axis labels and min/max values
42
+ # (default: white foreground)
43
+ # @param block [Widgets::Block, nil] optional wrapping block
44
+ def initialize(
45
+ datasets: [],
46
+ x_axis: nil, # [label, min, max] or nil for auto
47
+ y_axis: nil, # [label, min, max] or nil for auto
48
+ style: Style::DEFAULT,
49
+ axis_style: nil,
50
+ label_style: nil,
51
+ block: nil
52
+ )
53
+ @datasets = datasets
54
+ @x_axis = x_axis
55
+ @y_axis = y_axis
56
+ @style = style
57
+ @axis_style = axis_style || Style.new.fg(Color::BRIGHT_BLACK)
58
+ @label_style = label_style || Style.new.fg(Color::WHITE)
59
+ @block = block
60
+ end
61
+
62
+ # Render the axes and draw each dataset as connected line segments on a {Canvas}.
63
+ #
64
+ # Nothing is drawn, not even +block+, when there are no datasets.
65
+ #
66
+ # @param area [Rect] the rectangular area to render into
67
+ # @param buf [Buffer] the buffer to write cells into
68
+ # @return [void]
69
+ def render(area, buf)
70
+ return if area.empty? || @datasets.empty?
71
+
72
+ render_area = area
73
+ if @block
74
+ @block.render(area, buf)
75
+ render_area = @block.inner(area)
76
+ end
77
+
78
+ return if render_area.empty?
79
+
80
+ # Reserve space for axes
81
+ y_label_width = compute_y_label_width
82
+ x_label_height = @x_axis ? 1 : 0
83
+ y_label_area_width = y_label_width > 0 ? y_label_width + 1 : 0
84
+
85
+ chart_area = Rect.new(
86
+ x: render_area.x + y_label_area_width,
87
+ y: render_area.y,
88
+ width: [render_area.width - y_label_area_width, 0].max,
89
+ height: [render_area.height - x_label_height, 0].max
90
+ )
91
+
92
+ return if chart_area.empty?
93
+
94
+ # Compute data bounds
95
+ x_min, x_max = compute_x_bounds
96
+ y_min, y_max = compute_y_bounds
97
+
98
+ # Draw axes
99
+ render_y_axis(render_area, chart_area, buf, y_min, y_max)
100
+ render_x_axis(render_area, chart_area, buf, x_min, x_max)
101
+
102
+ # Draw data using Canvas
103
+ canvas = Canvas.new(
104
+ x_bounds: [x_min, x_max],
105
+ y_bounds: [y_min, y_max]
106
+ )
107
+
108
+ @datasets.each do |ds|
109
+ points = ds.data
110
+ next if points.length < 2
111
+
112
+ # Draw lines between consecutive points
113
+ points.each_cons(2) do |(x1, y1), (x2, y2)|
114
+ canvas.line(x1, y1, x2, y2, style: ds.style)
115
+ end
116
+ end
117
+
118
+ canvas.render(chart_area, buf)
119
+ end
120
+
121
+ private
122
+
123
+ def compute_y_label_width
124
+ return 0 unless @y_axis
125
+
126
+ _, y_min, y_max = @y_axis
127
+ if y_min && y_max
128
+ [format_num(y_min).length, format_num(y_max).length].max
129
+ else
130
+ all_y = @datasets.flat_map { |ds| ds.data.map { |_, y| y } }
131
+ return 0 if all_y.empty?
132
+
133
+ [format_num(all_y.min).length, format_num(all_y.max).length].max
134
+ end
135
+ end
136
+
137
+ def compute_x_bounds
138
+ if @x_axis
139
+ _, mn, mx = @x_axis
140
+ return [mn, mx] if mn && mx
141
+ end
142
+
143
+ all_x = @datasets.flat_map { |ds| ds.data.map { |x, _| x } }
144
+ return [0.0, 1.0] if all_x.empty?
145
+
146
+ mn = all_x.min.to_f
147
+ mx = all_x.max.to_f
148
+ mx = mn + 1 if mx == mn
149
+ [mn, mx]
150
+ end
151
+
152
+ def compute_y_bounds
153
+ if @y_axis
154
+ _, mn, mx = @y_axis
155
+ return [mn, mx] if mn && mx
156
+ end
157
+
158
+ all_y = @datasets.flat_map { |ds| ds.data.map { |_, y| y } }
159
+ return [0.0, 1.0] if all_y.empty?
160
+
161
+ mn = all_y.min.to_f
162
+ mx = all_y.max.to_f
163
+ mx = mn + 1 if mx == mn
164
+ [mn, mx]
165
+ end
166
+
167
+ def render_y_axis(render_area, chart_area, buf, y_min, y_max)
168
+ return unless @y_axis
169
+
170
+ label = @y_axis[0]
171
+
172
+ # Draw vertical axis line
173
+ (chart_area.y...chart_area.bottom).each do |y|
174
+ buf[chart_area.x - 1, y]&.set("\u2502", @axis_style) if chart_area.x > render_area.x
175
+ end
176
+
177
+ # Y labels at top and bottom
178
+ y_label_x = render_area.x
179
+ buf.set_string(y_label_x, chart_area.y, format_num(y_max), @label_style)
180
+ buf.set_string(y_label_x, chart_area.bottom - 1, format_num(y_min), @label_style)
181
+
182
+ # Y axis label (vertically centered, first char)
183
+ if label && !label.empty?
184
+ mid_y = chart_area.y + chart_area.height / 2
185
+ buf.set_string(render_area.x, mid_y, label[0], @label_style)
186
+ end
187
+ end
188
+
189
+ def render_x_axis(render_area, chart_area, buf, x_min, x_max)
190
+ x_axis_y = chart_area.bottom
191
+
192
+ # Draw horizontal axis line
193
+ (chart_area.x...chart_area.right).each do |x|
194
+ buf[x, x_axis_y]&.set("\u2500", @axis_style)
195
+ end
196
+
197
+ # Corner
198
+ if chart_area.x > render_area.x
199
+ buf[chart_area.x - 1, x_axis_y]&.set("\u2514", @axis_style)
200
+ end
201
+
202
+ # X labels at left and right
203
+ min_str = format_num(x_min)
204
+ max_str = format_num(x_max)
205
+ buf.set_string(chart_area.x, x_axis_y, min_str, @label_style)
206
+ max_x = chart_area.right - Buffer.string_width(max_str)
207
+ buf.set_string([max_x, chart_area.x].max, x_axis_y, max_str, @label_style)
208
+
209
+ # X axis label (centered)
210
+ if @x_axis
211
+ label = @x_axis[0]
212
+ if label && !label.empty?
213
+ lx = chart_area.x + (chart_area.width - Buffer.string_width(label)) / 2
214
+ buf.set_string(lx, x_axis_y, label, @label_style)
215
+ end
216
+ end
217
+ end
218
+
219
+ def format_num(n)
220
+ n == n.to_i ? n.to_i.to_s : format("%.1f", n)
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # Gauge renders a horizontal progress bar with an optional label.
6
+ #
7
+ # @example
8
+ # gauge = RubyTUI::Widgets::Gauge.new(ratio: 0.42) # label "42%"
9
+ # frame.render_widget(gauge, area)
10
+ class Gauge
11
+ include Widget
12
+
13
+ # Block characters for smooth progress rendering
14
+
15
+ # Full block (8/8).
16
+ FULL = "\u2588" # █
17
+ # Left seven-eighths block (7/8).
18
+ SEVEN = "\u2589" # ▉
19
+ # Left three-quarters block (6/8).
20
+ SIX = "\u258A" # ▊
21
+ # Left five-eighths block (5/8).
22
+ FIVE = "\u258B" # ▋
23
+ # Left half block (4/8).
24
+ FOUR = "\u258C" # ▌
25
+ # Left three-eighths block (3/8).
26
+ THREE = "\u258D" # ▍
27
+ # Left one-quarter block (2/8).
28
+ TWO = "\u258E" # ▎
29
+ # Left one-eighth block (1/8).
30
+ ONE = "\u258F" # ▏
31
+ # Empty cell (0/8).
32
+ EMPTY = " "
33
+
34
+ # Bar characters indexed by filled eighths, from {EMPTY} (0) to {FULL} (8).
35
+ EIGHTHS = [EMPTY, ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, FULL].freeze
36
+
37
+ # @param ratio [Numeric] fill ratio, clamped to 0.0..1.0 (default: 0.0)
38
+ # @param label [String, nil] text centered on the middle row, drawn only if it
39
+ # fits the width (default: nil, the rounded percentage, e.g. <tt>"42%"</tt>)
40
+ # @param label_style [Style, nil] style for every label character (default: nil,
41
+ # bold black on the gauge color over the filled part and bold gauge-colored
42
+ # text over the unfilled part)
43
+ # @param style [Style] style of the unfilled cells (default: {Style::DEFAULT})
44
+ # @param gauge_style [Style, nil] style of the filled blocks; its foreground color
45
+ # is also used by the default label styles (default: green foreground)
46
+ # @param block [Widgets::Block, nil] optional wrapping block
47
+ def initialize(
48
+ ratio: 0.0,
49
+ label: nil,
50
+ label_style: nil,
51
+ style: Style::DEFAULT,
52
+ gauge_style: nil,
53
+ block: nil
54
+ )
55
+ @ratio = ratio.clamp(0.0, 1.0)
56
+ @label = label
57
+ @label_style = label_style
58
+ @style = style
59
+ @gauge_style = gauge_style || Style.new.fg(Color::GREEN)
60
+ @block = block
61
+ end
62
+
63
+ # Render the bar on every row of the area with eighth-cell precision, then the
64
+ # label centered on the middle row.
65
+ #
66
+ # @param area [Rect] the rectangular area to render into
67
+ # @param buf [Buffer] the buffer to write cells into
68
+ # @return [void]
69
+ def render(area, buf)
70
+ return if area.empty?
71
+
72
+ render_area = area
73
+ if @block
74
+ @block.render(area, buf)
75
+ render_area = @block.inner(area)
76
+ end
77
+
78
+ return if render_area.empty?
79
+
80
+ width = render_area.width
81
+ filled_exact = width * @ratio * 8.0
82
+ filled_full = (filled_exact / 8).floor
83
+ partial_eighth = (filled_exact % 8).round
84
+ # The boundary column (first non-full cell)
85
+ boundary = filled_full + (partial_eighth > 0 ? 1 : 0)
86
+
87
+ # Render on each row of the gauge area
88
+ (0...render_area.height).each do |row|
89
+ y = render_area.y + row
90
+ x = render_area.x
91
+
92
+ # Full blocks
93
+ filled_full.times do |i|
94
+ break if x + i >= render_area.right
95
+ buf[x + i, y]&.set(FULL, @gauge_style)
96
+ end
97
+
98
+ # Partial block
99
+ if partial_eighth > 0 && filled_full < width
100
+ buf[x + filled_full, y]&.set(EIGHTHS[partial_eighth], @gauge_style)
101
+ end
102
+
103
+ # Empty space
104
+ (boundary...width).each do |i|
105
+ buf[x + i, y]&.set(EMPTY, @style)
106
+ end
107
+ end
108
+
109
+ # Render label centered, with per-character style blending
110
+ label_text = @label || "#{(@ratio * 100).round}%"
111
+ label_width = Buffer.string_width(label_text)
112
+ if label_width <= width
113
+ label_x = render_area.x + (width - label_width) / 2
114
+ label_y = render_area.y + render_area.height / 2
115
+
116
+ # Compute styles for label chars on filled vs empty portions
117
+ # On filled portion: gauge color as bg, contrasting fg
118
+ gauge_fg = @gauge_style.fg
119
+ filled_label_style = @label_style || Style.new.fg(Color::BLACK).bg(gauge_fg).bold
120
+ # On empty portion: gauge color as fg, default bg
121
+ empty_label_style = @label_style || Style.new.fg(gauge_fg).bold
122
+
123
+ cx = label_x
124
+ label_text.each_char do |ch|
125
+ ch_w = Buffer.char_width(ch)
126
+ col_offset = cx - render_area.x
127
+ char_style = col_offset < boundary ? filled_label_style : empty_label_style
128
+ buf[cx, label_y]&.set(ch, char_style)
129
+ # Mark spacer for wide chars
130
+ if ch_w == 2 && cx + 1 < render_area.right
131
+ buf[cx + 1, label_y]&.set("", char_style)
132
+ end
133
+ cx += ch_w
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RubyTUI
6
+ module Widgets
7
+ # Renders an image in the terminal using the Kitty graphics protocol.
8
+ #
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.
12
+ #
13
+ # JPEG, WebP, GIF, and BMP are NOT supported — convert to PNG first:
14
+ # system("magick", "input.jpg", "-resize", "800x600>", "output.png")
15
+ #
16
+ # @example From a PNG file
17
+ # image = RubyTUI::Widgets::Image.new(path: "photo.png")
18
+ # frame.render_widget(image, area)
19
+ #
20
+ # @example From a PNG file with border
21
+ # block = RubyTUI::Widgets::Block.new(title: " Photo ", borders: :all)
22
+ # image = RubyTUI::Widgets::Image.new(path: "photo.png", block: block)
23
+ # frame.render_widget(image, area)
24
+ #
25
+ # @example From raw RGBA pixel data
26
+ # image = RubyTUI::Widgets::Image.new(
27
+ # data: rgba_bytes,
28
+ # pixel_width: 800,
29
+ # pixel_height: 600,
30
+ # format: :rgba
31
+ # )
32
+ #
33
+ # @example Aspect-ratio-preserving fit
34
+ # image = RubyTUI::Widgets::Image.new(path: "photo.png", fit: :contain)
35
+ # frame.render_widget(image, area) # Image computes sub-rect
36
+ #
37
+ # @example Source-rect cropping (scrollable image)
38
+ # image = RubyTUI::Widgets::Image.new(
39
+ # path: "tall_image.png",
40
+ # src_rect: Rect.new(0, scroll_offset, pixel_width, visible_height)
41
+ # )
42
+ class Image
43
+ include Widget
44
+
45
+ # PNG file signature (first 8 bytes)
46
+ PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
47
+
48
+ # JPEG signatures (SOI marker)
49
+ JPEG_SIGNATURE = "\xFF\xD8\xFF".b.freeze
50
+
51
+ # WebP signature (RIFF....WEBP)
52
+
53
+ # RIFF container tag at byte offset 0 of a WebP file.
54
+ WEBP_RIFF = "RIFF".b.freeze
55
+ # WebP form type at byte offset 8 of a WebP file.
56
+ WEBP_MARKER = "WEBP".b.freeze
57
+
58
+ # GIF signatures
59
+
60
+ # GIF87a signature.
61
+ GIF87A_SIGNATURE = "GIF87a".b.freeze
62
+ # GIF89a signature.
63
+ GIF89A_SIGNATURE = "GIF89a".b.freeze
64
+
65
+ # BMP signature
66
+ BMP_SIGNATURE = "BM".b.freeze
67
+
68
+ # Fit modes for aspect-ratio-preserving sizing
69
+ FIT_MODES = %i[stretch contain cover].freeze
70
+
71
+ # @return [Integer] image width in pixels
72
+ attr_reader :pixel_width
73
+
74
+ # @return [Integer] image height in pixels
75
+ attr_reader :pixel_height
76
+
77
+ # @return [Symbol] image format (:png or :rgba)
78
+ attr_reader :format
79
+
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
+ # Ignored when +path+ is given (the format is always detected).
86
+ # @param style [Style] background style for the image area (default: {Style::DEFAULT})
87
+ # @param block [Widgets::Block, nil] optional wrapping block for borders/title
88
+ # @param fit [Symbol, nil] aspect-ratio mode — +:stretch+ (default), +:contain+, +:cover+
89
+ # @param src_rect [Rect, nil] source rectangle for cropping (pixel coordinates within the image)
90
+ # @param clip [Boolean] if true, clamp rendering to the available area (default: true)
91
+ # @param cell_size [Array(Integer, Integer), nil] cell pixel dimensions [width, height] for aspect calculations
92
+ # (default: nil, uses <tt>[9, 18]</tt>)
93
+ # @raise [ArgumentError] if neither +path+ nor +data+ is given, if PNG data is
94
+ # truncated, or if +pixel_width+/+pixel_height+ are missing for +:rgba+ data
95
+ # (including data whose format is not recognized)
96
+ # @raise [UnsupportedImageFormat] if the image is JPEG, WebP, GIF or BMP
97
+ # @raise [SystemCallError] if +path+ cannot be read
98
+ def initialize(path: nil, data: nil, pixel_width: nil, pixel_height: nil,
99
+ format: :png, style: Style::DEFAULT, block: nil,
100
+ fit: nil, src_rect: nil, clip: true, cell_size: nil)
101
+ @style = style
102
+ @block = block
103
+ @fit = fit
104
+ @src_rect = src_rect
105
+ @clip = clip
106
+ @cell_size = cell_size
107
+
108
+ if path
109
+ @image_data = File.binread(path)
110
+ @format = self.class.detect_format(@image_data)
111
+ elsif data
112
+ @image_data = data
113
+ @format = format == :png ? self.class.detect_format(data) : format
114
+ else
115
+ raise ArgumentError, "Image requires either path: or data:"
116
+ end
117
+
118
+ validate_format!(@format)
119
+
120
+ if @format == :png
121
+ dims = self.class.png_dimensions(@image_data)
122
+ raise ArgumentError, "Invalid PNG data" unless dims
123
+
124
+ @pixel_width = pixel_width || dims[0]
125
+ @pixel_height = pixel_height || dims[1]
126
+ else
127
+ raise ArgumentError, "pixel_width and pixel_height required for :rgba format" unless pixel_width && pixel_height
128
+
129
+ @pixel_width = pixel_width
130
+ @pixel_height = pixel_height
131
+ end
132
+
133
+ @content_hash = Digest::MD5.hexdigest(@image_data)[0, 8]
134
+ end
135
+
136
+ # Render this image into the buffer.
137
+ #
138
+ # Fills the area with placeholder cells and registers an image placement
139
+ # via {::RubyTUI::Buffer#set_image}. The backend emits the Kitty graphics protocol
140
+ # sequences during {::RubyTUI::Terminal#draw}.
141
+ #
142
+ # @param area [Rect] the rectangular area to render into
143
+ # @param buf [Buffer] the buffer to write cells into
144
+ # @return [void]
145
+ def render(area, buf)
146
+ return if area.empty?
147
+
148
+ render_area = area
149
+ if @block
150
+ @block.render(area, buf)
151
+ render_area = @block.inner(area)
152
+ end
153
+
154
+ return if render_area.empty?
155
+
156
+ # Apply fit mode to compute actual image placement
157
+ if @fit && @fit != :stretch
158
+ render_area = compute_fit_area(render_area)
159
+ end
160
+
161
+ # Clip to buffer bounds if requested
162
+ if @clip
163
+ render_area = clip_to_buffer(render_area, buf.area)
164
+ end
165
+
166
+ return if render_area.empty?
167
+
168
+ # Fill the area with spaces to claim it in the buffer
169
+ render_area.height.times do |row|
170
+ render_area.width.times do |col|
171
+ buf[render_area.x + col, render_area.y + row]&.set(" ", @style)
172
+ end
173
+ end
174
+
175
+ # Build placement hash
176
+ placement = {
177
+ x: render_area.x,
178
+ y: render_area.y,
179
+ cols: render_area.width,
180
+ rows: render_area.height,
181
+ data: @image_data,
182
+ pixel_width: @pixel_width,
183
+ pixel_height: @pixel_height,
184
+ format: @format,
185
+ content_hash: @content_hash
186
+ }
187
+
188
+ # Add source rectangle if specified (for cropping/scrolling)
189
+ if @src_rect
190
+ placement[:src_x] = @src_rect.x
191
+ placement[:src_y] = @src_rect.y
192
+ placement[:src_w] = @src_rect.width
193
+ placement[:src_h] = @src_rect.height
194
+ end
195
+
196
+ # Register the image placement on the buffer
197
+ buf.set_image(**placement)
198
+ end
199
+
200
+ # Read width and height from a PNG file's IHDR chunk.
201
+ #
202
+ # @param data [String] raw PNG file bytes
203
+ # @return [Array(Integer, Integer), nil] [width, height] or nil if not valid PNG
204
+ def self.png_dimensions(data)
205
+ return nil unless data.bytesize >= 24
206
+ return nil unless data.byteslice(0, 8) == PNG_SIGNATURE
207
+
208
+ width = data.byteslice(16, 4).unpack1("N")
209
+ height = data.byteslice(20, 4).unpack1("N")
210
+ [width, height]
211
+ end
212
+
213
+ # Detect image format from magic bytes.
214
+ #
215
+ # @param data [String] raw image bytes
216
+ # @return [Symbol] detected format — +:png+, +:jpeg+, +:webp+, +:gif+, +:bmp+, or +:rgba+ (unknown)
217
+ def self.detect_format(data)
218
+ return :rgba if data.nil? || data.bytesize < 8
219
+
220
+ # Force binary encoding for comparison
221
+ header = data.byteslice(0, 12).b
222
+
223
+ if header.start_with?(PNG_SIGNATURE)
224
+ :png
225
+ elsif header.start_with?(JPEG_SIGNATURE)
226
+ :jpeg
227
+ elsif header.start_with?(WEBP_RIFF) && data.byteslice(8, 4).b == WEBP_MARKER
228
+ :webp
229
+ elsif header.start_with?(GIF87A_SIGNATURE) || header.start_with?(GIF89A_SIGNATURE)
230
+ :gif
231
+ elsif header.start_with?(BMP_SIGNATURE)
232
+ :bmp
233
+ else
234
+ :rgba
235
+ end
236
+ end
237
+
238
+ # Compute area that preserves aspect ratio.
239
+ #
240
+ # @param area [Rect] available area in terminal cells
241
+ # @param pixel_width [Integer] image width in pixels
242
+ # @param pixel_height [Integer] image height in pixels
243
+ # @param cell_size [Array(Integer, Integer)] cell dimensions [width_px, height_px]
244
+ # @param mode [Symbol] +:contain+ (fit inside, preserving aspect ratio) or
245
+ # +:cover+ (computes an overflowing size, then clamps it to +area+; nothing
246
+ # is cropped, so the result is the whole area, as with +:stretch+)
247
+ # @return [Rect] computed placement within +area+
248
+ def self.fit_area(area, pixel_width, pixel_height, cell_size: [9, 18], mode: :contain)
249
+ cell_w, cell_h = cell_size
250
+ cell_ratio = cell_h.to_f / cell_w
251
+
252
+ image_aspect = pixel_width.to_f / pixel_height
253
+ area_aspect = (area.width * cell_w).to_f / (area.height * cell_h)
254
+
255
+ if mode == :contain
256
+ if image_aspect > area_aspect
257
+ # Image is wider — constrain by width
258
+ cols = area.width
259
+ rows = (cols / image_aspect / cell_ratio).round
260
+ else
261
+ # Image is taller — constrain by height
262
+ rows = area.height
263
+ cols = (rows * image_aspect * cell_ratio).round
264
+ end
265
+ else # :cover
266
+ if image_aspect > area_aspect
267
+ # Image is wider — constrain by height (will overflow width)
268
+ rows = area.height
269
+ cols = (rows * image_aspect * cell_ratio).round
270
+ else
271
+ # Image is taller — constrain by width (will overflow height)
272
+ cols = area.width
273
+ rows = (cols / image_aspect / cell_ratio).round
274
+ end
275
+ end
276
+
277
+ cols = [[cols, 1].max, area.width].min
278
+ rows = [[rows, 1].max, area.height].min
279
+
280
+ # Center within area
281
+ x = area.x + (area.width - cols) / 2
282
+ y = area.y + (area.height - rows) / 2
283
+
284
+ Rect.new(x, y, cols, rows)
285
+ end
286
+
287
+ private
288
+
289
+ def validate_format!(fmt)
290
+ case fmt
291
+ when :png, :rgba
292
+ # OK
293
+ when :jpeg
294
+ raise UnsupportedImageFormat,
295
+ "JPEG format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
296
+ " system(\"magick\", \"input.jpg\", \"output.png\")"
297
+ when :webp
298
+ raise UnsupportedImageFormat,
299
+ "WebP format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
300
+ " system(\"magick\", \"input.webp\", \"output.png\")"
301
+ when :gif
302
+ raise UnsupportedImageFormat,
303
+ "GIF format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
304
+ " system(\"magick\", \"input.gif[0]\", \"output.png\") # [0] = first frame"
305
+ when :bmp
306
+ raise UnsupportedImageFormat,
307
+ "BMP format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
308
+ " system(\"magick\", \"input.bmp\", \"output.png\")"
309
+ end
310
+ end
311
+
312
+ def compute_fit_area(area)
313
+ cell_size = @cell_size || [9, 18]
314
+ self.class.fit_area(area, @pixel_width, @pixel_height, cell_size: cell_size, mode: @fit)
315
+ end
316
+
317
+ def clip_to_buffer(area, buf_area)
318
+ x1 = [area.x, buf_area.x].max
319
+ y1 = [area.y, buf_area.y].max
320
+ x2 = [area.right, buf_area.right].min
321
+ y2 = [area.bottom, buf_area.bottom].min
322
+
323
+ width = [x2 - x1, 0].max
324
+ height = [y2 - y1, 0].max
325
+
326
+ Rect.new(x1, y1, width, height)
327
+ end
328
+ end
329
+ end
330
+ end