Skip to content

Commit ce53007

Browse files
committed
perf: optimize kitty plot interactions
Add Kitty image-id prefetching for interactive plot frames so repeated pan and zoom can place already transmitted images instead of resending pixel payloads.\n\nKeep plot updates stable by tracking the previously visible image placement and deleting only that placement after the replacement has been drawn. Expand PTY perf coverage and architecture notes for the new prefetch and placement path.
1 parent 65252d7 commit ce53007

12 files changed

Lines changed: 1209 additions & 75 deletions

File tree

docs/architecture.md

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,36 @@ rasterization is still gated until an SVG rasterizer is added.
110110
Interactive plot viewing keeps terminal input ahead of expensive protocol
111111
payload work. The event loop drains pending key and resize events before drawing
112112
so burst input renders the latest state instead of every intermediate state. It
113-
also caches the last rendered frame by protocol, plot kind, viewport, and
114-
terminal size. Kitty frames request the full terminal cell area while rendering
115-
normal terminal windows at the full terminal pixel estimate. Very large windows
116-
use a bounded internal raster budget to keep redraw and protocol encoding cost
117-
predictable. Plot Kitty frames use zlib-compressed raw RGBA direct-data payloads
118-
so terminal updates avoid PNG decode work while still working when the terminal
119-
process cannot read files from the app's filesystem, such as SSH, container, or
120-
sandboxed sessions. The terminal chrome is rendered as a styled status bar with
121-
a stable dark background and segmented state text. For plot pixel protocols,
122-
the chrome also owns the header, legend, and axis labels so crisp terminal text
123-
surrounds a smaller body-only image payload; static chrome is repainted only on
124-
first draw or resize.
113+
keeps a bounded cache of encoded frames by protocol, plot kind, viewport, and
114+
terminal size. After user navigation, a small background prefetcher warms likely
115+
next frames without blocking the foreground draw. For repeated pan actions on
116+
large scenes, the prefetcher can render a transparent marks atlas once and crop
117+
future same-zoom pan frames, then composite those marks over the current
118+
grid/frame layer so axis labels and grid lines stay correct.
119+
120+
Kitty plot frames use zlib-compressed raw RGBA direct-data payloads so terminal
121+
updates avoid PNG decode work while still working when the terminal process
122+
cannot read files from the app's filesystem, such as SSH, container, or
123+
sandboxed sessions. Prefetched Kitty frames are transmitted with image IDs
124+
during idle time. If the next key lands on an already-transmitted frame, the
125+
foreground path writes only a small image placement command instead of sending
126+
the image bytes again. Each visible plot image uses a stable placement id and a
127+
unique image id. Updates place the new image first, then delete only the
128+
previous visible image placement by image id. This keeps old pixels visible
129+
until replacement pixels are placed while avoiding broad z-index or full-screen
130+
deletes that can blank the plot during fast navigation. The prefetch list stays
131+
intentionally small: more candidates increase background raster work and hidden
132+
terminal bytes, so newer directional batches suppress stale, not-yet-transmitted
133+
candidates.
134+
135+
Kitty frames request the full terminal cell area while rendering normal terminal
136+
windows at the full terminal pixel estimate. Very large windows use a bounded
137+
internal raster budget to keep redraw and protocol encoding cost predictable.
138+
The terminal chrome is rendered as a styled status bar with a stable dark
139+
background and segmented state text. For plot pixel protocols, the chrome also
140+
owns the header, legend, and axis labels so crisp terminal text surrounds a
141+
smaller body-only image payload; static chrome is repainted only on first draw
142+
or resize.
125143

126144
## Current Profiles
127145

docs/testing.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ benchmark drives a direct PTY and avoids tmux passthrough ambiguity, but the
7272
millisecond timings still stop at PTY-observable bytes rather than at terminal
7373
GPU composition or physical display scanout.
7474

75+
The `*_prefetched` metrics wait briefly between repeated navigation actions.
76+
They measure whether the direction-biased encoded-frame cache and pan prefetch
77+
path are actually helping repeated `+` and arrow-key interactions, separate
78+
from the uncached first keypress metrics. For Kitty, a healthy prefetched
79+
navigation hit should show `payload_bytes_delta` near zero because the
80+
foreground update is an image placement command for an idle-transmitted image,
81+
not a new image transfer.
82+
7583
## Selector Tests
7684

7785
Selector tests live in `src/render/terminal.rs`.

scripts/bench-plot-e2e.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ def payload_count(data):
6666
current = False
6767
for match in PAYLOAD_RE.finditer(data):
6868
controls = match.group(1).decode("ascii", "ignore")
69+
if "a=p" in controls:
70+
count += 1
71+
current = False
72+
continue
6973
if "a=T" in controls:
7074
if "t=f" in controls or "m=1" not in controls:
7175
count += 1
@@ -236,6 +240,28 @@ def run_iteration(iteration):
236240
after_bytes - before_bytes,
237241
)
238242
243+
before_payloads = after_payloads
244+
before_payload_bytes = after_payload_bytes
245+
before_bytes = after_bytes
246+
time.sleep(0.04)
247+
read_available(session.master, session.output)
248+
before_payloads = payload_count(session.output)
249+
before_payload_bytes = payload_bytes(session.output)
250+
before_bytes = len(session.output)
251+
start = time.perf_counter()
252+
session.send(b"+")
253+
after_payloads, end = wait_for_payload_count(session.master, session.output, before_payloads + 1)
254+
after_payload_bytes = payload_bytes(session.output)
255+
after_bytes = len(session.output)
256+
print_metric(
257+
"plot_e2e_key_zoom_prefetched",
258+
iteration,
259+
int((end - start) * 1000),
260+
after_payloads - before_payloads,
261+
after_payload_bytes - before_payload_bytes,
262+
after_bytes - before_bytes,
263+
)
264+
239265
before_payloads = after_payloads
240266
before_payload_bytes = after_payload_bytes
241267
before_bytes = after_bytes
@@ -253,6 +279,28 @@ def run_iteration(iteration):
253279
after_bytes - before_bytes,
254280
)
255281
282+
before_payloads = after_payloads
283+
before_payload_bytes = after_payload_bytes
284+
before_bytes = after_bytes
285+
time.sleep(0.04)
286+
read_available(session.master, session.output)
287+
before_payloads = payload_count(session.output)
288+
before_payload_bytes = payload_bytes(session.output)
289+
before_bytes = len(session.output)
290+
start = time.perf_counter()
291+
session.send(b"\x1b[C")
292+
after_payloads, end = wait_for_payload_count(session.master, session.output, before_payloads + 1)
293+
after_payload_bytes = payload_bytes(session.output)
294+
after_bytes = len(session.output)
295+
print_metric(
296+
"plot_e2e_key_pan_prefetched",
297+
iteration,
298+
int((end - start) * 1000),
299+
after_payloads - before_payloads,
300+
after_payload_bytes - before_payload_bytes,
301+
after_bytes - before_bytes,
302+
)
303+
256304
before_payloads = after_payloads
257305
before_payload_bytes = after_payload_bytes
258306
before_bytes = after_bytes

src/render/protocols/kitty.rs

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,63 @@ pub(crate) fn render_rgba_zlib_for_size(
3131
render_with_rgba_zlib_chunks_for_size(image, Some((columns.max(1), rows.max(1))))
3232
}
3333

34+
pub(crate) fn render_rgba_zlib_for_size_with_id(
35+
image: &RgbaImage,
36+
image_id: u32,
37+
columns: u32,
38+
rows: u32,
39+
) -> Result<String> {
40+
let z_index = z_index_for_image_id(image_id);
41+
let compressed =
42+
zlib_compress_bytes(image.as_raw()).context("compressing kitty RGBA payload")?;
43+
let encoded = STANDARD.encode(compressed);
44+
render_chunked_payload_str_with_action(
45+
image.width(),
46+
image.height(),
47+
Some((columns.max(1), rows.max(1))),
48+
&encoded,
49+
"a=T",
50+
&format!("i={image_id},p=1,z={z_index},f=32,o=z,q=2"),
51+
false,
52+
)
53+
}
54+
55+
pub(crate) fn transmit_rgba_zlib_with_id(image: &RgbaImage, image_id: u32) -> Result<String> {
56+
let compressed =
57+
zlib_compress_bytes(image.as_raw()).context("compressing kitty RGBA payload")?;
58+
let encoded = STANDARD.encode(compressed);
59+
render_chunked_payload_str_with_action(
60+
image.width(),
61+
image.height(),
62+
None,
63+
&encoded,
64+
"a=t",
65+
&format!("i={image_id},f=32,o=z,q=2"),
66+
false,
67+
)
68+
}
69+
70+
pub(crate) fn place_image_for_size(image_id: u32, columns: u32, rows: u32) -> String {
71+
let z_index = z_index_for_image_id(image_id);
72+
format!(
73+
"{KITTY_PREFIX}a=p,i={image_id},p=1,z={z_index},c={},r={},C=1,q=2;{KITTY_SUFFIX}",
74+
columns.max(1),
75+
rows.max(1)
76+
)
77+
}
78+
79+
pub(crate) fn delete_visible_placements() -> &'static str {
80+
"\u{1b}_Ga=d,q=2;\u{1b}\\"
81+
}
82+
83+
fn z_index_for_image_id(image_id: u32) -> i32 {
84+
if image_id % 2 == 0 { 0 } else { 1 }
85+
}
86+
87+
pub(crate) fn delete_image_placement(image_id: u32, placement_id: u32) -> String {
88+
format!("{KITTY_PREFIX}a=d,d=i,i={image_id},p={placement_id},q=2;{KITTY_SUFFIX}")
89+
}
90+
3491
fn render_with_png_chunks(image: &DynamicImage) -> Result<String> {
3592
render_with_png_chunks_for_size(image, None)
3693
}
@@ -90,11 +147,15 @@ fn render_chunked_payload_with_format(
90147
format_control: &str,
91148
) -> Result<String> {
92149
if chunks.is_empty() {
93-
return Ok(format!("{KITTY_PREFIX}f=100,t=d,m=0;\u{1b}\\"));
150+
return Ok(format!(
151+
"{}{KITTY_PREFIX}f=100,t=d,m=0;\u{1b}\\",
152+
delete_visible_placements()
153+
));
94154
}
95155

96156
let payload_len = chunks.iter().map(String::len).sum::<usize>();
97157
let mut output = String::with_capacity(payload_len + chunks.len() * 64);
158+
output.push_str(delete_visible_placements());
98159

99160
for (index, chunk) in chunks.iter().enumerate() {
100161
let is_last = index + 1 == chunks.len();
@@ -124,13 +185,41 @@ fn render_chunked_payload_str_with_format(
124185
display_cells: Option<(u32, u32)>,
125186
base64_payload: &str,
126187
format_control: &str,
188+
) -> Result<String> {
189+
render_chunked_payload_str_with_action(
190+
width,
191+
height,
192+
display_cells,
193+
base64_payload,
194+
"a=T",
195+
format_control,
196+
true,
197+
)
198+
}
199+
200+
fn render_chunked_payload_str_with_action(
201+
width: u32,
202+
height: u32,
203+
display_cells: Option<(u32, u32)>,
204+
base64_payload: &str,
205+
action_control: &str,
206+
format_control: &str,
207+
clear_before_display: bool,
127208
) -> Result<String> {
128209
let chunks = chunked_base64_payload(base64_payload, 4096);
129210
if chunks.is_empty() {
130-
return Ok(format!("{KITTY_PREFIX}f=100,t=d,m=0;\u{1b}\\"));
211+
let clear = if clear_before_display {
212+
delete_visible_placements()
213+
} else {
214+
""
215+
};
216+
return Ok(format!("{clear}{KITTY_PREFIX}f=100,t=d,m=0;\u{1b}\\"));
131217
}
132218

133219
let mut output = String::with_capacity(base64_payload.len() + chunks.len() * 64);
220+
if clear_before_display {
221+
output.push_str(delete_visible_placements());
222+
}
134223
for (index, chunk) in chunks.iter().enumerate() {
135224
let is_last = index + 1 == chunks.len();
136225
let chunk_mode = if is_last { 0 } else { 1 };
@@ -141,7 +230,7 @@ fn render_chunked_payload_str_with_format(
141230
.map(|(columns, rows)| format!(",c={columns},r={rows},C=1"))
142231
.unwrap_or_default();
143232
output.push_str(&format!(
144-
"a=T,{format_control},t=d,s={width},v={height}{display},m={chunk_mode};{chunk}",
233+
"{action_control},{format_control},t=d,s={width},v={height}{display},m={chunk_mode};{chunk}",
145234
chunk = chunk
146235
));
147236
} else {
@@ -164,6 +253,7 @@ mod tests {
164253
let payload = super::render(&image).unwrap();
165254
assert!(payload.starts_with("\u{1b}_G"));
166255
assert!(payload.ends_with("\u{1b}\\"));
256+
assert!(payload.starts_with(super::delete_visible_placements()));
167257
assert!(payload.contains("t=d"));
168258
}
169259

@@ -176,6 +266,37 @@ mod tests {
176266
assert!(payload.contains(",c=80,r=24,C=1,"));
177267
}
178268

269+
#[test]
270+
fn kitty_can_transmit_and_place_image_by_id() {
271+
let image = ImageBuffer::from_pixel(2, 2, Rgba([0, 0, 0, 255]));
272+
273+
let transmit = super::transmit_rgba_zlib_with_id(&image, 42).unwrap();
274+
let place = super::place_image_for_size(42, 80, 24);
275+
276+
assert!(transmit.contains("a=t,"));
277+
assert!(transmit.contains("i=42"));
278+
assert!(transmit.contains("f=32,o=z"));
279+
assert!(transmit.contains("q=2"));
280+
assert!(!transmit.contains(",c=80,r=24"));
281+
assert!(!transmit.contains("a=d"));
282+
assert_eq!(place, "\u{1b}_Ga=p,i=42,p=1,z=0,c=80,r=24,C=1,q=2;\u{1b}\\");
283+
}
284+
285+
#[test]
286+
fn kitty_id_display_places_without_predelete() {
287+
let image = ImageBuffer::from_pixel(2, 2, Rgba([0, 0, 0, 255]));
288+
289+
let payload = super::render_rgba_zlib_for_size_with_id(&image, 7, 80, 24).unwrap();
290+
291+
assert!(!payload.starts_with(super::delete_visible_placements()));
292+
assert!(payload.contains("a=T,i=7,p=1,z=1,f=32,o=z,q=2"));
293+
assert!(!payload.contains("a=d"));
294+
assert_eq!(
295+
super::delete_image_placement(7, 1),
296+
"\u{1b}_Ga=d,d=i,i=7,p=1,q=2;\u{1b}\\"
297+
);
298+
}
299+
179300
#[test]
180301
fn kitty_multichunk_payload_uses_continuation_headers_only_after_first_chunk() {
181302
let image = image::DynamicImage::ImageRgba8(ImageBuffer::from_fn(96, 96, |x, y| {
@@ -192,8 +313,9 @@ mod tests {
192313
.collect::<Vec<_>>();
193314

194315
assert!(packets.len() > 1);
195-
assert!(packets[0].starts_with("a=T,f=100,t=d,"));
196-
for packet in packets.iter().skip(1) {
316+
assert!(packets[0].starts_with("a=d,q=2"));
317+
assert!(packets[1].starts_with("a=T,f=100,t=d,"));
318+
for packet in packets.iter().skip(2) {
197319
assert!(packet.starts_with("m="));
198320
assert!(!packet.contains("a=T"));
199321
let (_, data) = packet.split_once(';').unwrap();

src/render/protocols/plot/display_list.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,65 @@ pub(super) fn build_body_display_list(
133133
list
134134
}
135135

136+
pub(super) fn build_body_base_display_list(
137+
scene: &PlotScene,
138+
viewport: PlotBounds,
139+
dimensions: PlotDimensions,
140+
theme: PlotTheme,
141+
text: TextMetrics,
142+
) -> PlotDisplayList {
143+
let mut list = PlotDisplayList {
144+
dimensions,
145+
background: theme.background,
146+
text,
147+
commands: Vec::new(),
148+
};
149+
150+
if scene.series.is_empty() {
151+
return list;
152+
}
153+
154+
let _bounds = viewport.normalized();
155+
let layout = body_layout_for(dimensions, text);
156+
push_frame(&mut list, layout.area, theme.axis);
157+
push_grid_lines(&mut list, layout.area, theme.grid);
158+
list
159+
}
160+
161+
pub(super) fn build_body_marks_display_list(
162+
scene: &PlotScene,
163+
kind: PlotKind,
164+
viewport: PlotBounds,
165+
dimensions: PlotDimensions,
166+
theme: PlotTheme,
167+
text: TextMetrics,
168+
) -> PlotDisplayList {
169+
let mut list = PlotDisplayList {
170+
dimensions,
171+
background: Rgba([0, 0, 0, 0]),
172+
text,
173+
commands: Vec::new(),
174+
};
175+
176+
if scene.series.is_empty() {
177+
return list;
178+
}
179+
180+
let bounds = viewport.normalized();
181+
let layout = body_layout_for(dimensions, text);
182+
for (series_index, series) in scene.series.iter().enumerate() {
183+
let color = Rgba(theme.strokes[series_index % theme.strokes.len()]);
184+
match kind {
185+
PlotKind::Line => push_line_series(&mut list, series, bounds, &layout.area, color),
186+
PlotKind::Scatter => {
187+
push_scatter_series(&mut list, series, bounds, &layout.area, color)
188+
}
189+
}
190+
}
191+
192+
list
193+
}
194+
136195
fn push_frame(list: &mut PlotDisplayList, area: PlotArea, color: Rgba<u8>) {
137196
push_line(
138197
list,

0 commit comments

Comments
 (0)