echoes 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 840cfd55fa04459db94048feb0ace4ac1fcd24afe1be90782f90ad10ee849c3f
4
- data.tar.gz: 93f77b0d7360472791d898ceef10049a72019ef3facacbe73280648f740eb929
3
+ metadata.gz: 988274d532cf54ecb84bae874115f635b993e6817c259c41811b33265c55d725
4
+ data.tar.gz: 73f6868b563a851c1d171908f92ad7cf6ad8dd046f101c178f826a18790f8d0f
5
5
  SHA512:
6
- metadata.gz: 68c56c9b24cf3bd2456cea2b559bff0d7e4a25df9562561c6d29b79b0a9f54b171074f5dcc6cbf0731336037821ddd641f95295f1bfc81ae158e080089be2210
7
- data.tar.gz: 9f86318197b4796b239013d626fdbf0e2214a5fa343c215c56518fdd77f25bce3c04995929840b496dbb1d8fa61bd3c769db4eb794ea45a7d47fa6808c97b1ca
6
+ metadata.gz: 5120ced363551ab708776569564b96754c70d7dfe06056b3c97e0b3749e24b4fd87e060f126cacea6de655fb9b78dd1910a22eb49988b772aace46b6e0a5d99f
7
+ data.tar.gz: 7131de98f47c8a16d7ae6aeaef86a74af5d58412fa9c2d2b4948ee03734c97ed63d4329e186dbf83c1d4765ae148141119a20a55e4b1facfc26a7db181346dd7
data/README.md CHANGED
@@ -52,6 +52,37 @@ bundle exec exe/echoes -t # TTY mode
52
52
  Insert mode, `:w`, `:q`, search, visual mode, undo — the full vim
53
53
  surface. The dialog opens at the active pane's pwd.
54
54
 
55
+ ## Terminal features
56
+
57
+ - **Images** — Kitty graphics protocol (PNG via `f=100`, raw RGB /
58
+ RGBA via `f=24` / `f=32`, zlib-compressed payloads with `o=z`,
59
+ file-path transmission via `t=f` / `t=t`, sub-cell pixel offsets,
60
+ placements with `q=` / `a=p` / `a=d`, `z=` for layering — negative
61
+ `z` blits the image beneath cell text, the default `z=0` and any
62
+ positive value blit on top) and iTerm2 inline images
63
+ (OSC 1337 `File=` with PNG / JPEG / TIFF / GIF). **SVG** is also
64
+ rendered by both protocols — detected by content-sniffing the
65
+ payload, rasterized at the cell footprint requested (`c=`/`r=` for
66
+ Kitty, `width=`/`height=` for iTerm2). Path-only SVGs (paths,
67
+ basic shapes, `<g>`, transforms) go through a native CoreGraphics
68
+ fast path — synchronous, no XPC. SVGs containing `<text>`,
69
+ `<filter>`, gradients, `<use>`, etc. fall through transparently
70
+ to a WKWebView backend (slower first paint, but full CSS / SVG
71
+ surface). JavaScript is disabled and external resources blocked
72
+ on both paths.
73
+ - **Desktop notifications** — OSC 9 (`\e]9;message\a`, iTerm2 style)
74
+ and OSC 777 (`\e]777;notify;title;message\a`, VTE style) deliver
75
+ to the macOS Notification Center.
76
+ - **Programming-font ligatures** — `=>`, `!=`, `<=`, `->`, etc. shape
77
+ through Core Text when the active font (Fira Code, JetBrains Mono,
78
+ …) ships them.
79
+ - **Synchronized output (DEC 2026)** — full-frame updates from tmux,
80
+ Neovim, and IDEs land atomically instead of tearing.
81
+ - **Find** — Cmd+F opens the search bar; while it's focused, Cmd+R
82
+ toggles regex mode and Cmd+I toggles case-insensitive matching.
83
+ - **Pane capture** — a running program can snapshot its own pane to
84
+ disk as raster PNG or vector PDF via `\e]7772;capture` (see below).
85
+
55
86
  ## Keyboard shortcuts
56
87
 
57
88
  | Shortcut | Action |
@@ -72,14 +103,84 @@ bundle exec exe/echoes -t # TTY mode
72
103
  | Cmd+Shift+C | Toggle copy mode |
73
104
  | Cmd+Ctrl+F | Enter / leave full screen |
74
105
 
106
+ ## Configuration
107
+
108
+ Echoes reads `~/.config/echoes/echoes.conf` at startup. The file is
109
+ plain Ruby `instance_eval`'d against `Echoes.config`; every setter
110
+ below has the same name as its config attribute.
111
+
112
+ ```ruby
113
+ font_family "JetBrains Mono"
114
+ font_size 14.0
115
+ rows 24
116
+ cols 80
117
+ shell "/bin/zsh"
118
+ scrollback_limit 10_000
119
+ tab_position :top # or :bottom
120
+ window_title "Echoes"
121
+ foreground "#e0e0e0"
122
+ background "#1a1a2e"
123
+ cursor_color "#b58900"
124
+ selection_color "#586e75"
125
+ ```
126
+
127
+ ### Profiles (color themes)
128
+
129
+ Two profiles ship built-in (Solarized Dark, Solarized Light), so the
130
+ View → Profile submenu has alternatives without any config. Declare
131
+ your own — or re-declare a built-in by the same name to override it.
132
+ Switching at runtime through the menu repaints the palette, fg/bg,
133
+ selection, and cursor without a restart.
134
+
135
+ ```ruby
136
+ profile "Tokyo Night" do
137
+ foreground "#c0caf5"
138
+ background "#1a1b26"
139
+ cursor_color "#c0caf5"
140
+ selection_color "#283457"
141
+ color_palette %w[
142
+ #15161e #f7768e #9ece6a #e0af68 #7aa2f7 #bb9af7 #7dcfff #a9b1d6
143
+ #414868 #f7768e #9ece6a #e0af68 #7aa2f7 #bb9af7 #7dcfff #c0caf5
144
+ ]
145
+ end
146
+
147
+ default_profile "Tokyo Night"
148
+ ```
149
+
150
+ ### Custom keybinds
151
+
152
+ Override any menu shortcut by action symbol. Pass an empty string to
153
+ disable a default shortcut entirely.
154
+
155
+ ```ruby
156
+ keybind "Cmd+Shift+T", :new_tab
157
+ keybind "Cmd+K", :toggle_find
158
+ keybind "", :toggle_pointer # disable the default
159
+ ```
160
+
161
+ Available actions (one per menu item): `:new_window`, `:new_tab`,
162
+ `:close_tab`, `:close_pane`, `:edit_file`, `:split_right`, `:split_down`,
163
+ `:select_next_pane`, `:select_previous_pane`, `:show_next_tab`,
164
+ `:show_previous_tab`, `:increase_font_size`, `:decrease_font_size`,
165
+ `:reset_font_size`, `:toggle_find`, `:find_next`, `:find_previous`,
166
+ `:toggle_pointer`, `:toggle_copy_mode`.
167
+
168
+ Modifier names are case-insensitive and accept the obvious aliases:
169
+ `Cmd`/`Command`/`Super`, `Ctrl`/`Control`, `Opt`/`Option`/`Alt`, `Shift`.
170
+
75
171
  ## OSC extensions
76
172
 
77
- Echoes recognizes a private OSC namespace under code `7772`. Other
78
- terminals ignore unknown OSC codes, so emitters degrade gracefully.
173
+ ### Echoes private namespace (OSC 7772)
174
+
175
+ All Echoes-specific escape sequences live under OSC code `7772`.
176
+ Other terminals ignore unknown OSC codes, so emitters degrade
177
+ gracefully.
178
+
179
+ **Background painting** (pane-scoped):
79
180
 
80
181
  ```
81
182
  \e]7772;bg-color;#rrggbb\a
82
- \e]7772;bg-gradient;type=linear:angle=N:colors=#rrggbb,#rrggbb\a
183
+ \e]7772;bg-gradient;type=linear:angle=N:colors=#rrggbb,#rrggbb[,...]\a
83
184
  \e]7772;bg-fill;color=#rrggbb:rect=row1,col1,row2,col2\a
84
185
  \e]7772;bg-clear\a
85
186
  ```
@@ -89,15 +190,54 @@ slide layout (header bar, sidebar, accent stripe) on top of a base
89
190
  `bg-color` or `bg-gradient`. `bg-clear` wipes both the base layer
90
191
  and all fills.
91
192
 
92
- OSC 66 is also extended:
193
+ **Multicell glyphs** (OSC-66-shaped, with Echoes-only knobs):
194
+
195
+ ```
196
+ \e]7772;multicell;<key=value:...>;<text>\a
197
+ ```
198
+
199
+ Accepts the standard kitty `s/w/n/d/v/h` keys plus:
200
+
201
+ - `f=Family Name` — render the glyph(s) in a specific font.
202
+ Proportional fonts (Helvetica, Noto Serif, …) are measured
203
+ per-glyph at layout time so the cells reserved match the actual
204
+ rendered width — `Hello` in Noto Serif at 2× lays out cleanly
205
+ without overflow or gaps.
206
+ - `flip=h|v|hv` — mirror the rendered glyph(s) horizontally,
207
+ vertically, or both. Handy for direction-having emojis (e.g.
208
+ flipping 🐇 to face the other way).
209
+
210
+ `h=` (halign) is honored for non-fractional / proportional text:
211
+ the whole string lands in an `s × source_chars` cell block with
212
+ the renderer's center / right-align math applied.
93
213
 
94
- - `f=Family Name` selects a font family for the multicell glyph.
95
- Proportional fonts are measured per-glyph at layout time so the
96
- cells reserved match the actual rendered width — `Hello` in Noto
97
- Serif at 2× lays out cleanly without overflow or gaps.
98
- - `h=` (halign) is honored for non-fractional / proportional text:
99
- the whole string lands in a `s × source_chars` cell block, with
100
- the renderer's existing center / right-align math applied.
214
+ These knobs are routed through OSC 7772 rather than OSC 66 so a
215
+ future kitty-spec extension claiming the same param names can't
216
+ collide with ours.
217
+
218
+ **Pane snapshots, multi-display, child windows**:
219
+
220
+ ```
221
+ \e]7772;capture;<absolute-path.png|.pdf>\a
222
+ \e]7772;display-info\a
223
+ \e]7772;open-window;display=N:program=<base64-argv>:fullscreen=yes|no\a
224
+ ```
225
+
226
+ - `capture` writes a snapshot of the active pane to disk. `.png`
227
+ rasterizes via `NSBitmapImageRep`; anything else (default `.pdf`)
228
+ saves vector — usually smaller and crisper because text and
229
+ background gradients stay resolution-independent.
230
+ - `display-info` is a sync query: the host replies on the same pty
231
+ with `\e]7772;display-info;<json>\a`, where `<json>` is an array
232
+ of `{index, w, h, primary, current}` per `NSScreen`. A presentation
233
+ tool uses `current` to pick "anywhere but here" for a second-screen
234
+ slide window.
235
+ - `open-window` spawns a child program in a new Echoes window on the
236
+ chosen display. `program` is the argv JSON-encoded then
237
+ base64-wrapped (e.g. `Base64.strict_encode64(JSON.dump(argv))`).
238
+ `fullscreen=yes` uses a borderless, above-menu-bar window covering
239
+ the full screen frame; otherwise the visible frame with default
240
+ chrome.
101
241
 
102
242
  A small Ruby helper (`Echoes::Client`) lets in-pane Ruby tools emit
103
243
  these without hand-rolling escape sequences:
@@ -108,9 +248,36 @@ require 'echoes/client'
108
248
  Echoes::Client.bg_gradient(from: '#1a1a2e', to: '#16213e', angle: 90)
109
249
  Echoes::Client.bg_fill('#ff6b35', row1: 0, col1: 0, row2: 2, col2: 79)
110
250
  Echoes::Client.styled_text("Title", scale: 3, family: "Helvetica Neue")
251
+ Echoes::Client.capture("/tmp/slide.pdf")
111
252
  Echoes::Client.bg_clear
112
253
  ```
113
254
 
255
+ `Echoes::Client.styled_text` auto-routes: OSC 66 when only standard
256
+ knobs are used (portable), OSC 7772 `;multicell` when an Echoes-only
257
+ knob like `family:` is set.
258
+
259
+ ### Compatibility with other terminals
260
+
261
+ | Code | Use |
262
+ |----------------------|-----------------------------------------------------------|
263
+ | OSC 4 / 10 / 11 / 12 | Get/set 256-color palette + default fg / bg / cursor |
264
+ | OSC 7 | Working directory (`file://host/path`) |
265
+ | OSC 9 | iTerm2-style notification — `\e]9;message\a` |
266
+ | OSC 52 | Clipboard read/write |
267
+ | OSC 66 | Kitty multicell (standard `s/w/n/d/v/h` only) |
268
+ | OSC 133 | Semantic prompts (FinalTerm / iTerm2 style) |
269
+ | OSC 777 | VTE-style notification — `\e]777;notify;title;message\a` |
270
+ | OSC 1337 | iTerm2 inline images — `\e]1337;File=<args>:<base64>\a` |
271
+ | APC `_G…` | Kitty graphics protocol (PNG, raw RGB/RGBA, zlib, file) |
272
+ | (sniffed) | SVG payloads — auto-detected in both image protocols |
273
+ | DECSET 2026 | Synchronized output |
274
+
275
+ OSC 9 / OSC 777 notifications go through `terminal-notifier` when it's
276
+ on `$PATH`, otherwise fall back to `osascript`'s `display notification`
277
+ primitive. Echoes-only knobs that would otherwise extend OSC 66 (font
278
+ family, mirror flips) live on OSC 7772 `;multicell` instead, so portable
279
+ emitters can keep OSC 66 strictly kitty-spec compatible.
280
+
114
281
  ## Development
115
282
 
116
283
  ```sh
data/lib/echoes/gui.rb CHANGED
@@ -1040,6 +1040,19 @@ module Echoes
1040
1040
  draw_pane_background(screen.background, px, py, pane_cols, pane_rows) if screen.background
1041
1041
  draw_pane_fills(screen.bg_fills, px, py, pane_cols, pane_rows) if screen.bg_fills && !screen.bg_fills.empty?
1042
1042
 
1043
+ # Kitty graphics z<0 placements blit BEFORE cells, so cell glyphs
1044
+ # (and explicit cell bg fills) draw on top. Sorted ascending by
1045
+ # z so a less-negative z stacks atop a more-negative one. Cells
1046
+ # with the default (transparent) bg skip the fill rect — the
1047
+ # placement shows through; cells with an explicit ANSI bg paint
1048
+ # opaque and occlude the placement at those cells.
1049
+ below = screen.placements.select { |pl| pl[:z_index].to_i < 0 }
1050
+ if !below.empty?
1051
+ below.sort_by { |pl| pl[:z_index].to_i }.each do |pl|
1052
+ blit_kitty_placement(pl, px, py, pane_rows)
1053
+ end
1054
+ end
1055
+
1043
1056
  pane_rows.times do |r|
1044
1057
  y = py + r * @cell_height
1045
1058
  next if y + @cell_height < dirty_min_y || y > dirty_max_y
@@ -1308,15 +1321,17 @@ module Echoes
1308
1321
  flush_run.call
1309
1322
  end
1310
1323
 
1311
- # Re-blit kitty graphics placements ON TOP of the rendered
1312
- # cells. Anchors are stored as logical cell coords on the
1313
- # Screen; we convert to pixels here using current cell
1314
- # metrics so font-size and pane-resize changes pick up the
1315
- # right pixel position automatically placements need no
1316
- # bookkeeping when @cell_width / @cell_height shift.
1317
- screen.placements.each do |pl|
1318
- blit_kitty_placement(pl, px, py, pane_rows)
1319
- end
1324
+ # Re-blit kitty graphics z>=0 placements ON TOP of the rendered
1325
+ # cells. (z<0 placements were drawn above before the cell loop,
1326
+ # so cell text appears in front of them.) Anchors are stored as
1327
+ # logical cell coords on the Screen; we convert to pixels here
1328
+ # using current cell metrics so font-size and pane-resize changes
1329
+ # pick up the right pixel position automatically — placements
1330
+ # need no bookkeeping when @cell_width / @cell_height shift.
1331
+ screen.placements
1332
+ .select { |pl| pl[:z_index].to_i >= 0 }
1333
+ .sort_by { |pl| pl[:z_index].to_i }
1334
+ .each { |pl| blit_kitty_placement(pl, px, py, pane_rows) }
1320
1335
 
1321
1336
  # Draw cursor or copy mode cursor
1322
1337
  if copy_mode&.active
@@ -41,7 +41,11 @@ module Echoes
41
41
  bytes = decode_payload(payload_b64)
42
42
  return nil unless bytes
43
43
 
44
- image = decode_image(bytes)
44
+ image = if svg?(bytes)
45
+ decode_svg(bytes, params, screen)
46
+ else
47
+ decode_image(bytes)
48
+ end
45
49
  return nil unless image
46
50
 
47
51
  cells_w, cells_h = compute_cell_dimensions(params, image, screen)
@@ -86,6 +90,57 @@ module Echoes
86
90
  KittyGraphics::AppKitPng.decode(bytes)
87
91
  end
88
92
 
93
+ # SVG → {rgba:, width:, height:}. Computes the target pixel size
94
+ # from the wire-format width / height (cells, px, or %) before
95
+ # rasterizing, because the renderer needs an explicit target —
96
+ # vector input has no intrinsic raster dimensions.
97
+ def decode_svg(bytes, params, screen)
98
+ require_relative 'svg_renderer'
99
+ w, h = svg_target_pixels(params, screen, bytes)
100
+ SvgRenderer.rasterize(bytes, width: w, height: h)
101
+ end
102
+
103
+ def svg?(bytes)
104
+ require_relative 'svg_sniffer'
105
+ SvgSniffer.svg?(bytes)
106
+ end
107
+
108
+ # Pick the rasterization target. Priority:
109
+ # 1. Explicit wire dim from the client (cells × cell_px, Npx, or %)
110
+ # 2. Intrinsic width/height/viewBox parsed from the <svg> tag
111
+ # 3. 512² fallback
112
+ # Capped at 4096 per axis so a runaway `viewBox="0 0 1e6 1e6"`
113
+ # can't ask for gigabyte buffers.
114
+ def svg_target_pixels(params, screen, bytes)
115
+ cell_w = screen.cell_pixel_width.to_f
116
+ cell_h = screen.cell_pixel_height.to_f
117
+ w = svg_dim_to_pixels(params['width'], cell_w, screen.cols)
118
+ h = svg_dim_to_pixels(params['height'], cell_h, screen.rows)
119
+ if w.nil? || h.nil?
120
+ iw, ih = SvgSniffer.intrinsic_size(bytes)
121
+ w ||= iw if iw
122
+ h ||= ih if ih
123
+ end
124
+ w ||= 512
125
+ h ||= 512
126
+ [w.clamp(1, 4096), h.clamp(1, 4096)]
127
+ end
128
+
129
+ def svg_dim_to_pixels(value, cell_px, screen_size)
130
+ return nil if value.nil? || value.empty? || value == 'auto'
131
+ if value.end_with?('px')
132
+ n = value.to_f
133
+ n.positive? ? n.round : nil
134
+ elsif value.end_with?('%')
135
+ return nil if cell_px <= 0
136
+ (screen_size * value.to_f / 100.0 * cell_px).round
137
+ else
138
+ return nil if cell_px <= 0
139
+ n = value.to_f
140
+ n.positive? ? (n * cell_px).round : nil
141
+ end
142
+ end
143
+
89
144
  # Translate the wire's `width=` / `height=` into cell counts
90
145
  # for the renderer. Supported forms (per iTerm2 docs):
91
146
  # N — N character cells
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'zlib'
4
+ require_relative 'svg_sniffer'
4
5
 
5
6
  module Echoes
6
7
  # Minimum-viable Kitty graphics protocol decoder. Wire format:
@@ -98,7 +99,7 @@ module Echoes
98
99
  bytes = inflate_if_needed(bytes, opts['o'])
99
100
  return respond(writer, opts, error: 'EBADDATA') unless bytes
100
101
 
101
- image = decode_image(bytes, opts['f'] || DEFAULT_FORMAT, opts)
102
+ image = decode_image(bytes, opts['f'] || DEFAULT_FORMAT, opts, screen: screen)
102
103
  return respond(writer, opts, error: 'EBADPNG') unless image
103
104
 
104
105
  cache_image(state, opts['i'] || opts['I'] || '', image)
@@ -240,11 +241,13 @@ module Echoes
240
241
  end
241
242
 
242
243
  # Decode an image payload to {rgba:, width:, height:}.
244
+ # <sniffed SVG> — render via WKWebView regardless of f=
243
245
  # f=100 / unset — PNG (and anything else NSBitmapImageRep
244
246
  # eats: JPEG, GIF, TIFF, BMP)
245
247
  # f=24 — raw RGB packed, dims from s= / v=
246
248
  # f=32 — raw RGBA packed, dims from s= / v=
247
- def decode_image(bytes, format, opts = {})
249
+ def decode_image(bytes, format, opts = {}, screen: nil)
250
+ return decode_svg(bytes, opts, screen) if SvgSniffer.svg?(bytes)
248
251
  case format.to_s
249
252
  when '100', ''
250
253
  decode_png(bytes)
@@ -257,6 +260,44 @@ module Echoes
257
260
  end
258
261
  end
259
262
 
263
+ # SVG → {rgba:, width:, height:}. Picks the rasterization target
264
+ # before handing off to SvgRenderer (vector input has no intrinsic
265
+ # raster size, so the renderer needs explicit pixel dims).
266
+ def decode_svg(bytes, opts, screen)
267
+ require_relative 'svg_renderer'
268
+ w, h = svg_target_pixels(opts, screen, bytes)
269
+ SvgRenderer.rasterize(bytes, width: w, height: h)
270
+ end
271
+
272
+ # Priority: explicit pixels (s= / v=) → explicit cells (c= / r=)
273
+ # × cell pixel size → SVG intrinsic → 512² fallback. Capped at
274
+ # 4096 per axis so a runaway `viewBox="0 0 1e6 1e6"` can't ask
275
+ # for gigabyte buffers.
276
+ def svg_target_pixels(opts, screen, bytes)
277
+ cell_w = screen&.cell_pixel_width.to_f
278
+ cell_h = screen&.cell_pixel_height.to_f
279
+ w = svg_explicit_pixels(opts['s'], opts['c'], cell_w)
280
+ h = svg_explicit_pixels(opts['v'], opts['r'], cell_h)
281
+ if w.nil? || h.nil?
282
+ iw, ih = SvgSniffer.intrinsic_size(bytes)
283
+ w ||= iw if iw
284
+ h ||= ih if ih
285
+ end
286
+ w ||= 512
287
+ h ||= 512
288
+ [w.clamp(1, 4096), h.clamp(1, 4096)]
289
+ end
290
+
291
+ def svg_explicit_pixels(px_val, cell_val, cell_px)
292
+ if px_val && !px_val.empty? && px_val.to_i > 0
293
+ return px_val.to_i
294
+ end
295
+ if cell_val && !cell_val.empty? && cell_val.to_i > 0 && cell_px > 0
296
+ return (cell_val.to_i * cell_px).round
297
+ end
298
+ nil
299
+ end
300
+
260
301
  # PNG → {rgba:, width:, height:}. Implemented in
261
302
  # kitty_graphics_appkit.rb; loaded lazily so non-GUI tests
262
303
  # (which don't link AppKit) still pass.
@@ -294,6 +335,10 @@ module Echoes
294
335
  px_y_offset: opts['Y'].to_i,
295
336
  suppress_cursor: opts['C'] == '1',
296
337
  image_id: raw_id.empty? ? nil : raw_id,
338
+ # z<0 layers the image beneath cell text (the GUI splits the
339
+ # placement blit into pre-cell and post-cell passes by z sign);
340
+ # z>=0 (the spec default of 0 included) layers on top.
341
+ z_index: opts['z'].to_i,
297
342
  )
298
343
  end
299
344
 
@@ -83,6 +83,20 @@ module Echoes
83
83
  cgimage = ObjC::MSG_PTR.call(rep, ObjC.sel('CGImage'))
84
84
  return nil if cgimage.null?
85
85
 
86
+ cgimage_to_rgba(cgimage, width, height)
87
+ end
88
+
89
+ # Draw a CGImage into a fresh premultiplied-RGBA8 bitmap context
90
+ # and return the raw pixel buffer as {rgba:, width:, height:}.
91
+ # Shared between the PNG decoder and SvgRenderer (which gets its
92
+ # CGImage from a WKWebView snapshot rather than NSBitmapImageRep).
93
+ # Returns nil if the bitmap context can't be allocated.
94
+ #
95
+ # CoreGraphics' coordinate system has y up; PNGs decode top-down.
96
+ # The renderer expects pixel row 0 at the top, which matches
97
+ # CGContextDrawImage's natural output here because the bitmap
98
+ # context we created has the same orientation we'll later read.
99
+ def cgimage_to_rgba(cgimage, width, height)
86
100
  bytes_per_row = width * 4
87
101
  buf = Fiddle::Pointer.malloc(width * height * 4, Fiddle::RUBY_FREE)
88
102
  cs = ColorSpaceCreateDeviceRGB.call
@@ -93,11 +107,6 @@ module Echoes
93
107
  )
94
108
  return nil if ctx.null?
95
109
  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
110
  ContextDrawImage.call(ctx, 0.0, 0.0, width.to_f, height.to_f, cgimage)
102
111
  rgba = buf.to_str(width * height * 4)
103
112
  {rgba: rgba, width: width, height: height}
data/lib/echoes/objc.rb CHANGED
@@ -174,12 +174,59 @@ module Echoes
174
174
  CGContextRestoreGState = Fiddle::Function.new(COREGRAPHICS['CGContextRestoreGState'], [P], V)
175
175
  CGContextTranslateCTM = Fiddle::Function.new(COREGRAPHICS['CGContextTranslateCTM'], [P, D, D], V)
176
176
  CGContextScaleCTM = Fiddle::Function.new(COREGRAPHICS['CGContextScaleCTM'], [P, D, D], V)
177
+ CGContextRotateCTM = Fiddle::Function.new(COREGRAPHICS['CGContextRotateCTM'], [P, D], V)
178
+ # CGAffineTransform inlined as 6 doubles (a, b, c, d, tx, ty).
179
+ CGContextConcatCTM = Fiddle::Function.new(COREGRAPHICS['CGContextConcatCTM'], [P, D, D, D, D, D, D], V)
180
+ CGContextClearRect = Fiddle::Function.new(COREGRAPHICS['CGContextClearRect'], [P, D, D, D, D], V)
177
181
  CGImageRelease = Fiddle::Function.new(COREGRAPHICS['CGImageRelease'], [P], V)
178
182
  CGContextRelease = Fiddle::Function.new(COREGRAPHICS['CGContextRelease'], [P], V)
179
183
 
184
+ # Path construction
185
+ CGContextBeginPath = Fiddle::Function.new(COREGRAPHICS['CGContextBeginPath'], [P], V)
186
+ CGContextMoveToPoint = Fiddle::Function.new(COREGRAPHICS['CGContextMoveToPoint'], [P, D, D], V)
187
+ CGContextAddLineToPoint = Fiddle::Function.new(COREGRAPHICS['CGContextAddLineToPoint'], [P, D, D], V)
188
+ CGContextAddCurveToPoint = Fiddle::Function.new(COREGRAPHICS['CGContextAddCurveToPoint'], [P, D, D, D, D, D, D], V)
189
+ CGContextAddQuadCurveToPoint = Fiddle::Function.new(COREGRAPHICS['CGContextAddQuadCurveToPoint'], [P, D, D, D, D], V)
190
+ # CGContextAddArc(ctx, x, y, radius, startAngle, endAngle, clockwise)
191
+ CGContextAddArc = Fiddle::Function.new(COREGRAPHICS['CGContextAddArc'], [P, D, D, D, D, D, I], V)
192
+ CGContextClosePath = Fiddle::Function.new(COREGRAPHICS['CGContextClosePath'], [P], V)
193
+
194
+ # Paint state
195
+ CGContextSetRGBFillColor = Fiddle::Function.new(COREGRAPHICS['CGContextSetRGBFillColor'], [P, D, D, D, D], V)
196
+ CGContextSetRGBStrokeColor = Fiddle::Function.new(COREGRAPHICS['CGContextSetRGBStrokeColor'], [P, D, D, D, D], V)
197
+ CGContextSetAlpha = Fiddle::Function.new(COREGRAPHICS['CGContextSetAlpha'], [P, D], V)
198
+ CGContextSetLineWidth = Fiddle::Function.new(COREGRAPHICS['CGContextSetLineWidth'], [P, D], V)
199
+ CGContextSetLineCap = Fiddle::Function.new(COREGRAPHICS['CGContextSetLineCap'], [P, I], V)
200
+ CGContextSetLineJoin = Fiddle::Function.new(COREGRAPHICS['CGContextSetLineJoin'], [P, I], V)
201
+ CGContextSetMiterLimit = Fiddle::Function.new(COREGRAPHICS['CGContextSetMiterLimit'], [P, D], V)
202
+
203
+ # Path rasterization
204
+ CGContextFillPath = Fiddle::Function.new(COREGRAPHICS['CGContextFillPath'], [P], V)
205
+ CGContextEOFillPath = Fiddle::Function.new(COREGRAPHICS['CGContextEOFillPath'], [P], V)
206
+ CGContextStrokePath = Fiddle::Function.new(COREGRAPHICS['CGContextStrokePath'], [P], V)
207
+ # CGContextDrawPath(ctx, mode) — mode picks fill / EO-fill / stroke / fill+stroke.
208
+ CGContextDrawPath = Fiddle::Function.new(COREGRAPHICS['CGContextDrawPath'], [P, I], V)
209
+
180
210
  # kCGImageAlphaPremultipliedLast | kCGBitmapByteOrderDefault
181
211
  KCGImageAlphaPremultipliedLast = 1
182
212
 
213
+ # CGPathDrawingMode
214
+ KCG_PATH_FILL = 0
215
+ KCG_PATH_EO_FILL = 1
216
+ KCG_PATH_STROKE = 2
217
+ KCG_PATH_FILL_STROKE = 3
218
+ KCG_PATH_EO_FILL_STROKE = 4
219
+
220
+ # CGLineCap
221
+ KCG_LINE_CAP_BUTT = 0
222
+ KCG_LINE_CAP_ROUND = 1
223
+ KCG_LINE_CAP_SQUARE = 2
224
+
225
+ # CGLineJoin
226
+ KCG_LINE_JOIN_MITER = 0
227
+ KCG_LINE_JOIN_ROUND = 1
228
+ KCG_LINE_JOIN_BEVEL = 2
229
+
183
230
  # CoreText framework
184
231
  CORETEXT = Fiddle.dlopen('/System/Library/Frameworks/CoreText.framework/CoreText')
185
232
 
data/lib/echoes/screen.rb CHANGED
@@ -235,7 +235,7 @@ module Echoes
235
235
  # rather than introducing a parallel `:image` key.
236
236
  def put_kitty_image(rgba:, width:, height:, cells_w: nil, cells_h: nil,
237
237
  px_x_offset: 0, px_y_offset: 0,
238
- suppress_cursor: false, image_id: nil)
238
+ suppress_cursor: false, image_id: nil, z_index: 0)
239
239
  return if rgba.nil? || width <= 0 || height <= 0
240
240
  return if @cell_pixel_width.to_f <= 0 || @cell_pixel_height.to_f <= 0
241
241
 
@@ -314,6 +314,7 @@ module Echoes
314
314
  cell_rows: mc_rows,
315
315
  x_off: px_x_offset.to_i,
316
316
  y_off: px_y_offset.to_i,
317
+ z_index: z_index.to_i,
317
318
  image: {rgba: rgba, width: width, height: height},
318
319
  }
319
320