tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-ui/src/eventloop/helpers.rs Rust · 380 líneas · 14.7 KB Historia Quién tocó qué crudo

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

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