tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-ui/src/eventloop/helpers.rs
Historia
Quién tocó qué
crudo
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1// helpers.rs — Funciones puras auxiliares del bucle Elm.
2// Todas sin efecto observable sobre el runtime; se testean fácil de forma aislada.
3
4use super::super::*;
5
6/// Mapea el [`Cursor`](llimphi_compositor::Cursor) llimphi-native (resuelto por
7/// el hit-test de hover) a `winit::window::CursorIcon`. `None` → flecha default.
8/// Mantiene al compositor winit-free: la traducción vive sólo en el runtime.
9pub(super) fn to_winit_cursor(c: Option<llimphi_compositor::Cursor>) -> llimphi_hal::winit::window::CursorIcon {
10 use llimphi_compositor::Cursor as C;
11 use llimphi_hal::winit::window::CursorIcon as I;
12 match c {
13 None | Some(C::Default) => I::Default,
14 Some(C::Pointer) => I::Pointer,
15 Some(C::Text) => I::Text,
16 Some(C::Crosshair) => I::Crosshair,
17 Some(C::Move) => I::Move,
18 Some(C::Grab) => I::Grab,
19 Some(C::Grabbing) => I::Grabbing,
20 Some(C::NotAllowed) => I::NotAllowed,
21 Some(C::Wait) => I::Wait,
22 Some(C::Progress) => I::Progress,
23 Some(C::Help) => I::Help,
24 Some(C::ColResize) => I::ColResize,
25 Some(C::RowResize) => I::RowResize,
26 Some(C::EwResize) => I::EwResize,
27 Some(C::NsResize) => I::NsResize,
28 Some(C::NeswResize) => I::NeswResize,
29 Some(C::NwseResize) => I::NwseResize,
30 Some(C::ZoomIn) => I::ZoomIn,
31 Some(C::ZoomOut) => I::ZoomOut,
32 }
33}
34
35/// Resuelve el handler de **escala** (pinch-to-zoom) bajo el punto `(x, y)`
36/// contra el cache del último frame (overlay con prioridad, igual que clicks).
37/// Devuelve `(handler, focal_x, focal_y)` con el punto focal ya en coordenadas
38/// **locales** al rect del nodo. `None` si no hay nodo `on_scale` bajo el
39/// cursor. Compartido por el camino Ctrl+rueda y el de `PinchGesture`.
40pub(super) fn scale_hit_from_cache<Msg: Clone>(
41 cache: &RenderCache<Msg>,
42 x: f32,
43 y: f32,
44) -> Option<(ScaleFn<Msg>, f32, f32)> {
45 let (m, c) = match cache.overlay.as_ref() {
46 Some(ov) => (&ov.mounted, &ov.computed),
47 None => (&cache.mounted, &cache.computed),
48 };
49 hit_test_scale(m, c, x, y).and_then(|i| {
50 let node = &m.nodes[i];
51 node.on_scale.clone().map(|h| {
52 let (fx, fy) = c
53 .get(node.id)
54 .map(|r| (x - r.x, y - r.y))
55 .unwrap_or((0.0, 0.0));
56 (h, fx, fy)
57 })
58 })
59}
60
61/// Resuelve el handler de **rotación** (trackpad) bajo `(x, y)` contra el
62/// cache del último frame (overlay con prioridad). Espejo de
63/// [`scale_hit_from_cache`]. Devuelve `(handler, focal_x, focal_y)` con el
64/// punto focal local al rect del nodo. `None` si no hay nodo `on_rotate`.
65pub(super) fn rotate_hit_from_cache<Msg: Clone>(
66 cache: &RenderCache<Msg>,
67 x: f32,
68 y: f32,
69) -> Option<(RotateFn<Msg>, f32, f32)> {
70 let (m, c) = match cache.overlay.as_ref() {
71 Some(ov) => (&ov.mounted, &ov.computed),
72 None => (&cache.mounted, &cache.computed),
73 };
74 hit_test_rotate(m, c, x, y).and_then(|i| {
75 let node = &m.nodes[i];
76 node.on_rotate.clone().map(|h| {
77 let (fx, fy) = c
78 .get(node.id)
79 .map(|r| (x - r.x, y - r.y))
80 .unwrap_or((0.0, 0.0));
81 (h, fx, fy)
82 })
83 })
84}
85
86/// Resuelve el handler de **doble-tap** bajo `(x, y)` contra el cache del
87/// último frame (overlay con prioridad). Elige la variante `_at` (con focal
88/// local) si está, o el `Msg` directo. `None` si no hay nodo con doble-tap.
89///
90/// Devuelve además si ese nodo pidió **doble-tap exclusivo**
91/// (`View::double_tap_exclusive`): con eso el press decide si retiene el click
92/// del primer tap en vez de dispararlo aditivo.
93pub(super) fn double_tap_hit_from_cache<Msg: Clone>(
94 cache: &RenderCache<Msg>,
95 x: f32,
96 y: f32,
97) -> Option<(GestureResolved<Msg>, bool)> {
98 let (m, c) = match cache.overlay.as_ref() {
99 Some(ov) => (&ov.mounted, &ov.computed),
100 None => (&cache.mounted, &cache.computed),
101 };
102 hit_test_double_tap(m, c, x, y).and_then(|i| {
103 let node = &m.nodes[i];
104 let (rx, ry, rw, rh) = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)).unwrap_or_default();
105 let exclusivo = node.double_tap_exclusive.is_some();
106 let resuelto = if let Some(h) = node.on_double_tap_at.clone() {
107 Some(GestureResolved::At(h, x - rx, y - ry, rw, rh))
108 } else {
109 node.on_double_tap.clone().map(GestureResolved::Direct)
110 };
111 resuelto.map(|r| (r, exclusivo))
112 })
113}
114
115/// Como [`double_tap_hit_from_cache`] pero para **long-press**.
116pub(super) fn long_press_hit_from_cache<Msg: Clone>(
117 cache: &RenderCache<Msg>,
118 x: f32,
119 y: f32,
120) -> Option<GestureResolved<Msg>> {
121 let (m, c) = match cache.overlay.as_ref() {
122 Some(ov) => (&ov.mounted, &ov.computed),
123 None => (&cache.mounted, &cache.computed),
124 };
125 hit_test_long_press(m, c, x, y).and_then(|i| {
126 let node = &m.nodes[i];
127 let (rx, ry, rw, rh) = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)).unwrap_or_default();
128 if let Some(h) = node.on_long_press_at.clone() {
129 Some(GestureResolved::At(h, x - rx, y - ry, rw, rh))
130 } else {
131 node.on_long_press.clone().map(GestureResolved::Direct)
132 }
133 })
134}
135
136/// Resuelve el **ripple** bajo `(x, y)` contra el cache del último frame
137/// (overlay con prioridad). Devuelve `(Ripple, lx, ly)`: la config de la onda
138/// + el punto del tap relativo al rect del nodo. `None` si no hay nodo ripple.
139pub(super) fn ripple_hit_from_cache<Msg: Clone>(
140 cache: &RenderCache<Msg>,
141 x: f32,
142 y: f32,
143) -> Option<(llimphi_compositor::Ripple, f32, f32)> {
144 let (m, c) = match cache.overlay.as_ref() {
145 Some(ov) => (&ov.mounted, &ov.computed),
146 None => (&cache.mounted, &cache.computed),
147 };
148 hit_test_ripple(m, c, x, y).and_then(|i| {
149 let node = &m.nodes[i];
150 node.ripple.map(|rp| {
151 let (rx, ry) = c.get(node.id).map(|r| (r.x, r.y)).unwrap_or_default();
152 (rp, x - rx, y - ry)
153 })
154 })
155}
156
157// ── Selección de texto fuera del editor (ver `View::selectable`) ──
158
159/// Rect absoluto de un nodo: `(x, y, w, h)`.
160pub(super) type AbsRect = (f32, f32, f32, f32);
161
162/// `true` si el `TextSpec` es de texto **uniforme** (sin `runs`/`spans`): los
163/// únicos que la selección fuera-del-editor soporta. Los multicolor/RichText
164/// son del editor y se ignoran.
165pub(super) fn spec_is_uniform(spec: &llimphi_compositor::TextSpec) -> bool {
166 spec.runs.is_none() && spec.spans.is_none()
167}
168
169/// Bajo `(x, y)`, el nodo de texto seleccionable más al frente: su key, su
170/// `TextSpec` clonado y su rect absoluto. `None` si no hay texto seleccionable
171/// uniforme ahí.
172pub(super) fn selectable_hit_from_cache<Msg: Clone>(
173 cache: &RenderCache<Msg>,
174 x: f32,
175 y: f32,
176) -> Option<(u64, llimphi_compositor::TextSpec, AbsRect)> {
177 let (m, c) = match cache.overlay.as_ref() {
178 Some(ov) => (&ov.mounted, &ov.computed),
179 None => (&cache.mounted, &cache.computed),
180 };
181 let i = hit_test_selectable(m, c, x, y)?;
182 let node = &m.nodes[i];
183 let key = node.text_select_key?;
184 let spec = node.text.as_ref()?;
185 if !spec_is_uniform(spec) {
186 return None;
187 }
188 let r = c.get(node.id)?;
189 Some((key, spec.clone(), (r.x, r.y, r.w, r.h)))
190}
191
192/// Busca el nodo seleccionable por su `key` estable (para extender el drag o
193/// pintar el resaltado en frames posteriores, cuando el `NodeId` ya cambió).
194/// Recorre el overlay y el árbol principal. `None` si la key ya no está.
195pub(super) fn selectable_by_key<Msg>(
196 cache: &RenderCache<Msg>,
197 key: u64,
198) -> Option<(llimphi_compositor::TextSpec, AbsRect)> {
199 let trees = [
200 cache.overlay.as_ref().map(|ov| (&ov.mounted, &ov.computed)),
201 Some((&cache.mounted, &cache.computed)),
202 ];
203 trees
204 .into_iter()
205 .flatten()
206 .find_map(|(m, c)| selectable_node_in(m, c, key))
207}
208
209/// Busca en un árbol montado concreto el nodo de texto seleccionable con esa
210/// `key` y devuelve su `TextSpec` clonado + rect. Lo usa tanto la búsqueda por
211/// cache como el pintado del resaltado en el redraw (que tiene el `Mounted`
212/// del frame a mano, no un `RenderCache`).
213pub(super) fn selectable_node_in<Msg>(
214 m: &Mounted<Msg>,
215 c: &ComputedLayout,
216 key: u64,
217) -> Option<(llimphi_compositor::TextSpec, AbsRect)> {
218 for node in &m.nodes {
219 if node.text_select_key == Some(key) {
220 let spec = node.text.as_ref()?;
221 if !spec_is_uniform(spec) {
222 return None;
223 }
224 let r = c.get(node.id)?;
225 return Some((spec.clone(), (r.x, r.y, r.w, r.h)));
226 }
227 }
228 None
229}
230
231/// Reconstruye el `parley::Layout` de un nodo de texto, idéntico al que pinta
232/// el render (misma ruta cacheada `Typesetter::layout`), para hit-testear y
233/// medir la selección. El ancho de wrap es el del rect del nodo.
234pub(super) fn build_selectable_layout(
235 ts: &mut llimphi_text::Typesetter,
236 spec: &llimphi_compositor::TextSpec,
237 width: f32,
238) -> llimphi_text::parley::Layout<()> {
239 ts.layout(
240 &spec.content,
241 spec.size_px,
242 Some(width),
243 spec.alignment,
244 spec.line_height,
245 spec.italic,
246 spec.font_family.as_deref(),
247 spec.weight,
248 spec.underline,
249 spec.strikethrough,
250 spec.letter_spacing,
251 spec.word_spacing,
252 )
253}
254
255/// `true` si la tecla lógica es el carácter `c` (case-insensitive). Para
256/// atajos como Ctrl+C sin acoplarse a mayúsculas/minúsculas ni layout.
257pub(super) fn key_is_char(key: &Key, c: char) -> bool {
258 matches!(
259 key,
260 Key::Character(s) if s.chars().next().map(|k| k.eq_ignore_ascii_case(&c)).unwrap_or(false)
261 )
262}
263
264/// Copia texto al portapapeles del sistema (best-effort). Con la feature
265/// `clipboard` usa `arboard`; sin backend (headless) o sin la feature es no-op
266/// silencioso — nunca panica. En Android y en wasm no hay arboard (la dep va
267/// target-gated en Cargo.toml): siempre no-op.
268#[cfg(all(
269 feature = "clipboard",
270 not(target_os = "android"),
271 not(target_arch = "wasm32")
272))]
273pub(super) fn copy_to_clipboard(text: &str) {
274 if let Ok(mut cb) = arboard::Clipboard::new() {
275 let _ = cb.set_text(text.to_string());
276 }
277}
278
279#[cfg(any(
280 not(feature = "clipboard"),
281 target_os = "android",
282 target_arch = "wasm32"
283))]
284pub(super) fn copy_to_clipboard(_text: &str) {}
285
286/// Resuelve los [`View::layout_builder`] del árbol de la app en dos pasadas
287/// (ver [`llimphi_compositor::expand_layout_builders`]). **Coste cero** cuando
288/// ningún nodo usa el builder: devuelve el `view()` sin tocar tras un walk
289/// barato. Cuando hay builders: monta el árbol (builders como hojas), computa
290/// para conocer sus slots, y reconstruye un `view()` fresco expandiendo cada
291/// builder con sus constraints reales. `viewport` en px físicos; `ts` para medir
292/// texto igual que el compute principal. Lo llaman el redraw (vía cache) y el
293/// fallback de press.
294pub(super) fn resolve_layout_builders<A: App>(
295 model: &A::Model,
296 viewport: (f32, f32),
297 ts: &mut llimphi_text::Typesetter,
298) -> View<A::Msg> {
299 let view = super::vigia::aplicar_view::<A>(model);
300 if !has_layout_builder(&view) {
301 return view;
302 }
303 // Pasada 1: montar (builders = hojas con su Style) y computar el layout.
304 let mut l1 = LayoutTree::new();
305 let m1: Mounted<A::Msg> = mount(&mut l1, view);
306 let c1 = {
307 let tmap = &m1.text_measures;
308 l1.compute_with_measure(m1.root, viewport, |nid, known, avail| {
309 match tmap.get(&nid) {
310 Some(tm) => measure_text_node(ts, tm, known, avail),
311 None => llimphi_layout::taffy::Size::ZERO,
312 }
313 })
314 .expect("layout layout_builder pasada 1")
315 };
316 let cons = collect_builder_constraints(&m1, &c1);
317 // Pasada 2: árbol fresco (mismo Model → misma estructura, mismo pre-orden de
318 // builders) + expand con las constraints resueltas.
319 expand_layout_builders(super::vigia::aplicar_view::<A>(model), &cons)
320}
321
322// ── Sonda de teclado ─────────────────────────────────────────────────────────
323
324/// ¿Está prendida la sonda de teclas? (`LLIMPHI_DEBUG_KEY=1`). Cacheado: leer
325/// el entorno por cada tecla sería absurdo.
326fn sonda_teclas_activa() -> bool {
327 static D: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
328 *D.get_or_init(|| std::env::var("LLIMPHI_DEBUG_KEY").is_ok_and(|v| v != "0"))
329}
330
331/// Milisegundos de reloj de pared para estampar la traza. **Epoch y no un
332/// relativo propio**: esta traza se lee cruzada con la de la app (ver
333/// `examples/melaza_teclas.rs`) para saber si una repetición la inventó winit o
334/// la inventó llimphi, y dos relojes que arrancan en momentos distintos hacen
335/// ilegible justamente esa comparación. Lo que se mira son **huecos**, no fechas.
336fn sonda_ms() -> u128 {
337 std::time::SystemTime::now()
338 .duration_since(std::time::UNIX_EPOCH)
339 .map(|d| d.as_millis())
340 .unwrap_or(0)
341}
342
343/// Vuelca a **stderr** lo que winit entregó para una tecla, tal cual, antes de
344/// que llimphi lo traduzca. Existe porque la clase de bug que caza no se ve de
345/// ninguna otra forma desde afuera: un carácter que sale mal (un `@` que llega
346/// como `2` en layout español) puede perderse en el compositor, en winit, o en
347/// la traducción de llimphi, y las tres hipótesis se distinguen mirando
348/// **estos tres campos juntos** — `logical_key`, `text` y
349/// `text_with_all_modifiers`. Apagada cuesta una lectura de `OnceLock`.
350pub(super) fn depurar_tecla(
351 event: &llimphi_hal::winit::event::KeyEvent,
352 mods: Option<Modifiers>,
353) {
354 if !sonda_teclas_activa() {
355 return;
356 }
357 #[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))]
358 let con_todos = {
359 use llimphi_hal::winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
360 event.text_with_all_modifiers().map(|t| t.to_string())
361 };
362 #[cfg(not(all(unix, not(target_os = "android"), not(target_os = "macos"))))]
363 let con_todos: Option<String> = None;
364 // Con hora: la otra clase de bug que caza esta sonda es una tecla
365 // **inventada** (una repetición que sale cuando el usuario ya soltó), y ésa
366 // no se distingue de una real por los campos — se distingue por el hueco
367 // que la separa del press. Sin reloj la traza no puede contestarlo.
368 eprintln!(
369 "llimphi-key: t={} state={:?} repeat={} logical={:?} text={:?} text_all_mods={:?} mods={:?}",
370 sonda_ms(),
371 event.state,
372 event.repeat,
373 event.logical_key,
374 event.text,
375 con_todos,
376 mods,
377 );
378}