tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! kitty graphics protocol — parser de control APC + ensamblador de
2//! transmisión chunked + decodificación a RGBA.
3//!
4//! Cuerpo APC esperado (sin el `\e_` ni el `\e\\`): `G<control>;<base64>` donde
5//! `<control>` son pares `clave=valor` separados por comas. Claves que nos
6//! importan:
7//! - `a` acción: `t` transmitir, `T` transmitir+mostrar, `q` query, `d` delete.
8//! - `f` formato: `32` RGBA (default), `24` RGB, `100` PNG/imagen embebida.
9//! - `s`,`v` ancho,alto en px (para `f=24`/`f=32`).
10//! - `c`,`r` columnas,filas de celdas pedidas (0 = derivar de los px).
11//! - `i` id de imagen. `m` more-chunks (`1` = sigue, `0`/ausente = último).
12//! - `o` compresión: `z` = zlib (RFC1950) sobre los datos pre-base64.
13
14use std::io::Read;
15
16use base64::Engine;
17
18use crate::{DecodedImage, GraphicsCommand, Protocol};
19
20/// Errores de decodificación kitty. Hoy informativos; el scanner los traga
21/// (una imagen rota no debe tumbar la terminal) pero los exponemos por si el
22/// caller quiere loguearlos.
23#[derive(Debug)]
24pub enum KittyError {
25 Base64,
26 Zlib,
27 Image(String),
28 BadDims,
29}
30
31/// Acumula la transmisión chunked (`m=1`) hasta el último chunk.
32#[derive(Default)]
33pub struct KittyAssembler {
34 pending: Option<Pending>,
35}
36
37struct Pending {
38 fmt: u32,
39 s: u32,
40 v: u32,
41 id: u32,
42 cols: u16,
43 rows: u16,
44 compressed: bool,
45 payload_b64: Vec<u8>,
46}
47
48impl KittyAssembler {
49 /// Procesa un cuerpo APC completo. Devuelve un comando cuando la
50 /// transmisión cierra (o es query/delete); `None` mientras espera más
51 /// chunks o si el APC no es kitty.
52 pub fn feed_apc(&mut self, seq: &[u8]) -> Option<GraphicsCommand> {
53 // Debe empezar con 'G' (kitty graphics). Otros APC no son nuestros.
54 if seq.first() != Some(&b'G') {
55 return None;
56 }
57 let body = &seq[1..];
58 let (control, payload): (&[u8], &[u8]) = match body.iter().position(|&b| b == b';') {
59 Some(p) => (&body[..p], &body[p + 1..]),
60 None => (body, &[]),
61 };
62
63 let is_continuation = self.pending.is_some();
64
65 // Sólo el primer chunk trae el control completo; las continuaciones
66 // traen (a lo sumo) `m`. Parseamos siempre `m`; el resto sólo si es
67 // el primer chunk.
68 let mut more = false;
69 let mut action = b't';
70 let mut fmt = 32u32;
71 let (mut s, mut v) = (0u32, 0u32);
72 let mut id = 0u32;
73 let (mut cols, mut rows) = (0u16, 0u16);
74 let mut compressed = false;
75
76 for tok in control.split(|&b| b == b',') {
77 if tok.is_empty() {
78 continue;
79 }
80 let mut kv = tok.splitn(2, |&b| b == b'=');
81 let k = kv.next().unwrap_or(&[]);
82 let val = kv.next().unwrap_or(&[]);
83 match k {
84 b"m" => more = val == b"1",
85 b"a" => action = val.first().copied().unwrap_or(b't'),
86 b"f" => fmt = parse_u32(val).unwrap_or(32),
87 b"s" => s = parse_u32(val).unwrap_or(0),
88 b"v" => v = parse_u32(val).unwrap_or(0),
89 b"i" => id = parse_u32(val).unwrap_or(0),
90 b"c" => cols = parse_u32(val).unwrap_or(0) as u16,
91 b"r" => rows = parse_u32(val).unwrap_or(0) as u16,
92 b"o" => compressed = val == b"z",
93 _ => {}
94 }
95 }
96
97 // Acciones sin payload (sólo válidas en el primer chunk).
98 if !is_continuation {
99 match action {
100 b'q' => {
101 // Responder OK por el PTY para que el emisor sepa que hay
102 // soporte kitty. Formato: `\e_Gi=<id>;OK\e\\`.
103 let response = format!("\x1b_Gi={id};OK\x1b\\").into_bytes();
104 return Some(GraphicsCommand::Query { response });
105 }
106 b'd' => {
107 let id = if id == 0 { None } else { Some(id) };
108 return Some(GraphicsCommand::Delete { id });
109 }
110 _ => {}
111 }
112 }
113
114 // Acumular payload base64.
115 if is_continuation {
116 let p = self.pending.as_mut().unwrap();
117 p.payload_b64.extend_from_slice(payload);
118 } else {
119 self.pending = Some(Pending {
120 fmt,
121 s,
122 v,
123 id,
124 cols,
125 rows,
126 compressed,
127 payload_b64: payload.to_vec(),
128 });
129 }
130
131 if more {
132 return None;
133 }
134
135 // Último chunk: finalizar.
136 let p = self.pending.take()?;
137 match decode_pending(&p) {
138 Ok(image) => Some(GraphicsCommand::Image {
139 image,
140 cols: p.cols,
141 rows: p.rows,
142 id: p.id,
143 protocol: Protocol::Kitty,
144 }),
145 Err(_) => None,
146 }
147 }
148}
149
150fn decode_pending(p: &Pending) -> Result<DecodedImage, KittyError> {
151 // 1. base64 → bytes.
152 let raw = base64::engine::general_purpose::STANDARD
153 .decode(&p.payload_b64)
154 .map_err(|_| KittyError::Base64)?;
155
156 // 2. zlib si o=z.
157 let data = if p.compressed {
158 let mut dec = flate2::read::ZlibDecoder::new(&raw[..]);
159 let mut out = Vec::new();
160 dec.read_to_end(&mut out).map_err(|_| KittyError::Zlib)?;
161 out
162 } else {
163 raw
164 };
165
166 // 3. interpretar según formato.
167 match p.fmt {
168 32 => {
169 let (w, h) = (p.s, p.v);
170 let need = (w as usize) * (h as usize) * 4;
171 if w == 0 || h == 0 || data.len() < need {
172 return Err(KittyError::BadDims);
173 }
174 Ok(DecodedImage {
175 width: w,
176 height: h,
177 rgba: data[..need].to_vec(),
178 })
179 }
180 24 => {
181 let (w, h) = (p.s, p.v);
182 let need = (w as usize) * (h as usize) * 3;
183 if w == 0 || h == 0 || data.len() < need {
184 return Err(KittyError::BadDims);
185 }
186 let mut rgba = Vec::with_capacity((w as usize) * (h as usize) * 4);
187 for px in data[..need].chunks_exact(3) {
188 rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
189 }
190 Ok(DecodedImage {
191 width: w,
192 height: h,
193 rgba,
194 })
195 }
196 // f=100 (PNG) y cualquier otro: dejamos que el crate `image` adivine
197 // el formato por los magic bytes.
198 _ => {
199 let img = image::load_from_memory(&data).map_err(|e| KittyError::Image(e.to_string()))?;
200 let rgba = img.to_rgba8();
201 Ok(DecodedImage {
202 width: rgba.width(),
203 height: rgba.height(),
204 rgba: rgba.into_raw(),
205 })
206 }
207 }
208}
209
210fn parse_u32(b: &[u8]) -> Option<u32> {
211 std::str::from_utf8(b).ok()?.trim().parse().ok()
212}