tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-layer/src/wayland.rs Rust · 1487 lines · 61.5 KB History Who touched what raw

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

1//! La mitad Wayland de `llimphi-layer`: el runner wlr-layer-shell de verdad.
2//!
3//! Vive en su propio archivo desde el 2026-08-29, cuando la app que hospeda este
4//! runner —`shuma`— empezó a querer compilar fuera de Linux. `wlr-layer-shell`
5//! es un protocolo de Wayland: no tiene equivalente en Windows ni en macOS, y no
6//! se trata de emularlo. Lo que se separa es **la declaración**: los tipos de
7//! configuración (`LayerConfig`, `Edge`, `Corner`…) son datos y viajan a
8//! cualquier lado; esto de acá sólo se compila donde el protocolo existe, y
9//! `crate::run` contesta un error honesto en el resto.
10//!
11//! El código es el mismo de siempre, movido tal cual.
12
13use std::error::Error;
14use std::ffi::c_void;
15use std::ptr::NonNull;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::mpsc::{channel, Receiver};
18use std::sync::{Arc, Mutex};
19
20use raw_window_handle::{
21    RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
22};
23use smithay_client_toolkit::{
24    compositor::{CompositorHandler, CompositorState},
25    delegate_compositor, delegate_keyboard, delegate_layer, delegate_output, delegate_pointer,
26    delegate_registry, delegate_seat, delegate_shm,
27    output::{OutputHandler, OutputState},
28    registry::{ProvidesRegistryState, RegistryState},
29    registry_handlers,
30    seat::{
31        keyboard::{KeyboardHandler, Keysym, Modifiers},
32        pointer::{
33            CursorIcon, PointerEvent, PointerEventKind, PointerHandler, ThemeSpec, ThemedPointer,
34            BTN_LEFT, BTN_RIGHT,
35        },
36        Capability, SeatHandler, SeatState,
37    },
38    shm::{Shm, ShmHandler},
39    shell::{
40        wlr_layer::{
41            Anchor as LayerAnchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler,
42            LayerSurface, LayerSurfaceConfigure,
43        },
44        WaylandSurface,
45    },
46};
47use wayland_client::{
48    globals::registry_queue_init,
49    protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
50    Connection, Proxy, QueueHandle,
51};
52
53use llimphi_ui::llimphi_compositor::{
54    consume_scroll_chain, hit_test_click, hit_test_hover, hit_test_scroll_chain, measure_text_node,
55    mount, paint, paint_range, AnimRegistry, DragFn, DragPhase, Instant as AnimInstant, Mounted,
56};
57use llimphi_ui::llimphi_hal::{wgpu, Hal, RawSurface, Surface as _};
58use llimphi_ui::llimphi_layout::{taffy, ComputedLayout, LayoutTree};
59use llimphi_ui::llimphi_raster::{peniko::color::palette, vello, Renderer};
60use llimphi_ui::llimphi_text::Typesetter;
61use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, Modifiers as LMods, NamedKey};
62use crate::{Corner, Edge, Keyboard, LayerConfig, LayerKind, Margins};
63/// Levanta el backend layer-shell y corre `A` hasta que la superficie se cierra.
64/// Devuelve error si no hay sesión Wayland o el compositor no expone
65/// `wlr-layer-shell`.
66pub fn run<A: App>(cfg: LayerConfig) -> Result<(), Box<dyn Error>> {
67    // El sentido de la rueda, igual que en el runner de ventana: una barra no
68    // puede scrollear al revés que las ventanas de la misma sesión.
69    llimphi_ui::sembrar_politica_rueda();
70    let conn = Connection::connect_to_env()?;
71    let (globals, mut event_queue) = registry_queue_init(&conn)?;
72    let qh: QueueHandle<Runner<A>> = event_queue.handle();
73
74    let compositor = CompositorState::bind(&globals, &qh)?;
75    let layer_shell = LayerShell::bind(&globals, &qh)?;
76    // `wl_shm` para cargar los pixmaps del tema de cursores (el `ThemedPointer`
77    // los necesita). El render de la app va por wgpu; esto es sólo para el cursor.
78    let shm = Shm::bind(&globals, &qh)?;
79
80    // Handle channel-backed: el `update`/efectos de la app usan su propio `Msg`;
81    // cada dispatch/spawn/spawn_periodic cae en `rx`, que `draw` drena por frame.
82    // El truco (igual que en pata): el `for_test().lift(f)` ejecuta `f` —el
83    // `send`— antes del dispatch no-op del handle de test, así el canal recibe.
84    let (tx, rx) = channel::<A::Msg>();
85    let tx = Mutex::new(tx);
86    // La bandera que atiende `Handle::quit()`: este runner no es el event loop
87    // de winit, así que el `UserEvent::Quit` que manda `quit()` no lo ve nadie.
88    // Sin esto una app que se cierra a sí misma —un runner que ejecuta y se
89    // va— anda como ventana y queda colgada como capa.
90    let salir = Arc::new(AtomicBool::new(false));
91    let handle: Handle<A::Msg> = Handle::<()>::for_test()
92        .lift(move |m: A::Msg| {
93            let _ = tx.lock().expect("tx layer").send(m);
94        })
95        .con_salida(salir.clone());
96    let model = A::init(&handle);
97
98    let resolved = resolve_anchor(&cfg);
99    let wl_surface = compositor.create_surface(&qh);
100    let layer = layer_shell.create_layer_surface(
101        &qh,
102        wl_surface,
103        cfg.layer.into(),
104        Some(cfg.namespace.clone()),
105        None,
106    );
107    layer.set_anchor(resolved.anchor);
108    layer.set_size(resolved.size.0, resolved.size.1);
109    layer.set_exclusive_zone(resolved.exclusive_zone);
110    if let Some(m) = resolved.margins {
111        layer.set_margin(m.top, m.right, m.bottom, m.left);
112    }
113    layer.set_keyboard_interactivity(cfg.keyboard.into());
114    layer.commit();
115    let size = resolved.size;
116
117    let mut runner = Runner::<A> {
118        registry_state: RegistryState::new(&globals),
119        output_state: OutputState::new(&globals, &qh),
120        seat_state: SeatState::new(&globals, &qh),
121        conn,
122        hal: None,
123        keyboard: None,
124        pointer: None,
125        themed_pointer: None,
126        shm,
127        cursor_surface: None,
128        seat: None,
129        layer,
130        compositor,
131        layer_shell,
132        cfg,
133        width: size.0.max(1),
134        height: size.1.max(1),
135        gpu: None,
136        cache: None,
137        hover_idx: None,
138        anim_registry: AnimRegistry::new(),
139        anim_viva: false,
140        dirty: true,
141        frame_pending: false,
142        drag: None,
143        ultimo_tap: None,
144        clic_retenido: None,
145        ptr: (0.0, 0.0),
146        mods: Modifiers::default(),
147        model: Some(model),
148        handle,
149        rx,
150        recreate_count: 0,
151        output_actual: None,
152        exit: false,
153    };
154
155    // Nacer en un monitor concreto necesita saber cuáles anunció el compositor,
156    // y eso recién se sabe tras un roundtrip — la superficie de arriba ya nació
157    // donde él quiso. Si el conector pedido existe, la re-creamos allá antes de
158    // pintar el primer cuadro; si no está, se queda donde nació.
159    if let Some(nombre) = runner.cfg.output.clone() {
160        event_queue.roundtrip(&mut runner)?;
161        if runner.resolver_output(&nombre).is_some() {
162            let qh2 = event_queue.handle();
163            runner.recreate_surface(&qh2);
164        }
165    }
166
167    // Diagnóstico del cierre: con `LLIMPHI_LAYER_DIAG=1` cada vuelta del bucle
168    // cuenta por dónde va. Existe porque un runner que se cierra a sí mismo
169    // quedaba vivo y no había forma de saber si el bucle no salía o si el
170    // proceso no moría después — dos causas distintas con el mismo síntoma.
171    let diag = std::env::var("LLIMPHI_LAYER_DIAG").is_ok();
172    let mut vueltas: u64 = 0;
173    while !runner.exit && !salir.load(Ordering::SeqCst) {
174        vueltas += 1;
175        if diag {
176            eprintln!("layer-diag: vuelta {vueltas} — entro a blocking_dispatch");
177        }
178        event_queue.blocking_dispatch(&mut runner)?;
179        if diag {
180            eprintln!(
181                "layer-diag: vuelta {vueltas} — volvi; exit={} salir={}",
182                runner.exit,
183                salir.load(Ordering::SeqCst)
184            );
185        }
186    }
187    if diag {
188        eprintln!("layer-diag: SALI del bucle tras {vueltas} vueltas");
189    }
190    drop(runner);
191    if diag {
192        eprintln!("layer-diag: runner dropeado");
193    }
194    drop(event_queue);
195    if diag {
196        eprintln!("layer-diag: event_queue dropeada");
197    }
198    Ok(())
199}
200
201// ── Interno ─────────────────────────────────────────────────────────────────
202
203/// Tamaño por defecto de una caja de esquina cuando `cfg.size` es `None`.
204const DEFAULT_CORNER_SIZE: (u32, u32) = (360, 280);
205
206/// El anclaje resuelto para la superficie: anclas sctk, tamaño pedido, zona
207/// exclusiva y márgenes opcionales. Unifica los dos modos (barra/caja).
208struct Resolved {
209    anchor: LayerAnchor,
210    size: (u32, u32),
211    exclusive_zone: i32,
212    margins: Option<Margins>,
213}
214
215/// Resuelve el anclaje según el modo de `cfg`: caja de esquina si `corner` es
216/// `Some`, barra de borde en caso contrario.
217fn resolve_anchor(cfg: &LayerConfig) -> Resolved {
218    // Pantalla completa gana sobre los otros dos modos: los cuatro anclajes y
219    // tamaño 0 en ambos ejes = "estirame a la salida".
220    if cfg.fullscreen {
221        return Resolved {
222            anchor: LayerAnchor::TOP
223                | LayerAnchor::BOTTOM
224                | LayerAnchor::LEFT
225                | LayerAnchor::RIGHT,
226            size: (0, 0),
227            exclusive_zone: 0,
228            margins: None,
229        };
230    }
231    match cfg.corner {
232        Some(corner) => {
233            let size = cfg.size.unwrap_or(DEFAULT_CORNER_SIZE);
234            let anchor = match corner {
235                Corner::TopLeft => LayerAnchor::TOP | LayerAnchor::LEFT,
236                Corner::TopRight => LayerAnchor::TOP | LayerAnchor::RIGHT,
237                Corner::BottomLeft => LayerAnchor::BOTTOM | LayerAnchor::LEFT,
238                Corner::BottomRight => LayerAnchor::BOTTOM | LayerAnchor::RIGHT,
239            };
240            // Una caja flota siempre: no tiene sentido reservar zona exclusiva.
241            Resolved { anchor, size, exclusive_zone: 0, margins: Some(cfg.margins) }
242        }
243        None => {
244            let (anchor, size) = anchor_and_size(cfg.edge, cfg.thickness);
245            let exclusive_zone = if cfg.exclusive { cfg.thickness as i32 } else { 0 };
246            Resolved { anchor, size, exclusive_zone, margins: None }
247        }
248    }
249}
250
251/// El anclaje sctk + el tamaño `(w, h)` pedido para un borde y grosor. El eje
252/// paralelo al borde va en 0 → el compositor lo estira a la salida completa.
253fn anchor_and_size(edge: Edge, thickness: u32) -> (LayerAnchor, (u32, u32)) {
254    match edge {
255        Edge::Top => (
256            LayerAnchor::TOP | LayerAnchor::LEFT | LayerAnchor::RIGHT,
257            (0, thickness),
258        ),
259        Edge::Bottom => (
260            LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT,
261            (0, thickness),
262        ),
263        Edge::Left => (
264            LayerAnchor::LEFT | LayerAnchor::TOP | LayerAnchor::BOTTOM,
265            (thickness, 0),
266        ),
267        Edge::Right => (
268            LayerAnchor::RIGHT | LayerAnchor::TOP | LayerAnchor::BOTTOM,
269            (thickness, 0),
270        ),
271    }
272}
273
274impl From<LayerKind> for Layer {
275    fn from(k: LayerKind) -> Self {
276        match k {
277            LayerKind::Background => Layer::Background,
278            LayerKind::Bottom => Layer::Bottom,
279            LayerKind::Top => Layer::Top,
280            LayerKind::Overlay => Layer::Overlay,
281        }
282    }
283}
284
285impl From<Keyboard> for KeyboardInteractivity {
286    fn from(k: Keyboard) -> Self {
287        match k {
288            Keyboard::None => KeyboardInteractivity::None,
289            Keyboard::OnDemand => KeyboardInteractivity::OnDemand,
290            Keyboard::Exclusive => KeyboardInteractivity::Exclusive,
291        }
292    }
293}
294
295/// El estado wgpu de la superficie.
296struct PanelGpu {
297    surface: RawSurface,
298    renderer: Renderer,
299    typesetter: Typesetter,
300    scene: vello::Scene,
301    layout: LayoutTree,
302}
303
304/// El árbol pintado en el último frame, para hit-test entre frames.
305struct RenderCache<Msg> {
306    mounted: Mounted<Msg>,
307    computed: ComputedLayout,
308}
309
310/// El cliente Wayland que corre un `App` como una sola layer surface.
311struct Runner<A: App> {
312    registry_state: RegistryState,
313    output_state: OutputState,
314    seat_state: SeatState,
315    conn: Connection,
316    hal: Option<Hal>,
317    keyboard: Option<wl_keyboard::WlKeyboard>,
318    pointer: Option<wl_pointer::WlPointer>,
319    /// Puntero con tema de cursores: traduce el `Cursor` del nodo bajo el
320    /// puntero a una forma de cursor del sistema (I-beam sobre texto, mano sobre
321    /// links, etc). El backend winit ya lo hacía; el layer-shell no tenía NADA.
322    themed_pointer: Option<ThemedPointer>,
323    /// `wl_shm` retenido para el tema de cursores.
324    shm: Shm,
325    /// Superficie dedicada donde el compositor pinta el cursor (la usa el
326    /// `ThemedPointer`).
327    cursor_surface: Option<wl_surface::WlSurface>,
328    seat: Option<wl_seat::WlSeat>,
329    layer: LayerSurface,
330    /// Compositor + shell + config retenidos para **re-crear** la layer-surface
331    /// si el compositor nos manda `closed` (reset/quita del output) en vez de
332    /// morir. Ver [`Runner::recreate_surface`] y el handler `closed`.
333    compositor: CompositorState,
334    layer_shell: LayerShell,
335    cfg: LayerConfig,
336    width: u32,
337    height: u32,
338    gpu: Option<PanelGpu>,
339    cache: Option<RenderCache<A::Msg>>,
340    hover_idx: Option<usize>,
341    /// Registro de **animaciones implícitas** (`View::animated`,
342    /// `animated_enter/_exit/_inout/_switch`) retenido entre cuadros.
343    ///
344    /// Hasta el 2026-09-11 este camino —el de layer-shell, por donde pintan el
345    /// cajón de shuma, las barras, los rails y todos los flyouts de pata— hacía
346    /// `mount → compute → paint` **sin registro**: las animaciones implícitas
347    /// compilaban, se declaraban y eran **inertes**. Sólo corrían en el bucle
348    /// winit (`llimphi-ui`), o sea únicamente en las apps con ventana propia.
349    /// Eso se comió, entre otras, la paginación del lienzo de shuma al cambiar
350    /// de pestaña (`b145050a9`): andaba en la shuma con ventana y no en la del
351    /// cajón, que es la que se usa.
352    anim_registry: AnimRegistry,
353    /// Alguna animación implícita sigue viva: el próximo frame-callback tiene
354    /// que **repintar** aunque el modelo no haya cambiado. Es el equivalente del
355    /// `request_redraw` que el bucle winit dispara mientras `animating`; acá el
356    /// latido de frame-callbacks ya existe, así que alcanza con volver a marcar
357    /// sucio. Se autodetiene: cuando la última animación se asienta, el
358    /// `reconcile` devuelve `false` y volvemos a dormir.
359    anim_viva: bool,
360    /// Hay algo nuevo que pintar. Arranca en `true` (nunca se pintó nada) y lo
361    /// levantan `apply`, los cambios de hover/drag, `configure` y la re-creación
362    /// de la surface; lo baja `draw` al presentar. Ver [`Runner::draw`].
363    dirty: bool,
364    /// Ya hay un frame-callback pedido y sin responder. Evita encadenar uno por
365    /// cada `draw`: `configure` también llama a `draw`, así que sin esto cada
366    /// configure suma un latido paralelo y quedan N en vuelo — el commit-storm
367    /// que le clava un core al compositor. Es la misma guarda que pata tiene
368    /// por panel (`frame_pending`); `llimphi-layer` se extrajo de pata pero se
369    /// quedó sin ella. Se limpia al llegar el callback.
370    frame_pending: bool,
371    /// Arrastre en curso: handler del nodo + última posición del puntero.
372    drag: Option<(DragFn<A::Msg>, (f32, f32))>,
373    /// Último press izquierdo sobre un nodo con doble-tap: cuándo y dónde.
374    ///
375    /// Es la mitad que le faltaba a este bucle. El de ventana tiene su arena de
376    /// gestos desde hace rato; acá no había ninguna, así que `on_double_tap`
377    /// —puesto en una fila del árbol del IDE, por ejemplo— andaba en la app
378    /// suelta y **no hacía nada en el cajón**, sin error ni aviso.
379    ultimo_tap: Option<(std::time::Instant, (f32, f32))>,
380    /// Click **retenido** por un nodo con doble-tap exclusivo
381    /// (`View::double_tap_exclusive`): `(msg, origen, vence, soltado)`.
382    ///
383    /// Acá el click sale normalmente en el PRESS. Con el gesto arbitrado no
384    /// puede: hasta que no venza la ventana del doble no se sabe si el toque fue
385    /// uno o el primero de dos. Se guarda, y lo dispara `draw` al vencer (el
386    /// latido de frame-callbacks nos despierta igual) o lo mata el segundo tap.
387    /// Es lo que hace que un doble-click en un diente fije el espacio **sin**
388    /// desplegar y replegar su panel de paso.
389    clic_retenido: Option<(A::Msg, (f32, f32), std::time::Instant, bool)>,
390    /// Dónde está el puntero, en coordenadas de la superficie.
391    ///
392    /// Hace falta porque el evento de **rueda** de Wayland no trae posición: la
393    /// posición viaja en los eventos de movimiento, y el de rueda llega con lo
394    /// que haya quedado — se lo vio llegar con `y = 0`, así que el hit-test
395    /// caía fuera de la lista y la rueda no scrolleaba nada. Medido en metal el
396    /// 2026-08-25 con `LLIMPHI_LAYER_DIAG=1`.
397    ptr: (f32, f32),
398    mods: Modifiers,
399    model: Option<A::Model>,
400    handle: Handle<A::Msg>,
401    rx: Receiver<A::Msg>,
402    /// Cierres consecutivos sin lograr pintar un frame. Resetea al presentar; si
403    /// pasa el tope, nos rendimos (`exit`) para que el supervisor reinicie con
404    /// estado limpio (output realmente ausente, no un reset transitorio).
405    recreate_count: u32,
406    /// Nombre del conector al que está atada la superficie **ahora**. `None`
407    /// mientras la eligió el compositor y nadie pidió mudarse. Es contra esto
408    /// que se compara [`App::output_deseado`] por cuadro.
409    output_actual: Option<String>,
410    exit: bool,
411}
412
413impl<A: App> Runner<A> {
414    /// Aplica un `Msg` a la app (transición pura del modelo) y marca para
415    /// repintar invalidando la caché de hit-test.
416    fn apply(&mut self, msg: A::Msg) {
417        if let Some(model) = self.model.take() {
418            self.model = Some(A::update(model, msg, &self.handle));
419        }
420        self.cache = None;
421        self.dirty = true;
422    }
423
424    /// Traduce un evento de teclado sctk al `KeyEvent` de Llimphi.
425    fn keysym_to_keyevent(
426        &self,
427        event: &smithay_client_toolkit::seat::keyboard::KeyEvent,
428    ) -> Option<KeyEvent> {
429        use smithay_client_toolkit::seat::keyboard::Keysym as K;
430        let named = match event.keysym {
431            K::Return | K::KP_Enter => Some(NamedKey::Enter),
432            K::BackSpace => Some(NamedKey::Backspace),
433            K::Tab | K::ISO_Left_Tab => Some(NamedKey::Tab),
434            K::Escape => Some(NamedKey::Escape),
435            K::Up => Some(NamedKey::ArrowUp),
436            K::Down => Some(NamedKey::ArrowDown),
437            K::Right => Some(NamedKey::ArrowRight),
438            K::Left => Some(NamedKey::ArrowLeft),
439            K::Home => Some(NamedKey::Home),
440            K::End => Some(NamedKey::End),
441            K::Page_Up => Some(NamedKey::PageUp),
442            K::Page_Down => Some(NamedKey::PageDown),
443            K::Delete => Some(NamedKey::Delete),
444            K::Insert => Some(NamedKey::Insert),
445            K::F1 => Some(NamedKey::F1),
446            K::F2 => Some(NamedKey::F2),
447            K::F3 => Some(NamedKey::F3),
448            K::F4 => Some(NamedKey::F4),
449            K::F5 => Some(NamedKey::F5),
450            K::F6 => Some(NamedKey::F6),
451            K::F7 => Some(NamedKey::F7),
452            K::F8 => Some(NamedKey::F8),
453            K::F9 => Some(NamedKey::F9),
454            K::F10 => Some(NamedKey::F10),
455            K::F11 => Some(NamedKey::F11),
456            K::F12 => Some(NamedKey::F12),
457            _ => None,
458        };
459        let modifiers = LMods {
460            shift: self.mods.shift,
461            ctrl: self.mods.ctrl,
462            alt: self.mods.alt,
463            meta: self.mods.logo,
464        };
465        let (key, text) = if let Some(n) = named {
466            (Key::Named(n), None)
467        } else {
468            let txt = match event.utf8.as_deref() {
469                Some(s) if !s.is_empty() && !s.chars().all(char::is_control) => s.to_string(),
470                _ => event.keysym.key_char()?.to_string(),
471            };
472            (Key::Character(txt.as_str().into()), Some(txt))
473        };
474        Some(KeyEvent {
475            key,
476            state: KeyState::Pressed,
477            text,
478            modifiers,
479            repeat: false,
480        })
481    }
482
483    /// Crea el estado wgpu de la superficie (idempotente).
484    fn ensure_gpu(&mut self) {
485        if self.gpu.is_some() {
486            return;
487        }
488        let display_ptr = self.conn.backend().display_ptr() as *mut c_void;
489        let surface_ptr = self.layer.wl_surface().id().as_ptr() as *mut c_void;
490        let (w, h) = (self.width, self.height);
491        let display_handle = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(
492            NonNull::new(display_ptr).expect("wl_display ptr"),
493        ));
494        let window_handle = RawWindowHandle::Wayland(WaylandWindowHandle::new(
495            NonNull::new(surface_ptr).expect("wl_surface ptr"),
496        ));
497        // SAFETY: los handles apuntan a objetos Wayland que `self` mantiene vivos.
498        let make_target = || wgpu::SurfaceTargetUnsafe::RawHandle {
499            // wgpu 29 volvió opcional el display del target.
500            raw_display_handle: Some(display_handle),
501            raw_window_handle: window_handle,
502        };
503
504        let surface = if self.hal.is_none() {
505            match pollster::block_on(unsafe { Hal::new_for_raw_surface(make_target, w, h) }) {
506                Ok((hal, surface)) => {
507                    self.hal = Some(hal);
508                    surface
509                }
510                Err(e) => {
511                    eprintln!("llimphi-layer · sin gpu: {e}");
512                    return;
513                }
514            }
515        } else {
516            let hal = self.hal.as_ref().expect("hal");
517            let wgpu_surface = match unsafe { hal.instance.create_surface_unsafe(make_target()) } {
518                Ok(s) => s,
519                Err(e) => {
520                    eprintln!("llimphi-layer · sin gpu: {e}");
521                    return;
522                }
523            };
524            match RawSurface::from_surface(hal, wgpu_surface, display_handle, window_handle, w, h) {
525                Ok(s) => s,
526                Err(e) => {
527                    eprintln!("llimphi-layer · sin gpu: {e}");
528                    return;
529                }
530            }
531        };
532        let hal = self.hal.as_ref().expect("hal");
533        let renderer = Renderer::new(hal).expect("renderer");
534        self.gpu = Some(PanelGpu {
535            surface,
536            renderer,
537            typesetter: Typesetter::new(),
538            scene: vello::Scene::new(),
539            layout: LayoutTree::new(),
540        });
541    }
542
543    /// Traduce el nombre de un conector (`"DP-1"`) al `wl_output` que el
544    /// compositor anunció. `None` si ese monitor no está (desenchufado, o un
545    /// nombre que no existe) — el caller cae a «que elija el compositor».
546    fn resolver_output(&self, nombre: &str) -> Option<wl_output::WlOutput> {
547        self.output_state.outputs().find(|o| {
548            self.output_state
549                .info(o)
550                .and_then(|i| i.name)
551                .is_some_and(|n| n == nombre)
552        })
553    }
554
555    /// ¿La app pidió mudarse de monitor? Compara [`App::output_deseado`] con la
556    /// salida a la que está atada la superficie y, si difieren, la re-crea allá.
557    ///
558    /// Devuelve `true` si mudó — el caller debe **abandonar el cuadro**: la
559    /// superficie nueva todavía no tiene `configure`, y el `configure` que
560    /// llegue vuelve a llamar a `draw` con la GPU rehecha.
561    ///
562    /// Se re-crea porque no hay alternativa: en wlr-layer-shell la salida se
563    /// elige en `get_layer_surface` y después es inmutable. Lo que se paga es
564    /// la superficie y su swapchain; el proceso, el modelo (mayúsculas, capa
565    /// activa) y el dispositivo `wgpu` sobreviven intactos.
566    fn seguir_output(&mut self, qh: &QueueHandle<Self>) -> bool {
567        let Some(model) = self.model.as_ref() else {
568            return false;
569        };
570        let Some(quiere) = A::output_deseado(model).filter(|n| !n.is_empty()) else {
571            return false;
572        };
573        if self.output_actual.as_deref() == Some(quiere.as_str()) {
574            return false;
575        }
576        if self.resolver_output(&quiere).is_none() {
577            // El monitor pedido no está anunciado (todavía, o ya no). Quedarse
578            // donde está es lo correcto; reintenta solo en el próximo cuadro.
579            return false;
580        }
581        self.cfg.output = Some(quiere);
582        self.recreate_surface(qh);
583        true
584    }
585
586    /// Re-crea la layer-surface tras un `closed` del compositor (reset/quita del
587    /// output: churn DRM, DPMS, hotplug). Suelta la surface GPU vieja —atada al
588    /// `wl_surface` ya muerto; el `hal`/device **sobrevive**— y crea una nueva
589    /// con la misma config. El próximo `configure` que mande el compositor
590    /// reconstruye la GPU vía [`ensure_gpu`](Self::ensure_gpu) y `draw` re-arma
591    /// el latido. Así la barra sobrevive a un reset de output sin morir.
592    fn recreate_surface(&mut self, qh: &QueueHandle<Self>) {
593        self.gpu = None;
594        self.cache = None;
595        self.dirty = true;
596        // La surface vieja murió: su callback no va a llegar nunca. Si no
597        // liberamos el slot, el latido queda trabado para siempre.
598        self.frame_pending = false;
599        let resolved = resolve_anchor(&self.cfg);
600        // La salida pedida, si el compositor la anuncia. `None` = que elija él
601        // (el camino de siempre, y el fallback si el conector no existe: mejor
602        // una barra en el monitor equivocado que ninguna barra).
603        let salida = self
604            .cfg
605            .output
606            .as_deref()
607            .and_then(|n| self.resolver_output(n));
608        self.output_actual = salida.as_ref().and(self.cfg.output.clone());
609        let wl_surface = self.compositor.create_surface(qh);
610        let layer = self.layer_shell.create_layer_surface(
611            qh,
612            wl_surface,
613            self.cfg.layer.into(),
614            Some(self.cfg.namespace.clone()),
615            salida.as_ref(),
616        );
617        layer.set_anchor(resolved.anchor);
618        layer.set_size(resolved.size.0, resolved.size.1);
619        layer.set_exclusive_zone(resolved.exclusive_zone);
620        if let Some(m) = resolved.margins {
621            layer.set_margin(m.top, m.right, m.bottom, m.left);
622        }
623        layer.set_keyboard_interactivity(self.cfg.keyboard.into());
624        layer.commit();
625        self.layer = layer; // dropea la LayerSurface cerrada
626    }
627
628    /// Mantiene vivo el latido: pide el siguiente frame-callback. El loop de
629    /// frames se auto-sostiene así (cada `frame` re-pide el próximo), lo que
630    /// permite drenar el canal de la app por frame sin un timer aparte.
631    fn latido(&mut self, qh: &QueueHandle<Self>) {
632        if self.frame_pending {
633            return; // ya hay uno pedido; encadenar otro es el commit-storm
634        }
635        self.frame_pending = true;
636        let surface = self.layer.wl_surface();
637        surface.frame(qh, surface.clone());
638        surface.commit();
639    }
640
641    /// Avanza un frame: drena el canal, construye la vista, layout, pinta y
642    /// presenta. Cachea el árbol para el hit-test del próximo evento.
643    ///
644    /// **Sale temprano si nada cambió.** El latido de frame-callbacks se
645    /// mantiene siempre (es lo que nos despierta para drenar el canal), pero
646    /// construir vista + layout + pintar + presentar sólo se paga cuando hay
647    /// algo nuevo: un `Msg` aplicado, un cambio de hover/drag, un `configure` o
648    /// una re-creación de la surface. Antes se re-renderizaba en CADA frame
649    /// aunque la pantalla fuera idéntica — medido en metal 2026-07-21,
650    /// `pata-notify` quemaba 2.9% de un core sin un solo toast en pantalla.
651    /// Es además el criterio que ya usa el backend de ventanas (`llimphi-ui`
652    /// duerme en `ControlFlow::Wait` y pide `request_redraw` a demanda).
653    ///
654    /// Las apps que animan ya empujan `Msg` por [`Handle::spawn_periodic`]
655    /// (pata: reloj 1 s, cava y dientes 100 ms), así que siguen andando igual.
656    /// Lo que NO se sostiene solo es una `view()` que dependa del reloj sin
657    /// emitir un `Msg`: eso hay que despertarlo con un tick, como corresponde.
658    fn draw(&mut self, qh: &QueueHandle<Self>) {
659        // Click retenido que ya venció con el botón arriba: era un click simple
660        // y recién ahora se sabe (ver `clic_retenido`). El latido de
661        // frame-callbacks es el que nos trae acá a tiempo.
662        if self
663            .clic_retenido
664            .as_ref()
665            .is_some_and(|(_, _, vence, soltado)| *soltado && std::time::Instant::now() >= *vence)
666        {
667            let (msg, _, _, _) = self.clic_retenido.take().expect("retenido");
668            self.apply(msg);
669        }
670        // Drenar los Msg que la app empujó al canal (ticks/async/follow-ups).
671        // `apply` marca sucio, así que esto decide el early-return de abajo.
672        while let Ok(m) = self.rx.try_recv() {
673            self.apply(m);
674        }
675        // ¿La app quiere estar en otro monitor? Se muda ANTES de pintar: la
676        // superficie que sobrevive a esto es otra, y pintar la vieja sería tirar
677        // el cuadro. El `configure` de la nueva vuelve a entrar acá.
678        if self.seguir_output(qh) {
679            return;
680        }
681        // Nada nuevo y ya hay un frame presentado: mantené el latido y volvé.
682        // `anim_viva` cuenta como «hay algo nuevo»: una animación implícita
683        // cambia lo que se ve sin que nadie mande un `Msg`, así que es el único
684        // caso en que hay que repintar con el modelo quieto.
685        if !self.dirty && !self.anim_viva && self.cache.is_some() {
686            self.latido(qh);
687            return;
688        }
689        self.ensure_gpu();
690
691        let (w, h) = (self.width, self.height);
692        let view = A::view(self.model.as_ref().expect("model"));
693        let hover_idx = self.hover_idx;
694        let hal = match self.hal.as_ref() {
695            Some(h) => h,
696            None => {
697                self.latido(qh);
698                return;
699            }
700        };
701        let gpu = match self.gpu.as_mut() {
702            Some(g) => g,
703            None => {
704                self.latido(qh);
705                return;
706            }
707        };
708        gpu.surface.resize(w, h);
709        let frame = match gpu.surface.acquire() {
710            Ok(f) => f,
711            Err(_) => {
712                self.latido(qh);
713                return;
714            }
715        };
716        gpu.layout.clear();
717        let mounted = mount(&mut gpu.layout, view);
718        let computed = {
719            let ts = &mut gpu.typesetter;
720            let tmap = &mounted.text_measures;
721            gpu.layout
722                .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
723                    match tmap.get(&nid) {
724                        Some(tm) => measure_text_node(ts, tm, known, avail),
725                        None => taffy::Size::ZERO,
726                    }
727                })
728                .expect("layout")
729        };
730        // Input-region recortada al contenido interactivo (opt-in): la caja fija
731        // del overlay (un toast de 352×420, p. ej.) se traga todos los clicks de
732        // la ventana debajo aunque esté vacía o transparente. Recortando la
733        // región a la unión de los rects de nodos con handler (o VACÍA si no hay
734        // ninguno), las zonas ociosas quedan click-through. Se aplica antes del
735        // `present` (que commitea la surface) para que entre en el mismo frame.
736        if self.cfg.input_from_content {
737            use smithay_client_toolkit::compositor::Region;
738            if let Ok(region) = Region::new(&self.compositor) {
739                for n in &mounted.nodes {
740                    let interactivo = n.on_click.is_some()
741                        || n.on_click_at.is_some()
742                        || n.on_click_screen.is_some()
743                        || n.drag.is_some()
744                        || n.drag_at.is_some()
745                        || n.drag_velocity.is_some()
746                        || n.on_right_click.is_some()
747                        || n.on_right_click_at.is_some()
748                        || n.on_middle_click.is_some()
749                        || n.hover_fill.is_some();
750                    if !interactivo {
751                        continue;
752                    }
753                    if let Some(r) = computed.get(n.id) {
754                        let (x, y, w, h) =
755                            (r.x as i32, r.y as i32, r.w.ceil() as i32, r.h.ceil() as i32);
756                        if w > 0 && h > 0 {
757                            region.add(x, y, w, h);
758                        }
759                    }
760                }
761                // Región (posiblemente vacía = todo click-through) → la surface.
762                self.layer
763                    .wl_surface()
764                    .set_input_region(Some(region.wl_region()));
765            }
766        }
767        // **Animaciones implícitas.** El mismo orden que el bucle winit
768        // (`llimphi-ui/src/eventloop/redraw.rs`): reconciliar DESPUÉS del layout
769        // y ANTES del paint —ahí se interpolan fill/radius/alpha/transform— y
770        // hornear los underlays (el contenido viejo de un `animated_switch` con
771        // `exit_under`, que el paint apendea debajo del nuevo en su posición z).
772        let ahora = AnimInstant::now();
773        let mut mounted = mounted;
774        let anim_activa = self.anim_registry.reconcile(&mut mounted, ahora);
775        self.anim_registry.prepare_underlays(&mut mounted, ahora);
776        gpu.scene.reset();
777        paint(&mut gpu.scene, &mounted, &computed, &mut gpu.typesetter, hover_idx, None);
778        // Salidas y cross-fades. 1) Guardar la subescena de cada nodo `exit`
779        // mientras todavía está vivo (cuando desaparezca ya no hay de dónde
780        // sacarla). 2) Reproducir los fantasmas de los que se fueron, encima del
781        // contenido. Cuesta cero si ningún nodo declara salida.
782        for (idx, end, key) in self.anim_registry.live_exit_nodes(&mounted, ahora) {
783            let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim");
784            let centro = computed
785                .get(mounted.nodes[idx].id)
786                .map(|r| (r.x + r.w * 0.5, r.y + r.h * 0.5))
787                .unwrap_or((0.0, 0.0));
788            // El fantasma ANTERIOR no entra a la captura del contenido vivo: si
789            // entrara, el próximo fantasma arrastraría al previo.
790            let underlay_guardado = mounted.nodes[idx].underlay.take();
791            let mut sub = vello::Scene::new();
792            paint_range(
793                &mut sub,
794                &mounted,
795                &computed,
796                &mut gpu.typesetter,
797                None,
798                None,
799                idx,
800                end,
801                vello::kurbo::Affine::IDENTITY,
802            );
803            mounted.nodes[idx].underlay = underlay_guardado;
804            self.anim_registry.store_live_exit_en(key, sub, centro, &a, ahora);
805        }
806        let fantasmas = self
807            .anim_registry
808            .replay_ghosts(&mut gpu.scene, ahora, w as f32, h as f32);
809        self.anim_viva = anim_activa || fantasmas;
810        if let Err(e) = gpu.renderer.render(hal, &gpu.scene, &frame, palette::css::TRANSPARENT) {
811            eprintln!("llimphi-layer · render: {e}");
812        }
813        gpu.surface.present(frame, hal);
814
815        // Pintamos un frame con éxito: el ciclo de re-creación (si lo hubo) se
816        // recuperó del todo. Resetea el backoff de `closed`.
817        self.recreate_count = 0;
818        self.cache = Some(RenderCache { mounted, computed });
819        // Frame presentado: hasta que algo vuelva a cambiar, los próximos
820        // callbacks sólo re-arman el latido.
821        self.dirty = false;
822        self.latido(qh);
823    }
824}
825
826// ── Handlers sctk ───────────────────────────────────────────────────────────
827
828impl<A: App> CompositorHandler for Runner<A> {
829    fn scale_factor_changed(
830        &mut self,
831        _: &Connection,
832        _: &QueueHandle<Self>,
833        _: &wl_surface::WlSurface,
834        _: i32,
835    ) {
836    }
837    fn transform_changed(
838        &mut self,
839        _: &Connection,
840        _: &QueueHandle<Self>,
841        _: &wl_surface::WlSurface,
842        _: wl_output::Transform,
843    ) {
844    }
845    fn frame(
846        &mut self,
847        _: &Connection,
848        qh: &QueueHandle<Self>,
849        _: &wl_surface::WlSurface,
850        _: u32,
851    ) {
852        // Llegó el callback que pidió `latido`: liberamos el slot para que el
853        // próximo `latido` pueda pedir el siguiente.
854        self.frame_pending = false;
855        self.draw(qh);
856    }
857    fn surface_enter(
858        &mut self,
859        _: &Connection,
860        _: &QueueHandle<Self>,
861        _: &wl_surface::WlSurface,
862        _: &wl_output::WlOutput,
863    ) {
864    }
865    fn surface_leave(
866        &mut self,
867        _: &Connection,
868        _: &QueueHandle<Self>,
869        _: &wl_surface::WlSurface,
870        _: &wl_output::WlOutput,
871    ) {
872    }
873}
874
875impl<A: App> LayerShellHandler for Runner<A> {
876    fn closed(&mut self, _: &Connection, qh: &QueueHandle<Self>, _: &LayerSurface) {
877        // El compositor cerró nuestra superficie — típicamente al resetear o
878        // quitar el output al que estábamos anclados (churn DRM, DPMS, hotplug).
879        // Antes esto mataba el proceso (`exit = true`) y la barra quedaba muerta
880        // hasta un respawn externo. Ahora re-creamos la layer-surface sobre el
881        // output vigente y seguimos vivos. Con tope: si nos cierran repetido sin
882        // lograr pintar (output realmente ausente, no un reset transitorio), nos
883        // rendimos y salimos para que el supervisor reinicie con estado limpio.
884        const MAX_RECREATES: u32 = 16;
885        self.recreate_count += 1;
886        if self.recreate_count > MAX_RECREATES {
887            eprintln!(
888                "llimphi-layer · cerrados {} veces sin recuperar; salgo (que reinicie el supervisor).",
889                self.recreate_count
890            );
891            self.exit = true;
892            return;
893        }
894        eprintln!(
895            "llimphi-layer · el compositor cerró la superficie; re-creo (intento {}).",
896            self.recreate_count
897        );
898        self.recreate_surface(qh);
899    }
900    fn configure(
901        &mut self,
902        _: &Connection,
903        qh: &QueueHandle<Self>,
904        _: &LayerSurface,
905        configure: LayerSurfaceConfigure,
906        _: u32,
907    ) {
908        let (cw, ch) = configure.new_size;
909        const MAX_DIM: u32 = 16384;
910        if (1..=MAX_DIM).contains(&cw) {
911            self.width = cw;
912        }
913        if (1..=MAX_DIM).contains(&ch) {
914            self.height = ch;
915        }
916        // Un configure siempre exige repintar: cambió el tamaño, o el compositor
917        // nos re-mapeó y el frame anterior ya no vale.
918        self.dirty = true;
919        // La app puede necesitar su tamaño (p. ej. para calcular el viewport de
920        // un scroll). El compositor estira el eje paralelo al borde, así que es
921        // aquí —no en `run`— donde se conoce el alto/ancho real.
922        let resize_msg = self
923            .model
924            .as_ref()
925            .and_then(|m| A::on_resize(m, self.width, self.height));
926        if let Some(msg) = resize_msg {
927            self.apply(msg);
928        }
929        self.draw(qh);
930    }
931}
932
933impl<A: App> OutputHandler for Runner<A> {
934    fn output_state(&mut self) -> &mut OutputState {
935        &mut self.output_state
936    }
937    fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
938    fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
939    fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
940}
941
942impl<A: App> SeatHandler for Runner<A> {
943    fn seat_state(&mut self) -> &mut SeatState {
944        &mut self.seat_state
945    }
946    fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
947        if self.seat.is_none() {
948            self.seat = Some(seat);
949        }
950    }
951    fn new_capability(
952        &mut self,
953        _: &Connection,
954        qh: &QueueHandle<Self>,
955        seat: wl_seat::WlSeat,
956        capability: Capability,
957    ) {
958        match capability {
959            Capability::Keyboard if self.keyboard.is_none() => {
960                if let Ok(kbd) = self.seat_state.get_keyboard(qh, &seat, None) {
961                    self.keyboard = Some(kbd);
962                }
963            }
964            Capability::Pointer if self.pointer.is_none() => {
965                // Puntero CON tema de cursores: una superficie dedicada para el
966                // cursor + el tema del sistema. Si algo falla (raro), caemos al
967                // puntero pelado (sin cambio de forma, como estaba antes).
968                let surface = self.compositor.create_surface(qh);
969                match self.seat_state.get_pointer_with_theme(
970                    qh,
971                    &seat,
972                    self.shm.wl_shm(),
973                    surface.clone(),
974                    ThemeSpec::default(),
975                ) {
976                    Ok(themed) => {
977                        self.pointer = Some(themed.pointer().clone());
978                        self.cursor_surface = Some(surface);
979                        self.themed_pointer = Some(themed);
980                    }
981                    Err(_) => {
982                        if let Ok(ptr) = self.seat_state.get_pointer(qh, &seat) {
983                            self.pointer = Some(ptr);
984                        }
985                    }
986                }
987            }
988            _ => {}
989        }
990    }
991    fn remove_capability(
992        &mut self,
993        _: &Connection,
994        _: &QueueHandle<Self>,
995        _: wl_seat::WlSeat,
996        capability: Capability,
997    ) {
998        match capability {
999            Capability::Keyboard => {
1000                if let Some(k) = self.keyboard.take() {
1001                    k.release();
1002                }
1003            }
1004            Capability::Pointer => {
1005                self.themed_pointer = None;
1006                self.cursor_surface = None;
1007                if let Some(p) = self.pointer.take() {
1008                    p.release();
1009                }
1010            }
1011            _ => {}
1012        }
1013    }
1014    fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
1015}
1016
1017impl<A: App> KeyboardHandler for Runner<A> {
1018    fn enter(
1019        &mut self,
1020        _: &Connection,
1021        _: &QueueHandle<Self>,
1022        _: &wl_keyboard::WlKeyboard,
1023        _: &wl_surface::WlSurface,
1024        _: u32,
1025        _: &[u32],
1026        _: &[Keysym],
1027    ) {
1028    }
1029    fn leave(
1030        &mut self,
1031        _: &Connection,
1032        _: &QueueHandle<Self>,
1033        _: &wl_keyboard::WlKeyboard,
1034        _: &wl_surface::WlSurface,
1035        _: u32,
1036    ) {
1037    }
1038    fn press_key(
1039        &mut self,
1040        _: &Connection,
1041        _: &QueueHandle<Self>,
1042        _: &wl_keyboard::WlKeyboard,
1043        _: u32,
1044        event: smithay_client_toolkit::seat::keyboard::KeyEvent,
1045    ) {
1046        if let Some(ke) = self.keysym_to_keyevent(&event) {
1047            let msg = A::on_key(self.model.as_ref().expect("model"), &ke);
1048            if let Some(msg) = msg {
1049                self.apply(msg);
1050            }
1051        }
1052    }
1053    fn release_key(
1054        &mut self,
1055        _: &Connection,
1056        _: &QueueHandle<Self>,
1057        _: &wl_keyboard::WlKeyboard,
1058        _: u32,
1059        _: smithay_client_toolkit::seat::keyboard::KeyEvent,
1060    ) {
1061    }
1062    fn update_modifiers(
1063        &mut self,
1064        _: &Connection,
1065        _: &QueueHandle<Self>,
1066        _: &wl_keyboard::WlKeyboard,
1067        _: u32,
1068        modifiers: Modifiers,
1069        _: u32,
1070    ) {
1071        self.mods = modifiers;
1072    }
1073}
1074
1075/// Traduce el [`Cursor`](llimphi_ui::llimphi_compositor::Cursor) del nodo bajo
1076/// el puntero a la forma de cursor del sistema. Espeja `to_winit_cursor` del
1077/// backend winit — la misma cobertura de variantes. `None` → flecha default.
1078fn cursor_to_icon(c: Option<llimphi_ui::llimphi_compositor::Cursor>) -> CursorIcon {
1079    use llimphi_ui::llimphi_compositor::Cursor as C;
1080    match c {
1081        None | Some(C::Default) => CursorIcon::Default,
1082        Some(C::Pointer) => CursorIcon::Pointer,
1083        Some(C::Text) => CursorIcon::Text,
1084        Some(C::Crosshair) => CursorIcon::Crosshair,
1085        Some(C::Move) => CursorIcon::Move,
1086        Some(C::Grab) => CursorIcon::Grab,
1087        Some(C::Grabbing) => CursorIcon::Grabbing,
1088        Some(C::NotAllowed) => CursorIcon::NotAllowed,
1089        Some(C::Copy) => CursorIcon::Copy,
1090        Some(C::NoDrop) => CursorIcon::NoDrop,
1091        Some(C::Wait) => CursorIcon::Wait,
1092        Some(C::Progress) => CursorIcon::Progress,
1093        Some(C::Help) => CursorIcon::Help,
1094        Some(C::ColResize) => CursorIcon::ColResize,
1095        Some(C::RowResize) => CursorIcon::RowResize,
1096        Some(C::EwResize) => CursorIcon::EwResize,
1097        Some(C::NsResize) => CursorIcon::NsResize,
1098        Some(C::NeswResize) => CursorIcon::NeswResize,
1099        Some(C::NwseResize) => CursorIcon::NwseResize,
1100        Some(C::ZoomIn) => CursorIcon::ZoomIn,
1101        Some(C::ZoomOut) => CursorIcon::ZoomOut,
1102    }
1103}
1104
1105impl<A: App> Runner<A> {
1106    /// Aplica al puntero la forma de cursor del nodo `hover_idx` actual. No-op si
1107    /// no hay `ThemedPointer` (el compositor no dio la capacidad temada).
1108    fn aplicar_cursor(&mut self, conn: &Connection) {
1109        let Some(themed) = self.themed_pointer.as_ref() else { return };
1110        let cur = self
1111            .hover_idx
1112            .and_then(|i| self.cache.as_ref()?.mounted.nodes.get(i))
1113            .and_then(|n| n.cursor);
1114        let _ = themed.set_cursor(conn, cursor_to_icon(cur));
1115    }
1116}
1117
1118impl<A: App> PointerHandler for Runner<A> {
1119    fn pointer_frame(
1120        &mut self,
1121        conn: &Connection,
1122        _: &QueueHandle<Self>,
1123        _: &wl_pointer::WlPointer,
1124        events: &[PointerEvent],
1125    ) {
1126        for e in events {
1127            match e.kind {
1128                PointerEventKind::Enter { .. } => {
1129                    // Al entrar, fijar el cursor según el nodo bajo el puntero
1130                    // (con el serial de Enter que el ThemedPointer ya rastrea).
1131                    let (px, py) = (e.position.0 as f32, e.position.1 as f32);
1132                    let nuevo = self
1133                        .cache
1134                        .as_ref()
1135                        .and_then(|c| hit_test_hover(&c.mounted, &c.computed, px, py));
1136                    self.ptr = (px, py);
1137                    if self.hover_idx != nuevo {
1138                        self.hover_idx = nuevo;
1139                        self.dirty = true; // el hover se PINTA (hover_fill)
1140                    }
1141                    self.aplicar_cursor(conn);
1142                    continue;
1143                }
1144                PointerEventKind::Motion { .. } => {
1145                    let (px, py) = (e.position.0 as f32, e.position.1 as f32);
1146                    self.ptr = (px, py);
1147                    // Alejarse del origen mata el click retenido: fue arrastre.
1148                    if let Some((_, (ox, oy), _, _)) = self.clic_retenido.as_ref() {
1149                        if ((px - ox).powi(2) + (py - oy).powi(2)).sqrt() as f64
1150                            > llimphi_ui::CLICK_MOVE_CANCEL
1151                        {
1152                            self.clic_retenido = None;
1153                        }
1154                    }
1155                    // Drag en curso: el delta va al handler del nodo.
1156                    if let Some((handler, last)) = self.drag.as_ref().map(|(h, l)| (h.clone(), *l)) {
1157                        if let Some(d) = self.drag.as_mut() {
1158                            d.1 = (px, py);
1159                        }
1160                        if let Some(msg) = (handler)(DragPhase::Move, px - last.0, py - last.1) {
1161                            self.apply(msg);
1162                        }
1163                        continue;
1164                    }
1165                    let nuevo = self
1166                        .cache
1167                        .as_ref()
1168                        .and_then(|c| hit_test_hover(&c.mounted, &c.computed, px, py));
1169                    if self.hover_idx != nuevo {
1170                        self.hover_idx = nuevo;
1171                        self.dirty = true; // el hover se PINTA (hover_fill)
1172                        // Cambió el nodo bajo el puntero → actualizar la forma.
1173                        self.aplicar_cursor(conn);
1174                    }
1175                    continue;
1176                }
1177                PointerEventKind::Leave { .. } => {
1178                    if self.hover_idx.is_some() {
1179                        self.hover_idx = None;
1180                        self.dirty = true; // se va el resaltado
1181                    }
1182                    continue;
1183                }
1184                _ => {}
1185            }
1186            if let PointerEventKind::Axis { vertical, .. } = e.kind {
1187                // Convención de Llimphi: el delta va en **líneas**. Wayland da
1188                // los clicks de rueda en `discrete` y el desplazamiento suave
1189                // (touchpad) en `absolute`, en px — la misma división por 20
1190                // que hace el runner de ventana con `PixelDelta`.
1191                //
1192                // Esa misma división dice la CLASE de dispositivo, que es lo
1193                // que decide el sentido: `discrete` sólo lo manda una rueda
1194                // física; un pad entrega nada más el continuo.
1195                let (dy, fuente) = if vertical.discrete != 0 {
1196                    (vertical.discrete as f32, llimphi_ui::FuenteRueda::Raton)
1197                } else {
1198                    (
1199                        vertical.absolute as f32 / 20.0,
1200                        llimphi_ui::FuenteRueda::Touchpad,
1201                    )
1202                };
1203                let dy = llimphi_ui::politica_rueda()
1204                    .aplicar(llimphi_ui::WheelDelta { x: 0.0, y: dy }, fuente)
1205                    .y;
1206                if dy != 0.0 {
1207                    // La posición sale de `self.ptr`, no del evento: el evento
1208                    // de rueda de Wayland no trae posición (ver el campo).
1209                    let (px, py) = self.ptr;
1210                    // La **cadena** de nodos scrollables bajo el puntero, no el
1211                    // primero: un área de scroll consume hasta su tope y deja
1212                    // el sobrante al ancestro, y eso puede dar varios Msg por
1213                    // evento. Es lo mismo que hace el runner de ventana.
1214                    //
1215                    // Esto estuvo roto desde siempre en el runner de capa:
1216                    // miraba el `on_scroll` viejo, y el widget de scroll publica
1217                    // la rueda por `on_scroll_take` (el reparto parcial). O sea
1218                    // que NINGUNA app de capa scrolleaba con la rueda. Cazado en
1219                    // metal el 2026-08-25 con `LLIMPHI_LAYER_DIAG=1`, que mostró
1220                    // el hit-test funcionando (`hover=Some(36)`) y la lista de
1221                    // nodos con rueda **vacía**.
1222                    let msgs: Vec<A::Msg> = match self.cache.as_ref() {
1223                        Some(c) => {
1224                            let chain = hit_test_scroll_chain(&c.mounted, &c.computed, px, py);
1225                            consume_scroll_chain(&c.mounted, &chain, 0.0, dy)
1226                        }
1227                        None => Vec::new(),
1228                    };
1229                    if std::env::var("LLIMPHI_LAYER_DIAG").is_ok() {
1230                        eprintln!(
1231                            "layer-diag: rueda dy={dy} en ({px},{py}) msgs={}",
1232                            msgs.len()
1233                        );
1234                    }
1235                    for msg in msgs {
1236                        self.apply(msg);
1237                    }
1238                }
1239                continue;
1240            }
1241            if let PointerEventKind::Release { button, .. } = e.kind {
1242                if button == BTN_LEFT {
1243                    if let Some((handler, _)) = self.drag.take() {
1244                        if let Some(msg) = (handler)(DragPhase::End, 0.0, 0.0) {
1245                            self.apply(msg);
1246                        }
1247                    }
1248                    // Click retenido: soltar no lo dispara —el segundo tap
1249                    // todavía puede llegar—, sólo lo marca. Sale al vencer.
1250                    if let Some((msg, origen, vence, _)) = self.clic_retenido.take() {
1251                        if std::time::Instant::now() >= vence {
1252                            self.apply(msg);
1253                        } else {
1254                            self.clic_retenido = Some((msg, origen, vence, true));
1255                        }
1256                    }
1257                }
1258                continue;
1259            }
1260            if let PointerEventKind::Press { button, .. } = e.kind {
1261                if button != BTN_LEFT && button != BTN_RIGHT {
1262                    continue;
1263                }
1264                let (px, py) = (e.position.0 as f32, e.position.1 as f32);
1265                let derecho = button == BTN_RIGHT;
1266                // ── Arena de gestos: doble-tap ──────────────────────────────
1267                // Aditiva, igual que en el bucle de ventana: **no** consume el
1268                // click de abajo. Un doble-tap es dos clicks, y quien puso los
1269                // dos gestos en el mismo nodo (elegir con uno, abrir con dos)
1270                // cuenta con que el primero también haya pasado.
1271                // Con `double_tap_exclusive` el gesto además ARBITRA contra el
1272                // click del mismo nodo: el primer tap lo deja retenido y el
1273                // segundo lo descarta (ver `clic_retenido`).
1274                let mut retener = None;
1275                let mut suprimir_click = false;
1276                if !derecho {
1277                    if let Some((msg, exclusivo)) = self.cache.as_ref().and_then(|c| {
1278                        let i = llimphi_ui::llimphi_compositor::hit_test_double_tap(
1279                            &c.mounted,
1280                            &c.computed,
1281                            px,
1282                            py,
1283                        )?;
1284                        let n = c.mounted.nodes.get(i)?;
1285                        Some((n.on_double_tap.clone()?, n.double_tap_exclusive.is_some()))
1286                    }) {
1287                        let ahora = std::time::Instant::now();
1288                        let califica = self.ultimo_tap.is_some_and(|(t, (x, y))| {
1289                            llimphi_ui::doble_tap_califica(
1290                                ahora.duration_since(t),
1291                                (x - px) as f64,
1292                                (y - py) as f64,
1293                            )
1294                        });
1295                        if califica {
1296                            // Consumido: un tercer tap no re-dispara.
1297                            self.ultimo_tap = None;
1298                            if exclusivo {
1299                                self.clic_retenido = None;
1300                                suprimir_click = true;
1301                            }
1302                            self.apply(msg);
1303                        } else {
1304                            self.ultimo_tap = Some((ahora, (px, py)));
1305                            if exclusivo {
1306                                retener = Some(ahora + llimphi_ui::DOUBLE_TAP_WINDOW);
1307                            }
1308                        }
1309                    }
1310                }
1311                // Nodo arrastrable bajo el press izquierdo: arranca un drag.
1312                if !derecho {
1313                    let handler = self.cache.as_ref().and_then(|c| {
1314                        let i = hit_test_click(&c.mounted, &c.computed, px, py)?;
1315                        c.mounted.nodes.get(i)?.drag.clone()
1316                    });
1317                    if let Some(handler) = handler {
1318                        self.drag = Some((handler, (px, py)));
1319                        continue;
1320                    }
1321                }
1322                let msg = self.cache.as_ref().and_then(|c| {
1323                    let i = hit_test_click(&c.mounted, &c.computed, px, py)?;
1324                    let n = c.mounted.nodes.get(i)?;
1325                    let r = c.computed.get(n.id)?;
1326                    mensaje_de_click(n, r, px, py, derecho)
1327                });
1328                if let Some(msg) = msg.filter(|_| !suprimir_click) {
1329                    match retener {
1330                        Some(vence) => self.clic_retenido = Some((msg, (px, py), vence, false)),
1331                        None => self.apply(msg),
1332                    }
1333                }
1334            }
1335        }
1336    }
1337}
1338
1339/// **Qué mensaje produce un click sobre `n`**, con `r` su rect ya calculado y
1340/// `(px, py)` el puntero en coordenadas de la surface.
1341///
1342/// Vive como función y no adentro del handler porque **acá se perdía un click**:
1343/// la rama del botón izquierdo no consultaba `on_click_screen`, así que un nodo
1344/// que sólo lo declara —el `⋯` de las opciones de shuma, el disparador del
1345/// `select`, el swatch del color-picker— caía al `on_click_at` que no tiene y el
1346/// click se iba al vacío. Andaba en winit (que sí lo despacha) y no bajo
1347/// layer-shell, o sea: andaba en la ventana suelta y no en el cajón, que es
1348/// donde se usa.
1349///
1350/// **El orden es el mismo para los dos botones**, y ésa es la invariante que el
1351/// test fija: primero el de coordenadas absolutas (lo necesitan los menús que se
1352/// anclan al puntero), después el plano, después el local. Dos órdenes distintos
1353/// para el mismo gesto es exactamente cómo se coló el agujero.
1354pub(crate) fn mensaje_de_click<Msg: Clone>(
1355    n: &llimphi_ui::llimphi_compositor::MountedNode<Msg>,
1356    r: llimphi_ui::llimphi_layout::Rect,
1357    px: f32,
1358    py: f32,
1359    derecho: bool,
1360) -> Option<Msg> {
1361    if derecho {
1362        if let Some(screen) = n.on_right_click_screen.as_ref() {
1363            if let Some(m) = screen(px, py, r.w, r.h) {
1364                return Some(m);
1365            }
1366        }
1367        if let Some(m) = n.on_right_click.clone() {
1368            return Some(m);
1369        }
1370        let at = n.on_right_click_at.as_ref()?;
1371        at(px - r.x, py - r.y, r.w, r.h)
1372    } else {
1373        if let Some(screen) = n.on_click_screen.as_ref() {
1374            if let Some(m) = screen(px, py, r.w, r.h) {
1375                return Some(m);
1376            }
1377        }
1378        if let Some(m) = n.on_click.clone() {
1379            return Some(m);
1380        }
1381        let at = n.on_click_at.as_ref()?;
1382        at(px - r.x, py - r.y, r.w, r.h)
1383    }
1384}
1385
1386#[cfg(test)]
1387mod tests_click {
1388    use super::mensaje_de_click;
1389    use llimphi_ui::llimphi_compositor::mount;
1390    use llimphi_ui::llimphi_layout::taffy::prelude::{length, Size, Style};
1391    use llimphi_ui::llimphi_layout::LayoutTree;
1392    use llimphi_ui::View;
1393
1394    /// Monta `v` y devuelve el mensaje que daría un click en el centro del nodo
1395    /// que tenga handlers — el mismo camino que corre el runner.
1396    fn click(v: View<&'static str>, derecho: bool) -> Option<&'static str> {
1397        let mut layout = LayoutTree::new();
1398        let mounted = mount(&mut layout, v);
1399        let computed = layout.compute(mounted.root, (200.0, 100.0)).expect("layout");
1400        let n = mounted
1401            .nodes
1402            .iter()
1403            .find(|n| {
1404                n.on_click.is_some()
1405                    || n.on_click_at.is_some()
1406                    || n.on_click_screen.is_some()
1407                    || n.on_right_click.is_some()
1408                    || n.on_right_click_at.is_some()
1409                    || n.on_right_click_screen.is_some()
1410            })
1411            .expect("el árbol de prueba tiene que tener un nodo con handler");
1412        let r = computed.get(n.id).expect("rect del nodo");
1413        mensaje_de_click(n, r, r.x + r.w / 2.0, r.y + r.h / 2.0, derecho)
1414    }
1415
1416    fn caja() -> Style {
1417        Style {
1418            size: Size { width: length(40.0_f32), height: length(20.0_f32) },
1419            ..Default::default()
1420        }
1421    }
1422
1423    /// **El caso que se rompió en metal:** un nodo que declara SÓLO
1424    /// `on_click_screen` tiene que disparar con el botón izquierdo. Antes caía al
1425    /// `on_click_at` que no tiene y el click se perdía sin decir nada.
1426    #[test]
1427    fn un_nodo_con_solo_on_click_screen_dispara() {
1428        let v = View::<&'static str>::new(caja()).on_click_screen(|_x, _y, _w, _h| Some("abrí"));
1429        assert_eq!(click(v, false), Some("abrí"));
1430    }
1431
1432    /// **Y el orden es el mismo para los dos botones.** Es la invariante que
1433    /// dejó pasar el agujero: la rama derecha consultaba el de coordenadas
1434    /// absolutas primero y la izquierda no lo consultaba nunca.
1435    #[test]
1436    fn los_dos_botones_resuelven_en_el_mismo_orden() {
1437        let izq = View::<&'static str>::new(caja())
1438            .on_click("plano")
1439            .on_click_screen(|_x, _y, _w, _h| Some("absoluto"));
1440        assert_eq!(click(izq, false), Some("absoluto"), "el absoluto gana al plano");
1441
1442        let der = View::<&'static str>::new(caja())
1443            .on_right_click("plano")
1444            .on_right_click_screen(|_x, _y, _w, _h| Some("absoluto"));
1445        assert_eq!(click(der, true), Some("absoluto"));
1446    }
1447
1448    /// Y lo que ya andaba sigue andando: sin el absoluto, el plano; sin ninguno
1449    /// de los dos, el local — y sin handlers, nada.
1450    #[test]
1451    fn la_cadena_de_respaldo_no_cambio() {
1452        let plano = View::<&'static str>::new(caja()).on_click("plano");
1453        assert_eq!(click(plano, false), Some("plano"));
1454
1455        let local = View::<&'static str>::new(caja()).on_click_at(|_x, _y, _w, _h| Some("local"));
1456        assert_eq!(click(local, false), Some("local"));
1457
1458        // Un handler que devuelve `None` no corta la cadena: cae al siguiente.
1459        let vacio = View::<&'static str>::new(caja())
1460            .on_click_screen(|_x, _y, _w, _h| None)
1461            .on_click("plano");
1462        assert_eq!(click(vacio, false), Some("plano"));
1463    }
1464}
1465
1466impl<A: App> ProvidesRegistryState for Runner<A> {
1467    fn registry(&mut self) -> &mut RegistryState {
1468        &mut self.registry_state
1469    }
1470    registry_handlers![OutputState, SeatState];
1471}
1472
1473delegate_compositor!(@<A: App> Runner<A>);
1474delegate_output!(@<A: App> Runner<A>);
1475delegate_layer!(@<A: App> Runner<A>);
1476delegate_seat!(@<A: App> Runner<A>);
1477delegate_keyboard!(@<A: App> Runner<A>);
1478delegate_pointer!(@<A: App> Runner<A>);
1479delegate_shm!(@<A: App> Runner<A>);
1480delegate_registry!(@<A: App> Runner<A>);
1481
1482impl<A: App> ShmHandler for Runner<A> {
1483    fn shm_state(&mut self) -> &mut Shm {
1484        &mut self.shm
1485    }
1486}
1487