| 8 |
8 |
|
//! [Kitty terminal graphics protocol]: https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
| 9 |
9 |
|
//!
|
| 10 |
10 |
|
//! MVP scope: `a=T` transmit+display, `a=t` transmit, `a=p` place, `a=d`
|
| 11 |
|
- |
//! delete, `a=f` frame append, `a=c` frame compose, formats `f=24` (RGB),
|
| 12 |
|
- |
//! `f=32` (RGBA), `f=100` (PNG), medium `t=d` (base64 inline), chunked
|
| 13 |
|
- |
//! payloads (`m=1`/`m=0`), placement in cells (`c=`, `r=`), don't-move-
|
| 14 |
|
- |
//! cursor (`C=1`), Unicode-placeholder flag (`U=1`).
|
|
11 |
+ |
//! delete, `a=f` frame append, `a=c` frame compose, `a=q` query, formats
|
|
12 |
+ |
//! `f=24` (RGB), `f=32` (RGBA), `f=100` (PNG), medium `t=d` (base64 inline),
|
|
13 |
+ |
//! chunked payloads (`m=1`/`m=0`), placement in cells (`c=`, `r=`), don't-
|
|
14 |
+ |
//! move-cursor (`C=1`), Unicode-placeholder flag (`U=1`).
|
| 15 |
15 |
|
//!
|
| 16 |
|
- |
//! Not covered yet: file/temp/shm media, `a=a` animation control, `a=q`
|
| 17 |
|
- |
//! query, placement IDs, z-order, delete sub-selectors.
|
|
16 |
+ |
//! Not covered yet: file/temp/shm media, `a=a` animation control, placement
|
|
17 |
+ |
//! IDs, z-order, delete sub-selectors.
|
|
18 |
+ |
//!
|
|
19 |
+ |
//! [`Command::Query`] is the one command the host must answer rather than
|
|
20 |
+ |
//! merely act on. [`query_response`] builds the reply; the host writes it
|
|
21 |
+ |
//! back to the PTY.
|
| 18 |
22 |
|
//!
|
| 19 |
23 |
|
//! Reference read: rio's `rio-backend/src/ansi/kitty_graphics_protocol.rs`
|
| 20 |
24 |
|
//! (MIT) — architecture consulted, no direct code copied.
|
| 95 |
99 |
|
/// Compose an already-transmitted frame from other frames (`a=c`).
|
| 96 |
100 |
|
/// Carries no payload — composition is metadata-only.
|
| 97 |
101 |
|
FrameCompose { control: Control },
|
|
102 |
+ |
/// Capability query (`a=q`). The sender is asking whether a transmission
|
|
103 |
+ |
/// shaped like this one would have worked; nothing is stored either way.
|
|
104 |
+ |
///
|
|
105 |
+ |
/// This is how a terminal that no program has heard of still gets its
|
|
106 |
+ |
/// graphics support noticed. Clients keep a list of terminals they know
|
|
107 |
+ |
/// by name and fall back to querying when the name means nothing to
|
|
108 |
+ |
/// them, so answering is the difference between being detected and being
|
|
109 |
+ |
/// assumed incapable.
|
|
110 |
+ |
///
|
|
111 |
+ |
/// The caller must reply. Unlike every other action, a query ignores
|
|
112 |
+ |
/// `q=` suppression: silence is not a valid answer to it, and a client
|
|
113 |
+ |
/// that asked will wait out its timeout before giving up.
|
|
114 |
+ |
Query { control: Control },
|
|
115 |
+ |
}
|
|
116 |
+ |
|
|
117 |
+ |
/// What a terminal should answer a [`Command::Query`] with.
|
|
118 |
+ |
///
|
|
119 |
+ |
/// The reply is addressed by the `i=` the query carried, so a client can
|
|
120 |
+ |
/// match it to the question. A query with no id is answered with `i=0`,
|
|
121 |
+ |
/// which is what the protocol's own examples do.
|
|
122 |
+ |
#[must_use]
|
|
123 |
+ |
pub fn query_response(control: &Control) -> Vec<u8> {
|
|
124 |
+ |
let id = control.id.unwrap_or(0);
|
|
125 |
+ |
// Direct is the only medium shop can satisfy: the others hand over a
|
|
126 |
+ |
// path or a shared-memory name to read out of band, and none of that is
|
|
127 |
+ |
// implemented. Saying OK to one would promise a picture that never
|
|
128 |
+ |
// arrives.
|
|
129 |
+ |
let supported =
|
|
130 |
+ |
control.format.is_some() && matches!(control.medium, None | Some(Medium::Direct));
|
|
131 |
+ |
if supported {
|
|
132 |
+ |
format!("\x1b_Gi={id};OK\x1b\\").into_bytes()
|
|
133 |
+ |
} else {
|
|
134 |
+ |
// ENOTSUPP is the protocol's spelling for "understood, cannot do it".
|
|
135 |
+ |
// Answering with an error still counts as answering: the client stops
|
|
136 |
+ |
// waiting and picks another path, which is the whole point.
|
|
137 |
+ |
format!("\x1b_Gi={id};ENOTSUPP\x1b\\").into_bytes()
|
|
138 |
+ |
}
|
| 98 |
139 |
|
}
|
| 99 |
140 |
|
|
| 100 |
141 |
|
/// Parser for kitty-graphics APC payloads. Feed one APC body at a time via
|
| 161 |
202 |
|
// Payload-less actions. If a `;<payload>` was sent anyway, validate
|
| 162 |
203 |
|
// that it's decodable so a malformed one still errors — preserves
|
| 163 |
204 |
|
// the pre-refactor contract without keeping the decoded bytes.
|
| 164 |
|
- |
'p' | 'c' | 'd' => {
|
|
205 |
+ |
//
|
|
206 |
+ |
// A query belongs here despite usually carrying a payload: it is
|
|
207 |
+ |
// asking about a shape, not sending an image, so the bytes are
|
|
208 |
+ |
// checked and dropped rather than reassembled. Probes send a
|
|
209 |
+ |
// single pixel, so there is no chunking to honour either.
|
|
210 |
+ |
'p' | 'c' | 'd' | 'q' => {
|
| 165 |
211 |
|
if let Some(b) = payload_b64 {
|
| 166 |
212 |
|
if !b.is_empty() && B64.decode(b).is_err() {
|
| 167 |
213 |
|
return None;
|
| 171 |
217 |
|
'p' => Some(Command::Place { control }),
|
| 172 |
218 |
|
'c' => Some(Command::FrameCompose { control }),
|
| 173 |
219 |
|
'd' => Some(Command::Delete { control }),
|
|
220 |
+ |
'q' => Some(Command::Query { control }),
|
| 174 |
221 |
|
_ => unreachable!(),
|
| 175 |
222 |
|
}
|
| 176 |
223 |
|
}
|
| 402 |
449 |
|
assert_eq!(payload, b"xyz");
|
| 403 |
450 |
|
}
|
| 404 |
451 |
|
|
|
452 |
+ |
fn query_of(body: &[u8]) -> Control {
|
|
453 |
+ |
let mut p = Parser::new();
|
|
454 |
+ |
match p.feed(body) {
|
|
455 |
+ |
Some(Command::Query { control }) => control,
|
|
456 |
+ |
other => panic!("expected a query, got {other:?}"),
|
|
457 |
+ |
}
|
|
458 |
+ |
}
|
|
459 |
+ |
|
|
460 |
+ |
#[test]
|
|
461 |
+ |
fn a_probe_query_parses_as_a_query() {
|
|
462 |
+ |
// Verbatim shape of what a client probe sends: one pixel, direct,
|
|
463 |
+ |
// 24-bit, asking rather than transmitting.
|
|
464 |
+ |
let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA");
|
|
465 |
+ |
assert_eq!(control.action, 'q');
|
|
466 |
+ |
assert_eq!(control.id, Some(31));
|
|
467 |
+ |
}
|
|
468 |
+ |
|
|
469 |
+ |
#[test]
|
|
470 |
+ |
fn a_query_is_answered_ok_and_addressed_to_its_id() {
|
|
471 |
+ |
let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA");
|
|
472 |
+ |
assert_eq!(query_response(&control), b"\x1b_Gi=31;OK\x1b\\".to_vec());
|
|
473 |
+ |
}
|
|
474 |
+ |
|
|
475 |
+ |
#[test]
|
|
476 |
+ |
fn a_query_with_no_id_is_answered_against_zero() {
|
|
477 |
+ |
let control = query_of(b"Ga=q,f=32,s=1,v=1;AAAA");
|
|
478 |
+ |
assert_eq!(query_response(&control), b"\x1b_Gi=0;OK\x1b\\".to_vec());
|
|
479 |
+ |
}
|
|
480 |
+ |
|
|
481 |
+ |
#[test]
|
|
482 |
+ |
fn every_format_shop_decodes_answers_ok() {
|
|
483 |
+ |
for body in [
|
|
484 |
+ |
b"Ga=q,i=1,f=24,s=1,v=1;AAAA".as_slice(),
|
|
485 |
+ |
b"Ga=q,i=1,f=32,s=1,v=1;AAAA".as_slice(),
|
|
486 |
+ |
b"Ga=q,i=1,f=100;AAAA".as_slice(),
|
|
487 |
+ |
] {
|
|
488 |
+ |
let reply = query_response(&query_of(body));
|
|
489 |
+ |
assert_eq!(reply, b"\x1b_Gi=1;OK\x1b\\".to_vec(), "for {body:?}");
|
|
490 |
+ |
}
|
|
491 |
+ |
}
|
|
492 |
+ |
|
|
493 |
+ |
#[test]
|
|
494 |
+ |
fn a_medium_shop_cannot_read_is_declined_rather_than_ignored() {
|
|
495 |
+ |
// Saying OK to a file transfer promises a picture that never
|
|
496 |
+ |
// arrives; saying nothing makes the client wait out its timeout.
|
|
497 |
+ |
// Both are worse than an honest refusal.
|
|
498 |
+ |
let control = query_of(b"Ga=q,i=7,f=100,t=f;L3RtcC94");
|
|
499 |
+ |
assert_eq!(
|
|
500 |
+ |
query_response(&control),
|
|
501 |
+ |
b"\x1b_Gi=7;ENOTSUPP\x1b\\".to_vec()
|
|
502 |
+ |
);
|
|
503 |
+ |
}
|
|
504 |
+ |
|
|
505 |
+ |
#[test]
|
|
506 |
+ |
fn an_absent_medium_means_direct() {
|
|
507 |
+ |
let control = query_of(b"Ga=q,i=2,f=24,s=1,v=1;AAAA");
|
|
508 |
+ |
assert_eq!(query_response(&control), b"\x1b_Gi=2;OK\x1b\\".to_vec());
|
|
509 |
+ |
}
|
|
510 |
+ |
|
|
511 |
+ |
#[test]
|
|
512 |
+ |
fn a_query_with_no_format_is_declined() {
|
|
513 |
+ |
let control = query_of(b"Ga=q,i=3;AAAA");
|
|
514 |
+ |
assert_eq!(
|
|
515 |
+ |
query_response(&control),
|
|
516 |
+ |
b"\x1b_Gi=3;ENOTSUPP\x1b\\".to_vec()
|
|
517 |
+ |
);
|
|
518 |
+ |
}
|
|
519 |
+ |
|
|
520 |
+ |
#[test]
|
|
521 |
+ |
fn a_query_with_an_undecodable_payload_is_rejected() {
|
|
522 |
+ |
let mut p = Parser::new();
|
|
523 |
+ |
assert!(p.feed(b"Ga=q,i=1,f=24;!!!!").is_none());
|
|
524 |
+ |
}
|
|
525 |
+ |
|
|
526 |
+ |
#[test]
|
|
527 |
+ |
fn a_query_stores_nothing() {
|
|
528 |
+ |
// A query must not leave a half-assembled transmission behind for
|
|
529 |
+ |
// the next chunk to attach itself to.
|
|
530 |
+ |
let mut p = Parser::new();
|
|
531 |
+ |
assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some());
|
|
532 |
+ |
assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some());
|
|
533 |
+ |
}
|
|
534 |
+ |
|
| 405 |
535 |
|
#[test]
|
| 406 |
536 |
|
fn unknown_action_returns_none() {
|
| 407 |
537 |
|
let mut p = Parser::new();
|