tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! **El explorador adentro de una jaula: nahual entero corriendo en otro proceso.**
2//!
3//! Es el hermano de `nada_enjaulado`, y existe por el mismo motivo: cinco fases
4//! de piezas probadas por separado no contestan si al juntarse pasa algo. Acá el
5//! **mismo** `nahual-shell-llimphi` que corre suelto en el escritorio compone
6//! hacia un cable en vez de hacia una ventana.
7//!
8//! ```bash
9//! cargo run --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
10//! --no-default-features --features std --example nahual_enjaulado -- <socket> [carpeta]
11//! ```
12//!
13//! Normalmente lo lanza el anfitrión, que le pasa el socket.
14//!
15//! ## Lo que este archivo NO hace, y es el punto
16//!
17//! No reimplementa nada del explorador. No hay una vista, ni un `update`, ni un
18//! mapeo de teclas propios: se llaman los de la lib, que son **los mismos** que
19//! usa el binario de escritorio. Si esto tuviera lógica de explorador adentro,
20//! todo el trabajo de cruzar los treinta y dos crates habría sido al pedo — el
21//! objetivo es que la app se vea **igual** enjaulada que suelta.
22//!
23//! Lo único propio son las tres piezas de la frontera: el medidor (pregunta en
24//! vez de shapear), el despachador (una cola que el bucle drena, donde el
25//! escritorio tiene el bucle de winit) y el bucle.
26//!
27//! ## Con qué arranca, y por qué importa decirlo
28//!
29//! Sin `terminal` ni `svg`: el terminal embebido trae wgpu y el visor de
30//! vectores rinde contra el rasterizador del escritorio, así que ninguno de los
31//! dos puede cruzar todavía. Un `.svg` cae al visor de texto y F4 no abre nada.
32//! Es una degradación **nombrada**, no un agujero: lo que se ve es el explorador
33//! completo menos esas dos cosas.
34
35use std::cell::RefCell;
36use std::collections::VecDeque;
37use std::rc::Rc;
38use std::sync::{Arc, Mutex};
39
40use llimphi_cable::{DelAnfitrion, Huesped};
41use llimphi_jaula::{medidor, Jaula};
42use nahual_shell_llimphi as nahual;
43
44/// El despachador del huésped: una **cola** que el bucle drena.
45///
46/// En el escritorio, el explorador le manda sus efectos al `Handle` de winit.
47/// Acá no hay ventana: la cola es ésta, y el bucle la vacía en cada vuelta. Para
48/// el explorador no cambia nada — habla con el trait, no con quien lo cumple.
49///
50/// `Arc<Mutex<…>>` y no `Rc<RefCell<…>>` porque el trait pide `Send + Sync`: el
51/// trabajo de fondo (una miniatura, un censo de disco) corre en un hilo de
52/// verdad, igual que en el escritorio.
53#[derive(Clone, Default)]
54struct Cola(Arc<Mutex<VecDeque<nahual::Msg>>>);
55
56impl llimphi_despacho::Despachador<nahual::Msg> for Cola {
57 fn despachar(&self, msg: nahual::Msg) {
58 // Si el mutex está envenenado, un efecto perdido no justifica tirar el
59 // explorador abajo: se descarta y se sigue. El síntoma sería una
60 // miniatura que no aparece, no una pantalla negra.
61 if let Ok(mut c) = self.0.lock() {
62 c.push_back(msg);
63 }
64 }
65
66 /// El trabajo pesado va a un hilo, como en el escritorio. El default del
67 /// trait lo correría inline y bloquearía el fotograma — un censo de disco
68 /// adentro del `view` congelaría la ventana del anfitrión, no la propia.
69 fn lanzar(&self, trabajo: Box<dyn FnOnce() -> nahual::Msg + Send + 'static>) {
70 let cola = self.clone();
71 std::thread::spawn(move || {
72 llimphi_despacho::Despachador::despachar(&cola, trabajo());
73 });
74 }
75}
76
77impl Cola {
78 fn sacar(&self) -> Option<nahual::Msg> {
79 self.0.lock().ok()?.pop_front()
80 }
81}
82
83fn main() {
84 let mut args = std::env::args().skip(1);
85 let Some(ruta) = args.next() else {
86 eprintln!("uso: nahual_enjaulado <ruta-del-socket> [carpeta]");
87 std::process::exit(2);
88 };
89 let cwd = args
90 .next()
91 .map(std::path::PathBuf::from)
92 .filter(|p| p.is_dir())
93 .or_else(|| std::env::current_dir().ok())
94 .unwrap_or_else(|| std::path::PathBuf::from("/"));
95
96 let flujo = match std::os::unix::net::UnixStream::connect(&ruta) {
97 Ok(f) => f,
98 Err(e) => {
99 eprintln!("nahual enjaulado: no pude conectarme a {ruta}: {e}");
100 std::process::exit(1);
101 }
102 };
103
104 // El cable se comparte entre el medidor —que corre ADENTRO de `fotograma`—
105 // y el bucle, que manda el cuadro después. Un solo hilo: las dos tomas
106 // nunca se anidan.
107 let cable = Rc::new(RefCell::new(Huesped::new(flujo)));
108
109 let medidor_cable = Rc::clone(&cable);
110 let (mut ancho, mut alto) = nahual::TAMANO_INICIAL;
111 let mut jaula = Jaula::new(ancho as f32, alto as f32, move |p| {
112 // El marshalling es el compartido con el camino de wawa: si el huésped
113 // armara su propia petición, un campo nuevo del estilo quedaría sin
114 // viajar de un solo lado y el texto se mediría distinto según quién
115 // hospeda.
116 medidor::medir_con(p, |peticion, salida| {
117 let Ok(pedido) = postcard::from_bytes(peticion) else { return -1 };
118 match medidor_cable.borrow_mut().medir(&pedido) {
119 Ok(m) => {
120 salida.copy_from_slice(&m.a_bytes());
121 0
122 }
123 Err(_) => -1,
124 }
125 })
126 .unwrap_or(llimphi_text::wire::Medida { width: 0.0, full_width: 0.0, height: 0.0 })
127 });
128
129 // **El explorador de verdad.** El watcher de config va en `None`: vigilar el
130 // archivo es un efecto del escritorio, y el anfitrión ya tiene el suyo.
131 let mut modelo = nahual::build_model(cwd, llimphi_theme::Theme::dark(), None);
132 let cola = Cola::default();
133 let despacho: nahual::Despacho = Arc::new(cola.clone());
134
135 loop {
136 // 1) Componer con la vista de nahual —la misma que pinta el binario
137 // suelto— y mandar el cuadro.
138 let montado = jaula.fotograma(nahual::vista(&modelo));
139 // `escena_para_enviar` y no `escena`: lleva los píxeles de las imágenes
140 // que el anfitrión todavía no tiene, y **sólo ésos**. Sin los píxeles el
141 // anfitrión no puede dibujar ninguna imagen (las cuenta y sigue), que es
142 // el «todo negro» del reporte; mandándolos todos en cada cuadro serían
143 // megabytes por fotograma para pintar lo mismo.
144 if cable.borrow_mut().enviar_cuadro(jaula.escena_para_enviar()).is_err() {
145 return; // el anfitrión se fue
146 }
147
148 // 2) Esperar. Un bucle que compusiera sin parar quemaría un núcleo para
149 // pintar siempre lo mismo.
150 let evento = match cable.borrow_mut().esperar_evento() {
151 Ok(e) => e,
152 Err(_) => return,
153 };
154
155 let mut pendientes: Vec<nahual::Msg> = Vec::new();
156 match evento {
157 // El hit-test lo hace el huésped contra SU layout: el anfitrión
158 // manda una coordenada, qué hay debajo lo sabe la app.
159 DelAnfitrion::Puntero { x, y, apretado } if apretado => {
160 if let Some(msg) = jaula.click(&montado, x, y) {
161 pendientes.push(msg);
162 }
163 }
164 DelAnfitrion::Tecla(ev) => {
165 // Las teclas las enruta nahual, con la MISMA función que usa el
166 // binario de escritorio. Acá no hay un mapeo paralelo.
167 if let Some(msg) = nahual::enrutar_tecla(&modelo, &ev) {
168 pendientes.push(msg);
169 }
170 }
171 DelAnfitrion::Rueda(d) => {
172 if d.y != 0.0 {
173 pendientes.push(nahual::Msg::ScrollLines(d.y));
174 }
175 }
176 DelAnfitrion::Tamano { ancho: a, alto: h } => {
177 ancho = a as u32;
178 alto = h as u32;
179 jaula.redimensionar(a, h);
180 pendientes.push(nahual::Msg::Resized(a, h));
181 }
182 // El explorador no tiene mensaje de preedición: sus campos de texto
183 // son los del widget compartido, que hoy recibe las teclas ya
184 // resueltas. Cuando lo tenga, va acá — y hasta entonces tragar el
185 // evento es correcto: reenviarlo como tecla perdería la preedición,
186 // que es justo lo que el carril aparte existe para no perder.
187 DelAnfitrion::Ime(_) => {}
188 // **El anfitrión dice a dónde ir.** Es el clic del árbol de carpetas
189 // que vive en SU sidebar: sin esto ese árbol es decoración, porque
190 // mueve el estado del anfitrión y el que se ve es éste.
191 DelAnfitrion::Ir(ruta) => {
192 pendientes.push(nahual::Msg::TreeSelect(std::path::PathBuf::from(ruta)));
193 }
194 // **Y las otras dos secciones del sidebar del anfitrión.** Un disco
195 // y una colección NO son carpetas: son fuentes sintéticas con su
196 // propio espacio de ids, así que no caben en `Ir`. El `Msg` lo arma
197 // la lib del explorador y no este bucle — acá no hay un segundo
198 // parser de ids que se pueda despegar del primero.
199 DelAnfitrion::AbrirDispositivo(id) => {
200 pendientes.push(nahual::msg_abrir_dispositivo(&id));
201 }
202 DelAnfitrion::AbrirColeccion(id) => {
203 // Un id que no parsea se descarta y se dice: abrir «alguna»
204 // colección sería peor que no abrir ninguna.
205 match nahual::msg_abrir_coleccion(&id) {
206 Some(m) => pendientes.push(m),
207 None => eprintln!("nahual-jailed: id de colección ilegible: {id}"),
208 }
209 }
210 DelAnfitrion::Adios => return,
211 DelAnfitrion::Puntero { .. } | DelAnfitrion::PunteroSalio => {}
212 // La medida se contesta adentro del medidor; si llega acá es que
213 // sobró una, y descartarla es lo correcto.
214 DelAnfitrion::Medida(_) => {}
215 }
216
217 // 3) Aplicar lo del evento **y todo lo que los efectos hayan encolado**.
218 // El drenaje va después y en bucle porque un `update` puede despachar
219 // otro `Msg` —una miniatura que terminó, un censo que reporta— y
220 // dejarlo para la vuelta siguiente sería mandar un cuadro ya viejo.
221 for msg in pendientes {
222 modelo = nahual::update(modelo, msg, &despacho);
223 }
224 while let Some(msg) = cola.sacar() {
225 modelo = nahual::update(modelo, msg, &despacho);
226 }
227 }
228}