feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "llimphi-compositor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-compositor — el núcleo declarativo de Llimphi sin winit: el árbol `View<Msg>`, el mount sobre taffy, el paint a `vello::Scene` y el hit-test. No depende de llimphi-hal ni de una surface concreta, así que la misma composición sirve sobre winit (llimphi-ui) o, a futuro, sobre el framebuffer del kernel wawa. `wgpu` entra sólo por la firma de `GpuPaintFn` (tipos, no windowing)."
|
||||
|
||||
[dependencies]
|
||||
llimphi-layout = { path = "../llimphi-layout" }
|
||||
llimphi-text = { path = "../llimphi-text" }
|
||||
vello = { workspace = true }
|
||||
# Sólo para los tipos de la firma de GpuPaintFn (Device/Queue/Encoder/View).
|
||||
# wgpu NO depende de winit — el compositor sigue libre de windowing.
|
||||
wgpu = { workspace = true }
|
||||
@@ -0,0 +1,348 @@
|
||||
//! llimphi-compositor — el núcleo declarativo de Llimphi, sin winit.
|
||||
//!
|
||||
//! Aquí vive el árbol de vista `View<Msg>` (DSL declarativo), su instalación
|
||||
//! sobre taffy (`mount`), el pintado a `vello::Scene` (`paint`/`paint_gpu`) y
|
||||
//! el hit-test. Nada de esto necesita una ventana ni `llimphi-hal`: la
|
||||
//! composición `view → layout → scene` es pura y reutilizable.
|
||||
//!
|
||||
//! El runtime que la maneja vive aparte:
|
||||
//! - `llimphi-ui` la corre sobre winit (`run<A: App>()`).
|
||||
//! - a futuro, un runtime sobre el framebuffer del kernel `wawa` puede
|
||||
//! reusar exactamente este compositor sin arrastrar winit.
|
||||
//!
|
||||
//! `wgpu` entra sólo por la firma de [`GpuPaintFn`] (tipos de Device/Queue/
|
||||
//! Encoder/TextureView); `wgpu` no depende de winit, así que el compositor
|
||||
//! sigue libre de windowing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use llimphi_layout::taffy::NodeId;
|
||||
use llimphi_layout::{ComputedLayout, LayoutTree, Style};
|
||||
use vello::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect};
|
||||
use vello::peniko::{Color, Fill, Image, Mix};
|
||||
|
||||
mod render;
|
||||
mod view;
|
||||
pub use render::*;
|
||||
|
||||
/// Texto a pintar dentro de un nodo. Alineación por defecto `Center`
|
||||
/// (horizontal y vertical), apta para labels de botón. Para layouts tipo
|
||||
/// editor o párrafo, usar `.text_aligned(...)` con `Alignment::Start`.
|
||||
pub struct TextSpec {
|
||||
pub content: String,
|
||||
pub size_px: f32,
|
||||
pub color: Color,
|
||||
pub alignment: llimphi_text::Alignment,
|
||||
/// `true` = forzar variante italic en la fuente activa. Default false.
|
||||
pub italic: bool,
|
||||
/// CSS-style font-family string (acepta lista con fallbacks). `None`
|
||||
/// = la fuente default de parley.
|
||||
pub font_family: Option<String>,
|
||||
/// Múltiplo de interlínea (`line-height` / `font-size`). 1.2 es el
|
||||
/// default que usaban todos los callers; puriy lo sobreescribe con el
|
||||
/// valor computado de CSS. Se usa tanto al **medir** (para que taffy
|
||||
/// reserve el alto correcto) como al **pintar**, así medida y dibujo
|
||||
/// coinciden.
|
||||
pub line_height: f32,
|
||||
/// Colores por rango de **bytes** sobre `content`, para texto multicolor
|
||||
/// (syntax highlighting) en una sola pasada de shaping. `None` = color
|
||||
/// uniforme (`color`). Cuando es `Some`, el runtime usa
|
||||
/// `Typesetter::layout_runs` + `draw_layout_runs`, y `color` actúa como
|
||||
/// color por defecto de lo no cubierto por ningún run.
|
||||
pub runs: Option<Vec<(usize, usize, Color)>>,
|
||||
}
|
||||
|
||||
/// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el
|
||||
/// delta desde el evento anterior; `End` se emite al soltar el botón.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DragPhase {
|
||||
Move,
|
||||
End,
|
||||
}
|
||||
|
||||
/// Handler de drag. Recibe la fase + delta (`dx`, `dy`) **desde el evento
|
||||
/// anterior** (no acumulado desde el press). Devolver `None` deja el drag
|
||||
/// activo sin disparar Msg. `Arc<dyn Fn>` para que el runtime pueda
|
||||
/// clonarlo barato al iniciar el drag y mantenerlo vivo aunque el cache
|
||||
/// de la vista se regenere mientras tanto.
|
||||
pub type DragFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
|
||||
|
||||
/// Handler de drop. El runtime lo invoca cuando un drag activo se suelta
|
||||
/// sobre este nodo. Recibe el `payload` `u64` que el origen del drag
|
||||
/// declaró vía [`View::drag_payload`]. Devolver `None` ignora el drop.
|
||||
///
|
||||
/// Los IDs `u64` son opacos para el runtime: el widget elige una
|
||||
/// convención (índice de tile, hash del item, etc.) y el handler decide
|
||||
/// qué Msg emitir en función de ese ID.
|
||||
pub type DropFn<Msg> = Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>;
|
||||
|
||||
/// Handler de click con posición. Recibe `(x_local, y_local, rect_w,
|
||||
/// rect_h)`: las dos primeras son la posición del cursor **relativa a
|
||||
/// la esquina superior-izquierda del nodo** y las dos últimas son el
|
||||
/// ancho/alto actual del nodo en pixels — útil cuando el caller
|
||||
/// necesita centrar o normalizar. Devolver `None` no dispara update.
|
||||
pub type ClickAtFn<Msg> = Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
|
||||
|
||||
/// Handler de rueda **local a un nodo**. Recibe el delta `(dx, dy)` en
|
||||
/// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo
|
||||
/// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el
|
||||
/// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el
|
||||
/// handler devuelve `Some(Msg)`, el evento se consume acá. Permite áreas
|
||||
/// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app
|
||||
/// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el
|
||||
/// evento al `on_wheel` global.
|
||||
pub type ScrollFn<Msg> = Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>;
|
||||
|
||||
/// Variante de [`DragFn`] que **conoce la posición inicial del press**
|
||||
/// relativa al rect del nodo. Útil cuando el caller necesita identificar
|
||||
/// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag.
|
||||
/// Recibe `(phase, dx, dy, initial_lx, initial_ly)`.
|
||||
pub type DragAtFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
|
||||
|
||||
/// Rect absoluto del nodo (en coordenadas físicas del frame). Lo
|
||||
/// recibe el callback de [`View::paint_with`] para que pueda
|
||||
/// posicionar sus primitivas custom dentro del nodo.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct PaintRect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
/// Callback de pintura custom. El runtime lo invoca durante el paint
|
||||
/// del nodo (entre el `fill`/`image` y el `text`) con el `Scene` vivo
|
||||
/// + el `Typesetter` cacheado del runtime + el rect absoluto del nodo.
|
||||
/// Pensado para "canvas elements" tipo `dominium-canvas`,
|
||||
/// `pluma-editor` (osciloscopio de coherencia), `cosmos` (charts).
|
||||
///
|
||||
/// El `Typesetter` se pasa porque crearlo por frame es caro
|
||||
/// (`FontContext::new` enumera las fontes del sistema vía fontique).
|
||||
/// Los callers que no necesiten texto pueden ignorar el argumento.
|
||||
///
|
||||
/// El callback no debe llamar a `scene.push_layer` sin un `pop_layer`
|
||||
/// correspondiente, ni reset el scene — sólo agregar primitivas que
|
||||
/// pertenezcan al rect del nodo.
|
||||
pub type PaintFn = Arc<
|
||||
dyn Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect) + Send + Sync,
|
||||
>;
|
||||
|
||||
/// Callback de pintura GPU directo, sin vello intermedio. Recibe el
|
||||
/// `device`/`queue` ya construidos por el runtime más un
|
||||
/// `CommandEncoder` y la `TextureView` del frame (la intermediate
|
||||
/// `Rgba8Unorm` de `WinitSurface`), todo durante el paint del nodo.
|
||||
///
|
||||
/// El caller abre su propio `begin_render_pass` con `LoadOp::Load` para
|
||||
/// no sobrescribir lo que ya pintó vello, dibuja sus primitivas y
|
||||
/// cierra el pass. El runtime se encarga de dispatchear (`queue.submit`)
|
||||
/// el encoder ya con todas las pasadas de todos los nodos acumuladas —
|
||||
/// es un solo submit por frame.
|
||||
///
|
||||
/// **Orden de pintura en Fase 1**: todos los `gpu_painter` corren
|
||||
/// DESPUÉS de la pasada completa de vello (fill, image, painter,
|
||||
/// text) sobre el `mounted` tree. Entre sí mantienen el orden DFS
|
||||
/// pre-orden. Si una app necesita pintar texto **encima** del render
|
||||
/// GPU directo, la forma idiomática es ponerlo en `App::view_overlay`,
|
||||
/// que se renderiza como una segunda Scene de vello encima de todo.
|
||||
///
|
||||
/// Pensado para apps con volumen masivo de primitivos (cosmos
|
||||
/// starfield Gaia, tinkuy particle viewer, nakui viewport, pineal
|
||||
/// denso) — el hook que paga el costo de mantener pipelines WGSL
|
||||
/// propias en `llimphi-raster` (ver `02_ruway/llimphi/SDD.md`
|
||||
/// §"Roadmap — GPU directo wgpu").
|
||||
pub type GpuPaintFn = Arc<
|
||||
dyn Fn(
|
||||
&wgpu::Device,
|
||||
&wgpu::Queue,
|
||||
&mut wgpu::CommandEncoder,
|
||||
&wgpu::TextureView,
|
||||
PaintRect,
|
||||
(u32, u32),
|
||||
) + Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Nodo de la vista declarativa. Estilo de layout (taffy) + relleno opcional
|
||||
/// (vello) + texto opcional (skrifa+vello) + Msg al click opcional + hijos.
|
||||
pub struct View<Msg> {
|
||||
pub style: Style,
|
||||
pub fill: Option<Color>,
|
||||
/// Relleno cuando el cursor está sobre este nodo. Sin valor (`None`)
|
||||
/// = no se reacciona al hover.
|
||||
pub hover_fill: Option<Color>,
|
||||
pub radius: f64,
|
||||
pub text: Option<TextSpec>,
|
||||
/// Imagen a pintar dentro del rect del nodo. Se centra y escala
|
||||
/// preservando aspect ratio (`min(rect.w/img.w, rect.h/img.h)`).
|
||||
/// El alfa por píxel de la imagen y el `Image::alpha` global se
|
||||
/// respetan; el `fill` (si lo hay) se pinta debajo como background.
|
||||
pub image: Option<Image>,
|
||||
/// Callback de pintura custom. Si está presente, el runtime lo
|
||||
/// invoca durante el paint del nodo con el `Scene` vivo + el rect
|
||||
/// absoluto. Pensado para "canvas elements" (dominium, pluma,
|
||||
/// cosmos) que pintan primitivas custom no expresables como una
|
||||
/// composición de Views.
|
||||
pub painter: Option<PaintFn>,
|
||||
/// Pintor GPU directo. Se invoca DESPUÉS de la pasada vello del
|
||||
/// frame; comparte tree y orden DFS con los demás. Ver
|
||||
/// [`GpuPaintFn`].
|
||||
pub gpu_painter: Option<GpuPaintFn>,
|
||||
pub on_click: Option<Msg>,
|
||||
/// Handler de click que recibe la posición **relativa al rect del
|
||||
/// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil
|
||||
/// para canvas elements que quieren mapear el click a coordenadas
|
||||
/// de mundo. Si está presente, gana sobre `on_click`. Devolver
|
||||
/// `None` no dispara update.
|
||||
pub on_click_at: Option<ClickAtFn<Msg>>,
|
||||
/// Equivalente a `on_click` pero para el botón derecho del ratón.
|
||||
/// Pensado para menús contextuales: el nodo declara qué `Msg`
|
||||
/// emitir cuando se le hace right-click, y la app abre el overlay
|
||||
/// con el menú.
|
||||
pub on_right_click: Option<Msg>,
|
||||
/// Variante posicional de [`Self::on_right_click`]. Útil para
|
||||
/// grillas que necesitan saber *qué celda* del rect recibió el
|
||||
/// click derecho (la celda no es un nodo aparte, sino una región
|
||||
/// dentro del nodo). Si está presente, gana sobre `on_right_click`.
|
||||
pub on_right_click_at: Option<ClickAtFn<Msg>>,
|
||||
/// Equivalente a `on_click` pero para el botón del medio del ratón
|
||||
/// (rueda presionada). Pensado para abrir en pestaña nueva — los
|
||||
/// browsers usan middle-click como atajo equivalente a Ctrl+Click.
|
||||
pub on_middle_click: Option<Msg>,
|
||||
/// Handler de drag. Si está presente, este nodo arrastra (y NO emite
|
||||
/// `on_click` al presionar — un nodo es uno u otro).
|
||||
pub drag: Option<DragFn<Msg>>,
|
||||
/// Variante de drag que recibe la posición inicial del press relativa
|
||||
/// al rect del nodo. Gana sobre `drag` si ambos están presentes.
|
||||
pub drag_at: Option<DragAtFn<Msg>>,
|
||||
/// Payload `u64` que viaja con el drag iniciado sobre este nodo. Lo
|
||||
/// recibe el handler [`Self::on_drop`] del drop target. Sin payload,
|
||||
/// el drag funciona igual pero ningún drop target reacciona.
|
||||
pub drag_payload: Option<u64>,
|
||||
/// Handler invocado al soltar un drag sobre este nodo (drop target).
|
||||
pub on_drop: Option<DropFn<Msg>>,
|
||||
/// Color a pintar mientras un drag activo está hovereando este drop
|
||||
/// target. Sobrepone a `fill`/`hover_fill` cuando aplica.
|
||||
pub drop_hover_fill: Option<Color>,
|
||||
/// Si `true`, los descendientes se recortan al rect del nodo (vía
|
||||
/// `scene.push_layer` con `Mix::Clip`). El hit-test también respeta
|
||||
/// el recorte: clicks fuera del rect ignoran a los hijos.
|
||||
pub clip: bool,
|
||||
/// Msg a emitir cuando el cursor entra al rect del nodo (transición
|
||||
/// no-hover → hover). Útil para previews tipo "URL del link al
|
||||
/// pasar el mouse".
|
||||
pub on_pointer_enter: Option<Msg>,
|
||||
/// Msg a emitir cuando el cursor sale del rect del nodo.
|
||||
pub on_pointer_leave: Option<Msg>,
|
||||
/// Handler de rueda local. Si está presente y el cursor cae sobre este
|
||||
/// nodo, el runtime lo invoca antes del `App::on_wheel` global; un
|
||||
/// `Some(Msg)` consume el evento. Base de las áreas de scroll
|
||||
/// autocontenidas. Ver [`ScrollFn`].
|
||||
pub on_scroll: Option<ScrollFn<Msg>>,
|
||||
/// Marca este nodo como **enfocable** con el id opaco `u64`. El runtime
|
||||
/// mantiene el foco (uno por ventana) y lo mueve con Tab/Shift+Tab en
|
||||
/// orden de árbol (pre-orden) y al clickear un nodo enfocable; notifica
|
||||
/// a la app vía `App::on_focus` para que pinte el ring y rutee el
|
||||
/// teclado. El id lo elige el caller (índice de campo, hash, etc.).
|
||||
pub focusable: Option<u64>,
|
||||
/// Opacidad multiplicada sobre TODO el subtree (este nodo + hijos),
|
||||
/// en `[0.0, 1.0]`. Se realiza con `scene.push_layer(Mix::Normal, a, …)`
|
||||
/// alrededor del rect del nodo: el subárbol se rasteriza en una capa
|
||||
/// intermedia y se compone al alfa indicado contra lo que ya hay
|
||||
/// detrás. `None` = sin capa (caso de la abrumadora mayoría de
|
||||
/// nodos). Útil para fade-in/out de overlays, ghosts mientras se
|
||||
/// arrastra, modales que aparecen, panels "vidrio". Note que la
|
||||
/// composición tiene costo (allocate + blit), por lo que sólo
|
||||
/// poblar este slot cuando hace falta — no es un atributo gratis.
|
||||
pub alpha: Option<f32>,
|
||||
/// Transformación afín 2D aplicada a este nodo y todo su subtree
|
||||
/// **alrededor del centro de su propio rect** (convención CSS
|
||||
/// `transform-origin: 50% 50%`). El runtime resuelve el centro en
|
||||
/// `paint` (sólo entonces conoce el layout computado) y compone
|
||||
/// `T(centro) · transform · T(-centro)` sobre la transformación
|
||||
/// acumulada del padre, así nodos anidados transforman en el espacio
|
||||
/// ya transformado de su ancestro — igual que CSS. `None` = identidad
|
||||
/// (la abrumadora mayoría de nodos). Pensado para `transform`/
|
||||
/// `@keyframes` CSS de puriy (rotate/scale/translate). El hit-test
|
||||
/// **respeta** el afín (un nodo transformado recibe clicks donde se ve
|
||||
/// pintado). Limitación restante: los `painter`/`runs` custom no heredan
|
||||
/// el afín, y la posición local que reciben los handlers `*_at` se
|
||||
/// reporta en espacio de pantalla, no en el espacio local del nodo.
|
||||
pub transform: Option<Affine>,
|
||||
/// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un
|
||||
/// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo
|
||||
/// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay
|
||||
/// del runtime, una surface popup del cliente) lo decide el consumidor. El
|
||||
/// hit-test de hover ya localiza el nodo bajo el cursor. `None` = sin tip.
|
||||
pub tooltip: Option<String>,
|
||||
pub children: Vec<View<Msg>>,
|
||||
}
|
||||
|
||||
/// Versión "instalada" del árbol: cada nodo tiene su NodeId de taffy, color
|
||||
/// y handler. Se mantiene en orden de inserción (recorrido pre-orden), así
|
||||
/// el hit-test puede iterar al revés para honrar el orden de pintado.
|
||||
///
|
||||
/// `pub` (con campos `pub`) porque el runtime (llimphi-ui) lee el árbol
|
||||
/// montado para hit-test y para la pasada GPU directa, pero vive en otro
|
||||
/// crate. No se construye fuera de [`mount`].
|
||||
pub struct Mounted<Msg> {
|
||||
pub root: NodeId,
|
||||
pub nodes: Vec<MountedNode<Msg>>,
|
||||
/// Contenido de texto por nodo-hoja, para que el runtime lo mida con
|
||||
/// parley durante `compute_with_measure` y taffy reserve el alto real
|
||||
/// del texto envuelto (varias líneas) en vez de una sola. Sin esto un
|
||||
/// párrafo que envuelve a N líneas se aplastaría en la altura de una
|
||||
/// (el bug clásico de "textos aplastados"). Sólo se pueblan hojas con
|
||||
/// texto uniforme (sin `runs` multicolor, que el caller dimensiona).
|
||||
pub text_measures: HashMap<NodeId, TextMeasure>,
|
||||
}
|
||||
|
||||
/// Datos de un nodo-hoja de texto necesarios para medirlo (shaping +
|
||||
/// line-break) sin volver a tocar el `View`. Lo consume el runtime en la
|
||||
/// función de medición que le pasa a [`LayoutTree::compute_with_measure`].
|
||||
#[derive(Clone)]
|
||||
pub struct TextMeasure {
|
||||
pub content: String,
|
||||
pub size_px: f32,
|
||||
pub alignment: llimphi_text::Alignment,
|
||||
pub italic: bool,
|
||||
pub font_family: Option<String>,
|
||||
pub line_height: f32,
|
||||
}
|
||||
|
||||
pub struct MountedNode<Msg> {
|
||||
pub id: NodeId,
|
||||
pub fill: Option<Color>,
|
||||
pub hover_fill: Option<Color>,
|
||||
pub radius: f64,
|
||||
pub text: Option<TextSpec>,
|
||||
pub image: Option<Image>,
|
||||
pub painter: Option<PaintFn>,
|
||||
pub gpu_painter: Option<GpuPaintFn>,
|
||||
pub on_click: Option<Msg>,
|
||||
pub on_click_at: Option<ClickAtFn<Msg>>,
|
||||
pub on_right_click: Option<Msg>,
|
||||
pub on_right_click_at: Option<ClickAtFn<Msg>>,
|
||||
pub on_middle_click: Option<Msg>,
|
||||
pub drag: Option<DragFn<Msg>>,
|
||||
pub drag_at: Option<DragAtFn<Msg>>,
|
||||
pub drag_payload: Option<u64>,
|
||||
pub on_drop: Option<DropFn<Msg>>,
|
||||
pub drop_hover_fill: Option<Color>,
|
||||
pub clip: bool,
|
||||
pub on_pointer_enter: Option<Msg>,
|
||||
pub on_pointer_leave: Option<Msg>,
|
||||
pub on_scroll: Option<ScrollFn<Msg>>,
|
||||
pub focusable: Option<u64>,
|
||||
pub alpha: Option<f32>,
|
||||
/// Transformación afín 2D del nodo (alrededor del centro de su rect).
|
||||
/// Ver [`View::transform`]. `paint` la compone con la del padre.
|
||||
pub transform: Option<Affine>,
|
||||
/// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo
|
||||
/// lee tras un hit-test de hover para pintar el rótulo flotante.
|
||||
pub tooltip: Option<String>,
|
||||
/// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los
|
||||
/// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en
|
||||
/// paint/hit_test para `pop_layer` y para saltar subárboles enteros.
|
||||
pub subtree_end: usize,
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
use super::*;
|
||||
|
||||
pub fn mount<Msg: Clone>(layout: &mut LayoutTree, v: View<Msg>) -> Mounted<Msg> {
|
||||
let mut nodes = Vec::new();
|
||||
let mut text_measures = std::collections::HashMap::new();
|
||||
let root = mount_recursive(layout, v, &mut nodes, &mut text_measures);
|
||||
Mounted { root, nodes, text_measures }
|
||||
}
|
||||
|
||||
/// Mount en pre-orden directo sobre `out`: pusheamos el padre como
|
||||
/// placeholder (id real desconocido hasta crear el taffy node), recursamos
|
||||
/// hijos sobre el mismo `out`, y al volver completamos `id` + `subtree_end`.
|
||||
pub fn mount_recursive<Msg: Clone>(
|
||||
layout: &mut LayoutTree,
|
||||
v: View<Msg>,
|
||||
out: &mut Vec<MountedNode<Msg>>,
|
||||
text_measures: &mut std::collections::HashMap<NodeId, TextMeasure>,
|
||||
) -> NodeId {
|
||||
let View {
|
||||
style,
|
||||
fill,
|
||||
hover_fill,
|
||||
radius,
|
||||
text,
|
||||
image,
|
||||
painter,
|
||||
gpu_painter,
|
||||
on_click,
|
||||
on_click_at,
|
||||
on_right_click,
|
||||
on_right_click_at,
|
||||
on_middle_click,
|
||||
drag,
|
||||
drag_at,
|
||||
drag_payload,
|
||||
on_drop,
|
||||
drop_hover_fill,
|
||||
clip,
|
||||
on_pointer_enter,
|
||||
on_pointer_leave,
|
||||
on_scroll,
|
||||
focusable,
|
||||
alpha,
|
||||
transform,
|
||||
tooltip,
|
||||
children,
|
||||
} = v;
|
||||
let parent_idx = out.len();
|
||||
out.push(MountedNode {
|
||||
id: NodeId::new(0), // placeholder, lo sobreescribimos abajo
|
||||
fill,
|
||||
hover_fill,
|
||||
radius,
|
||||
text,
|
||||
image,
|
||||
painter,
|
||||
gpu_painter,
|
||||
on_click,
|
||||
on_click_at,
|
||||
on_right_click,
|
||||
on_right_click_at,
|
||||
on_middle_click,
|
||||
drag,
|
||||
drag_at,
|
||||
drag_payload,
|
||||
on_drop,
|
||||
drop_hover_fill,
|
||||
clip,
|
||||
on_pointer_enter,
|
||||
on_pointer_leave,
|
||||
on_scroll,
|
||||
focusable,
|
||||
alpha,
|
||||
transform,
|
||||
tooltip,
|
||||
subtree_end: 0,
|
||||
});
|
||||
let mut child_ids = Vec::with_capacity(children.len());
|
||||
for child in children {
|
||||
child_ids.push(mount_recursive(layout, child, out, text_measures));
|
||||
}
|
||||
let id = if child_ids.is_empty() {
|
||||
layout.leaf(style).expect("layout leaf")
|
||||
} else {
|
||||
layout.node(style, &child_ids).expect("layout node")
|
||||
};
|
||||
out[parent_idx].id = id;
|
||||
out[parent_idx].subtree_end = out.len();
|
||||
// Hoja de texto uniforme: registrá su contenido para que el runtime lo
|
||||
// mida con parley. El texto multicolor (`runs`) lo dimensiona el caller
|
||||
// (editor: un nodo por línea), así que no lo medimos acá.
|
||||
if child_ids.is_empty() {
|
||||
if let Some(text) = out[parent_idx].text.as_ref() {
|
||||
if text.runs.is_none() {
|
||||
text_measures.insert(
|
||||
id,
|
||||
TextMeasure {
|
||||
content: text.content.clone(),
|
||||
size_px: text.size_px,
|
||||
alignment: text.alignment,
|
||||
italic: text.italic,
|
||||
font_family: text.font_family.clone(),
|
||||
line_height: text.line_height,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Mide una hoja de texto para taffy: shaping + line-break con parley contra
|
||||
/// el ancho disponible, devolviendo el bounding box. Si el ancho ya está
|
||||
/// resuelto (`known.width`) se usa ese; si no, se deriva del `available`
|
||||
/// (Definite → ese ancho; MaxContent → sin límite = una línea; MinContent →
|
||||
/// 0 = envuelve a la palabra más ancha). El `line_height` sale del propio
|
||||
/// `TextMeasure`, el mismo que usa `paint`, así medida y pintado coinciden.
|
||||
pub fn measure_text_node(
|
||||
ts: &mut llimphi_text::Typesetter,
|
||||
tm: &TextMeasure,
|
||||
known: llimphi_layout::taffy::Size<Option<f32>>,
|
||||
available: llimphi_layout::taffy::Size<llimphi_layout::taffy::AvailableSpace>,
|
||||
) -> llimphi_layout::taffy::Size<f32> {
|
||||
use llimphi_layout::taffy::AvailableSpace;
|
||||
let max_width: Option<f32> = known.width.or(match available.width {
|
||||
AvailableSpace::Definite(w) => Some(w),
|
||||
AvailableSpace::MaxContent => None,
|
||||
AvailableSpace::MinContent => Some(0.0),
|
||||
});
|
||||
let block = llimphi_text::TextBlock {
|
||||
text: &tm.content,
|
||||
size_px: tm.size_px,
|
||||
color: Color::BLACK,
|
||||
origin: (0.0, 0.0),
|
||||
max_width,
|
||||
alignment: tm.alignment,
|
||||
line_height: tm.line_height,
|
||||
italic: tm.italic,
|
||||
font_family: tm.font_family.clone(),
|
||||
};
|
||||
let m = llimphi_text::measure(ts, &block);
|
||||
llimphi_layout::taffy::Size { width: m.width, height: m.height }
|
||||
}
|
||||
|
||||
pub fn paint<Msg>(
|
||||
scene: &mut vello::Scene,
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
typesetter: &mut llimphi_text::Typesetter,
|
||||
hover_idx: Option<usize>,
|
||||
drop_hover_idx: Option<usize>,
|
||||
) {
|
||||
// Stack de subtree_end de los `push_layer` activos (clip y/o alpha).
|
||||
// Vello requiere pop_layer en orden LIFO estricto, así que mantenemos
|
||||
// un único stack común y popeamos en el orden en que se pushearon.
|
||||
// Dos entradas con el mismo `subtree_end` (alpha + clip sobre el
|
||||
// mismo nodo) se cierran en el orden inverso al push.
|
||||
let mut layer_stack: Vec<usize> = Vec::new();
|
||||
// Stack de transformaciones afines de subtree. Cada entrada guarda el
|
||||
// `subtree_end` y la `cur_xf` previa para restaurarla al salir del
|
||||
// subárbol. `cur_xf` es el producto acumulado de todos los `transform`
|
||||
// de los ancestros activos — se multiplica en cada draw call. Cuando
|
||||
// ningún nodo transforma, queda en `IDENTITY` y el paint es idéntico
|
||||
// al previo (cero regresión).
|
||||
let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
|
||||
let mut cur_xf = Affine::IDENTITY;
|
||||
for (idx, node) in mounted.nodes.iter().enumerate() {
|
||||
// Cierre de capas que ya quedaron atrás (idx ≥ subtree_end).
|
||||
while let Some(&end) = layer_stack.last() {
|
||||
if idx >= end {
|
||||
scene.pop_layer();
|
||||
layer_stack.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Restaurá la transformación al salir de subárboles transformados.
|
||||
while let Some(&(end, prev)) = xf_stack.last() {
|
||||
if idx >= end {
|
||||
cur_xf = prev;
|
||||
xf_stack.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(r) = computed.get(node.id) else {
|
||||
continue;
|
||||
};
|
||||
// Transform CSS del nodo: se aplica alrededor del centro de su rect
|
||||
// (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se
|
||||
// empuja ANTES del alpha/fill para que toda la pintura del subtree
|
||||
// (incl. la capa de alpha y el clip) caiga en el espacio transformado.
|
||||
if let Some(local) = node.transform {
|
||||
let cx = (r.x + r.w * 0.5) as f64;
|
||||
let cy = (r.y + r.h * 0.5) as f64;
|
||||
let centered =
|
||||
Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy));
|
||||
xf_stack.push((node.subtree_end, cur_xf));
|
||||
cur_xf *= centered;
|
||||
}
|
||||
// Alpha de subtree: push ANTES de cualquier paint de este nodo
|
||||
// para que fill/text/image/painter/children entren en la misma
|
||||
// capa y se compongan juntos al alfa indicado. Si el nodo tiene
|
||||
// hijos, su `subtree_end > idx + 1` y la capa permanece abierta
|
||||
// hasta que el loop alcance el primer índice fuera del subárbol.
|
||||
// Para nodos hoja con alpha el push y el pop son consecutivos —
|
||||
// funcionalmente equivalente a multiplicar el alpha del fill,
|
||||
// pero permite usar el mismo API sin distinguir hoja vs rama.
|
||||
if let Some(a) = node.alpha {
|
||||
let rect = KurboRect::new(
|
||||
r.x as f64,
|
||||
r.y as f64,
|
||||
(r.x + r.w) as f64,
|
||||
(r.y + r.h) as f64,
|
||||
);
|
||||
scene.push_layer(Mix::Normal, a, cur_xf, &rect);
|
||||
layer_stack.push(node.subtree_end);
|
||||
}
|
||||
// Prioridad de pintura: drop-hover (drag activo) > hover normal >
|
||||
// fill base. Solo aplica el override si el slot correspondiente
|
||||
// está poblado; el siguiente cae como fallback.
|
||||
let effective_fill = if Some(idx) == drop_hover_idx {
|
||||
node.drop_hover_fill.or(node.hover_fill).or(node.fill)
|
||||
} else if Some(idx) == hover_idx {
|
||||
node.hover_fill.or(node.fill)
|
||||
} else {
|
||||
node.fill
|
||||
};
|
||||
if let Some(color) = effective_fill {
|
||||
let rr = RoundedRect::new(
|
||||
r.x as f64,
|
||||
r.y as f64,
|
||||
(r.x + r.w) as f64,
|
||||
(r.y + r.h) as f64,
|
||||
node.radius,
|
||||
);
|
||||
scene.fill(Fill::NonZero, cur_xf, color, None, &rr);
|
||||
}
|
||||
if let Some(image) = node.image.as_ref() {
|
||||
// Aspect-fit centrado: el min de las dos escalas ocupa
|
||||
// todo el rect en el eje más restrictivo y deja banda en
|
||||
// el otro. Defensivo: envolvemos en push_layer/pop_layer
|
||||
// con el rect del nodo para que, aunque el caller pida
|
||||
// un layout mal-dimensionado, la imagen nunca pinte fuera
|
||||
// del nodo (visualmente preferible a un overflow opaco).
|
||||
if image.width > 0 && image.height > 0 && r.w > 0.0 && r.h > 0.0 {
|
||||
let sx = r.w as f64 / image.width as f64;
|
||||
let sy = r.h as f64 / image.height as f64;
|
||||
let s = sx.min(sy);
|
||||
let disp_w = image.width as f64 * s;
|
||||
let disp_h = image.height as f64 * s;
|
||||
let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
|
||||
let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
|
||||
let transform = Affine::translate((tx, ty)) * Affine::scale(s);
|
||||
let node_rect = KurboRect::new(
|
||||
r.x as f64,
|
||||
r.y as f64,
|
||||
(r.x + r.w) as f64,
|
||||
(r.y + r.h) as f64,
|
||||
);
|
||||
scene.push_layer(Mix::Clip, 1.0, cur_xf, &node_rect);
|
||||
scene.draw_image(image, cur_xf * transform);
|
||||
scene.pop_layer();
|
||||
}
|
||||
}
|
||||
if let Some(painter) = node.painter.as_ref() {
|
||||
(painter)(
|
||||
scene,
|
||||
typesetter,
|
||||
PaintRect {
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
w: r.w,
|
||||
h: r.h,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(text) = node.text.as_ref() {
|
||||
if let Some(runs) = text.runs.as_ref() {
|
||||
// Texto multicolor (syntax highlighting): una sola pasada de
|
||||
// shaping con color por rango, anclado arriba-izquierda. Cae
|
||||
// por el flujo normal (clip/alpha se cierran como siempre).
|
||||
let layout = typesetter.layout_runs(
|
||||
&text.content,
|
||||
text.size_px,
|
||||
text.color,
|
||||
runs,
|
||||
text.alignment,
|
||||
text.line_height,
|
||||
);
|
||||
llimphi_text::draw_layout_runs(scene, &layout, (r.x as f64, r.y as f64));
|
||||
} else {
|
||||
// Parley resuelve la alineación horizontal vía max_width +
|
||||
// alignment. Para Center también centramos verticalmente; para
|
||||
// Start/End/Justify anclamos arriba (párrafo/editor).
|
||||
let block = llimphi_text::TextBlock {
|
||||
text: &text.content,
|
||||
size_px: text.size_px,
|
||||
color: text.color,
|
||||
origin: (r.x as f64, r.y as f64),
|
||||
max_width: Some(r.w),
|
||||
alignment: text.alignment,
|
||||
line_height: text.line_height,
|
||||
italic: text.italic,
|
||||
font_family: text.font_family.clone(),
|
||||
};
|
||||
// Shaping una sola vez: el `Layout` retornado se reusa para
|
||||
// medir (cuando hay centrado vertical) y para pintar.
|
||||
let layout = llimphi_text::layout_block(typesetter, &block);
|
||||
let origin =
|
||||
if matches!(text.alignment, llimphi_text::Alignment::Center) {
|
||||
let m = llimphi_text::measurement(&layout);
|
||||
(
|
||||
r.x as f64,
|
||||
r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0),
|
||||
)
|
||||
} else {
|
||||
block.origin
|
||||
};
|
||||
llimphi_text::draw_layout_xf(
|
||||
scene,
|
||||
&layout,
|
||||
text.color,
|
||||
cur_xf * Affine::translate(origin),
|
||||
);
|
||||
}
|
||||
}
|
||||
if node.clip {
|
||||
let clip_rect = KurboRect::new(
|
||||
r.x as f64,
|
||||
r.y as f64,
|
||||
(r.x + r.w) as f64,
|
||||
(r.y + r.h) as f64,
|
||||
);
|
||||
scene.push_layer(Mix::Clip, 1.0, cur_xf, &clip_rect);
|
||||
layer_stack.push(node.subtree_end);
|
||||
}
|
||||
}
|
||||
// Cerrá capas (clip + alpha) que llegaron al final sin pop intermedio.
|
||||
while layer_stack.pop().is_some() {
|
||||
scene.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasada GPU directo: recorre el `Mounted` en pre-orden DFS (mismo orden
|
||||
/// que [`paint`]) e invoca cada `gpu_painter` con el encoder y la
|
||||
/// `TextureView` del frame. Se ejecuta DESPUÉS de la pasada vello — la
|
||||
/// intermediate ya tiene fill/image/painter/text encima cuando los
|
||||
/// callbacks corren, así que su `LoadOp` debe ser `Load`. Devuelve si
|
||||
/// se invocó al menos un painter (para que el caller decida si vale la
|
||||
/// pena finalizar y submitir el encoder).
|
||||
/// `true` si algún nodo del árbol registró un `gpu_painter` (p. ej. el video
|
||||
/// de media vía `gpu_paint_with`). El eventloop lo usa para decidir si la
|
||||
/// capa de overlay necesita componerse aparte (sobre el contenido gpu) en vez
|
||||
/// de pintarse en la escena principal.
|
||||
pub fn has_gpu_painter<Msg>(mounted: &Mounted<Msg>) -> bool {
|
||||
mounted.nodes.iter().any(|n| n.gpu_painter.is_some())
|
||||
}
|
||||
|
||||
pub fn paint_gpu<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
viewport: (u32, u32),
|
||||
) -> bool {
|
||||
let mut any = false;
|
||||
for node in &mounted.nodes {
|
||||
let Some(painter) = node.gpu_painter.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = computed.get(node.id) else {
|
||||
continue;
|
||||
};
|
||||
(painter)(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
view,
|
||||
PaintRect {
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
w: r.w,
|
||||
h: r.h,
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
any = true;
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo
|
||||
/// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para
|
||||
/// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae
|
||||
/// afuera de un nodo con clip, el subárbol entero es invisible.
|
||||
///
|
||||
/// **Respeta `transform`**: igual que [`paint`], compone el afín acumulado
|
||||
/// de los ancestros (cada `transform` alrededor del centro del rect del
|
||||
/// nodo, convención CSS `transform-origin: 50% 50%`). El punto de pantalla
|
||||
/// `(x, y)` se lleva al espacio local del nodo invirtiendo ese afín, y se
|
||||
/// testea contra el rect sin transformar. Así un nodo rotado/escalado/
|
||||
/// trasladado recibe los clicks donde realmente se ve pintado (recorrido
|
||||
/// tipo Prezi, lienzos de tullpu, `@keyframes` de puriy). Un subárbol con
|
||||
/// afín singular (escala 0) es inalcanzable, igual que es invisible.
|
||||
pub fn hit_test_pred<Msg, F>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
pred: F,
|
||||
) -> Option<usize>
|
||||
where
|
||||
F: Fn(&MountedNode<Msg>) -> bool,
|
||||
{
|
||||
let mut hit: Option<usize> = None;
|
||||
let mut clip_stack: Vec<usize> = Vec::new();
|
||||
// Espejo del stack de transformaciones de `paint`: `cur_xf` es el
|
||||
// producto acumulado de los `transform` de los ancestros activos
|
||||
// (local → pantalla). Vacío ⇒ identidad ⇒ camino directo sin invertir
|
||||
// (cero costo para la abrumadora mayoría de árboles sin transform).
|
||||
let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
|
||||
let mut cur_xf = Affine::IDENTITY;
|
||||
let mut idx = 0;
|
||||
while idx < mounted.nodes.len() {
|
||||
while let Some(&end) = clip_stack.last() {
|
||||
if idx >= end {
|
||||
clip_stack.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&(end, prev)) = xf_stack.last() {
|
||||
if idx >= end {
|
||||
cur_xf = prev;
|
||||
xf_stack.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let node = &mounted.nodes[idx];
|
||||
let Some(r) = computed.get(node.id) else {
|
||||
idx += 1;
|
||||
continue;
|
||||
};
|
||||
// Componé el transform de este nodo igual que `paint`, ANTES de
|
||||
// resolver el punto local (su propio rect ya cae en el espacio
|
||||
// transformado).
|
||||
if let Some(local) = node.transform {
|
||||
let cx = (r.x + r.w * 0.5) as f64;
|
||||
let cy = (r.y + r.h * 0.5) as f64;
|
||||
let centered =
|
||||
Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy));
|
||||
xf_stack.push((node.subtree_end, cur_xf));
|
||||
cur_xf *= centered;
|
||||
}
|
||||
// Punto en el espacio local del nodo. Sin transform activo, es el
|
||||
// punto de pantalla tal cual. Con transform, se invierte el afín;
|
||||
// si es singular (no invertible) el subárbol es inalcanzable.
|
||||
let (lx, ly) = if xf_stack.is_empty() {
|
||||
(x as f64, y as f64)
|
||||
} else if cur_xf.determinant().abs() < 1e-9 {
|
||||
idx = node.subtree_end;
|
||||
continue;
|
||||
} else {
|
||||
let p = cur_xf.inverse() * Point::new(x as f64, y as f64);
|
||||
(p.x, p.y)
|
||||
};
|
||||
let inside = lx >= r.x as f64
|
||||
&& lx < (r.x + r.w) as f64
|
||||
&& ly >= r.y as f64
|
||||
&& ly < (r.y + r.h) as f64;
|
||||
if node.clip {
|
||||
if !inside {
|
||||
idx = node.subtree_end;
|
||||
continue;
|
||||
}
|
||||
clip_stack.push(node.subtree_end);
|
||||
}
|
||||
if inside && pred(node) {
|
||||
hit = Some(idx);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
/// Hit-test específico para clicks (incluye nodos draggables).
|
||||
pub fn hit_test_click<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<usize> {
|
||||
hit_test_pred(mounted, computed, x, y, |n| {
|
||||
n.on_click.is_some()
|
||||
|| n.on_click_at.is_some()
|
||||
|| n.drag.is_some()
|
||||
|| n.drag_at.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// Hit-test específico para right-click. Sólo considera nodos que
|
||||
/// declararon `on_right_click` o `on_right_click_at` — un right-click
|
||||
/// sobre un nodo sin handler no hace nada (no se "filtra" al click
|
||||
/// izquierdo).
|
||||
pub fn hit_test_right_click<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<usize> {
|
||||
hit_test_pred(mounted, computed, x, y, |n| {
|
||||
n.on_right_click.is_some() || n.on_right_click_at.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// Hit-test específico para middle-click. Mismo modelo que right-click:
|
||||
/// sólo nodos que declararon `on_middle_click` reaccionan.
|
||||
pub fn hit_test_middle_click<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<usize> {
|
||||
hit_test_pred(mounted, computed, x, y, |n| n.on_middle_click.is_some())
|
||||
}
|
||||
|
||||
/// Hit-test específico para hover (nodos con `hover_fill`).
|
||||
pub fn hit_test_hover<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<usize> {
|
||||
hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some())
|
||||
}
|
||||
|
||||
/// Hit-test específico para drop targets (nodos con `on_drop`). Usado
|
||||
/// durante un drag activo para resaltar el destino y para invocar el
|
||||
/// handler al soltar.
|
||||
pub fn hit_test_drop<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<usize> {
|
||||
hit_test_pred(mounted, computed, x, y, |n| n.on_drop.is_some())
|
||||
}
|
||||
|
||||
/// Hit-test específico para áreas de scroll (nodos con `on_scroll`). El
|
||||
/// runtime lo usa al recibir la rueda: el nodo más al frente bajo el
|
||||
/// cursor con handler de scroll consume el evento antes del `on_wheel`
|
||||
/// global.
|
||||
pub fn hit_test_scroll<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<usize> {
|
||||
hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some())
|
||||
}
|
||||
|
||||
/// Hit-test para foco: el id `focusable` del nodo más al frente bajo el
|
||||
/// cursor (click-to-focus). `None` si no se clickeó nada enfocable.
|
||||
pub fn hit_test_focusable<Msg>(
|
||||
mounted: &Mounted<Msg>,
|
||||
computed: &ComputedLayout,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<u64> {
|
||||
hit_test_pred(mounted, computed, x, y, |n| n.focusable.is_some())
|
||||
.and_then(|i| mounted.nodes[i].focusable)
|
||||
}
|
||||
|
||||
/// Ids enfocables en orden de Tab (pre-orden del árbol = orden de
|
||||
/// inserción de `Mounted::nodes`). Sólo nodos con rect computado
|
||||
/// (presentes en el layout). Es el orden DOM-like de tabulación.
|
||||
pub fn focus_order<Msg>(mounted: &Mounted<Msg>, computed: &ComputedLayout) -> Vec<u64> {
|
||||
mounted
|
||||
.nodes
|
||||
.iter()
|
||||
.filter_map(|n| {
|
||||
n.focusable
|
||||
.filter(|_| computed.get(n.id).is_some())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Próximo id de foco al pulsar Tab (o Shift+Tab si `reverse`), dado el
|
||||
/// `order` (de [`focus_order`]) y el `current`. Envuelve en los extremos.
|
||||
/// Si no hay enfocables devuelve `None`; si `current` ya no existe en el
|
||||
/// orden, arranca por el primero (Tab) o el último (Shift+Tab).
|
||||
pub fn next_focus(order: &[u64], current: Option<u64>, reverse: bool) -> Option<u64> {
|
||||
if order.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n = order.len();
|
||||
let pos = current.and_then(|c| order.iter().position(|&id| id == c));
|
||||
let next_idx = match pos {
|
||||
Some(i) => {
|
||||
if reverse {
|
||||
(i + n - 1) % n
|
||||
} else {
|
||||
(i + 1) % n
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if reverse {
|
||||
n - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(order[next_idx])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{hit_test_click, mount, View};
|
||||
use llimphi_layout::taffy::prelude::*;
|
||||
use llimphi_layout::{LayoutTree, Style};
|
||||
use vello::kurbo::Affine;
|
||||
|
||||
/// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve
|
||||
/// `(mounted, computed)` ya layouteados sobre un viewport 400×400.
|
||||
fn fixture(
|
||||
transform: Option<Affine>,
|
||||
) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
|
||||
let mut child = View::<()>::new(Style {
|
||||
size: Size {
|
||||
width: length(100.0),
|
||||
height: length(100.0),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.on_click(());
|
||||
if let Some(xf) = transform {
|
||||
child = child.transform(xf);
|
||||
}
|
||||
let root = View::<()>::new(Style {
|
||||
align_items: Some(AlignItems::FlexStart),
|
||||
justify_content: Some(JustifyContent::FlexStart),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![child]);
|
||||
let mut layout = LayoutTree::new();
|
||||
let mounted = mount(&mut layout, root);
|
||||
let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
|
||||
(mounted, computed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sin_transform_el_hit_cae_en_el_rect() {
|
||||
let (m, c) = fixture(None);
|
||||
assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro
|
||||
assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traslacion_mueve_el_area_clickeable() {
|
||||
// El nodo se ve corrido +200px en x; el click debe seguirlo.
|
||||
let (m, c) = fixture(Some(Affine::translate((200.0, 0.0))));
|
||||
assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve
|
||||
assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotacion_180_grados_alrededor_del_centro() {
|
||||
// Rotar 180° alrededor del centro (50,50) deja el rect en su sitio:
|
||||
// una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo.
|
||||
let (m, c) = fixture(Some(Affine::rotate(std::f64::consts::PI)));
|
||||
assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1));
|
||||
assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1));
|
||||
assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escala_cero_es_inalcanzable() {
|
||||
let (m, c) = fixture(Some(Affine::scale(0.0)));
|
||||
assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_traversal_envuelve_en_los_extremos() {
|
||||
use crate::next_focus;
|
||||
let order = [10u64, 20, 30];
|
||||
// Avanza.
|
||||
assert_eq!(next_focus(&order, Some(10), false), Some(20));
|
||||
assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap
|
||||
// Retrocede (Shift+Tab).
|
||||
assert_eq!(next_focus(&order, Some(20), true), Some(10));
|
||||
assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap
|
||||
// Sin foco previo: Tab → primero, Shift+Tab → último.
|
||||
assert_eq!(next_focus(&order, None, false), Some(10));
|
||||
assert_eq!(next_focus(&order, None, true), Some(30));
|
||||
// Foco obsoleto (id que ya no está) → arranca por el extremo.
|
||||
assert_eq!(next_focus(&order, Some(99), false), Some(10));
|
||||
// Lista vacía.
|
||||
assert_eq!(next_focus(&[], Some(10), false), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
use super::*;
|
||||
|
||||
impl<Msg> View<Msg> {
|
||||
pub fn new(style: Style) -> Self {
|
||||
Self {
|
||||
style,
|
||||
fill: None,
|
||||
hover_fill: None,
|
||||
radius: 0.0,
|
||||
text: None,
|
||||
image: None,
|
||||
painter: None,
|
||||
gpu_painter: None,
|
||||
on_pointer_enter: None,
|
||||
on_pointer_leave: None,
|
||||
on_click: None,
|
||||
on_click_at: None,
|
||||
on_right_click: None,
|
||||
on_right_click_at: None,
|
||||
on_middle_click: None,
|
||||
drag: None,
|
||||
drag_at: None,
|
||||
drag_payload: None,
|
||||
on_drop: None,
|
||||
drop_hover_fill: None,
|
||||
clip: false,
|
||||
on_scroll: None,
|
||||
focusable: None,
|
||||
alpha: None,
|
||||
transform: None,
|
||||
tooltip: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asocia un texto de **tooltip** a este nodo. Llimphi sólo lo transporta
|
||||
/// hasta el [`MountedNode`](crate::MountedNode); el consumidor decide cómo
|
||||
/// mostrarlo (un overlay del runtime, una surface popup del cliente) tras
|
||||
/// localizar el nodo bajo el cursor con el hit-test de hover.
|
||||
pub fn tooltip(mut self, text: impl Into<String>) -> Self {
|
||||
self.tooltip = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Registra un handler de rueda local: si el cursor está sobre este
|
||||
/// nodo cuando la rueda gira, el runtime lo invoca con el delta
|
||||
/// `(dx, dy)` en líneas lógicas ANTES de caer al `App::on_wheel`
|
||||
/// global. Devolver `Some(Msg)` consume el evento. Es la base de las
|
||||
/// áreas de scroll autocontenidas (`llimphi-widget-scroll`).
|
||||
pub fn on_scroll<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
self.on_scroll = Some(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Marca este nodo como enfocable con el id opaco `id`. El runtime lo
|
||||
/// incluye en el orden de Tab (pre-orden del árbol) y le da foco al
|
||||
/// clickearlo; cada cambio de foco se notifica vía `App::on_focus`.
|
||||
/// El caller pinta el focus-ring comparando el id contra el foco que
|
||||
/// guardó en su `Model`.
|
||||
pub fn focusable(mut self, id: u64) -> Self {
|
||||
self.focusable = Some(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Aplica una transformación afín 2D a este nodo y todo su subtree,
|
||||
/// **alrededor del centro de su rect** (CSS `transform-origin: 50%
|
||||
/// 50%`). El centro se resuelve en `paint` contra el layout computado;
|
||||
/// el caller sólo provee el afín "local" (producto de sus
|
||||
/// `rotate`/`scale`/`translate`). Nodos anidados componen en el
|
||||
/// espacio ya transformado del padre. Pensado para `transform` y
|
||||
/// `@keyframes` CSS de puriy. `Affine::IDENTITY` equivale a no setear.
|
||||
pub fn transform(mut self, xf: Affine) -> Self {
|
||||
self.transform = Some(xf);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn fill(mut self, color: Color) -> Self {
|
||||
self.fill = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Opacidad uniforme aplicada a este nodo y todos sus descendientes
|
||||
/// vía `scene.push_layer(Mix::Normal, a, …)`. Pensado para fade-in/out
|
||||
/// de overlays, toasts y modales sin tener que tunear el alpha de
|
||||
/// cada color del subtree. Valores fuera de `[0.0, 1.0]` se clampean.
|
||||
/// Hace que el subtree se componga en una capa intermedia — usar sólo
|
||||
/// cuando sea necesario (no es gratuito).
|
||||
pub fn alpha(mut self, a: f32) -> Self {
|
||||
self.alpha = Some(a.clamp(0.0, 1.0));
|
||||
self
|
||||
}
|
||||
|
||||
/// Color a usar cuando el cursor está sobre este nodo. Habilita
|
||||
/// el hit-test de hover sobre el nodo.
|
||||
pub fn hover_fill(mut self, color: Color) -> Self {
|
||||
self.hover_fill = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Marca este nodo como draggable. Mientras el usuario sostenga el
|
||||
/// botón izquierdo sobre él, el runtime llama `handler(Move, dx, dy)`
|
||||
/// por cada `CursorMoved` (dx/dy = delta desde el evento anterior) y
|
||||
/// `handler(End, 0, 0)` al soltar. Sobreescribe `on_click` para este
|
||||
/// nodo: un nodo es draggable **o** clickable.
|
||||
pub fn draggable<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
self.drag = Some(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Como `draggable`, pero el handler también recibe la posición
|
||||
/// inicial del press relativa al rect del nodo `(initial_lx,
|
||||
/// initial_ly)`. Útil cuando el caller necesita resolver qué
|
||||
/// entidad bajo el cursor inició el drag (Conceptos, lemmings,
|
||||
/// nodos de un grafo, etc.). Gana sobre `draggable` si ambos están.
|
||||
pub fn draggable_at<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
self.drag_at = Some(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Declara el payload `u64` que viaja con el drag de este nodo. Los
|
||||
/// drop targets bajo cursor al soltar reciben este valor en su
|
||||
/// `on_drop`. Sin payload, los drop targets no reaccionan (útil para
|
||||
/// drags de "resize/scroll" que no representan transferencia).
|
||||
pub fn drag_payload(mut self, payload: u64) -> Self {
|
||||
self.drag_payload = Some(payload);
|
||||
self
|
||||
}
|
||||
|
||||
/// Marca este nodo como drop target. El runtime invoca `handler(payload)`
|
||||
/// cuando un drag termina sobre el rect de este nodo y el origen del
|
||||
/// drag declaró un payload. Si devuelve `Some(Msg)`, se dispatchea al
|
||||
/// `update` antes del `DragPhase::End` del origen.
|
||||
pub fn on_drop<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
self.on_drop = Some(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Color de relleno cuando un drag activo está hovereando este drop
|
||||
/// target. Análogo a `hover_fill` pero solo aplica mientras dura un
|
||||
/// drag. Útil para resaltar el destino válido.
|
||||
pub fn drop_hover_fill(mut self, color: Color) -> Self {
|
||||
self.drop_hover_fill = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn radius(mut self, r: f64) -> Self {
|
||||
self.radius = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text(mut self, content: impl Into<String>, size_px: f32, color: Color) -> Self {
|
||||
self.text = Some(TextSpec {
|
||||
content: content.into(),
|
||||
size_px,
|
||||
color,
|
||||
alignment: llimphi_text::Alignment::Center,
|
||||
italic: false,
|
||||
font_family: None,
|
||||
line_height: 1.2,
|
||||
runs: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text_aligned(
|
||||
mut self,
|
||||
content: impl Into<String>,
|
||||
size_px: f32,
|
||||
color: Color,
|
||||
alignment: llimphi_text::Alignment,
|
||||
) -> Self {
|
||||
self.text = Some(TextSpec {
|
||||
content: content.into(),
|
||||
size_px,
|
||||
color,
|
||||
alignment,
|
||||
italic: false,
|
||||
font_family: None,
|
||||
line_height: 1.2,
|
||||
runs: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Como `text_aligned` pero con un flag `italic`. Si la fuente activa
|
||||
/// no tiene variante italic, parley aplica synthesizing.
|
||||
pub fn text_aligned_italic(
|
||||
mut self,
|
||||
content: impl Into<String>,
|
||||
size_px: f32,
|
||||
color: Color,
|
||||
alignment: llimphi_text::Alignment,
|
||||
italic: bool,
|
||||
) -> Self {
|
||||
self.text = Some(TextSpec {
|
||||
content: content.into(),
|
||||
size_px,
|
||||
color,
|
||||
alignment,
|
||||
italic,
|
||||
font_family: None,
|
||||
line_height: 1.2,
|
||||
runs: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Como `text_aligned_italic` pero con font-family explícito.
|
||||
/// La cadena se pasa como `parley::FontStack::Source` (acepta listas
|
||||
/// CSS con fallbacks).
|
||||
pub fn text_aligned_full(
|
||||
mut self,
|
||||
content: impl Into<String>,
|
||||
size_px: f32,
|
||||
color: Color,
|
||||
alignment: llimphi_text::Alignment,
|
||||
italic: bool,
|
||||
font_family: Option<String>,
|
||||
) -> Self {
|
||||
self.text = Some(TextSpec {
|
||||
content: content.into(),
|
||||
size_px,
|
||||
color,
|
||||
alignment,
|
||||
italic,
|
||||
font_family,
|
||||
line_height: 1.2,
|
||||
runs: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Texto **multicolor** en una sola pasada de shaping: `content` se pinta
|
||||
/// con `default_color` y cada `(start_byte, end_byte, color)` de `runs`
|
||||
/// sobreescribe su rango (offsets en bytes). Pensado para syntax
|
||||
/// highlighting — un nodo por línea en vez de uno por token. Anclado
|
||||
/// arriba-izquierda (sin centrado vertical); el caller dimensiona el rect.
|
||||
pub fn text_runs(
|
||||
mut self,
|
||||
content: impl Into<String>,
|
||||
size_px: f32,
|
||||
default_color: Color,
|
||||
runs: Vec<(usize, usize, Color)>,
|
||||
alignment: llimphi_text::Alignment,
|
||||
) -> Self {
|
||||
self.text = Some(TextSpec {
|
||||
content: content.into(),
|
||||
size_px,
|
||||
color: default_color,
|
||||
alignment,
|
||||
italic: false,
|
||||
font_family: None,
|
||||
line_height: 1.2,
|
||||
runs: Some(runs),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Sobreescribe el múltiplo de interlínea del texto ya seteado (default
|
||||
/// 1.2). No-op si el nodo no tiene texto. Pensado para puriy, que pasa
|
||||
/// el `line-height` computado de CSS para que medición y pintado usen
|
||||
/// el mismo valor.
|
||||
pub fn line_height(mut self, mult: f32) -> Self {
|
||||
if let Some(t) = self.text.as_mut() {
|
||||
t.line_height = mult;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(mut self, msg: Msg) -> Self {
|
||||
self.on_click = Some(msg);
|
||||
self
|
||||
}
|
||||
|
||||
/// Dispatch `msg` cuando el cursor entra al rect del nodo
|
||||
/// (transición no-hover → hover). Sólo emite una vez por entrada —
|
||||
/// el runtime no repite el msg si el cursor se mueve dentro del rect.
|
||||
pub fn on_pointer_enter(mut self, msg: Msg) -> Self {
|
||||
self.on_pointer_enter = Some(msg);
|
||||
self
|
||||
}
|
||||
|
||||
/// Dispatch `msg` cuando el cursor sale del rect del nodo.
|
||||
pub fn on_pointer_leave(mut self, msg: Msg) -> Self {
|
||||
self.on_pointer_leave = Some(msg);
|
||||
self
|
||||
}
|
||||
|
||||
/// Como `on_click`, pero el handler recibe `(local_x, local_y,
|
||||
/// rect_w, rect_h)` — la posición del cursor relativa al rect del
|
||||
/// nodo más las dimensiones actuales del nodo. Útil para canvas
|
||||
/// elements que necesitan saber dónde fue el click para convertirlo
|
||||
/// a coordenadas de mundo. Sobrescribe `on_click` para este nodo
|
||||
/// si ambos están presentes.
|
||||
pub fn on_click_at<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
self.on_click_at = Some(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Declara el `Msg` a emitir cuando el usuario hace click derecho
|
||||
/// sobre este nodo. Para menús contextuales, conviene pasar un
|
||||
/// `Msg::OpenMenu { ... }` y dejar que el modelo guarde la
|
||||
/// posición; el overlay se abre vía [`App::view_overlay`].
|
||||
pub fn on_right_click(mut self, msg: Msg) -> Self {
|
||||
self.on_right_click = Some(msg);
|
||||
self
|
||||
}
|
||||
|
||||
/// Variante posicional de [`Self::on_right_click`]. El handler recibe
|
||||
/// `(local_x, local_y, rect_w, rect_h)` para que un nodo "grilla"
|
||||
/// pueda resolver internamente qué subcelda recibió el click. La
|
||||
/// posición está relativa al rect del nodo.
|
||||
pub fn on_right_click_at<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
self.on_right_click_at = Some(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Declara el `Msg` a emitir cuando el usuario hace click con el
|
||||
/// botón del medio (rueda presionada). Usado típicamente para abrir
|
||||
/// links en pestaña nueva — igual que Ctrl+Click pero más rápido.
|
||||
pub fn on_middle_click(mut self, msg: Msg) -> Self {
|
||||
self.on_middle_click = Some(msg);
|
||||
self
|
||||
}
|
||||
|
||||
/// Pinta `image` dentro del rect del nodo, centrada y escalada
|
||||
/// preservando aspect ratio. Re-exporta `peniko::Image` vía
|
||||
/// `llimphi_raster::peniko::Image` — el caller decodifica los
|
||||
/// bytes con el crate `image` (u otro) y construye el `Image`
|
||||
/// con `Blob<u8>` + `ImageFormat::Rgba8`.
|
||||
pub fn image(mut self, image: Image) -> Self {
|
||||
self.image = Some(image);
|
||||
self
|
||||
}
|
||||
|
||||
/// Registra una closure de pintura custom. El runtime la invoca
|
||||
/// con `(&mut vello::Scene, &mut Typesetter, PaintRect)` durante
|
||||
/// el paint del nodo. La closure es responsable de pintar
|
||||
/// primitivas custom dentro del rect; no debe dejar `push_layer`
|
||||
/// sin par. Soporte para canvas elements estilo
|
||||
/// dominium/pluma/cosmos.
|
||||
pub fn paint_with<F>(mut self, painter: F) -> Self
|
||||
where
|
||||
F: Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect)
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
{
|
||||
self.painter = Some(Arc::new(painter));
|
||||
self
|
||||
}
|
||||
|
||||
/// Registra una closure de pintura GPU directo. La closure recibe
|
||||
/// `(&Device, &Queue, &mut CommandEncoder, &TextureView, PaintRect, (viewport_w, viewport_h))`
|
||||
/// y debe escribir sobre el `TextureView` con `LoadOp::Load` (no
|
||||
/// clear) para preservar la pasada vello previa. El último
|
||||
/// argumento es el tamaño en pixels de la `TextureView` destino
|
||||
/// (la intermedia del frame) — necesario para calcular NDC sin
|
||||
/// asumir un viewport fijo. Ver [`GpuPaintFn`] para semántica
|
||||
/// completa, contexto y orden de pintura.
|
||||
pub fn gpu_paint_with<F>(mut self, painter: F) -> Self
|
||||
where
|
||||
F: Fn(
|
||||
&wgpu::Device,
|
||||
&wgpu::Queue,
|
||||
&mut wgpu::CommandEncoder,
|
||||
&wgpu::TextureView,
|
||||
PaintRect,
|
||||
(u32, u32),
|
||||
) + Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
{
|
||||
self.gpu_painter = Some(Arc::new(painter));
|
||||
self
|
||||
}
|
||||
|
||||
/// Recorta los hijos al rect de este nodo (paint y hit-test). Útil
|
||||
/// para paneles con contenido virtualizado que no debe sangrar a
|
||||
/// vecinos (listas, scrollers, viewers).
|
||||
pub fn clip(mut self, enabled: bool) -> Self {
|
||||
self.clip = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: Vec<View<Msg>>) -> Self {
|
||||
self.children = children;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Verifica que un párrafo largo, dentro de un bloque angosto, reserva el
|
||||
//! alto de **varias líneas** (no se aplasta en una). Es el regresor del bug
|
||||
//! "textos aplastados" de puriy: sin medición con parley, taffy le daba a la
|
||||
//! hoja de texto una sola línea de alto y las líneas envueltas se solapaban.
|
||||
|
||||
use llimphi_compositor::{measure_text_node, mount, View};
|
||||
use llimphi_layout::taffy::prelude::*;
|
||||
use llimphi_layout::taffy::Size as TSize;
|
||||
use llimphi_layout::LayoutTree;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Msg {}
|
||||
|
||||
#[test]
|
||||
fn parrafo_largo_reserva_varias_lineas() {
|
||||
// Bloque de 200px de ancho con un párrafo que claramente excede una línea.
|
||||
let texto = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do \
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua ut \
|
||||
enim ad minim veniam quis nostrud exercitation ullamco laboris.";
|
||||
let block: View<Msg> = View::new(Style {
|
||||
size: TSize { width: length(200.0_f32), height: auto() },
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![View::new(Style {
|
||||
size: TSize { width: auto(), height: auto() },
|
||||
flex_shrink: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(texto, 16.0_f32, vello::peniko::Color::BLACK, llimphi_text::Alignment::Start)]);
|
||||
|
||||
let mut layout = LayoutTree::new();
|
||||
let mounted = mount(&mut layout, block);
|
||||
let mut ts = llimphi_text::Typesetter::new();
|
||||
let tmap = &mounted.text_measures;
|
||||
assert_eq!(tmap.len(), 1, "debería haber exactamente una hoja de texto");
|
||||
|
||||
let computed = layout
|
||||
.compute_with_measure(mounted.root, (800.0, 600.0), |nid, known, avail| match tmap.get(&nid)
|
||||
{
|
||||
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
|
||||
None => TSize::ZERO,
|
||||
})
|
||||
.expect("layout");
|
||||
|
||||
// El nodo de texto es el segundo en orden DFS (root, luego la hoja).
|
||||
let leaf_id = mounted.nodes[1].id;
|
||||
let rect = computed.get(leaf_id).expect("rect de la hoja");
|
||||
// A 16px y ~1.2 de interlínea, una línea ≈ 19px. Con ~150px de texto en
|
||||
// 200px de ancho deberían ser >= 4 líneas → bastante más de una.
|
||||
assert!(
|
||||
rect.h > 40.0,
|
||||
"el párrafo se aplastó: alto={} (esperaba varias líneas)",
|
||||
rect.h
|
||||
);
|
||||
assert!(rect.w <= 200.0 + 1.0, "no debería exceder el ancho del bloque");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_mayor_reserva_mas_alto() {
|
||||
let texto = "una línea de texto que envuelve en dos o tres renglones según \
|
||||
el ancho disponible para el bloque contenedor angosto";
|
||||
let medir = |lh: f32| -> f32 {
|
||||
let mut ts = llimphi_text::Typesetter::new();
|
||||
let tm = llimphi_compositor::TextMeasure {
|
||||
content: texto.to_string(),
|
||||
size_px: 16.0,
|
||||
alignment: llimphi_text::Alignment::Start,
|
||||
italic: false,
|
||||
font_family: None,
|
||||
line_height: lh,
|
||||
};
|
||||
let known = TSize { width: Some(180.0_f32), height: None };
|
||||
let avail = TSize {
|
||||
width: AvailableSpace::Definite(180.0),
|
||||
height: AvailableSpace::MaxContent,
|
||||
};
|
||||
measure_text_node(&mut ts, &tm, known, avail).height
|
||||
};
|
||||
let compacto = medir(1.0);
|
||||
let comodo = medir(2.0);
|
||||
assert!(
|
||||
comodo > compacto * 1.5,
|
||||
"line-height: 2 debería reservar bastante más alto que 1.0 (got {compacto} vs {comodo})"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user