tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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}