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//! **Llimphi directo al framebuffer del kernel**, sin compositor y sin GPU.
2//!
3//! Es el hueco que `llimphi-hal` declara en su primera línea —*«framebuffer
4//! directo del kernel wawa (TODO)»*— resuelto para Linux: la cáscara que
5//! convierte una `View<Msg>` en bytes **ya existe** (`llimphi-wawa`, escrita para
6//! el kernel de wawa y explícitamente agnóstica del hardware: *«recibe un
7//! `&mut [u8]` y su stride; quién mapeó esa memoria es problema de afuera»*).
8//! Este crate es el «afuera»: consigue esa memoria de un dumb buffer DRM.
9//!
10//! # Para qué existe
11//!
12//! Para que el medio de instalación abra una interfaz **gráfica** sin arrastrar
13//! Mesa, ni Vulkan, ni un compositor, ni un rootfs. `SDD-CAMINOS.md` §1 dice
14//! gráfica siempre, y §3.4 daba por hecho que eso costaba un sistema de archivos
15//! con bibliotecas de video. No: cuesta este archivo.
16//!
17//! La cuenta, medida: el instalador gráfico sobre wgpu enlaza libc y carga
18//! Mesa/Vulkan en tiempo de ejecución — un initramfs tendría que llevarlas. Por
19//! este camino no hay nada que cargar: todo se resuelve en CPU con `vello_cpu`,
20//! que es el mismo pintor que ya certifica `check-llimphi-cpu-vs-gpu.sh` contra
21//! el camino de GPU.
22//!
23//! # El bucle
24//!
25//! [`correr`] es el ciclo Elm completo sobre esta superficie: pinta, espera una
26//! tecla, la pasa por `on_key`, actualiza el modelo y vuelve a pintar.
27//! **Repinta sólo cuando algo cambió** — un instalador que redibuja sesenta
28//! veces por segundo una pantalla quieta gasta batería y no gana nada.
29//!
30//! # Lo que este crate NO hace
31//!
32//! - **No hace modeset.** Se usa el modo **que ya está puesto**, que es la regla
33//! del arranque sin parpadeo: cambiar de modo es lo que hace parpadear, y el
34//! modo bueno lo eligió el firmware.
35//! # El puntero (2026-10-09)
36//!
37//! Acá decía «**no tiene mouse**… en un medio de instalación el teclado
38//! alcanza». No alcanzaba: el operador lo reportó tres veces, y la tercera fue
39//! *«todavía no hay mouse y no sé por qué lo sigues negando»*. Tenía razón —y la
40//! razón escrita acá era una racionalización: el hit-test ya existía
41//! (`Cascara::click`) y lo único que faltaba era leer el aparato y dibujar la
42//! flecha.
43//!
44//! Hoy se lee el movimiento **relativo** y los botones (ver [`entrada`]), el
45//! cursor lo dibuja esta superficie —no la aplicación, que se lo podría
46//! olvidar— y un click se resuelve contra el `Mounted` del cuadro que está
47//! **en la pantalla**, no contra uno nuevo. Lo que sigue faltando es el panel
48//! táctil por `EV_ABS`, que necesita el rango de cada eje y eso sí es un
49//! `ioctl`.
50//! - **No usa `llimphi_ui::App`.** Ese trait vive en un crate que arrastra winit
51//! y wgpu por su perfil normal, que es exactamente lo que este camino existe
52//! para no traer. Acá el contrato es [`Programa`], que es el mismo ciclo sin
53//! el `Handle` de concurrencia: un instalador no tiene workers.
54
55pub mod entrada;
56
57// Lo que hace falta para escribir una pantalla, servido desde acá: el DSL de la
58// vista, el layout y los colores. Es lo mismo que hace `llimphi-ui` con winit y
59// wgpu — quien usa la superficie no debería tener que averiguar de qué crate
60// sale cada tipo.
61pub use llimphi_compositor::{Mounted, View};
62pub use llimphi_cpu::{kurbo, peniko};
63pub use llimphi_entrada::{Key, KeyEvent, Modifiers, NamedKey};
64pub use llimphi_layout::taffy;
65
66use std::os::fd::{AsFd, BorrowedFd};
67
68use llimphi_entrada::KeyState;
69
70use entrada::{Boton, Evento};
71
72use drm::buffer::DrmFourcc;
73use drm::control::{connector, crtc, Device as ControlDevice};
74use llimphi_wawa::{Cascara, FormatoPixel};
75
76/// El nodo DRM abierto. Envoltorio mínimo: el crate `drm` pide `AsFd` y sus dos
77/// traits marcadores.
78pub struct Nodo(std::fs::File);
79
80impl AsFd for Nodo {
81 fn as_fd(&self) -> BorrowedFd<'_> {
82 self.0.as_fd()
83 }
84}
85impl drm::Device for Nodo {}
86impl ControlDevice for Nodo {}
87
88#[derive(Debug)]
89pub enum ErrorDrm {
90 /// Ningún `/dev/dri/cardN` se pudo abrir. En un initramfs suele ser que el
91 /// módulo del driver todavía no cargó.
92 SinNodo,
93 /// Hay nodo pero **ninguna pantalla conectada**. No es un error del
94 /// programa: es una máquina sin monitor, y quien llama decide qué hacer.
95 SinPantalla,
96 /// La pantalla la tiene otro proceso: en DRM hay **un solo master** por
97 /// nodo, y el que no lo es recibe `EACCES` al presentar —no al abrir—, así
98 /// que el diagnóstico aparece tarde y disfrazado de permiso de archivo.
99 ///
100 /// Medido en QEMU el 2026-09-28 arrancando el medio: `arje-splash` estaba
101 /// pintando la chakana y esto salía como «Permission denied (os error 13)»,
102 /// que manda a mirar dueños y modos de `/dev/dri/card0` — donde no hay nada
103 /// que arreglar. El splash tiene protocolo para soltarla
104 /// (`arje-splash/src/handoff.rs`: el cliente manda READY, el splash suelta
105 /// el master y contesta RELEASED); quien vaya a pintar tiene que hablarlo.
106 Ocupada,
107 Io(std::io::Error),
108}
109
110impl std::fmt::Display for ErrorDrm {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 ErrorDrm::SinNodo => write!(f, "no encontré ningún nodo de video (/dev/dri/card*)"),
114 ErrorDrm::SinPantalla => write!(f, "hay video pero ninguna pantalla conectada"),
115 ErrorDrm::Ocupada => write!(
116 f,
117 "la pantalla la tiene otro proceso (DRM master) — hay que pedirle que la suelte"
118 ),
119 ErrorDrm::Io(e) => write!(f, "{e}"),
120 }
121 }
122}
123
124impl From<std::io::Error> for ErrorDrm {
125 fn from(e: std::io::Error) -> Self {
126 ErrorDrm::Io(e)
127 }
128}
129
130/// Una pantalla lista para recibir vistas.
131pub struct Pantalla {
132 nodo: Nodo,
133 crtc: crtc::Handle,
134 conector: connector::Handle,
135 modo: drm::control::Mode,
136 cascara: Cascara,
137 /// 🔴 **Un solo búfer, reusado.** Acá se creaba un dumb buffer y un
138 /// framebuffer **en cada cuadro** y no se destruía ninguno: `DumbBuffer` no
139 /// tiene `Drop` —se libera con `destroy_dumb_buffer`, que lo consume— y un
140 /// `framebuffer::Handle` es un número, no un recurso con dueño. O sea que
141 /// cada cuadro dejaba 4 MB de memoria de video y un objeto del kernel.
142 ///
143 /// Con teclado no se notaba: un instalador pinta algunas decenas de cuadros
144 /// en toda su vida. Con el puntero sí — mover el mouse pinta en cada gesto,
145 /// y eso convierte una fuga despreciable en quedarse sin memoria de video
146 /// mientras alguien elige dónde instalar.
147 /// En un `Option` **sólo para poder devolverlo**: `destroy_dumb_buffer` lo
148 /// consume, y de un `&mut self` en `Drop` no se puede sacar un valor sin
149 /// dejar algo en su lugar. `DumbBuffer` no tiene `Default`.
150 buffer: Option<drm::control::dumbbuffer::DumbBuffer>,
151 fb: drm::control::framebuffer::Handle,
152 /// ¿Ya se le dijo al CRTC que muestre este framebuffer? Se hace **una vez**:
153 /// después se escribe en el mismo búfer que la pantalla está leyendo, y un
154 /// `set_crtc` por cuadro es un modeset de más por cuadro.
155 presentado: bool,
156 /// Dónde está el cursor, si hay alguno. Lo dibuja **esta superficie** y no
157 /// la aplicación: un cursor que cada pantalla tiene que acordarse de pintar
158 /// es un cursor que falta en alguna.
159 cursor: Option<(f32, f32)>,
160}
161
162impl Pantalla {
163 /// Abre la primera pantalla conectada y prepara la cáscara a su medida.
164 ///
165 /// Recorre `card0`, `card1`… porque **cuál es el bueno cambia**: en una
166 /// máquina con `simpledrm` el 0 lo toma el framebuffer heredado del firmware
167 /// y lo suelta recién cuando carga el driver de verdad. Es el mismo criterio
168 /// que ya usa `arje-splash`.
169 pub fn abrir() -> Result<Self, ErrorDrm> {
170 for n in 0..8 {
171 let ruta = format!("/dev/dri/card{n}");
172 let Ok(f) = std::fs::OpenOptions::new().read(true).write(true).open(&ruta) else {
173 continue;
174 };
175 let nodo = Nodo(f);
176 let Ok(recursos) = nodo.resource_handles() else { continue };
177 for &c in recursos.connectors() {
178 let Ok(info) = nodo.get_connector(c, false) else { continue };
179 if info.state() != connector::State::Connected {
180 continue;
181 }
182 let Some(&modo) = info.modes().first() else { continue };
183 // El CRTC que ya está manejando este conector, si lo hay: se
184 // reusa el que el firmware dejó puesto en vez de elegir otro.
185 let Some(&crtc) = recursos.crtcs().first() else { continue };
186 let (w, h) = modo.size();
187 let cascara = Cascara::new(w, h);
188 // El búfer y su framebuffer, una vez y para siempre. Si no se
189 // pueden crear no hay pantalla que ofrecer, así que el error
190 // sale acá y no en el primer cuadro.
191 let buffer = nodo
192 .create_dumb_buffer((w as u32, h as u32), DrmFourcc::Xrgb8888, 32)
193 .map_err(traducir)?;
194 let fb = nodo.add_framebuffer(&buffer, 24, 32).map_err(traducir)?;
195 return Ok(Pantalla {
196 nodo,
197 crtc,
198 conector: c,
199 modo,
200 cascara,
201 buffer: Some(buffer),
202 fb,
203 presentado: false,
204 cursor: None,
205 });
206 }
207 return Err(ErrorDrm::SinPantalla);
208 }
209 Err(ErrorDrm::SinNodo)
210 }
211
212 /// Cuánto mide, en píxeles.
213 pub fn tamano(&self) -> (u16, u16) {
214 self.modo.size()
215 }
216
217 /// Dónde poner el cursor. `None` lo saca.
218 pub fn mover_cursor(&mut self, donde: Option<(f32, f32)>) {
219 self.cursor = donde;
220 }
221
222 /// Pinta una vista y la muestra. **Un cuadro, sin bucle.**
223 ///
224 /// Devuelve el `Mounted` de ESE cuadro, que es contra el que hay que
225 /// resolver un click: el hit-test del cuadro que está en la pantalla, no de
226 /// uno nuevo. Antes se descartaba (`let _ = …`) y por eso el acierto de clic
227 /// existía y no se podía usar.
228 pub fn pintar<Msg: Clone>(&mut self, vista: View<Msg>) -> Result<Mounted<Msg>, ErrorDrm> {
229 let montado = self.cascara.fotograma(vista);
230 self.volcar()?;
231 Ok(montado)
232 }
233
234 /// Vuelve a mostrar **el mismo cuadro**, con el cursor donde esté ahora.
235 ///
236 /// Es el camino de mover el mouse: la cáscara guarda su lienzo rasterizado,
237 /// así que mover el cursor no obliga a montar la vista, calcular el layout
238 /// ni rasterizar de nuevo — sólo a copiar los píxeles que ya están y estampar
239 /// la flecha encima. Es la diferencia entre un cursor que sigue la mano y
240 /// uno que va a saltos.
241 pub fn repintar(&mut self) -> Result<(), ErrorDrm> {
242 self.volcar()
243 }
244
245 /// El `Msg` de lo que haya bajo `(x, y)` en el cuadro de `montado`.
246 pub fn click<Msg: Clone>(&self, montado: &Mounted<Msg>, x: f32, y: f32) -> Option<Msg> {
247 self.cascara.click(montado, x, y)
248 }
249
250 /// Copia el lienzo al búfer de video, estampa el cursor y lo muestra.
251 fn volcar(&mut self) -> Result<(), ErrorDrm> {
252 let (w, _) = self.modo.size();
253 let stride = w as usize * 4;
254 {
255 let Some(buffer) = self.buffer.as_mut() else { return Ok(()) };
256 let mut mapa = self.nodo.map_dumb_buffer(buffer).map_err(traducir)?;
257 // `Bgra8` y no `Rgba8`: `XRGB8888` en memoria little-endian son los
258 // bytes B, G, R, X. Equivocarse acá no da error — da una pantalla
259 // con los colores cambiados, que es peor.
260 self.cascara.volcar(mapa.as_mut(), stride, FormatoPixel::Bgra8);
261 if let Some((x, y)) = self.cursor {
262 let (w, h) = self.modo.size();
263 estampar_cursor(mapa.as_mut(), stride, w, h, x, y);
264 }
265 }
266 if !self.presentado {
267 // **Sin modeset**: se usa el modo que ya está puesto. Cambiarlo es lo
268 // que hace parpadear, y el bueno lo eligió el firmware.
269 self.nodo
270 .set_crtc(self.crtc, Some(self.fb), (0, 0), &[self.conector], Some(self.modo))
271 .map_err(traducir)?;
272 self.presentado = true;
273 return Ok(());
274 }
275 // 🔴 **Escribir el búfer NO es presentarlo.**
276 //
277 // Con un búfer reusado, escribir los píxeles del cuadro nuevo en la memoria que el CRTC
278 // está leyendo **no los muestra**: un driver de pantalla virtual (lo que hay en QEMU, y lo
279 // que hay en cualquier máquina sin GPU dedicada) copia del búfer a su propia superficie y
280 // sólo lo hace cuando se le avisa qué cambió. Sin el aviso, la pantalla se queda con el
281 // primer cuadro para siempre.
282 //
283 // Lo encontró el banco el 2026-10-09, y es el defecto que esta vuelta estuvo más cerca de
284 // llegarle al operador: la versión anterior creaba un framebuffer nuevo por cuadro y hacía
285 // `set_crtc` cada vez —eso empujaba el cuadro, y de paso fugaba 4 MB por vuelta—, así que
286 // al arreglar la fuga se rompió la presentación. **Los números de CPU no lo habrían
287 // visto**: el programa pintaba, montaba la vista y contaba sus píxeles igual que siempre,
288 // sólo que del otro lado de la frontera no pasaba nada. Se vio porque el instrumento mira
289 // el panel: la flecha del cursor dibujada y quieta en el centro.
290 //
291 // `dirty_framebuffer` es ese aviso. Si el driver no lo implementa contesta error, y ahí el
292 // camino es volver a `set_crtc` con el MISMO framebuffer — que no asigna nada, así que la
293 // fuga no vuelve.
294 let todo = [drm::control::ClipRect::new(0, 0, w, self.modo.size().1)];
295 if self.nodo.dirty_framebuffer(self.fb, &todo).is_err() {
296 self.nodo
297 .set_crtc(self.crtc, Some(self.fb), (0, 0), &[self.conector], Some(self.modo))
298 .map_err(traducir)?;
299 }
300 Ok(())
301 }
302}
303
304impl Drop for Pantalla {
305 /// Devuelve lo que se le pidió al kernel. No es cosmético: sin esto, cada
306 /// corrida del programa deja un framebuffer y 4 MB de memoria de video
307 /// colgados hasta que el nodo se cierre del todo.
308 fn drop(&mut self) {
309 let _ = self.nodo.destroy_framebuffer(self.fb);
310 // `destroy_dumb_buffer` consume el búfer: se lo saca del `Option`.
311 if let Some(buffer) = self.buffer.take() {
312 let _ = self.nodo.destroy_dumb_buffer(buffer);
313 }
314 }
315}
316
317/// La flecha del cursor, estampada **sobre los píxeles ya volcados**.
318///
319/// Va acá y no en el árbol de la vista por dos razones: una aplicación no se
320/// puede olvidar de dibujarla, y un nodo más en el layout haría que el cursor
321/// participe del cálculo de tamaños — el puntero no es parte de la interfaz, va
322/// encima de ella.
323///
324/// La forma es la flecha de siempre, escrita como texto para que se pueda leer y
325/// corregir sin un editor de imágenes: `#` es el borde oscuro y `.` el relleno
326/// claro. Borde **y** relleno porque un cursor de un solo color desaparece
327/// sobre el fondo de su mismo tono, y esta pantalla tiene fondos claros y
328/// oscuros a la vez.
329const CURSOR: [&str; 19] = [
330 "# ",
331 "## ",
332 "#.# ",
333 "#..# ",
334 "#...# ",
335 "#....# ",
336 "#.....# ",
337 "#......# ",
338 "#.......# ",
339 "#........# ",
340 "#.....#####",
341 "#..#..# ",
342 "#.# #..# ",
343 "## #..# ",
344 "# #..# ",
345 " #..# ",
346 " #.# ",
347 " ### ",
348 " ",
349];
350
351fn estampar_cursor(destino: &mut [u8], stride: usize, w: u16, h: u16, x: f32, y: f32) {
352 let (x0, y0) = (x.max(0.0) as usize, y.max(0.0) as usize);
353 for (dy, fila) in CURSOR.iter().enumerate() {
354 let py = y0 + dy;
355 if py >= h as usize {
356 break;
357 }
358 for (dx, c) in fila.bytes().enumerate() {
359 let px = x0 + dx;
360 if px >= w as usize {
361 break;
362 }
363 // BGRX, que es lo que espera `XRGB8888` en little-endian.
364 let (b, g, r) = match c {
365 b'#' => (20u8, 20u8, 20u8),
366 b'.' => (245u8, 245u8, 245u8),
367 _ => continue,
368 };
369 let i = py * stride + px * 4;
370 if i + 3 < destino.len() {
371 destino[i] = b;
372 destino[i + 1] = g;
373 destino[i + 2] = r;
374 destino[i + 3] = 255;
375 }
376 }
377 }
378}
379
380/// La misma vista, pintada **a memoria** en vez de a una pantalla.
381///
382/// Devuelve los píxeles en RGBA, uno por cada punto de `ancho × alto`. Es el
383/// mismo compositor y el mismo rasterizador que usa [`Pantalla::pintar`]: lo
384/// único que cambia es dónde caen los bytes.
385///
386/// Existe para poder **mirar lo que se dibuja sin tener el hardware delante**.
387/// Sin esto, la única forma de ver esta cara era arrancar un medio en una
388/// máquina o en un emulador, y eso convierte cada ajuste de un color en una
389/// tanda de minutos.
390pub fn pintar_en_memoria<Msg: Clone>(ancho: u16, alto: u16, vista: View<Msg>) -> Vec<u8> {
391 let mut cascara = Cascara::new(ancho, alto);
392 let _ = cascara.fotograma(vista);
393 let mut bytes = vec![0u8; ancho as usize * alto as usize * 4];
394 cascara.volcar(&mut bytes, ancho as usize * 4, FormatoPixel::Rgba8);
395 bytes
396}
397
398/// El contrato de una aplicación sobre esta superficie.
399///
400/// Es el ciclo Elm de siempre —modelo inmutable, un mensaje por vez— sin el
401/// `Handle` de `llimphi_ui::App`: la superficie no sabe de hilos, y no tiene
402/// por qué.
403///
404/// Lo que sí hay es trabajo que tarda —medir los discos de la máquina son
405/// segundos— y cuya respuesta llega cuando ya se pintó otra cosa. Para eso está
406/// [`Programa::pendientes`]: el programa guarda sus resultados donde quiera y el
407/// bucle se los pide por vuelta.
408pub trait Programa {
409 type Model;
410 type Msg: Clone;
411
412 fn init() -> Self::Model;
413 fn update(model: Self::Model, msg: Self::Msg) -> Self::Model;
414 fn view(model: &Self::Model) -> View<Self::Msg>;
415 /// `None` ignora la tecla. Devolver `Some` es lo único que mueve el modelo.
416 fn on_key(model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg>;
417 /// `true` cuando hay que salir. Se pregunta después de cada mensaje.
418 fn termino(_model: &Self::Model) -> bool {
419 false
420 }
421
422 /// Mensajes que llegaron por fuera del teclado, para entregar ahora.
423 ///
424 /// El bucle la llama una vez por vuelta y aplica lo que devuelva. Un
425 /// programa sin trabajo de fondo no la implementa; uno que lanza algo largo
426 /// —medir los discos— devuelve acá el resultado cuando esté.
427 ///
428 /// **Devolver lo mismo dos veces es repetir el mensaje**: quien la
429 /// implementa vacía su cola al contestar.
430 fn pendientes(_model: &Self::Model) -> Vec<Self::Msg> {
431 Vec::new()
432 }
433}
434
435/// Un error de DRM con el caso del master dicho por su nombre.
436///
437/// `EACCES` acá no es un problema de permisos del archivo —el nodo ya se abrió
438/// bien— sino la pantalla tomada por otro proceso. `EPERM` es el mismo caso en
439/// kernels que lo contestan así.
440fn traducir(io: std::io::Error) -> ErrorDrm {
441 match io.raw_os_error() {
442 Some(13) | Some(1) => ErrorDrm::Ocupada,
443 _ => ErrorDrm::Io(io),
444 }
445}
446
447/// Correr un programa hasta que diga que terminó.
448///
449/// **Repinta sólo cuando el modelo cambió.** Un instalador pasa casi todo su
450/// tiempo esperando a que alguien lea la pantalla; redibujarla en un bucle
451/// cerrado calienta la máquina y no mejora nada. Cuando no hay teclas, se
452/// duerme un rato corto — lo suficiente para que una pulsación no se sienta
453/// demorada, y lo bastante como para no ocupar un núcleo.
454pub fn correr<P: Programa>(pantalla: &mut Pantalla) -> Result<(), ErrorDrm> {
455 let (w, h) = pantalla.tamano();
456 let mut teclados = entrada::Entradas::abrir(w, h);
457 if teclados.vacio() {
458 // Se dice y se sigue: una máquina sin teclado es un caso real, y lo que
459 // corresponde es mostrar la pantalla, no negarse a arrancar.
460 eprintln!("llimphi-drm: no encontré ningún dispositivo de entrada en /dev/input");
461 }
462 let mut modelo = P::init();
463 let mut hay_que_pintar = true;
464 // **Dos razones distintas para escribir la pantalla.** Un cuadro nuevo
465 // monta la vista, calcula el layout y rasteriza; mover el cursor no cambia
466 // nada de eso y sólo necesita volver a copiar lo que ya está pintado. Si
467 // fueran la misma bandera, cada milímetro de mouse costaría un cuadro
468 // entero y el cursor iría a saltos.
469 let mut mover_el_cursor = false;
470 // El `Mounted` del cuadro que está EN la pantalla. Es contra éste que se
471 // resuelve un click — contra uno nuevo sería adivinar.
472 let mut montado: Option<Mounted<P::Msg>> = None;
473 if teclados.tiene_puntero() {
474 pantalla.mover_cursor(Some(teclados.puntero()));
475 }
476 // Cada cuántas vueltas se vuelve a mirar `/dev/input`. Con 16 ms por vuelta,
477 // 60 es un segundo: lo bastante seguido para que enchufar un teclado se
478 // sienta inmediato, y lo bastante espaciado para que leer un directorio no
479 // aparezca en ningún perfil.
480 const VUELTAS_ENTRE_ESCANEOS: u32 = 60;
481 let mut vueltas: u32 = 0;
482 loop {
483 vueltas = vueltas.wrapping_add(1);
484 if vueltas % VUELTAS_ENTRE_ESCANEOS == 0 {
485 // Los dispositivos no están todos al arrancar: un teclado USB
486 // enumera de forma asíncrona y tarda segundos. Sin esto, la pantalla
487 // queda pintada y sorda para siempre.
488 let nuevos = teclados.refrescar();
489 if nuevos > 0 {
490 eprintln!("llimphi-drm: {nuevos} dispositivo(s) de entrada nuevo(s)");
491 // Un mouse USB enumera tarde, igual que un teclado. Si apareció
492 // ahora, el cursor tiene que aparecer con él.
493 if teclados.tiene_puntero() && pantalla.cursor.is_none() {
494 pantalla.mover_cursor(Some(teclados.puntero()));
495 mover_el_cursor = true;
496 }
497 }
498 }
499 if hay_que_pintar {
500 montado = Some(pantalla.pintar(P::view(&modelo))?);
501 hay_que_pintar = false;
502 mover_el_cursor = false;
503 } else if mover_el_cursor {
504 pantalla.repintar()?;
505 mover_el_cursor = false;
506 }
507 // Primero lo que llegó de afuera: si el trabajo largo terminó mientras
508 // la pantalla mostraba «midiendo…», lo que corresponde es mostrarlo
509 // antes de atender una tecla que se apretó contra la pantalla vieja.
510 for msg in P::pendientes(&modelo) {
511 modelo = P::update(modelo, msg);
512 hay_que_pintar = true;
513 }
514 if P::termino(&modelo) {
515 return Ok(());
516 }
517 for ev in teclados.leer() {
518 match ev {
519 Evento::Tecla(ev) => {
520 // Sólo las pulsaciones mueven el modelo. Actuar también al
521 // soltar haría que cada tecla cuente dos veces.
522 if ev.state != KeyState::Pressed {
523 continue;
524 }
525 if let Some(msg) = P::on_key(&modelo, &ev) {
526 modelo = P::update(modelo, msg);
527 hay_que_pintar = true;
528 }
529 }
530 // **Mover el puntero NO es un mensaje.** El modelo no cambia, así
531 // que no se le pregunta nada a la aplicación: se repinta el mismo
532 // cuadro con la flecha en otro lado. Meterlo por `update` haría
533 // que cada gesto del mouse pase por la lógica del instalador.
534 Evento::Puntero { x, y } => {
535 pantalla.mover_cursor(Some((x, y)));
536 mover_el_cursor = true;
537 }
538 // **Al apretar, no al soltar**, igual que las teclas. Y sólo el
539 // izquierdo: los otros dos no tienen significado en esta cara, y
540 // hacer que el derecho elija sería una sorpresa.
541 Evento::Boton { boton: Boton::Izquierdo, apretado: true, x, y } => {
542 if let Some(m) = &montado {
543 if let Some(msg) = pantalla.click(m, x, y) {
544 modelo = P::update(modelo, msg);
545 hay_que_pintar = true;
546 }
547 }
548 }
549 Evento::Boton { .. } => {}
550 }
551 }
552 if P::termino(&modelo) {
553 return Ok(());
554 }
555 if !hay_que_pintar && !mover_el_cursor {
556 std::thread::sleep(std::time::Duration::from_millis(16));
557 }
558 }
559}