tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-term-graphics/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
1//! `llimphi-term-graphics` — decodificador agnóstico de protocolos de gráficos
2//! de terminal.
3//!
4//! Dos protocolos cubiertos:
5//! - **kitty graphics protocol** — secuencias APC `\e_G<control>;<base64>\e\\`.
6//! Soporta transmisión chunked (`m=1`), formatos `f=100` (PNG/JPEG/… vía el
7//! crate `image`), `f=24` (RGB crudo) y `f=32` (RGBA crudo), y compresión
8//! `o=z` (zlib). Acciones `t`/`T` (transmitir [y mostrar]), `q` (query),
9//! `d` (delete).
10//! - **sixel** — secuencias DCS `\eP<params>q<datos>\e\\`. Decodificador a mano
11//! (paleta, selección de color `#`, RLE `!`, CR `$`, LF `-`, atributos
12//! raster `"`).
13//!
14//! El corazón es [`GraphicsScanner`]: un autómata streaming al que se le
15//! alimentan los chunks de bytes del PTY. Deja pasar **intacto** todo el texto
16//! y los escapes ANSI normales (para que el emulador vt100 los procese) y sólo
17//! **secuestra** las secuencias gráficas completas, que devuelve decodificadas
18//! como [`GraphicsCommand`]. No tiene estado de UI ni dependencias de render:
19//! produce píxeles RGBA8, el caller los sube a una textura / `peniko::Image`.
20//!
21//! Robusto a fronteras de chunk: una secuencia partida entre dos `feed()`
22//! queda buffereada y se completa en la llamada siguiente.
23
24#![forbid(unsafe_code)]
25
26mod kitty;
27mod sixel;
28
29pub use kitty::KittyError;
30
31/// Imagen decodificada: RGBA8 sin premultiplicar, fila-mayor, sin padding.
32/// `rgba.len() == width * height * 4`.
33#[derive(Clone, PartialEq, Eq)]
34pub struct DecodedImage {
35 pub width: u32,
36 pub height: u32,
37 pub rgba: Vec<u8>,
38}
39
40impl std::fmt::Debug for DecodedImage {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.debug_struct("DecodedImage")
43 .field("width", &self.width)
44 .field("height", &self.height)
45 .field("rgba_len", &self.rgba.len())
46 .finish()
47 }
48}
49
50/// Protocolo de origen de un comando gráfico.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Protocol {
53 Kitty,
54 Sixel,
55}
56
57/// Un comando gráfico completo extraído del stream del PTY.
58#[derive(Debug, Clone)]
59pub enum GraphicsCommand {
60 /// Transmitir (y normalmente mostrar) una imagen en la posición actual del
61 /// cursor. `cols`/`rows` son las celdas pedidas por el protocolo (kitty
62 /// `c=`/`r=`); `0` = el caller deriva el tamaño de los píxeles.
63 Image {
64 image: DecodedImage,
65 cols: u16,
66 rows: u16,
67 /// id de imagen kitty (para placement/delete posterior); sixel = 0.
68 id: u32,
69 protocol: Protocol,
70 },
71 /// kitty `a=d`: borrar placements. `id == None` = borrar todo.
72 Delete { id: Option<u32> },
73 /// Una query de capacidad (kitty `a=q`) que el emulador debe **responder**
74 /// escribiendo `response` de vuelta por el stdin del PTY.
75 Query { response: Vec<u8> },
76}
77
78const ESC: u8 = 0x1b;
79const BEL: u8 = 0x07;
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum State {
83 /// Texto normal; copiando a passthrough.
84 Normal,
85 /// Vimos ESC, esperando el byte que decide (no lo copiamos todavía).
86 Esc,
87 /// Dentro de APC (`\e_`), acumulando en `seq` hasta ST/BEL.
88 Apc,
89 /// Dentro de APC y vimos ESC (esperando `\` del ST).
90 ApcEsc,
91 /// Dentro de DCS (`\eP`), acumulando en `seq` hasta ST/BEL.
92 Dcs,
93 /// Dentro de DCS y vimos ESC.
94 DcsEsc,
95 /// Dentro de CSI (`\e[`), acumulando params/intermedios hasta el byte
96 /// final (`0x40..=0x7e`). La CSI se reenvía **verbatim** al passthrough;
97 /// sólo emitimos una respuesta si resulta una query de capacidad.
98 Csi,
99}
100
101/// Autómata streaming que separa el texto/ANSI normal de las secuencias
102/// gráficas. Ver el módulo para el contrato completo.
103pub struct GraphicsScanner {
104 state: State,
105 /// Cuerpo de la secuencia en curso (sin el ESC inicial ni el terminador).
106 seq: Vec<u8>,
107 /// Acumulador de la transmisión chunked de kitty (`m=1`).
108 kitty: kitty::KittyAssembler,
109 /// Tope de bytes para una sola secuencia — defensa contra un stream malo
110 /// que nunca cierra el APC/DCS (no crecemos sin límite).
111 max_seq: usize,
112}
113
114impl Default for GraphicsScanner {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl GraphicsScanner {
121 pub fn new() -> Self {
122 Self {
123 state: State::Normal,
124 seq: Vec::new(),
125 kitty: kitty::KittyAssembler::default(),
126 // 64 MiB: una imagen base64 grande cabe; un stream roto no nos cuelga.
127 max_seq: 64 * 1024 * 1024,
128 }
129 }
130
131 /// Consume `input`: escribe los bytes no-gráficos en `passthrough` (para el
132 /// vt100) y devuelve los comandos gráficos completados en este chunk.
133 pub fn feed(&mut self, input: &[u8], passthrough: &mut Vec<u8>) -> Vec<GraphicsCommand> {
134 let mut out = Vec::new();
135 for &b in input {
136 match self.state {
137 State::Normal => {
138 if b == ESC {
139 self.state = State::Esc;
140 } else {
141 passthrough.push(b);
142 }
143 }
144 State::Esc => match b {
145 b'_' => {
146 self.seq.clear();
147 self.state = State::Apc;
148 }
149 b'P' => {
150 self.seq.clear();
151 self.state = State::Dcs;
152 }
153 b'[' => {
154 self.seq.clear();
155 self.state = State::Csi;
156 }
157 ESC => {
158 // ESC ESC: el primero no era nuestro → a passthrough;
159 // seguimos evaluando este nuevo ESC.
160 passthrough.push(ESC);
161 }
162 _ => {
163 // Cualquier otra secuencia (CSI `[`, OSC `]`, charset…)
164 // no nos interesa: devolvemos el ESC + este byte y el
165 // resto fluye como texto normal al vt100.
166 passthrough.push(ESC);
167 passthrough.push(b);
168 self.state = State::Normal;
169 }
170 },
171 State::Apc => self.accumulate(b, &mut out, false),
172 State::ApcEsc => self.accumulate_esc(b, &mut out, false),
173 State::Dcs => self.accumulate(b, &mut out, true),
174 State::DcsEsc => self.accumulate_esc(b, &mut out, true),
175 State::Csi => self.accumulate_csi(b, passthrough, &mut out),
176 }
177 }
178 out
179 }
180
181 /// Byte dentro de un cuerpo APC/DCS (no estábamos tras un ESC).
182 fn accumulate(&mut self, b: u8, out: &mut Vec<GraphicsCommand>, is_dcs: bool) {
183 if b == ESC {
184 self.state = if is_dcs { State::DcsEsc } else { State::ApcEsc };
185 } else if b == BEL {
186 // BEL también cierra (algunas terminales lo aceptan para APC).
187 self.finish(out, is_dcs);
188 } else {
189 if self.seq.len() < self.max_seq {
190 self.seq.push(b);
191 }
192 // Si rebalsa el tope, seguimos consumiendo bytes (descartándolos)
193 // hasta el terminador, para no resincronizar mal.
194 }
195 }
196
197 /// Byte dentro de un cuerpo APC/DCS justo después de un ESC.
198 fn accumulate_esc(&mut self, b: u8, out: &mut Vec<GraphicsCommand>, is_dcs: bool) {
199 if b == b'\\' {
200 // ST: secuencia completa.
201 self.finish(out, is_dcs);
202 } else if b == ESC {
203 // ESC ESC dentro del cuerpo: el primero es literal.
204 if self.seq.len() < self.max_seq {
205 self.seq.push(ESC);
206 }
207 // seguimos en *Esc esperando el resolutor.
208 } else {
209 // El ESC era literal y este byte también.
210 if self.seq.len() + 2 <= self.max_seq {
211 self.seq.push(ESC);
212 self.seq.push(b);
213 }
214 self.state = if is_dcs { State::Dcs } else { State::Apc };
215 }
216 }
217
218 /// Byte dentro de una CSI (`\e[…`). La CSI siempre se reenvía verbatim al
219 /// passthrough (el vt100 la ve idéntica); si es una query de capacidad,
220 /// además emitimos su respuesta.
221 fn accumulate_csi(
222 &mut self,
223 b: u8,
224 passthrough: &mut Vec<u8>,
225 out: &mut Vec<GraphicsCommand>,
226 ) {
227 if b == ESC {
228 // CSI abortada por un ESC nuevo: descargamos lo acumulado y
229 // re-evaluamos este ESC.
230 passthrough.extend_from_slice(&[ESC, b'[']);
231 passthrough.extend_from_slice(&self.seq);
232 self.seq.clear();
233 self.state = State::Esc;
234 } else if (0x40..=0x7e).contains(&b) {
235 // Byte final: la CSI está completa.
236 self.seq.push(b);
237 passthrough.extend_from_slice(&[ESC, b'[']);
238 passthrough.extend_from_slice(&self.seq);
239 if let Some(response) = csi_query_response(&self.seq) {
240 out.push(GraphicsCommand::Query { response });
241 }
242 self.seq.clear();
243 self.state = State::Normal;
244 } else {
245 // Params (0x30..0x3f) / intermedios (0x20..0x2f).
246 if self.seq.len() < self.max_seq {
247 self.seq.push(b);
248 }
249 }
250 }
251
252 /// Cierra la secuencia acumulada: la decodifica y resetea a Normal.
253 fn finish(&mut self, out: &mut Vec<GraphicsCommand>, is_dcs: bool) {
254 let seq = std::mem::take(&mut self.seq);
255 self.state = State::Normal;
256 if is_dcs {
257 if let Some(img) = sixel::decode(&seq) {
258 out.push(GraphicsCommand::Image {
259 image: img,
260 cols: 0,
261 rows: 0,
262 id: 0,
263 protocol: Protocol::Sixel,
264 });
265 }
266 } else if let Some(cmd) = self.kitty.feed_apc(&seq) {
267 out.push(cmd);
268 }
269 }
270}
271
272/// Si `csi` (los bytes de una CSI tras `\e[`, incluido el byte final) es una
273/// query de capacidad gráfica, devuelve la respuesta que el emulador debe
274/// escribir por el stdin del PTY para anunciar soporte. Anunciamos sixel
275/// porque el decodificador lo soporta; las demás CSI devuelven `None`.
276fn csi_query_response(csi: &[u8]) -> Option<Vec<u8>> {
277 match csi {
278 // Primary Device Attributes (`\e[c` / `\e[0c`). El `4` en la respuesta
279 // es el flag que chafa/otros leen como "soporta sixel". 62 = VT220.
280 b"c" | b"0c" => Some(b"\x1b[?62;4c".to_vec()),
281 // Secondary DA (`\e[>c` / `\e[>0c`) — versión del terminal, estilo
282 // xterm. Algunos programas la piden antes de decidir capacidades.
283 b">c" | b">0c" => Some(b"\x1b[>1;95;0c".to_vec()),
284 // XTSMGRAPHICS (`\e[?Pi;Pa;…S`): consulta de registros de color /
285 // geometría sixel. Respondemos valores holgados (la sizing real la
286 // hace el caller con su tamaño de celda).
287 _ if csi.first() == Some(&b'?') && csi.last() == Some(&b'S') => {
288 let body = &csi[1..csi.len() - 1];
289 let mut it = body.split(|&b| b == b';');
290 let pi = it.next().and_then(|v| std::str::from_utf8(v).ok()?.parse::<u32>().ok());
291 match pi {
292 Some(1) => Some(b"\x1b[?1;0;65536S".to_vec()), // registros de color
293 Some(2) => Some(b"\x1b[?2;0;1000;1000S".to_vec()), // geometría máx.
294 _ => None,
295 }
296 }
297 _ => None,
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 /// El texto plano pasa intacto y no genera comandos.
306 #[test]
307 fn texto_normal_pasa_intacto() {
308 let mut sc = GraphicsScanner::new();
309 let mut pt = Vec::new();
310 let cmds = sc.feed(b"hola mundo\n", &mut pt);
311 assert!(cmds.is_empty());
312 assert_eq!(pt, b"hola mundo\n");
313 }
314
315 /// Una secuencia CSI (color SGR) NO se secuestra: fluye al passthrough.
316 #[test]
317 fn csi_sgr_no_se_secuestra() {
318 let mut sc = GraphicsScanner::new();
319 let mut pt = Vec::new();
320 let input = b"\x1b[31mrojo\x1b[0m";
321 let cmds = sc.feed(input, &mut pt);
322 assert!(cmds.is_empty());
323 assert_eq!(pt, input);
324 }
325
326 /// kitty RGBA crudo 1x1: APC con f=32, s=1, v=1, payload = 4 bytes base64.
327 #[test]
328 fn kitty_rgba_1x1() {
329 use base64::Engine;
330 let px = [10u8, 20, 30, 255];
331 let b64 = base64::engine::general_purpose::STANDARD.encode(px);
332 let seq = format!("\x1b_Gf=32,s=1,v=1,a=T;{b64}\x1b\\");
333 let mut sc = GraphicsScanner::new();
334 let mut pt = Vec::new();
335 let cmds = sc.feed(seq.as_bytes(), &mut pt);
336 assert!(pt.is_empty(), "no debería pasar nada al vt100");
337 assert_eq!(cmds.len(), 1);
338 match &cmds[0] {
339 GraphicsCommand::Image { image, protocol, .. } => {
340 assert_eq!(*protocol, Protocol::Kitty);
341 assert_eq!((image.width, image.height), (1, 1));
342 assert_eq!(image.rgba, vec![10, 20, 30, 255]);
343 }
344 other => panic!("se esperaba Image, vino {other:?}"),
345 }
346 }
347
348 /// La secuencia partida entre dos `feed()` se completa igual.
349 #[test]
350 fn secuencia_partida_entre_chunks() {
351 use base64::Engine;
352 let px = [1u8, 2, 3, 4];
353 let b64 = base64::engine::general_purpose::STANDARD.encode(px);
354 let seq = format!("\x1b_Gf=32,s=1,v=1;{b64}\x1b\\");
355 let bytes = seq.as_bytes();
356 let mid = bytes.len() / 2;
357 let mut sc = GraphicsScanner::new();
358 let mut pt = Vec::new();
359 let mut cmds = sc.feed(&bytes[..mid], &mut pt);
360 cmds.extend(sc.feed(&bytes[mid..], &mut pt));
361 assert!(pt.is_empty());
362 assert_eq!(cmds.len(), 1);
363 match &cmds[0] {
364 GraphicsCommand::Image { image, .. } => {
365 assert_eq!(image.rgba, vec![1, 2, 3, 4]);
366 }
367 other => panic!("se esperaba Image, vino {other:?}"),
368 }
369 }
370
371 /// Texto antes y después de la imagen sobrevive en el passthrough.
372 #[test]
373 fn texto_rodeando_imagen() {
374 use base64::Engine;
375 let b64 = base64::engine::general_purpose::STANDARD.encode([9u8, 9, 9, 9]);
376 let seq = format!("antes\x1b_Gf=32,s=1,v=1;{b64}\x1b\\después");
377 let mut sc = GraphicsScanner::new();
378 let mut pt = Vec::new();
379 let cmds = sc.feed(seq.as_bytes(), &mut pt);
380 assert_eq!(cmds.len(), 1);
381 assert_eq!(pt, "antesdespués".as_bytes());
382 }
383
384 /// Primary DA (`\e[c`) → respuesta con flag sixel (`4`), y la query igual
385 /// se reenvía verbatim al vt100.
386 #[test]
387 fn da_primary_anuncia_sixel() {
388 let mut sc = GraphicsScanner::new();
389 let mut pt = Vec::new();
390 let cmds = sc.feed(b"\x1b[c", &mut pt);
391 assert_eq!(pt, b"\x1b[c", "la CSI debe pasar idéntica al vt100");
392 assert_eq!(cmds.len(), 1);
393 match &cmds[0] {
394 GraphicsCommand::Query { response } => {
395 assert_eq!(response, b"\x1b[?62;4c");
396 }
397 other => panic!("se esperaba Query, vino {other:?}"),
398 }
399 }
400
401 /// XTSMGRAPHICS de registros de color (`\e[?1;1;0S`) → respuesta `?1;0;…S`.
402 #[test]
403 fn xtsmgraphics_registros_color() {
404 let mut sc = GraphicsScanner::new();
405 let mut pt = Vec::new();
406 let cmds = sc.feed(b"\x1b[?1;1;0S", &mut pt);
407 assert_eq!(pt, b"\x1b[?1;1;0S");
408 assert_eq!(cmds.len(), 1);
409 match &cmds[0] {
410 GraphicsCommand::Query { response } => {
411 let s = String::from_utf8_lossy(response);
412 assert!(s.starts_with("\x1b[?1;0;"), "resp: {s:?}");
413 }
414 other => panic!("se esperaba Query, vino {other:?}"),
415 }
416 }
417
418 /// Una CSI normal (cursor home `\e[H`) no genera respuesta y pasa intacta.
419 #[test]
420 fn csi_normal_sin_respuesta() {
421 let mut sc = GraphicsScanner::new();
422 let mut pt = Vec::new();
423 let cmds = sc.feed(b"\x1b[H\x1b[2J", &mut pt);
424 assert!(cmds.is_empty());
425 assert_eq!(pt, b"\x1b[H\x1b[2J");
426 }
427
428 /// kitty f=100 (PNG embebido) — el camino real de chafa/icat. Construimos
429 /// un PNG 2×2 con el crate `image`, lo metemos en un APC y verificamos que
430 /// vuelve a salir como RGBA 2×2.
431 #[test]
432 fn kitty_png_embebido() {
433 use base64::Engine;
434 // PNG 2×2: rojo, verde / azul, blanco.
435 let mut img = image::RgbaImage::new(2, 2);
436 img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
437 img.put_pixel(1, 0, image::Rgba([0, 255, 0, 255]));
438 img.put_pixel(0, 1, image::Rgba([0, 0, 255, 255]));
439 img.put_pixel(1, 1, image::Rgba([255, 255, 255, 255]));
440 let mut png = std::io::Cursor::new(Vec::new());
441 image::DynamicImage::ImageRgba8(img)
442 .write_to(&mut png, image::ImageFormat::Png)
443 .unwrap();
444 let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner());
445 let seq = format!("\x1b_Gf=100,a=T,c=4,r=2;{b64}\x1b\\");
446
447 let mut sc = GraphicsScanner::new();
448 let mut pt = Vec::new();
449 let cmds = sc.feed(seq.as_bytes(), &mut pt);
450 assert_eq!(cmds.len(), 1);
451 match &cmds[0] {
452 GraphicsCommand::Image { image, cols, rows, .. } => {
453 assert_eq!((image.width, image.height), (2, 2));
454 assert_eq!((*cols, *rows), (4, 2));
455 assert_eq!(&image.rgba[0..4], &[255, 0, 0, 255]); // rojo
456 }
457 other => panic!("se esperaba Image, vino {other:?}"),
458 }
459 }
460
461 /// Transmisión chunked: control + payload en varios APC con `m=1` y el
462 /// último con `m=0`. El payload base64 se concatena y decodifica una vez.
463 #[test]
464 fn kitty_chunked() {
465 use base64::Engine;
466 // 2×1 RGBA crudo = 8 bytes.
467 let raw = [10u8, 11, 12, 13, 20, 21, 22, 23];
468 let b64 = base64::engine::general_purpose::STANDARD.encode(raw);
469 let (a, b) = b64.split_at(b64.len() / 2);
470 // primer chunk: control completo + m=1; segundo: sólo m=0 + resto.
471 let seq = format!("\x1b_Gf=32,s=2,v=1,m=1;{a}\x1b\\\x1b_Gm=0;{b}\x1b\\");
472
473 let mut sc = GraphicsScanner::new();
474 let mut pt = Vec::new();
475 let cmds = sc.feed(seq.as_bytes(), &mut pt);
476 assert_eq!(cmds.len(), 1, "un solo Image al cerrar el último chunk");
477 match &cmds[0] {
478 GraphicsCommand::Image { image, .. } => {
479 assert_eq!((image.width, image.height), (2, 1));
480 assert_eq!(image.rgba, raw.to_vec());
481 }
482 other => panic!("se esperaba Image, vino {other:?}"),
483 }
484 }
485
486 /// Compresión zlib (o=z) sobre RGBA crudo.
487 #[test]
488 fn kitty_zlib() {
489 use base64::Engine;
490 use std::io::Write;
491 let raw = [1u8, 2, 3, 4, 5, 6, 7, 8];
492 let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
493 enc.write_all(&raw).unwrap();
494 let compressed = enc.finish().unwrap();
495 let b64 = base64::engine::general_purpose::STANDARD.encode(compressed);
496 let seq = format!("\x1b_Gf=32,s=2,v=1,o=z;{b64}\x1b\\");
497
498 let mut sc = GraphicsScanner::new();
499 let mut pt = Vec::new();
500 let cmds = sc.feed(seq.as_bytes(), &mut pt);
501 assert_eq!(cmds.len(), 1);
502 match &cmds[0] {
503 GraphicsCommand::Image { image, .. } => {
504 assert_eq!(image.rgba, raw.to_vec());
505 }
506 other => panic!("se esperaba Image, vino {other:?}"),
507 }
508 }
509
510 /// Round-trip contra el encoder real `chafa`, en sus dos formatos. Genera
511 /// un PNG, lo pasa por `chafa -f {sixel,kitty}` y verifica que el scanner
512 /// recupera exactamente una imagen coherente — pese a la `CSI` de mostrar/
513 /// ocultar cursor que chafa intercala (debe ir a passthrough) y a que el
514 /// formato kitty viene en chunks. Se salta si chafa no está instalado.
515 #[test]
516 fn chafa_round_trip_real() {
517 use std::io::Write;
518 use std::process::{Command, Stdio};
519
520 // PNG 16×16 con patrón de dos colores.
521 let mut img = image::RgbaImage::new(16, 16);
522 for y in 0..16u32 {
523 for x in 0..16u32 {
524 let c = if (x / 4 + y / 4) % 2 == 0 {
525 image::Rgba([200, 30, 30, 255])
526 } else {
527 image::Rgba([30, 30, 200, 255])
528 };
529 img.put_pixel(x, y, c);
530 }
531 }
532 let mut png = std::io::Cursor::new(Vec::new());
533 image::DynamicImage::ImageRgba8(img)
534 .write_to(&mut png, image::ImageFormat::Png)
535 .unwrap();
536 let png = png.into_inner();
537
538 for (fmt, want) in [("sixel", Protocol::Sixel), ("kitty", Protocol::Kitty)] {
539 let mut child = match Command::new("chafa")
540 .args(["-f", fmt, "--size", "20x10", "-"])
541 .stdin(Stdio::piped())
542 .stdout(Stdio::piped())
543 .stderr(Stdio::null())
544 .spawn()
545 {
546 Ok(c) => c,
547 Err(_) => {
548 eprintln!("chafa no instalado — test saltado");
549 return;
550 }
551 };
552 child.stdin.take().unwrap().write_all(&png).unwrap();
553 let out = child.wait_with_output().unwrap();
554 assert!(out.status.success(), "chafa -f {fmt} falló");
555
556 // Alimentamos en dos mitades para ejercitar también la frontera de
557 // chunk del scanner sobre datos reales.
558 let mut sc = GraphicsScanner::new();
559 let mut pt = Vec::new();
560 let mid = out.stdout.len() / 2;
561 let mut cmds = sc.feed(&out.stdout[..mid], &mut pt);
562 cmds.extend(sc.feed(&out.stdout[mid..], &mut pt));
563 let imgs: Vec<_> = cmds
564 .iter()
565 .filter_map(|c| match c {
566 GraphicsCommand::Image { image, protocol, .. } => Some((image, *protocol)),
567 _ => None,
568 })
569 .collect();
570 assert_eq!(imgs.len(), 1, "fmt={fmt}: se esperaba 1 imagen");
571 let (image, protocol) = imgs[0];
572 assert_eq!(protocol, want, "fmt={fmt}: protocolo");
573 assert!(image.width > 0 && image.height > 0, "fmt={fmt}: dims nulas");
574 assert_eq!(
575 image.rgba.len(),
576 (image.width as usize) * (image.height as usize) * 4,
577 "fmt={fmt}: largo del buffer RGBA"
578 );
579 // El passthrough debe contener la CSI de cursor (no la tragamos).
580 assert!(!pt.is_empty(), "fmt={fmt}: la CSI debió ir a passthrough");
581 }
582 }
583
584 /// Query de capacidad kitty → comando Query con respuesta OK.
585 #[test]
586 fn kitty_query_responde_ok() {
587 let mut sc = GraphicsScanner::new();
588 let mut pt = Vec::new();
589 let cmds = sc.feed(b"\x1b_Gi=31,s=1,v=1,a=q;AAAA\x1b\\", &mut pt);
590 assert_eq!(cmds.len(), 1);
591 match &cmds[0] {
592 GraphicsCommand::Query { response } => {
593 let s = String::from_utf8_lossy(response);
594 assert!(s.contains("i=31"), "respuesta: {s}");
595 assert!(s.contains("OK"), "respuesta: {s}");
596 }
597 other => panic!("se esperaba Query, vino {other:?}"),
598 }
599 }
600}