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

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-ui/src/file_drop.rs Rust · 407 lines · 17.9 KB History Who touched what raw

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! Soltar archivos en una ventana Llimphi **bajo Wayland** (`wl_data_device`).
2//!
3//! **Por qué existe.** `App::on_file_drop` se alimenta de
4//! `winit::WindowEvent::DroppedFile`, y en winit 0.30.13 ese evento **lo emite
5//! sólo el backend X11**: `DroppedFile` aparece únicamente en
6//! `platform_impl/linux/x11/event_processor.rs` y el árbol
7//! `platform_impl/linux/wayland/` no lo nombra. O sea que arrastrar-y-soltar
8//! —que en `soltar` es la UX central, y que también esperan `media`, `tullpu` y
9//! `takiy`— no funcionaba en el compositor propio del proyecto. Medido en metal
10//! el 2026-08-21: la zona de soltar nunca se ilumina y no llega nada, mientras
11//! el mismo arrastre sintético sí mueve un archivo entre carpetas de thunar. El
12//! compositor está bien: mirada expone `wl_data_device_manager` **v3**. El que
13//! faltaba era el cliente.
14//!
15//! **Cómo cruza el muro winit↔wayland.** El mismo patrón que ya validaron
16//! `content_type.rs` y el spike `llimphi-video-plane`: una segunda `Connection`
17//! sobre el **mismo** `wl_display` (`Backend::from_foreign_display`) y la
18//! `wl_surface` de winit envuelta por `ObjectId::from_ptr`. Los objetos nuevos
19//! van a nuestra propia cola, así que no le robamos eventos a winit.
20//!
21//! **Por qué un hilo propio y no un bombeo por cuadro.** Durante un arrastre el
22//! compositor manda eventos de `wl_data_device`, **no** de puntero: el bucle de
23//! winit puede estar dormido en `Wait` justo cuando llegan, y un
24//! `dispatch_pending` colgado del cuadro no correría nunca. Así que la cola
25//! vive en un hilo con `blocking_dispatch`, y lo que encuentra vuelve al hilo
26//! de UI por el `EventLoopProxy` que ya usa `Handle::dispatch`, como
27//! `UserEvent::FileDrop`. Del otro lado, `user_event` corre exactamente el
28//! mismo código que la rama `WindowEvent::DroppedFile` de siempre — las apps no
29//! se enteran de nada.
30//!
31//! **El `accept` es lo que hace que se pueda soltar.** Sin
32//! `wl_data_offer.accept(serial, "text/uri-list")` en el `enter`, el origen
33//! pinta «acá no se suelta» y jamás manda el `drop`. Es el detalle que separa
34//! «no hay drop» de «hay drop y lo ignoramos».
35
36#[cfg(all(target_os = "linux", not(target_arch = "wasm32"), not(target_os = "android")))]
37mod imp {
38    use std::collections::HashMap;
39    use std::io::Read;
40    use std::os::unix::net::UnixStream;
41    use std::path::PathBuf;
42    use std::time::Duration;
43
44    use llimphi_hal::winit::event_loop::EventLoopProxy;
45    use llimphi_hal::winit::raw_window_handle::{
46        HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle,
47    };
48    use llimphi_hal::winit::window::Window;
49    use wayland_client::backend::{Backend, ObjectId};
50    use wayland_client::protocol::wl_data_device::{Event as DdEvent, WlDataDevice};
51    use wayland_client::protocol::wl_data_device_manager::{DndAction, WlDataDeviceManager};
52    use wayland_client::protocol::wl_data_offer::{Event as OfferEvent, WlDataOffer};
53    use wayland_client::protocol::wl_registry;
54    use wayland_client::protocol::wl_seat::WlSeat;
55    use wayland_client::protocol::wl_surface::WlSurface;
56    use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
57
58    use crate::UserEvent;
59
60    /// El único tipo que pedimos. Es el que usa todo gestor de archivos.
61    const URI_LIST: &str = "text/uri-list";
62
63    /// Techo para leer el pipe del origen. Un `uri-list` son unos cientos de
64    /// bytes y el origen escribe en cuanto recibe el `receive`; el techo existe
65    /// para que un origen roto no cuelgue el hilo **para siempre**, no porque se
66    /// espere tardanza.
67    const ESPERA_LECTURA: Duration = Duration::from_secs(2);
68
69    struct Estado {
70        manager: Option<WlDataDeviceManager>,
71        seat: Option<WlSeat>,
72        /// La `wl_surface` de nuestra ventana, para no aceptar drops ajenos.
73        nuestra: Option<ObjectId>,
74        /// Los mime que declaró cada oferta viva. Llegan como una ráfaga de
75        /// `wl_data_offer.offer` **antes** del `enter`, así que hay que
76        /// juntarlos por oferta y no por «la oferta actual».
77        mimes: HashMap<ObjectId, Vec<String>>,
78        /// La oferta que está sobre NUESTRA superficie ahora mismo. `None`
79        /// también cuando el puntero pasa por encima con algo que no sabemos
80        /// leer: en ese caso el `drop` no nos corresponde.
81        activa: Option<WlDataOffer>,
82        /// Adónde mandar lo que se suelte. Genérico borrado a `PathBuf`: el
83        /// hilo no conoce el `Msg` de la app.
84        avisar: Box<dyn Fn(PathBuf) + Send>,
85    }
86
87    impl Dispatch<wl_registry::WlRegistry, ()> for Estado {
88        fn event(
89            state: &mut Self,
90            registry: &wl_registry::WlRegistry,
91            event: wl_registry::Event,
92            _: &(),
93            _: &Connection,
94            qh: &QueueHandle<Self>,
95        ) {
96            let wl_registry::Event::Global { name, interface, version } = event else { return };
97            if interface == WlDataDeviceManager::interface().name {
98                // v3 trae `finish` y `set_actions`, que es lo que un origen
99                // moderno espera para dar por buena la transferencia.
100                state.manager = Some(registry.bind(name, version.min(3), qh, ()));
101            } else if interface == WlSeat::interface().name && state.seat.is_none() {
102                // Bindear un segundo `wl_seat` no le saca el suyo a winit: cada
103                // bind es un proxy propio sobre el mismo global.
104                state.seat = Some(registry.bind(name, version.min(1), qh, ()));
105            }
106        }
107    }
108
109    wayland_client::delegate_noop!(Estado: ignore WlDataDeviceManager);
110    wayland_client::delegate_noop!(Estado: ignore WlSeat);
111
112    impl Dispatch<WlDataOffer, ()> for Estado {
113        fn event(
114            state: &mut Self,
115            offer: &WlDataOffer,
116            event: OfferEvent,
117            _: &(),
118            _: &Connection,
119            _: &QueueHandle<Self>,
120        ) {
121            if let OfferEvent::Offer { mime_type } = event {
122                state.mimes.entry(offer.id()).or_default().push(mime_type);
123            }
124            // `source_actions` y `action` no cambian nada acá: pedimos y
125            // aceptamos Copy siempre (soltar un archivo en una app no mueve
126            // nada en el origen).
127        }
128    }
129
130    impl Dispatch<WlDataDevice, ()> for Estado {
131        // `wl_data_device.data_offer` (opcode 0) CREA un objeto hijo, y
132        // wayland-client exige decir de qué interfaz es y con qué `UserData`
133        // nace. Sin esto el despachador entra por el camino de C y **aborta el
134        // proceso**: el panic sale de una función que no puede desenrollar, así
135        // que no hay `catch_unwind` que valga — se cae la app entera, no el
136        // hilo.
137        //
138        // **Va DENTRO del `impl`**, y eso no es cosmético: la macro expande a
139        // la función asociada `event_created_child`, así que puesta al lado del
140        // `impl` define una función suelta del módulo, **compila sin una sola
141        // advertencia** y no sobrescribe nada. Medido el 2026-08-21: con la
142        // macro afuera, el panic seguía saliendo idéntico y parecía que el
143        // arreglo no servía.
144        wayland_client::event_created_child!(Estado, WlDataDevice, [
145            wayland_client::protocol::wl_data_device::EVT_DATA_OFFER_OPCODE => (WlDataOffer, ()),
146        ]);
147
148        fn event(
149            state: &mut Self,
150            _dd: &WlDataDevice,
151            event: DdEvent,
152            _: &(),
153            conn: &Connection,
154            _: &QueueHandle<Self>,
155        ) {
156            match event {
157                // Nace una oferta: todavía no sabemos ni qué trae ni adónde va.
158                DdEvent::DataOffer { id } => {
159                    state.mimes.insert(id.id(), Vec::new());
160                }
161                DdEvent::Enter { serial, surface, id, .. } => {
162                    let Some(offer) = id else { return };
163                    let nuestra = state.nuestra.as_ref() == Some(&surface.id());
164                    let sirve = state
165                        .mimes
166                        .get(&offer.id())
167                        .is_some_and(|m| m.iter().any(|x| x == URI_LIST));
168                    if nuestra && sirve {
169                        // ESTE es el request que habilita el drop. Sin él el
170                        // origen muestra «no se puede soltar» y no hay `Drop`.
171                        offer.accept(serial, Some(URI_LIST.to_string()));
172                        if offer.version() >= 3 {
173                            offer.set_actions(DndAction::Copy, DndAction::Copy);
174                        }
175                        state.activa = Some(offer);
176                    } else {
177                        // Rechazar explícitamente es parte del protocolo: le
178                        // dice al origen que pinte el cursor de «no».
179                        offer.accept(serial, None);
180                    }
181                }
182                DdEvent::Leave => {
183                    state.activa = None;
184                }
185                DdEvent::Drop => {
186                    let Some(offer) = state.activa.take() else { return };
187                    match recibir(conn, &offer) {
188                        Ok(rutas) => {
189                            if offer.version() >= 3 {
190                                offer.finish();
191                            }
192                            for r in rutas {
193                                (state.avisar)(r);
194                            }
195                        }
196                        Err(e) => eprintln!("llimphi: no pude leer lo que soltaron ({e})"),
197                    }
198                    state.mimes.remove(&offer.id());
199                    offer.destroy();
200                }
201                // La selección (portapapeles) no es asunto de este módulo: de
202                // eso se ocupa `llimphi-clipboard`. Pero hay que soltar la
203                // oferta para no acumularlas.
204                DdEvent::Selection { id } => {
205                    if let Some(o) = id {
206                        state.mimes.remove(&o.id());
207                        o.destroy();
208                    }
209                }
210                _ => {}
211            }
212        }
213    }
214
215    /// Pide el `uri-list` por un fd y lo lee. Un `UnixStream::pair` en vez de un
216    /// pipe porque `std` le sabe poner **plazo de lectura** y un pipe pelado no:
217    /// sin plazo, un origen que abre el fd y no escribe cuelga el hilo del
218    /// arrastre para siempre.
219    fn recibir(conn: &Connection, offer: &WlDataOffer) -> Result<Vec<PathBuf>, String> {
220        let (mut nuestro, del_origen) =
221            UnixStream::pair().map_err(|e| format!("socketpair: {e}"))?;
222        offer.receive(URI_LIST.to_string(), del_origen.as_raw_fd_borrowed());
223        // El request tiene que llegar ANTES de ponerse a leer, y hay que cerrar
224        // nuestra copia del extremo del origen: si no, nunca hay EOF.
225        conn.flush().map_err(|e| format!("flush: {e}"))?;
226        drop(del_origen);
227        nuestro
228            .set_read_timeout(Some(ESPERA_LECTURA))
229            .map_err(|e| format!("plazo de lectura: {e}"))?;
230        let mut buf = Vec::new();
231        // Un timeout deja lo leído hasta ahí: se parsea igual, que es mejor que
232        // perder el drop entero por el último byte.
233        let _ = nuestro.read_to_end(&mut buf);
234        Ok(parsear_uri_list(&String::from_utf8_lossy(&buf)))
235    }
236
237    /// Extensión mínima para no arrastrar `libc`: `receive` quiere un
238    /// `BorrowedFd` y `UnixStream` lo da por `AsFd`.
239    trait FdPrestado {
240        fn as_raw_fd_borrowed(&self) -> std::os::fd::BorrowedFd<'_>;
241    }
242    impl FdPrestado for UnixStream {
243        fn as_raw_fd_borrowed(&self) -> std::os::fd::BorrowedFd<'_> {
244            use std::os::fd::AsFd;
245            self.as_fd()
246        }
247    }
248
249    /// `text/uri-list` (RFC 2483): una URI por línea, `#` comenta, saltos CRLF.
250    /// Sólo nos quedamos con `file://`, que es lo único que una app puede abrir
251    /// del disco.
252    pub(super) fn parsear_uri_list(s: &str) -> Vec<PathBuf> {
253        s.lines()
254            .map(|l| l.trim_end_matches('\r').trim())
255            .filter(|l| !l.is_empty() && !l.starts_with('#'))
256            .filter_map(|l| l.strip_prefix("file://"))
257            // El host de `file://host/ruta` casi siempre está vacío; si viene
258            // `localhost` es el mismo equipo y la ruta empieza en la barra.
259            .map(|r| r.strip_prefix("localhost").unwrap_or(r))
260            .filter(|r| r.starts_with('/'))
261            .map(|r| PathBuf::from(despercentar(r)))
262            .collect()
263    }
264
265    /// Decodifica `%XX`. Diez líneas en vez de una dependencia: es todo lo que
266    /// el formato usa, y un `%` suelto se deja tal cual en vez de tragárselo.
267    fn despercentar(s: &str) -> String {
268        let b = s.as_bytes();
269        let mut out = Vec::with_capacity(b.len());
270        let mut i = 0;
271        while i < b.len() {
272            if b[i] == b'%' && i + 2 < b.len() {
273                let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok();
274                if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
275                    out.push(v);
276                    i += 3;
277                    continue;
278                }
279            }
280            out.push(b[i]);
281            i += 1;
282        }
283        String::from_utf8_lossy(&out).into_owned()
284    }
285
286    /// Arranca el puente para `window`. Idempotente por ventana no: la llama
287    /// `instalar_estado`, una vez por superficie creada.
288    pub(crate) fn arrancar<Msg: Send + 'static>(
289        window: &Window,
290        proxy: EventLoopProxy<UserEvent<Msg>>,
291    ) {
292        let (display_ptr, surface_ptr) = match punteros(window) {
293            Some(p) => p,
294            // Bajo X11 no hace falta: winit ya entrega `DroppedFile`.
295            None => return,
296        };
297        std::thread::Builder::new()
298            .name("llimphi-drop".into())
299            .spawn(move || {
300                if let Err(e) = correr(display_ptr, surface_ptr, proxy) {
301                    eprintln!("llimphi: sin soltar-archivos ({e})");
302                }
303            })
304            .ok();
305    }
306
307    fn punteros(window: &Window) -> Option<(usize, usize)> {
308        let RawDisplayHandle::Wayland(dh) = window.display_handle().ok()?.as_raw() else {
309            return None;
310        };
311        let RawWindowHandle::Wayland(wh) = window.window_handle().ok()?.as_raw() else {
312            return None;
313        };
314        Some((dh.display.as_ptr() as usize, wh.surface.as_ptr() as usize))
315    }
316
317    fn correr<Msg: Send + 'static>(
318        display_ptr: usize,
319        surface_ptr: usize,
320        proxy: EventLoopProxy<UserEvent<Msg>>,
321    ) -> Result<(), String> {
322        // SAFETY: el puntero sale del display handle de winit, que es dueño de
323        // la conexión y la mantiene abierta mientras viva el proceso.
324        // `from_foreign_display` comparte, no toma posesión — el mismo
325        // razonamiento que `content_type.rs`.
326        let backend = unsafe { Backend::from_foreign_display(display_ptr as *mut _) };
327        let conn = Connection::from_backend(backend);
328        let mut queue = conn.new_event_queue();
329        let qh = queue.handle();
330        let _registry = conn.display().get_registry(&qh, ());
331
332        let mut estado = Estado {
333            manager: None,
334            seat: None,
335            nuestra: None,
336            mimes: HashMap::new(),
337            activa: None,
338            avisar: Box::new(move |ruta| {
339                // Si el bucle ya murió, el envío falla y no hay nada que hacer.
340                let _ = proxy.send_event(UserEvent::FileDrop(ruta));
341            }),
342        };
343        queue
344            .roundtrip(&mut estado)
345            .map_err(|e| format!("roundtrip del registry: {e}"))?;
346
347        let manager = estado
348            .manager
349            .clone()
350            .ok_or("el compositor no expone wl_data_device_manager")?;
351        let seat = estado.seat.clone().ok_or("no hay wl_seat")?;
352
353        // SAFETY: misma conexión, y la `wl_surface` es la de winit, viva
354        // mientras viva la ventana.
355        let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface_ptr as *mut _) }
356            .map_err(|e| format!("ObjectId::from_ptr(surface): {e}"))?;
357        estado.nuestra = Some(id);
358
359        let _dd = manager.get_data_device(&seat, &qh, ());
360        conn.flush().map_err(|e| format!("flush: {e}"))?;
361
362        // Bloquea esperando eventos de ESTA cola. No compite con winit: cada
363        // cola se despacha por separado sobre el mismo display.
364        loop {
365            queue
366                .blocking_dispatch(&mut estado)
367                .map_err(|e| format!("dispatch: {e}"))?;
368        }
369    }
370
371    #[cfg(test)]
372    mod tests {
373        use super::*;
374
375        #[test]
376        fn uri_list_saca_rutas_y_descarta_lo_que_no_es_archivo() {
377            let s = "#comentario\r\nfile:///tmp/una%20carpeta\r\nhttp://ejemplo/x\r\n\
378                     file://localhost/tmp/otra\r\n";
379            let v = parsear_uri_list(s);
380            assert_eq!(
381                v,
382                vec![PathBuf::from("/tmp/una carpeta"), PathBuf::from("/tmp/otra")],
383                "una URI por línea, el comentario fuera, el http fuera, el %20 decodificado"
384            );
385        }
386
387        #[test]
388        fn un_porcentaje_suelto_no_se_come_el_nombre() {
389            // Un nombre de archivo con un `%` que no abre un escape tiene que
390            // sobrevivir tal cual: perderlo renombraría el archivo en silencio.
391            let v = parsear_uri_list("file:///tmp/100%\n");
392            assert_eq!(v, vec![PathBuf::from("/tmp/100%")]);
393        }
394    }
395}
396
397#[cfg(all(target_os = "linux", not(target_arch = "wasm32"), not(target_os = "android")))]
398pub(crate) use imp::arrancar;
399
400/// Fuera de Wayland/Linux no hay puente que montar: en X11 winit ya entrega
401/// `DroppedFile`, y en los demás targets el evento es nativo.
402#[cfg(not(all(target_os = "linux", not(target_arch = "wasm32"), not(target_os = "android"))))]
403pub(crate) fn arrancar<Msg: Send + 'static>(
404    _window: &llimphi_hal::winit::window::Window,
405    _proxy: llimphi_hal::winit::event_loop::EventLoopProxy<crate::UserEvent<Msg>>,
406) {
407}