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 +4 -4
- data/README.md +178 -11
- data/lib/echoes/gui.rb +24 -9
- data/lib/echoes/iterm2_images.rb +56 -1
- data/lib/echoes/kitty_graphics.rb +47 -2
- data/lib/echoes/kitty_graphics_appkit.rb +14 -5
- data/lib/echoes/objc.rb +47 -0
- data/lib/echoes/screen.rb +2 -1
- data/lib/echoes/svg_cg_renderer.rb +689 -0
- data/lib/echoes/svg_color.rb +120 -0
- data/lib/echoes/svg_path_parser.rb +120 -0
- data/lib/echoes/svg_renderer.rb +272 -0
- data/lib/echoes/svg_sniffer.rb +81 -0
- data/lib/echoes/svg_transform.rb +54 -0
- data/lib/echoes/svg_walker.rb +107 -0
- data/lib/echoes/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 988274d532cf54ecb84bae874115f635b993e6817c259c41811b33265c55d725
|
|
4
|
+
data.tar.gz: 73f6868b563a851c1d171908f92ad7cf6ad8dd046f101c178f826a18790f8d0f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
1313
|
-
#
|
|
1314
|
-
#
|
|
1315
|
-
#
|
|
1316
|
-
#
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
data/lib/echoes/iterm2_images.rb
CHANGED
|
@@ -41,7 +41,11 @@ module Echoes
|
|
|
41
41
|
bytes = decode_payload(payload_b64)
|
|
42
42
|
return nil unless bytes
|
|
43
43
|
|
|
44
|
-
image =
|
|
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
|
|