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.
- checksums.yaml +4 -4
- data/lib/echoes/client.rb +30 -9
- data/lib/echoes/configuration.rb +131 -0
- data/lib/echoes/embedded_shell.rb +9 -4
- data/lib/echoes/gui.rb +962 -108
- data/lib/echoes/iterm2_images.rb +122 -0
- data/lib/echoes/kitty_graphics.rb +320 -0
- data/lib/echoes/kitty_graphics_appkit.rb +174 -0
- data/lib/echoes/objc.rb +2 -0
- data/lib/echoes/pane.rb +108 -9
- data/lib/echoes/parser.rb +145 -10
- data/lib/echoes/profile.rb +75 -0
- data/lib/echoes/screen.rb +144 -9
- data/lib/echoes/tab.rb +2 -2
- data/lib/echoes/version.rb +1 -1
- metadata +5 -1
|
@@ -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
|
|