tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-term-graphics/src/lib.rs Rust · 600 líneas · 23.2 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}