echoes 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ # iTerm2 inline-image protocol (OSC 1337 with the `File=` verb).
5
+ # Wire format:
6
+ #
7
+ # \e]1337;File=<key=value>[;<key=value>...]:<base64-payload>\a
8
+ #
9
+ # Most of the heavy lifting (NSBitmapImageRep PNG/JPEG/TIFF/GIF
10
+ # decode, RGBA pixmap storage on the screen, blit through the
11
+ # multicell renderer) is already shipped for the Kitty graphics
12
+ # protocol — this module just parses the OSC 1337 envelope and
13
+ # delegates to Echoes::KittyGraphics::AppKitPng /
14
+ # Screen#put_kitty_image.
15
+ #
16
+ # Out of scope (follow-ups, if anyone hits them):
17
+ # - `inline=0` (save to disk / clipboard) — we ignore these.
18
+ # - `preserveAspectRatio=0` (non-uniform stretch) — we always
19
+ # preserve aspect; user can request explicit `width=` + `height=`.
20
+ # - The `name=`-base64 metadata — useful for "Save As" UIs we
21
+ # don't have.
22
+ module Iterm2Images
23
+ module_function
24
+
25
+ # Top-level entry point. `rest` is everything between
26
+ # `\e]1337;` and the terminating BEL/ST. Returns truthy on
27
+ # successful image display; nil otherwise (silently).
28
+ def handle(rest, screen:, writer: nil)
29
+ verb_args = rest.to_s
30
+ return nil unless verb_args.start_with?('File=')
31
+
32
+ params_str, payload_b64 = verb_args.byteslice(5..).split(':', 2)
33
+ return nil if payload_b64.nil? || payload_b64.empty?
34
+
35
+ params = parse_params(params_str || '')
36
+
37
+ # iTerm2 only inlines images when `inline=1`. Absent means
38
+ # "save to disk", which we don't support — bail.
39
+ return nil unless params['inline'] == '1'
40
+
41
+ bytes = decode_payload(payload_b64)
42
+ return nil unless bytes
43
+
44
+ image = decode_image(bytes)
45
+ return nil unless image
46
+
47
+ cells_w, cells_h = compute_cell_dimensions(params, image, screen)
48
+ screen.put_kitty_image(
49
+ rgba: image[:rgba],
50
+ width: image[:width],
51
+ height: image[:height],
52
+ cells_w: cells_w,
53
+ cells_h: cells_h,
54
+ )
55
+ true
56
+ end
57
+
58
+ # Parse "k1=v1;k2=v2" into {"k1"=>"v1", "k2"=>"v2"}.
59
+ def parse_params(str)
60
+ out = {}
61
+ str.split(';').each do |pair|
62
+ k, v = pair.split('=', 2)
63
+ next if k.nil? || k.empty?
64
+ out[k] = (v || '').to_s
65
+ end
66
+ out
67
+ end
68
+
69
+ # Tolerant base64 decode (allows wrapped / whitespace payloads).
70
+ # Mirrors KittyGraphics.decode_payload to keep the two paths
71
+ # symmetric.
72
+ def decode_payload(b64)
73
+ cleaned = b64.to_s.delete("\r\n\t ")
74
+ return nil if cleaned.empty?
75
+ cleaned.unpack1('m0')
76
+ rescue ArgumentError
77
+ nil
78
+ end
79
+
80
+ # Bytes → {rgba:, width:, height:}. Despite the name,
81
+ # AppKitPng handles every format NSBitmapImageRep recognizes
82
+ # (PNG / JPEG / GIF / TIFF / BMP) because it draws the
83
+ # decoded CGImage into a known RGBA8 CGBitmapContext.
84
+ def decode_image(bytes)
85
+ require_relative 'kitty_graphics_appkit'
86
+ KittyGraphics::AppKitPng.decode(bytes)
87
+ end
88
+
89
+ # Translate the wire's `width=` / `height=` into cell counts
90
+ # for the renderer. Supported forms (per iTerm2 docs):
91
+ # N — N character cells
92
+ # Npx — N pixels; rounded up to a whole cell
93
+ # N% — N% of the screen dimension
94
+ # auto — natural pixel size / cell pixel size (default)
95
+ # nil or unrecognized → `auto`.
96
+ def compute_cell_dimensions(params, image, screen)
97
+ cell_w_px = screen.cell_pixel_width.to_f
98
+ cell_h_px = screen.cell_pixel_height.to_f
99
+ cells_w = parse_dim(params['width'],
100
+ natural_px: image[:width],
101
+ cell_px: cell_w_px,
102
+ screen_size: screen.cols)
103
+ cells_h = parse_dim(params['height'],
104
+ natural_px: image[:height],
105
+ cell_px: cell_h_px,
106
+ screen_size: screen.rows)
107
+ [cells_w, cells_h]
108
+ end
109
+
110
+ def parse_dim(value, natural_px:, cell_px:, screen_size:)
111
+ return nil if value.nil? || value.empty? || value == 'auto'
112
+ if value.end_with?('px')
113
+ return nil if cell_px <= 0
114
+ (value.to_f / cell_px).ceil
115
+ elsif value.end_with?('%')
116
+ (screen_size * value.to_f / 100.0).ceil
117
+ else
118
+ value.to_i
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Echoes
6
+ # Minimum-viable Kitty graphics protocol decoder. Wire format:
7
+ #
8
+ # \e_G<comma-separated-options>;<base64-payload>\e\
9
+ #
10
+ # Parser hands us the body (sans `\e_G…\e\\` framing) split into
11
+ # `meta` and the still-base64 `payload`. We accumulate chunks
12
+ # keyed by image id (m=1 = more, m=0 = last), base64-decode the
13
+ # full payload, decode PNG (f=100, the default) into RGBA via
14
+ # AppKit, then either cache the image (a=t) or display it via
15
+ # `screen.put_kitty_image` (a=T or a=p).
16
+ #
17
+ # State lives on the parser (per-pane) — not module globals —
18
+ # so chunks from different panes don't collide.
19
+ module KittyGraphics
20
+ CHUNK_LIMIT_BYTES = 16 * 1024 * 1024
21
+ CACHE_LIMIT = 16 # most-recent N images, LRU
22
+ DEFAULT_FORMAT = '100'.freeze # PNG
23
+
24
+ module_function
25
+
26
+ # Parse "a=T,f=100,i=1" into {"a"=>"T", "f"=>"100", "i"=>"1"}.
27
+ # Keys with no `=` map to '' so the caller can still tell they
28
+ # were present.
29
+ def parse_options(meta)
30
+ out = {}
31
+ meta.to_s.split(',').each do |pair|
32
+ k, v = pair.split('=', 2)
33
+ next if k.nil? || k.empty?
34
+ out[k] = (v || '').to_s
35
+ end
36
+ out
37
+ end
38
+
39
+ # Add one APC chunk's payload to the per-id buffer. Returns the
40
+ # full assembled payload + final-options Hash when this chunk
41
+ # closes the image (m=0 or no m=); returns nil while more
42
+ # chunks are still expected.
43
+ def assemble_chunk(state, meta, payload)
44
+ opts = parse_options(meta)
45
+ id = opts['i'] || opts['I'] || ''
46
+ more = opts['m'] == '1'
47
+
48
+ buf = state[:chunks][id]
49
+ if buf
50
+ # Subsequent chunk: append payload, update saved-options
51
+ # only with newly-seen non-id keys (Kitty's spec: only `m`
52
+ # / `q` / `S` / size differ across chunks).
53
+ return nil if buf[1].bytesize + payload.bytesize > CHUNK_LIMIT_BYTES
54
+ buf[0]['m'] = opts['m'] if opts.key?('m')
55
+ buf[0]['q'] = opts['q'] if opts.key?('q')
56
+ buf[1] << payload
57
+ else
58
+ # First chunk: stash both options and payload.
59
+ return nil if payload.bytesize > CHUNK_LIMIT_BYTES
60
+ buf = [opts, +"".b]
61
+ buf[1] << payload
62
+ state[:chunks][id] = buf
63
+ end
64
+
65
+ return nil if more
66
+
67
+ state[:chunks].delete(id)
68
+ [buf[0], buf[1]]
69
+ end
70
+
71
+ # Top-level dispatcher — one call per APC frame.
72
+ def handle_chunk(state, meta, payload, screen:, writer:)
73
+ assembled = assemble_chunk(state, meta, payload)
74
+ return unless assembled
75
+ opts, b64 = assembled
76
+
77
+ action = opts['a'].to_s
78
+ action = 'T' if action.empty? # default action
79
+
80
+ case action
81
+ when 'T', 't'
82
+ bytes = decode_payload(b64)
83
+ return respond(writer, opts, error: 'EBADPNG') unless bytes
84
+
85
+ # Transmission medium (`t=…`):
86
+ # d (default) — payload is the image bytes (already decoded above)
87
+ # f — payload is an absolute file path; we read it
88
+ # t — same as `f`, then delete the file (temp file)
89
+ # s — shared memory; not supported
90
+ bytes = resolve_transmission(bytes, opts)
91
+ return respond(writer, opts, error: 'ENOENT') unless bytes
92
+
93
+ # Compression (`o=…`):
94
+ # (unset) — payload is uncompressed
95
+ # z — payload is raw zlib (deflate). kitten icat
96
+ # defaults to o=z for any non-PNG transmission,
97
+ # so this is the common case in the wild.
98
+ bytes = inflate_if_needed(bytes, opts['o'])
99
+ return respond(writer, opts, error: 'EBADDATA') unless bytes
100
+
101
+ image = decode_image(bytes, opts['f'] || DEFAULT_FORMAT, opts)
102
+ return respond(writer, opts, error: 'EBADPNG') unless image
103
+
104
+ cache_image(state, opts['i'] || opts['I'] || '', image)
105
+ if action == 'T'
106
+ display_image(screen, image, opts)
107
+ end
108
+ respond(writer, opts, ok: true)
109
+
110
+ when 'p'
111
+ id = opts['i'] || opts['I'] || ''
112
+ image = state[:cache][id]
113
+ if image
114
+ display_image(screen, image, opts)
115
+ respond(writer, opts, ok: true)
116
+ else
117
+ respond(writer, opts, error: 'ENOENT')
118
+ end
119
+
120
+ when 'd'
121
+ # kitty spec: a=d deletes placements (lowercase d-selector)
122
+ # or placements + images (uppercase selector). Default
123
+ # selector is 'a' (all placements on the visible screen).
124
+ #
125
+ # Supported selectors (subset; expand as use cases arrive):
126
+ # d=a / d=A — all placements; uppercase also flushes
127
+ # the bitmap cache so the next a=p has to
128
+ # re-decode.
129
+ # d=i / d=I — placements matching i= / I=; uppercase
130
+ # also flushes that one cache entry.
131
+ # (omitted) — treated as d=a per spec.
132
+ sel = (opts['d'] || 'a').to_s
133
+ target = opts['i'] || opts['I']
134
+ upper = sel == sel.upcase && !sel.empty?
135
+ case sel.downcase
136
+ when 'a', ''
137
+ screen.placements.clear if screen.respond_to?(:placements)
138
+ state[:cache].clear if upper
139
+ when 'i', 'n'
140
+ if target && !target.empty?
141
+ if screen.respond_to?(:placements)
142
+ screen.placements.reject! { |pl| pl[:image_id] == target }
143
+ end
144
+ state[:cache].delete(target) if upper
145
+ end
146
+ end
147
+ respond(writer, opts, ok: true)
148
+
149
+ when 'q'
150
+ # Capability probe. kitten icat (and others) send a tiny
151
+ # dummy frame with a=q before transmitting real images;
152
+ # they read the OK/error reply to decide whether the
153
+ # terminal supports the protocol. We don't decode the
154
+ # payload — just answer whether we'd accept this
155
+ # format / transmission / compression combo.
156
+ if supported_format?(opts['f']) &&
157
+ supported_transmission?(opts['t']) &&
158
+ supported_compression?(opts['o'])
159
+ respond(writer, opts, ok: true)
160
+ else
161
+ respond(writer, opts, error: 'EBADF')
162
+ end
163
+ end
164
+ end
165
+
166
+ def supported_format?(f)
167
+ case f.to_s
168
+ when '', '100', '24', '32' then true
169
+ else false
170
+ end
171
+ end
172
+
173
+ def supported_transmission?(t)
174
+ case t.to_s
175
+ when '', 'd', 'f', 't' then true # direct, file, tempfile
176
+ else false # 's' (shared mem) etc.
177
+ end
178
+ end
179
+
180
+ def supported_compression?(o)
181
+ case o.to_s
182
+ when '', 'z' then true # uncompressed, or raw zlib
183
+ else false
184
+ end
185
+ end
186
+
187
+ # Raw-zlib inflate when the wire says `o=z`. Returns the
188
+ # original bytes when no compression was applied, or nil on a
189
+ # corrupt stream (so the caller can answer EBADDATA).
190
+ def inflate_if_needed(bytes, compression)
191
+ case compression.to_s
192
+ when '' then bytes
193
+ when 'z' then Zlib::Inflate.inflate(bytes)
194
+ else nil
195
+ end
196
+ rescue Zlib::Error
197
+ nil
198
+ end
199
+
200
+ # Tolerant base64 decode: strips whitespace (some clients
201
+ # line-wrap APC payloads) and returns nil on invalid input.
202
+ # Avoids `require 'base64'` (no longer in default gems on
203
+ # Ruby 3.4+) by going through `String#unpack1('m0')`.
204
+ #
205
+ # The kitty spec explicitly permits omitted padding, and
206
+ # `kitten icat --transfer-mode stream` splits its base64 at
207
+ # arbitrary byte boundaries — the *assembled* payload across
208
+ # all m=1 chunks can therefore end mid-quad. Pad to a multiple
209
+ # of 4 before strict decode so we don't silently EBADPNG.
210
+ def decode_payload(b64)
211
+ cleaned = b64.to_s.delete("\r\n\t ")
212
+ return ''.b if cleaned.empty?
213
+ pad = (4 - cleaned.bytesize % 4) % 4
214
+ cleaned += '=' * pad if pad > 0
215
+ cleaned.unpack1('m0')
216
+ rescue ArgumentError
217
+ nil
218
+ end
219
+
220
+ # Read image bytes off the filesystem when the wire requested
221
+ # `t=f` or `t=t`. Returns nil on missing / unreadable file or
222
+ # any read error. For `t=t` the file is unlinked after a
223
+ # successful read; the kitty client uses this when sending a
224
+ # one-shot tempfile it owns.
225
+ def resolve_transmission(decoded_payload, opts)
226
+ case opts['t'].to_s
227
+ when '', 'd'
228
+ decoded_payload
229
+ when 'f', 't'
230
+ path = decoded_payload.dup.force_encoding('UTF-8')
231
+ return nil if path.empty? || !File.file?(path) || !File.readable?(path)
232
+ data = File.binread(path)
233
+ File.delete(path) if opts['t'] == 't'
234
+ data
235
+ else
236
+ nil # 's' / anything else — not supported
237
+ end
238
+ rescue StandardError
239
+ nil
240
+ end
241
+
242
+ # Decode an image payload to {rgba:, width:, height:}.
243
+ # f=100 / unset — PNG (and anything else NSBitmapImageRep
244
+ # eats: JPEG, GIF, TIFF, BMP)
245
+ # f=24 — raw RGB packed, dims from s= / v=
246
+ # f=32 — raw RGBA packed, dims from s= / v=
247
+ def decode_image(bytes, format, opts = {})
248
+ case format.to_s
249
+ when '100', ''
250
+ decode_png(bytes)
251
+ when '24'
252
+ load_appkit
253
+ AppKitPng.from_rgb(bytes, opts['s'].to_i, opts['v'].to_i)
254
+ when '32'
255
+ load_appkit
256
+ AppKitPng.from_rgba(bytes, opts['s'].to_i, opts['v'].to_i)
257
+ end
258
+ end
259
+
260
+ # PNG → {rgba:, width:, height:}. Implemented in
261
+ # kitty_graphics_appkit.rb; loaded lazily so non-GUI tests
262
+ # (which don't link AppKit) still pass.
263
+ def decode_png(bytes)
264
+ load_appkit
265
+ AppKitPng.decode(bytes)
266
+ end
267
+
268
+ def load_appkit
269
+ require_relative 'kitty_graphics_appkit'
270
+ end
271
+
272
+ def cache_image(state, id, image)
273
+ return if id.empty?
274
+ state[:cache].delete(id) # touch (LRU)
275
+ state[:cache][id] = image
276
+ state[:cache].shift while state[:cache].size > CACHE_LIMIT
277
+ end
278
+
279
+ def display_image(screen, image, opts)
280
+ return unless screen.respond_to?(:put_kitty_image)
281
+ raw_id = opts['i'].to_s
282
+ raw_id = opts['I'].to_s if raw_id.empty?
283
+ screen.put_kitty_image(
284
+ rgba: image[:rgba],
285
+ width: image[:width],
286
+ height: image[:height],
287
+ cells_w: (opts['c'] && !opts['c'].empty?) ? opts['c'].to_i : nil,
288
+ cells_h: (opts['r'] && !opts['r'].empty?) ? opts['r'].to_i : nil,
289
+ # Sub-cell pixel offsets for fine alignment. The kitty spec
290
+ # caps these at one cell minus one pixel; we let them
291
+ # through as-is — the renderer clips at the cell rect, so
292
+ # anything larger just gets cropped.
293
+ px_x_offset: opts['X'].to_i,
294
+ px_y_offset: opts['Y'].to_i,
295
+ suppress_cursor: opts['C'] == '1',
296
+ image_id: raw_id.empty? ? nil : raw_id,
297
+ )
298
+ end
299
+
300
+ # Spec: q=0 verbose, q=1 suppress success, q=2 suppress all.
301
+ # We always carry `i=`/`I=` back so the client can correlate.
302
+ def respond(writer, opts, ok: false, error: nil)
303
+ return unless writer
304
+ quiet = opts['q'].to_s
305
+ return if quiet == '2'
306
+ return if quiet == '1' && ok
307
+
308
+ id_part =
309
+ if (id = opts['i']) && !id.empty? then "i=#{id}"
310
+ elsif (n = opts['I']) && !n.empty? then "I=#{n}"
311
+ else ''
312
+ end
313
+ msg = ok ? 'OK' : error.to_s
314
+ writer.call("\e_G#{id_part};#{msg}\e\\")
315
+ rescue StandardError
316
+ # Writing to a closed pty etc. — never let response failure
317
+ # break the pane.
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle'
4
+ require_relative 'objc'
5
+
6
+ module Echoes
7
+ module KittyGraphics
8
+ # AppKit + CoreGraphics-backed PNG decoder, isolated in its own
9
+ # file so headless tests don't pull CoreGraphics into the
10
+ # process. Decoding takes raw PNG bytes through
11
+ # NSBitmapImageRep → CGImage → CGBitmapContext (RGBA8 layout we
12
+ # control), then reads the bitmap context's backing buffer as a
13
+ # Ruby string. Format conversion (palette PNG, 16-bit, etc.) is
14
+ # handled inside CoreGraphics so we always get RGBA8 out.
15
+ module AppKitPng
16
+ CG = Fiddle.dlopen('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
17
+
18
+ P = ObjC::P
19
+ D = ObjC::D
20
+ L = ObjC::L
21
+ I = ObjC::I
22
+ V = ObjC::V
23
+
24
+ ColorSpaceCreateDeviceRGB = Fiddle::Function.new(
25
+ CG['CGColorSpaceCreateDeviceRGB'], [], P
26
+ )
27
+ ColorSpaceRelease = Fiddle::Function.new(
28
+ CG['CGColorSpaceRelease'], [P], V
29
+ )
30
+ # CGBitmapContextCreate(data, w, h, bitsPerComp, bytesPerRow, cs, bitmapInfo)
31
+ BitmapContextCreate = Fiddle::Function.new(
32
+ CG['CGBitmapContextCreate'], [P, L, L, L, L, P, I], P
33
+ )
34
+ # CGContextDrawImage(ctx, rect, image) — CGRect inlined as 4 doubles
35
+ ContextDrawImage = Fiddle::Function.new(
36
+ CG['CGContextDrawImage'], [P, D, D, D, D, P], V
37
+ )
38
+ ContextRelease = Fiddle::Function.new(
39
+ CG['CGContextRelease'], [P], V
40
+ )
41
+ # CGDataProviderCreateWithData(info, data, size, releaseCallback)
42
+ DataProviderCreateWithData = Fiddle::Function.new(
43
+ CG['CGDataProviderCreateWithData'], [P, P, L, P], P
44
+ )
45
+ DataProviderRelease = Fiddle::Function.new(
46
+ CG['CGDataProviderRelease'], [P], V
47
+ )
48
+ # CGImageCreate(w, h, bpc, bpp, bpr, cs, bitmapInfo, provider, decode, interp, intent)
49
+ ImageCreate = Fiddle::Function.new(
50
+ CG['CGImageCreate'], [L, L, L, L, L, P, I, P, P, I, I], P
51
+ )
52
+ ImageRelease = Fiddle::Function.new(
53
+ CG['CGImageRelease'], [P], V
54
+ )
55
+
56
+ # kCGImageAlphaNone — source RGB has no alpha channel (24bpp)
57
+ ALPHA_NONE = 0
58
+ # kCGImageAlphaPremultipliedLast (RGBA byte order, alpha last)
59
+ ALPHA_PREMULTIPLIED_LAST = 1
60
+
61
+ module_function
62
+
63
+ def decode(bytes)
64
+ return nil if bytes.nil? || bytes.bytesize.zero?
65
+
66
+ ns_data = ObjC::MSG_PTR_1L.call(
67
+ ObjC.cls('NSData'), ObjC.sel('dataWithBytes:length:'),
68
+ Fiddle::Pointer[bytes], bytes.bytesize
69
+ )
70
+ return nil if ns_data.null?
71
+
72
+ rep = ObjC::MSG_PTR_1.call(
73
+ ObjC.cls('NSBitmapImageRep'),
74
+ ObjC.sel('imageRepWithData:'),
75
+ ns_data
76
+ )
77
+ return nil if rep.null?
78
+
79
+ width = ObjC::MSG_RET_L.call(rep, ObjC.sel('pixelsWide'))
80
+ height = ObjC::MSG_RET_L.call(rep, ObjC.sel('pixelsHigh'))
81
+ return nil if width <= 0 || height <= 0
82
+
83
+ cgimage = ObjC::MSG_PTR.call(rep, ObjC.sel('CGImage'))
84
+ return nil if cgimage.null?
85
+
86
+ bytes_per_row = width * 4
87
+ buf = Fiddle::Pointer.malloc(width * height * 4, Fiddle::RUBY_FREE)
88
+ cs = ColorSpaceCreateDeviceRGB.call
89
+ begin
90
+ ctx = BitmapContextCreate.call(
91
+ buf, width, height, 8, bytes_per_row, cs,
92
+ ALPHA_PREMULTIPLIED_LAST
93
+ )
94
+ return nil if ctx.null?
95
+ begin
96
+ # CoreGraphics' coordinate system has y up; PNGs decode
97
+ # top-down. The renderer expects pixel row 0 at the top,
98
+ # which matches CGContextDrawImage's natural output here
99
+ # because the bitmap context we created has the same
100
+ # orientation we'll later read from.
101
+ ContextDrawImage.call(ctx, 0.0, 0.0, width.to_f, height.to_f, cgimage)
102
+ rgba = buf.to_str(width * height * 4)
103
+ {rgba: rgba, width: width, height: height}
104
+ ensure
105
+ ContextRelease.call(ctx)
106
+ end
107
+ ensure
108
+ ColorSpaceRelease.call(cs)
109
+ end
110
+ end
111
+
112
+ # Raw 24-bit RGB → RGBA8. The wire carries width*height*3
113
+ # bytes; we wrap them in a CGImage (kCGImageAlphaNone, 24bpp)
114
+ # and draw into a fresh kCGImageAlphaPremultipliedLast context.
115
+ # CG fills the alpha channel with 0xFF for us, so the renderer
116
+ # sees the same RGBA8 shape PNGs produce.
117
+ def from_rgb(bytes, width, height)
118
+ return nil if bytes.nil? || width <= 0 || height <= 0
119
+ return nil if bytes.bytesize != width * height * 3
120
+ draw_into_rgba(bytes, width, height,
121
+ bits_per_pixel: 24,
122
+ bytes_per_row: width * 3,
123
+ source_bitmap_info: ALPHA_NONE)
124
+ end
125
+
126
+ # Raw 32-bit RGBA → RGBA8 (premultiplied). The wire carries
127
+ # width*height*4 bytes; treated as alpha-premultiplied to
128
+ # match the PNG path's output exactly.
129
+ def from_rgba(bytes, width, height)
130
+ return nil if bytes.nil? || width <= 0 || height <= 0
131
+ return nil if bytes.bytesize != width * height * 4
132
+ draw_into_rgba(bytes, width, height,
133
+ bits_per_pixel: 32,
134
+ bytes_per_row: width * 4,
135
+ source_bitmap_info: ALPHA_PREMULTIPLIED_LAST)
136
+ end
137
+
138
+ # Wrap raw pixel bytes in a CGImage, draw into a fresh
139
+ # premultiplied-RGBA8 CGBitmapContext. We keep a Ruby
140
+ # reference to `bytes` so the GC can't collect it while CG
141
+ # still holds the data-provider pointer.
142
+ def draw_into_rgba(bytes, width, height,
143
+ bits_per_pixel:, bytes_per_row:, source_bitmap_info:)
144
+ src_ptr = Fiddle::Pointer[bytes]
145
+ provider = DataProviderCreateWithData.call(nil, src_ptr, bytes.bytesize, nil)
146
+ return nil if provider.null?
147
+ cs = ColorSpaceCreateDeviceRGB.call
148
+ begin
149
+ cgimage = ImageCreate.call(width, height, 8, bits_per_pixel, bytes_per_row,
150
+ cs, source_bitmap_info, provider, nil, 0, 0)
151
+ return nil if cgimage.null?
152
+ begin
153
+ buf = Fiddle::Pointer.malloc(width * height * 4, Fiddle::RUBY_FREE)
154
+ ctx = BitmapContextCreate.call(buf, width, height, 8, width * 4, cs,
155
+ ALPHA_PREMULTIPLIED_LAST)
156
+ return nil if ctx.null?
157
+ begin
158
+ ContextDrawImage.call(ctx, 0.0, 0.0, width.to_f, height.to_f, cgimage)
159
+ rgba = buf.to_str(width * height * 4)
160
+ {rgba: rgba, width: width, height: height}
161
+ ensure
162
+ ContextRelease.call(ctx)
163
+ end
164
+ ensure
165
+ ImageRelease.call(cgimage)
166
+ end
167
+ ensure
168
+ ColorSpaceRelease.call(cs)
169
+ DataProviderRelease.call(provider)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
data/lib/echoes/objc.rb CHANGED
@@ -35,6 +35,7 @@ module Echoes
35
35
  MSG_PTR_2 = new_msg([P, P, P, P], P) # id = msg(id, SEL, id, id)
36
36
  MSG_PTR_L = new_msg([P, P, L], P) # id = msg(id, SEL, long)
37
37
  MSG_PTR_1L = new_msg([P, P, P, L], P) # id = msg(id, SEL, id, long)
38
+ MSG_PTR_L_1 = new_msg([P, P, L, P], P) # id = msg(id, SEL, long, id)
38
39
  MSG_VOID = new_msg([P, P], V) # void = msg(id, SEL)
39
40
  MSG_VOID_1 = new_msg([P, P, P], V) # void = msg(id, SEL, id)
40
41
  MSG_VOID_2 = new_msg([P, P, P, P], V) # void = msg(id, SEL, id, id)
@@ -157,6 +158,7 @@ module Echoes
157
158
  NSForegroundColorAttributeName = appkit_const('NSForegroundColorAttributeName')
158
159
  NSUnderlineStyleAttributeName = appkit_const('NSUnderlineStyleAttributeName')
159
160
  NSStrikethroughStyleAttributeName = appkit_const('NSStrikethroughStyleAttributeName')
161
+ NSLigatureAttributeName = appkit_const('NSLigatureAttributeName')
160
162
  NSPasteboardTypeString = appkit_const('NSPasteboardTypeString')
161
163
  NSPasteboardTypeFileURL = appkit_const('NSPasteboardTypeFileURL')
162
164