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

⎇ main llimphi-drm/src/lib.rs Rust · 559 siq'ikuna · 24.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}