tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-term-graphics/src/kitty.rs Rust · 212 siq'ikuna · 6.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

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//! 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}