ccab39f140
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la estructura vieja de eventloop) y suma el 3D: - bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 / accesskit_winit 0.33 / vello_hybrid 0.0.9. - nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido, montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox). - README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif). - excluido modules/allichay (arrastra deps fuera del alcance del front-door). - cargo check --workspace: verde. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1754 lines
71 KiB
Rust
1754 lines
71 KiB
Rust
use super::*;
|
||
|
||
impl<Msg> View<Msg> {
|
||
pub fn new(style: Style) -> Self {
|
||
Self {
|
||
style,
|
||
fill: None,
|
||
hover_fill: None,
|
||
radius: 0.0,
|
||
corner_radii: None,
|
||
shadow: None,
|
||
fill_gradient: None,
|
||
border: None,
|
||
text: None,
|
||
image: None,
|
||
image_fit: None,
|
||
mask_image: None,
|
||
mask_placement: None,
|
||
mask_extra: Vec::new(),
|
||
painter: None,
|
||
gpu_painter: None,
|
||
over_painter: None,
|
||
on_pointer_enter: None,
|
||
on_pointer_leave: None,
|
||
on_pointer_move_at: 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_velocity: None,
|
||
drag_payload: None,
|
||
on_drop: None,
|
||
drop_hover_fill: None,
|
||
clip: false,
|
||
clip_inset: None,
|
||
clip_ellipse: None,
|
||
clip_polygon: None,
|
||
clip_path_svg: None,
|
||
clip_ref_inset: None,
|
||
on_scroll: None,
|
||
on_scale: None,
|
||
on_rotate: None,
|
||
on_double_tap: None,
|
||
on_double_tap_at: None,
|
||
on_long_press: None,
|
||
on_long_press_at: None,
|
||
focusable: None,
|
||
text_select_key: None,
|
||
alpha: None,
|
||
anim: None,
|
||
animated_size: None,
|
||
semantics: None,
|
||
hero: None,
|
||
transform: None,
|
||
transform_rel: None,
|
||
transform_origin: None,
|
||
tooltip: None,
|
||
cursor: None,
|
||
ripple: None,
|
||
layout_builder: None,
|
||
backdrop_blur: None,
|
||
filter: Vec::new(),
|
||
blend: None,
|
||
children: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Aplica un **backdrop blur** Gaussiano al contenido pintado **debajo**
|
||
/// de este nodo, restringido al rect del nodo (CSS `backdrop-filter:
|
||
/// blur(N)` / Flutter `BackdropFilter`). El runtime descompone el árbol
|
||
/// en "fondo + subárbol del nodo", renderiza el fondo a la intermediate,
|
||
/// borronea el rect con un Gauss separable, y compone el subárbol del
|
||
/// nodo sobre el backdrop borroso vía un buffer secundario. Útil para
|
||
/// chrome translúcido: sidebars/topbars con "vidrio esmerilado".
|
||
///
|
||
/// `sigma` (pixels) controla el ancho del kernel — `4.0` "frosted glass"
|
||
/// suave; `8.0`–`16.0` un blur fuerte; >`20` se ve apagado. v1 capa el
|
||
/// radius efectivo a 32 pixels (sigma > 10 empieza a clipear cola).
|
||
///
|
||
/// **Limitación v1**: sólo nodos top-level (children directos del root o
|
||
/// de un agrupador sin `clip`/`alpha`) renderizan correctamente — un
|
||
/// nodo dentro de una capa clippeada se pinta SIN clip en su pase, por
|
||
/// el reset de layer-stack al cambiar de scene. Documentado en
|
||
/// `PARIDAD-FLUTTER.md` Bloque 11.
|
||
pub fn backdrop_blur(mut self, sigma: f32) -> Self {
|
||
self.backdrop_blur = Some(sigma.max(0.0));
|
||
self
|
||
}
|
||
|
||
/// Aplica una lista de **filtros CSS** (`filter: …`) al **propio subárbol**
|
||
/// del nodo (a diferencia de [`Self::backdrop_blur`], que afecta lo pintado
|
||
/// *debajo*). El runtime los recolecta con [`collect_filters`] y los aplica
|
||
/// como post-pasada GPU sobre la intermediate, restringidos al rect del
|
||
/// nodo, en el orden de la lista. Hoy sólo se modela `blur` ([`FilterOp`]);
|
||
/// la lista crece por fase. Es **ortogonal** a clip/mask (un nodo puede
|
||
/// llevar todos). Lista vacía = sin filtro. Fase 7.1232.
|
||
///
|
||
/// **Limitación v1** (igual que `backdrop_blur`): la post-pasada opera sobre
|
||
/// los píxeles finales del rect, así que no aísla el subárbol del fondo que
|
||
/// asome detrás. Adecuado para nodos opacos.
|
||
pub fn filter(mut self, ops: Vec<FilterOp>) -> Self {
|
||
self.filter = ops;
|
||
self
|
||
}
|
||
|
||
/// Mezcla el **nodo entero** (su subárbol) contra su backdrop con el modo
|
||
/// `bm` (CSS `mix-blend-mode`). El runtime abre una capa aislada
|
||
/// (`push_layer(bm, …)`) alrededor del rect del nodo que envuelve fill +
|
||
/// contenido + hijos; al cerrarla, el subárbol se compone contra todo lo
|
||
/// pintado antes en el stacking context según `bm` (p. ej. `Mix::Multiply`).
|
||
/// Es **ortogonal** a clip/mask/filter/alpha (un nodo puede llevar todos).
|
||
/// Fase 7.1237.
|
||
///
|
||
/// **Limitación v1** (igual que mask/filter): el backdrop es lo que ya está
|
||
/// pintado en la escena, no un fondo aislado del subárbol — exacto cuando
|
||
/// debajo hay contenido opaco, aproximado si la capa de abajo es el padre.
|
||
pub fn blend(mut self, bm: BlendMode) -> Self {
|
||
self.blend = Some(bm);
|
||
self
|
||
}
|
||
|
||
/// Construye los hijos de este nodo **de forma diferida**, en función del
|
||
/// tamaño del slot que el layout le asigne (Flutter `LayoutBuilder`). El
|
||
/// runtime resuelve primero el rect del nodo (una pasada de layout con este
|
||
/// nodo como hoja, sized por su `Style`/contexto flex) y recién entonces
|
||
/// invoca `builder(Constraints)` para producir el subárbol — habilitando
|
||
/// paneles responsive cuyo punto de quiebre depende del **espacio local**,
|
||
/// no de la ventana (para eso alcanza `on_resize` + el Model).
|
||
///
|
||
/// El `Style` de este nodo define su tamaño (debe quedar acotado por el
|
||
/// contexto: `flex_grow`, `size` definido o `percent` — no intrínseco a los
|
||
/// hijos, que aún no existen). Cualquier `children` estático que se haya
|
||
/// seteado se ignora: el builder es la fuente de los hijos.
|
||
///
|
||
/// **Límite v1**: sin anidamiento — un `layout_builder` dentro del subárbol
|
||
/// que produce otro `layout_builder` no se resuelve (queda como hoja).
|
||
pub fn layout_builder<F>(mut self, builder: F) -> Self
|
||
where
|
||
F: Fn(Constraints) -> View<Msg> + Send + Sync + 'static,
|
||
{
|
||
self.layout_builder = Some(Arc::new(builder));
|
||
self
|
||
}
|
||
|
||
/// Marca este nodo para emitir un **ripple/InkWell** (la salpicadura de tap
|
||
/// de Material) al recibir un press: un círculo que se expande desde el
|
||
/// punto presionado y se desvanece, recortado al contorno del nodo. `key`
|
||
/// debe ser **estable** entre rebuilds del `View` (índice/hash del item),
|
||
/// igual que la key de [`Self::animated`]. `color` es el tinte de la onda —
|
||
/// usá un color semitransparente (blanco a alpha ~0.25 sobre superficies
|
||
/// oscuras, negro a alpha ~0.12 sobre claras); su alpha se atenúa con el
|
||
/// fade. Es **aditivo**: convive con `on_click`/`drag` sin pisarlos. Duración
|
||
/// por defecto 450 ms; para otra usar [`Self::ripple_styled`].
|
||
pub fn ripple(self, key: u64, color: Color) -> Self {
|
||
self.ripple_styled(key, color, std::time::Duration::from_millis(450))
|
||
}
|
||
|
||
/// Como [`Self::ripple`] pero con la duración explícita de la salpicadura.
|
||
pub fn ripple_styled(
|
||
mut self,
|
||
key: u64,
|
||
color: Color,
|
||
duration: std::time::Duration,
|
||
) -> Self {
|
||
self.ripple = Some(Ripple { key, color, duration });
|
||
self
|
||
}
|
||
|
||
/// Fija la forma del puntero del mouse mientras el cursor está sobre este
|
||
/// nodo (o un descendiente que no declare la suya — se hereda del ancestro
|
||
/// más cercano que la tenga). El runtime la resuelve en el hit-test de hover
|
||
/// y la aplica a la ventana. Ejemplos: `.cursor(Cursor::Text)` en un input,
|
||
/// `.cursor(Cursor::ColResize)` en un divisor de splitter,
|
||
/// `.cursor(Cursor::Pointer)` en un botón.
|
||
pub fn cursor(mut self, cursor: Cursor) -> Self {
|
||
self.cursor = Some(cursor);
|
||
self
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Declara la **semántica accesible** completa del nodo de una vez. Usar
|
||
/// cuando ya tenés un [`SemanticsSpec`] armado (p. ej. construido por un
|
||
/// widget); para los casos puntuales preferí los atajos
|
||
/// [`Self::role`]/[`Self::aria_label`]/etc.
|
||
pub fn semantics(mut self, spec: SemanticsSpec) -> Self {
|
||
self.semantics = Some(spec);
|
||
self
|
||
}
|
||
|
||
/// Fija el **rol** semántico del nodo. Si ya había semántica declarada,
|
||
/// preserva label/value/flags y sólo sobreescribe el rol; si no, crea una
|
||
/// `SemanticsSpec` con sólo el rol.
|
||
pub fn role(mut self, role: Role) -> Self {
|
||
self.semantics = Some(match self.semantics.take() {
|
||
Some(mut s) => {
|
||
s.role = Some(role);
|
||
s
|
||
}
|
||
None => SemanticsSpec::role(role),
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Fija el **label accesible** ("nombre" que el lector enuncia). Hace falta
|
||
/// cuando el contenido visible del nodo no alcanza (p. ej. un botón con
|
||
/// sólo un ícono). Preserva el resto de la `SemanticsSpec` si existía.
|
||
pub fn aria_label(mut self, label: impl Into<std::sync::Arc<str>>) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.label = Some(label.into());
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Fija la **descripción** (contexto adicional que el lector enuncia tras
|
||
/// el label, típicamente con un atajo). Para info que ayuda pero no es el
|
||
/// nombre principal — no abusar (los lectores perciben ruido).
|
||
pub fn aria_description(mut self, desc: impl Into<std::sync::Arc<str>>) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.description = Some(desc.into());
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Fija el **valor** (texto del input, valor del slider/spinner). Lo que
|
||
/// el lector lee después del label: "Volumen, 70".
|
||
pub fn aria_value(mut self, value: impl Into<std::sync::Arc<str>>) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.value = Some(value.into());
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Estado `checked` (checkbox/radio).
|
||
pub fn aria_checked(mut self, v: bool) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.flags.checked = Some(v);
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Estado `pressed` (toggle button).
|
||
pub fn aria_pressed(mut self, v: bool) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.flags.pressed = Some(v);
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Estado `expanded` (acordeón, menú abierto, tree row expandida).
|
||
pub fn aria_expanded(mut self, v: bool) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.flags.expanded = Some(v);
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Estado `disabled` — el control no responde a input.
|
||
pub fn aria_disabled(mut self, v: bool) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.flags.disabled = Some(v);
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Estado `readonly` — el control es visible/seleccionable pero no editable.
|
||
pub fn aria_readonly(mut self, v: bool) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.flags.readonly = Some(v);
|
||
self.semantics = Some(s);
|
||
self
|
||
}
|
||
|
||
/// Estado `required` (campo de formulario obligatorio).
|
||
pub fn aria_required(mut self, v: bool) -> Self {
|
||
let mut s = self.semantics.take().unwrap_or_default();
|
||
s.flags.required = Some(v);
|
||
self.semantics = Some(s);
|
||
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
|
||
}
|
||
|
||
/// Registra un handler de **pinch-to-zoom** (gesto de escala). El runtime
|
||
/// lo invoca cuando el cursor está sobre este nodo y el usuario hace un
|
||
/// gesto de escala: **Ctrl + rueda** en cualquier desktop (camino
|
||
/// universal) o un pinch de trackpad en macOS. El handler recibe
|
||
/// `(phase, factor, focal_x, focal_y)` — ver [`ScaleFn`]: `factor` es el
|
||
/// cambio multiplicativo incremental (`>1` agranda, `<1` achica) y
|
||
/// `(focal_x, focal_y)` es el punto bajo el cursor relativo al rect del
|
||
/// nodo, para zoomear "hacia el cursor". El típico patrón de canvas:
|
||
/// `Msg::Zoom { factor, fx, fy }` que multiplica la escala del viewport y
|
||
/// reajusta el pan para mantener el punto focal fijo. Devolver `Some(Msg)`
|
||
/// consume el gesto (no cae al scroll/`on_wheel`).
|
||
pub fn on_scale<F>(mut self, handler: F) -> Self
|
||
where
|
||
F: Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||
{
|
||
self.on_scale = Some(Arc::new(handler));
|
||
self
|
||
}
|
||
|
||
/// Registra un handler de **rotación con dos dedos** (gesto de trackpad).
|
||
/// El runtime lo invoca cuando el cursor está sobre este nodo y el usuario
|
||
/// rota dos dedos en el trackpad (winit emite `RotationGesture` **sólo en
|
||
/// macOS**). El handler recibe `(phase, delta_radianes, focal_x, focal_y)`
|
||
/// — ver [`RotateFn`]: `delta_radianes` es el incremento angular (positivo
|
||
/// = horario) y `(focal_x, focal_y)` el punto bajo el cursor relativo al
|
||
/// rect del nodo, para rotar "alrededor del cursor". Patrón típico de
|
||
/// canvas/imagen: `Msg::Rotate { delta, fx, fy }` que acumula el ángulo del
|
||
/// viewport. Devolver `Some(Msg)` consume el gesto.
|
||
pub fn on_rotate<F>(mut self, handler: F) -> Self
|
||
where
|
||
F: Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||
{
|
||
self.on_rotate = Some(Arc::new(handler));
|
||
self
|
||
}
|
||
|
||
/// Emite `msg` en **doble-tap** (dos clicks izquierdos rápidos y cercanos
|
||
/// sobre este nodo). Aditivo respecto de `on_click`. Ver
|
||
/// [`Self::on_double_tap`](#structfield.on_double_tap) (campo) para la
|
||
/// semántica completa; para la posición del tap usar
|
||
/// [`Self::on_double_tap_at`].
|
||
pub fn on_double_tap(mut self, msg: Msg) -> Self {
|
||
self.on_double_tap = Some(msg);
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::on_double_tap`] pero el handler recibe la posición del
|
||
/// segundo tap relativa al rect del nodo `(lx, ly, w, h)` — para
|
||
/// zoom-to-point o seleccionar la entidad bajo el cursor. Gana sobre
|
||
/// `on_double_tap` si ambos están.
|
||
pub fn on_double_tap_at<F>(mut self, handler: F) -> Self
|
||
where
|
||
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||
{
|
||
self.on_double_tap_at = Some(Arc::new(handler));
|
||
self
|
||
}
|
||
|
||
/// Emite `msg` en **long-press** (mantener el botón ~500 ms sin moverse).
|
||
/// El runtime lo cancela si el cursor se aleja (pasó a drag) o se suelta
|
||
/// antes. Aditivo respecto de `on_click`/`drag`. Ver
|
||
/// [`Self::on_long_press`](#structfield.on_long_press) (campo); para la
|
||
/// posición usar [`Self::on_long_press_at`].
|
||
pub fn on_long_press(mut self, msg: Msg) -> Self {
|
||
self.on_long_press = Some(msg);
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::on_long_press`] pero el handler recibe la posición del
|
||
/// press relativa al rect del nodo `(lx, ly, w, h)` — para abrir un menú
|
||
/// contextual en el punto. Gana sobre `on_long_press` si ambos están.
|
||
pub fn on_long_press_at<F>(mut self, handler: F) -> Self
|
||
where
|
||
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||
{
|
||
self.on_long_press_at = 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
|
||
}
|
||
|
||
/// Marca este nodo de **texto** como seleccionable con el mouse fuera del
|
||
/// editor: arrastrar sobre él resalta el rango y Ctrl/Cmd+C lo copia al
|
||
/// portapapeles. `key` debe ser **estable** entre rebuilds del `View`
|
||
/// (índice, hash del id) — la selección vive en el runtime anclada a esa
|
||
/// key, no al `NodeId` (que cambia cada frame). Pensá en labels, párrafos,
|
||
/// celdas de tabla, salidas de consola: cualquier texto que el usuario
|
||
/// querría copiar sin un editor. Sólo aplica a texto **uniforme** (el de
|
||
/// `.text(...)`/`.text_aligned(...)`); en nodos con `runs`/`spans` no tiene
|
||
/// efecto (esos son del editor / RichText). Componer con el texto:
|
||
/// `View::new(style).text_aligned(s, 14.0, col, al).selectable(key)`.
|
||
pub fn selectable(mut self, key: u64) -> Self {
|
||
self.text_select_key = Some(key);
|
||
self
|
||
}
|
||
|
||
/// Marca este nodo como **hero shared-element** con la `key` indicada.
|
||
/// Cuando la misma `key` aparece en un rect distinto en el frame siguiente
|
||
/// (entre rutas, paneles, layouts), el runtime interpola `transform` para
|
||
/// "volar" del rect anterior al actual durante `duration`. La `key` debe
|
||
/// ser estable y única dentro del frame; idéntica semántica a la `key`
|
||
/// de [`Self::animated`]. Para easing distinto al ease-out cúbico default,
|
||
/// ver [`Self::hero_curve`].
|
||
pub fn hero(mut self, key: u64, duration: std::time::Duration) -> Self {
|
||
self.hero = Some(Hero {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::hero`] pero con easing explícito.
|
||
pub fn hero_curve(
|
||
mut self,
|
||
key: u64,
|
||
duration: std::time::Duration,
|
||
easing: fn(f32) -> f32,
|
||
) -> Self {
|
||
self.hero = Some(Hero { key, duration, easing });
|
||
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
|
||
}
|
||
|
||
/// Traslación relativa al tamaño del propio nodo: `(fx, fy)` desplaza
|
||
/// `(fx · w, fy · h)` px, resueltos contra el rect computado en `paint`.
|
||
/// Es el `translate(<%>)` de CSS que no cabe en un `Affine` fijo (p. ej.
|
||
/// el centrado `translate(-50%, -50%)` ⇒ `transform_rel((-0.5, -0.5))`).
|
||
/// Compone con `transform` (si está) como factor más externo. Ver
|
||
/// [`View::transform`]. `(0.0, 0.0)` equivale a no setear.
|
||
pub fn transform_rel(mut self, frac: (f64, f64)) -> Self {
|
||
self.transform_rel = Some(frac);
|
||
self
|
||
}
|
||
|
||
/// Punto de pivote de `transform` (CSS `transform-origin`). Sin setear ⇒
|
||
/// centro del rect (`50% 50%`). Ver [`TransformPivot`]. Sólo tiene efecto
|
||
/// junto con `transform`/`transform_rel`.
|
||
pub fn transform_origin(mut self, pivot: crate::TransformPivot) -> Self {
|
||
self.transform_origin = Some(pivot);
|
||
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
|
||
}
|
||
|
||
/// Anima de forma **implícita** las props de paint de este nodo
|
||
/// (hoy `fill` y `radius`): cuando su valor cambia entre frames, el
|
||
/// runtime interpola en `duration` con ease-out cúbico en vez de saltar
|
||
/// (estilo Flutter `AnimatedContainer`). `key` debe ser **estable** entre
|
||
/// rebuilds del `View` (índice de item, hash de id) — es lo que enlaza
|
||
/// "el mismo nodo" entre frames; dos nodos distintos no deben compartir
|
||
/// key. La primera aparición no anima; sólo los cambios posteriores. Para
|
||
/// otra curva, [`Self::animated_curve`].
|
||
pub fn animated(mut self, key: u64, duration: std::time::Duration) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
enter: false,
|
||
exit: false,
|
||
enter_from_xf: None,
|
||
switch: None,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Anima de forma **implícita** el **tamaño** de este nodo (Flutter
|
||
/// `AnimatedSize` / Compose `animateContentSize()`). Cuando
|
||
/// `style.size` cambia entre frames, el runtime interpola en
|
||
/// `duration` con ease-out cúbico en vez de saltar — siblings y
|
||
/// hijos reflowean suave porque el reconciler parcha `style.size`
|
||
/// **antes** del layout. `key` debe ser estable entre rebuilds.
|
||
/// Para otra curva, [`Self::animated_size_curve`]. Bloque 15.
|
||
///
|
||
/// **Límite v1**: ambos `style.size.width` y `style.size.height`
|
||
/// tienen que ser `Dimension::Length(_)`. Si una es `Percent`/`Auto`,
|
||
/// el nodo se monta tal cual sin animación (no hay valor en píxeles
|
||
/// estable para interpolar). El caller que necesite animar un nodo
|
||
/// flex puede envolver el contenido en un wrap con `length(...)`
|
||
/// fijo y mover el flex al padre.
|
||
pub fn animated_size(mut self, key: u64, duration: std::time::Duration) -> Self {
|
||
self.animated_size = Some(SizeAnim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::animated_size`] pero con curva de easing custom.
|
||
pub fn animated_size_curve(
|
||
mut self,
|
||
key: u64,
|
||
duration: std::time::Duration,
|
||
easing: fn(f32) -> f32,
|
||
) -> Self {
|
||
self.animated_size = Some(SizeAnim { key, duration, easing });
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::animated`] pero además **anima la entrada**: la primera vez
|
||
/// que esta `key` aparece, su opacidad sube de 0 a su valor (`alpha` o 1.0)
|
||
/// en `duration` — fade-in estilo `AnimatedSwitcher`/`AnimatedVisibility`.
|
||
/// Útil para toasts, items de lista que aparecen, paneles que se montan,
|
||
/// resultados que entran. Como toda animación implícita, depende de una
|
||
/// `key` estable; reutilizar la key de un nodo que ya estaba NO refadea
|
||
/// (sólo la primera aparición anima). Para animar también la salida, ver
|
||
/// [`Self::animated_inout`].
|
||
pub fn animated_enter(mut self, key: u64, duration: std::time::Duration) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
enter: true,
|
||
exit: false,
|
||
enter_from_xf: None,
|
||
switch: None,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::animated_enter`] pero además arranca la entrada desde una
|
||
/// **transformación afín** específica hacia la del nodo (o la identidad si
|
||
/// no setea `.transform`). Habilita scale-in / slide-in / rotate-in
|
||
/// implícitos: el caller declara la pose inicial, el runtime interpola.
|
||
/// Ejemplos:
|
||
/// - `Affine::scale(0.6)` → "pop" (FAB de Material, modales).
|
||
/// - `Affine::translate((0.0, 60.0))` → slide-in vertical (snackbars).
|
||
/// - `Affine::translate((-w, 0.0))` → slide-in lateral (drawers).
|
||
/// Combina con el fade-in de entrada (`alpha 0 → opaque`). Sin animación
|
||
/// de salida; para entrada+salida con pose, ver
|
||
/// [`Self::animated_inout_from`].
|
||
pub fn animated_enter_from(
|
||
mut self,
|
||
key: u64,
|
||
duration: std::time::Duration,
|
||
from_xf: Affine,
|
||
) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
enter: true,
|
||
exit: false,
|
||
enter_from_xf: Some(from_xf),
|
||
switch: None,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Atajo Material: scale-in desde 0.6 (entrada "pop" del FAB). Combina con
|
||
/// fade-in. Equivalente a `.animated_enter_from(key, dur, Affine::scale(0.6))`.
|
||
pub fn animated_pop_in(self, key: u64, duration: std::time::Duration) -> Self {
|
||
self.animated_enter_from(key, duration, Affine::scale(0.6))
|
||
}
|
||
|
||
/// **Anima la salida** (fade-out): cuando esta `key` desaparece del árbol,
|
||
/// el runtime retiene la última subescena que pintó y la reproduce con
|
||
/// opacidad decreciente durante `duration` — estilo `AnimatedSwitcher` /
|
||
/// `AnimatedVisibility` al ocultarse. No anima la entrada (para ambas, ver
|
||
/// [`Self::animated_inout`]). Tiene coste por frame mientras el nodo vive
|
||
/// (captura su subárbol); usar con moderación (toasts, modales, paneles).
|
||
pub fn animated_exit(mut self, key: u64, duration: std::time::Duration) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
enter: false,
|
||
exit: true,
|
||
enter_from_xf: None,
|
||
switch: None,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Anima **entrada y salida**: fade-in en la primera aparición y fade-out al
|
||
/// desmontarse, ambos en `duration`. La pieza completa de "animación de
|
||
/// contenido" para un nodo que aparece y desaparece (un toast, un panel que
|
||
/// se abre y cierra, un resultado que entra y se va).
|
||
pub fn animated_inout(mut self, key: u64, duration: std::time::Duration) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
enter: true,
|
||
exit: true,
|
||
enter_from_xf: None,
|
||
switch: None,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::animated_inout`] pero arranca la entrada desde la
|
||
/// transformación afín `from_xf` (igual semántica que
|
||
/// [`Self::animated_enter_from`]). La salida sigue siendo el fade-out
|
||
/// estándar (la subescena retenida no transforma).
|
||
pub fn animated_inout_from(
|
||
mut self,
|
||
key: u64,
|
||
duration: std::time::Duration,
|
||
from_xf: Affine,
|
||
) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
enter: true,
|
||
exit: true,
|
||
enter_from_xf: Some(from_xf),
|
||
switch: None,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Como [`Self::animated`] pero con easing explícito (p. ej.
|
||
/// `llimphi_theme::motion::ease_in_out_cubic`).
|
||
pub fn animated_curve(
|
||
mut self,
|
||
key: u64,
|
||
duration: std::time::Duration,
|
||
easing: fn(f32) -> f32,
|
||
) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing,
|
||
enter: false,
|
||
exit: false,
|
||
enter_from_xf: None,
|
||
switch: None,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Cross-fade real entre **variantes de contenido** bajo la misma `key`
|
||
/// (Flutter `AnimatedSwitcher`). `variant` identifica el contenido actual
|
||
/// (índice de pestaña, hash del estado, discriminante de un enum…). Cuando
|
||
/// `variant` cambia entre frames, el runtime desvanece la subescena vieja
|
||
/// (fade-out, retenida del frame previo) mientras hace fade-in del subárbol
|
||
/// nuevo, en el mismo rect — la transición real entre dos identidades, en
|
||
/// vez de combinar `animated_enter`+`animated_exit` de dos keys distintas.
|
||
///
|
||
/// Envolvé el contenido conmutable en un nodo con esta marca; sus hijos son
|
||
/// el contenido. La primera aparición no cruza (sólo fija la variante).
|
||
/// Igual que `exit`, captura el subárbol por frame — usar en pocos nodos
|
||
/// (un panel central, un visor que cambia de documento), no por fila.
|
||
pub fn animated_switch(
|
||
mut self,
|
||
key: u64,
|
||
variant: u64,
|
||
duration: std::time::Duration,
|
||
) -> Self {
|
||
self.anim = Some(Anim {
|
||
key,
|
||
duration,
|
||
easing: ease_out_cubic,
|
||
enter: false,
|
||
exit: false,
|
||
enter_from_xf: None,
|
||
switch: Some(variant),
|
||
});
|
||
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
|
||
}
|
||
|
||
/// Como [`Self::draggable`] pero el handler recibe además la **velocidad
|
||
/// del drag al soltarlo** (`vx`, `vy` en px/s) en la fase
|
||
/// `DragPhase::End` — `Fn(DragPhase, dx, dy, vx, vy) -> Option<Msg>`. El
|
||
/// runtime mide el desplazamiento sobre los últimos ~100 ms y lo divide
|
||
/// por el tiempo transcurrido. Durante `DragPhase::Move`, `vx == vy == 0`
|
||
/// (la velocidad sólo se calcula al final). **Gana sobre `draggable` y
|
||
/// `draggable_at`** si conviven en el mismo nodo — un nodo elige un
|
||
/// único sabor de drag. Habilita **fling-desde-drag**: el caller emite
|
||
/// `Msg::Fling { vx, vy }` en End y arranca un ticker que decae la
|
||
/// velocidad con [`fling_step`] hasta asentar.
|
||
pub fn draggable_velocity<F>(mut self, handler: F) -> Self
|
||
where
|
||
F: Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||
{
|
||
self.drag_velocity = 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
|
||
}
|
||
|
||
/// Radio **por esquina** (top-left, top-right, bottom-right, bottom-left,
|
||
/// en sentido horario desde arriba-izquierda) — CSS `border-radius` con
|
||
/// cuatro valores. Sobreescribe a [`Self::radius`] mientras esté presente.
|
||
/// Para cards con sólo las esquinas de arriba redondeadas, pestañas,
|
||
/// bocadillos de chat asimétricos, etc. El **borde** respeta las cuatro
|
||
/// esquinas; la **sombra** sigue usando el `radius` escalar (el blur
|
||
/// nativo de vello no acepta radios por esquina).
|
||
pub fn radius_corners(mut self, tl: f64, tr: f64, br: f64, bl: f64) -> Self {
|
||
self.corner_radii = Some(RoundedRectRadii::new(tl, tr, br, bl));
|
||
self
|
||
}
|
||
|
||
/// Proyecta una sombra detrás del nodo (drop shadow), rasterizada con
|
||
/// el blur gaussiano nativo de vello. Se pinta antes del relleno, así
|
||
/// el fill opaco la tapa y la sombra asoma por el desenfoque/offset.
|
||
/// El radio de la sombra sigue al del nodo (más el `spread`). Ver
|
||
/// [`Shadow`] (`Shadow::soft(alpha, blur)` es el default tasteful).
|
||
pub fn shadow(mut self, shadow: Shadow) -> Self {
|
||
self.shadow = Some(shadow);
|
||
self
|
||
}
|
||
|
||
/// Rellena el nodo con un **gradiente** en vez de un color sólido. El
|
||
/// gradiente se autorea en el **cuadrado unidad** `[0,1]²` y el runtime
|
||
/// lo mapea al rect del nodo (así no necesitás saber el tamaño al
|
||
/// construir el `View`) — igual que `Alignment` relativo de Flutter.
|
||
///
|
||
/// ```ignore
|
||
/// use llimphi_ui::llimphi_raster::peniko::{Color, Gradient};
|
||
/// use llimphi_ui::llimphi_raster::kurbo::Point;
|
||
/// // vertical: arriba claro → abajo oscuro
|
||
/// let g = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
|
||
/// .with_stops([Color::from_rgba8(80,90,110,255), Color::from_rgba8(30,34,44,255)].as_slice());
|
||
/// view.fill_gradient(g)
|
||
/// ```
|
||
///
|
||
/// Gana sobre `fill` como base; un `hover_fill` (color) lo sigue
|
||
/// overrideando mientras el cursor está encima.
|
||
pub fn fill_gradient(mut self, gradient: Gradient) -> Self {
|
||
self.fill_gradient = Some(gradient);
|
||
self
|
||
}
|
||
|
||
/// Dibuja un borde (stroke) sobre el contorno redondeado del nodo,
|
||
/// inset media línea hacia adentro (el grosor queda dentro del rect).
|
||
/// Reemplaza el viejo truco de envolver el nodo en un rect-padre del
|
||
/// color del borde con padding de 1px.
|
||
pub fn border(mut self, width: f64, color: Color) -> Self {
|
||
self.border = Some(Border::new(width, color));
|
||
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,
|
||
weight: 400.0,
|
||
max_lines: None,
|
||
ellipsis: false,
|
||
runs: None,
|
||
underline: false,
|
||
strikethrough: false,
|
||
letter_spacing: 0.0,
|
||
word_spacing: 0.0,
|
||
spans: None,
|
||
no_wrap: false,
|
||
overflow_wrap: false,
|
||
});
|
||
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,
|
||
weight: 400.0,
|
||
max_lines: None,
|
||
ellipsis: false,
|
||
runs: None,
|
||
underline: false,
|
||
strikethrough: false,
|
||
letter_spacing: 0.0,
|
||
word_spacing: 0.0,
|
||
spans: None,
|
||
no_wrap: false,
|
||
overflow_wrap: false,
|
||
});
|
||
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,
|
||
weight: 400.0,
|
||
max_lines: None,
|
||
ellipsis: false,
|
||
runs: None,
|
||
underline: false,
|
||
strikethrough: false,
|
||
letter_spacing: 0.0,
|
||
word_spacing: 0.0,
|
||
spans: None,
|
||
no_wrap: false,
|
||
overflow_wrap: false,
|
||
});
|
||
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,
|
||
weight: 400.0,
|
||
max_lines: None,
|
||
ellipsis: false,
|
||
runs: None,
|
||
underline: false,
|
||
strikethrough: false,
|
||
letter_spacing: 0.0,
|
||
word_spacing: 0.0,
|
||
spans: None,
|
||
no_wrap: false,
|
||
overflow_wrap: false,
|
||
});
|
||
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,
|
||
weight: 400.0,
|
||
max_lines: None,
|
||
ellipsis: false,
|
||
runs: Some(runs),
|
||
underline: false,
|
||
strikethrough: false,
|
||
letter_spacing: 0.0,
|
||
word_spacing: 0.0,
|
||
spans: None,
|
||
no_wrap: false,
|
||
overflow_wrap: false,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Texto **RichText** (Bloque 13 de PARIDAD-FLUTTER, cierra Tier 2):
|
||
/// `content` se pinta con los defaults del bloque (`size_px`,
|
||
/// `default_color`, alignment, weight 400, no italic, line-height 1.2,
|
||
/// fuente default) y cada [`llimphi_text::TextSpan`] sobreescribe en su
|
||
/// rango de bytes uno o más de
|
||
/// `size_px`/`weight`/`italic`/`font_family`/`color`/`underline`/
|
||
/// `strikethrough`. Soporta wrap (el ancho lo fija el layout taffy del
|
||
/// nodo); apto para párrafos con un `<b>`/`<i>`/`<code>`/`<small>`
|
||
/// inline, links subrayados, headings dentro del mismo flujo, render
|
||
/// barato de markdown.
|
||
pub fn text_spans(
|
||
mut self,
|
||
content: impl Into<String>,
|
||
size_px: f32,
|
||
default_color: Color,
|
||
spans: Vec<llimphi_text::TextSpan>,
|
||
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,
|
||
weight: 400.0,
|
||
max_lines: None,
|
||
ellipsis: false,
|
||
runs: None,
|
||
underline: false,
|
||
strikethrough: false,
|
||
letter_spacing: 0.0,
|
||
word_spacing: 0.0,
|
||
spans: Some(spans),
|
||
no_wrap: false,
|
||
overflow_wrap: false,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// Adjunta o reemplaza los [`TextSpec::spans`] del texto ya seteado
|
||
/// (RichText). Permite construir el texto con los builders uniformes
|
||
/// (`.text_aligned(...).bold().underline()`) y luego inyectar overrides
|
||
/// inline. No-op si el nodo no tiene texto.
|
||
pub fn with_spans(mut self, spans: Vec<llimphi_text::TextSpan>) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.spans = Some(spans);
|
||
}
|
||
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
|
||
}
|
||
|
||
/// Sobreescribe el peso de fuente del texto ya seteado (default 400 =
|
||
/// normal). Convención CSS: 400 normal, 500 medium, 600 semibold, 700
|
||
/// bold. parley elige la variante más cercana de la familia activa o la
|
||
/// sintetiza. No-op si el nodo no tiene texto. Afecta medida y pintado.
|
||
pub fn text_weight(mut self, weight: f32) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.weight = weight;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Atajo de [`Self::text_weight`] a 700 (bold). No-op sin texto.
|
||
pub fn bold(self) -> Self {
|
||
self.text_weight(700.0)
|
||
}
|
||
|
||
/// Fija la familia de fuente del texto ya seteado a la monoespaciada
|
||
/// embebida ([`llimphi_text::MONOSPACE`]) — ancho fijo garantizado para
|
||
/// que `ls`, tablas y logs columneen. No-op si el nodo no tiene texto.
|
||
/// Afecta medida y pintado.
|
||
pub fn mono(mut self) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.font_family = Some(llimphi_text::MONOSPACE.to_string());
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Clampa el texto a `n` líneas **sin** glifo de ellipsis (corte seco del
|
||
/// prefijo que cupo). CSS `-webkit-line-clamp` sin `text-overflow`. No-op
|
||
/// sin texto. Para el corte con `…` usar [`Self::ellipsis`]. Sólo trunca si
|
||
/// hay envoltura (requiere ancho acotado por el layout).
|
||
pub fn max_lines(mut self, n: usize) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.max_lines = Some(n);
|
||
t.ellipsis = false;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Clampa el texto a `n` líneas terminando la última en `…` cuando excede
|
||
/// (CSS `text-overflow: ellipsis` + `-webkit-line-clamp: n`). Lo más común
|
||
/// para items de lista, celdas de tabla, breadcrumbs y labels en cajas
|
||
/// dimensionadas. `n = 1` es el clásico single-line ellipsis. No-op sin
|
||
/// texto.
|
||
pub fn ellipsis(mut self, n: usize) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.max_lines = Some(n.max(1));
|
||
t.ellipsis = true;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// `letter-spacing` (CSS): px **extra** entre letras (0 = normal, negativo
|
||
/// junta). Afecta medida y pintado. No-op sin texto. Sólo el camino
|
||
/// uniforme; el RichText con spans lo ignora en v1.
|
||
pub fn letter_spacing(mut self, px: f32) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.letter_spacing = px;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// `word-spacing` (CSS): px **extra** entre palabras (0 = normal). Mismo
|
||
/// régimen que [`Self::letter_spacing`]. No-op sin texto.
|
||
pub fn word_spacing(mut self, px: f32) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.word_spacing = px;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// `white-space: nowrap`/`pre` (CSS): el texto **no envuelve** — se shapea
|
||
/// en una sola línea (`break_all_lines(None)`) sin importar el ancho de la
|
||
/// caja, y desborda (lo recorta `overflow: hidden` si lo hay). Afecta
|
||
/// medida y pintado. No-op sin texto. Sólo el camino uniforme; el RichText
|
||
/// con spans lo ignora en v1.
|
||
pub fn no_wrap(mut self) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.no_wrap = true;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// `overflow-wrap: break-word`/`anywhere` (CSS): una palabra más ancha que
|
||
/// la caja se **parte** para que entre, en vez de desbordar. Afecta medida
|
||
/// y pintado. No-op sin texto. Sólo el camino uniforme; el RichText con
|
||
/// spans lo ignora en v1, igual que `no_wrap`.
|
||
pub fn overflow_wrap(mut self) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.overflow_wrap = true;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Activa subrayado del texto (CSS `text-decoration: underline` / Flutter
|
||
/// `TextDecoration.underline`). parley registra la decoración por run y el
|
||
/// runtime pinta la línea bajo la base usando `underline_offset` y
|
||
/// `underline_size` del font metric — proporcional al tamaño de fuente
|
||
/// elegido. No-op sin texto.
|
||
pub fn underline(mut self) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.underline = true;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Activa tachado del texto (CSS `text-decoration: line-through` /
|
||
/// Flutter `TextDecoration.lineThrough`). Mismo régimen que [`Self::underline`]
|
||
/// pero usando el strikethrough metric. No-op sin texto.
|
||
pub fn strikethrough(mut self) -> Self {
|
||
if let Some(t) = self.text.as_mut() {
|
||
t.strikethrough = true;
|
||
}
|
||
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
|
||
}
|
||
|
||
/// Handler de **movimiento del cursor** sobre el nodo. Recibe `(local_x,
|
||
/// local_y, rect_w, rect_h)` (posición relativa al rect del nodo) en CADA
|
||
/// `CursorMoved` mientras el cursor está encima — no sólo al entrar, a
|
||
/// diferencia de [`Self::on_pointer_enter`]. Útil para seguir el cursor:
|
||
/// thumbnail de hover sobre un timeline, drawer que reacciona a la posición.
|
||
/// Devolver `None` no dispara update.
|
||
pub fn on_pointer_move_at<F>(mut self, handler: F) -> Self
|
||
where
|
||
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
|
||
{
|
||
self.on_pointer_move_at = Some(Arc::new(handler));
|
||
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. El encaje default es
|
||
/// [`ImageFit::Contain`] (preservar aspect ratio cabiendo);
|
||
/// usar [`Self::image_fit`] para `Cover`/`Fill`/`None`. El clip
|
||
/// respeta `radius`/`corner_radii`, así avatares y cards
|
||
/// redondeadas funcionan sin envolver en `clip(true)`. 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
|
||
}
|
||
|
||
/// Política de encaje de la imagen (CSS `object-fit` / Flutter
|
||
/// `BoxFit`). Solo aplica si hay [`Self::image`] seteada. Ver
|
||
/// [`ImageFit`].
|
||
pub fn image_fit(mut self, fit: ImageFit) -> Self {
|
||
self.image_fit = Some(fit);
|
||
self
|
||
}
|
||
|
||
/// Aplica `image` como **máscara de luminancia** del subárbol del nodo
|
||
/// (CSS `mask-image`). El paint aísla el subárbol en una capa y multiplica
|
||
/// su alpha por la luminancia de la máscara (blanco = visible, negro =
|
||
/// oculto). La imagen se estira al border-box del nodo. Ortogonal a
|
||
/// [`Self::image`] (que pinta contenido) y a los `clip_*` (que recortan):
|
||
/// un nodo puede llevar máscara y recorte a la vez.
|
||
pub fn mask_image(mut self, image: Image) -> Self {
|
||
self.mask_image = Some(image);
|
||
self
|
||
}
|
||
|
||
/// Fija el encaje de la máscara (CSS `mask-size`/`-position`/`-repeat`).
|
||
/// Sólo surte efecto junto a [`Self::mask_image`]. Sin esto, la máscara se
|
||
/// estira al border-box (Fase 7.1226). Ver [`MaskPlacement`]. Fase 7.1227.
|
||
pub fn mask_placement(mut self, placement: MaskPlacement) -> Self {
|
||
self.mask_placement = Some(placement);
|
||
self
|
||
}
|
||
|
||
/// Capas de máscara adicionales `(imagen, operador)` (CSS `mask-image` con
|
||
/// lista). Comparten el [`Self::mask_placement`] con la capa 0
|
||
/// ([`Self::mask_image`]) y se combinan con ella según cada operador. Fase
|
||
/// 7.1231.
|
||
pub fn mask_extra(mut self, layers: Vec<(Image, MaskCompose)>) -> Self {
|
||
self.mask_extra = layers;
|
||
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
|
||
}
|
||
|
||
/// Registra una closure de pintura vello "over" — misma firma que
|
||
/// [`Self::paint_with`] `(&mut Scene, &mut Typesetter, PaintRect)`,
|
||
/// pero el runtime la ejecuta en una pasada vello FINAL **después**
|
||
/// del pase GPU directo del frame, componiéndola con alpha sobre la
|
||
/// intermedia. Es el complemento opt-in de [`Self::gpu_paint_with`]:
|
||
/// permite pintar sprites/texto AA por vello ENCIMA de las celdas
|
||
/// instanciadas por GPU del mismo (o de otro) nodo.
|
||
///
|
||
/// Orden resultante del frame: `[vello base] → [gpu_paint] →
|
||
/// [paint_over] → [overlay/menús]`. Backward-compat total: si nadie
|
||
/// usa `paint_over`, no se crea la pasada final (coste cero) y el
|
||
/// resto del pipeline es idéntico. La closure no debe dejar
|
||
/// `push_layer` sin par ni resetear la escena. Ver [`OverPaintFn`].
|
||
pub fn paint_over<F>(mut self, painter: F) -> Self
|
||
where
|
||
F: Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect)
|
||
+ Send
|
||
+ Sync
|
||
+ 'static,
|
||
{
|
||
self.over_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
|
||
}
|
||
|
||
/// Recorta los descendientes a un rect encogido por `insets` px
|
||
/// `[top, right, bottom, left]` desde el rect del nodo — modela
|
||
/// `clip-path: inset(...)`. Activa el recorte (paint + hit-test).
|
||
pub fn clip_inset(mut self, insets: [f32; 4]) -> Self {
|
||
self.clip = true;
|
||
self.clip_inset = Some(insets);
|
||
self
|
||
}
|
||
|
||
/// Recorta los descendientes a una elipse — modela
|
||
/// `clip-path: circle()`/`ellipse()`. `spec` es de 14 floats: centro
|
||
/// `[cx_px, cx_pct, cy_px, cy_pct]` + dos radios `[px, pct_w, pct_h,
|
||
/// pct_diag, side]`, todos resueltos contra el rect del nodo en el
|
||
/// pintado. Activa el recorte (paint; hit-test usa el rect completo).
|
||
pub fn clip_ellipse(mut self, spec: [f32; 14]) -> Self {
|
||
self.clip = true;
|
||
self.clip_ellipse = Some(spec);
|
||
self
|
||
}
|
||
|
||
/// Recorta los descendientes a un polígono — modela `clip-path:
|
||
/// polygon()`. `evenodd` = regla de relleno; cada punto `[x_px, x_pct,
|
||
/// y_px, y_pct]` resuelve contra el rect del nodo en el pintado. Activa el
|
||
/// recorte (paint; hit-test usa el rect completo).
|
||
pub fn clip_polygon(mut self, evenodd: bool, points: Vec<[f32; 4]>) -> Self {
|
||
self.clip = true;
|
||
self.clip_polygon = Some((evenodd, points));
|
||
self
|
||
}
|
||
|
||
/// Recorta los descendientes a un path SVG — modela `clip-path: path()`.
|
||
/// `d` es el string SVG crudo (user units px, relativos al origen del
|
||
/// rect); el pintado lo parsea con `BezPath::from_svg`. `evenodd` = regla
|
||
/// de relleno. Activa el recorte (paint; hit-test usa el rect completo).
|
||
pub fn clip_path_svg(mut self, evenodd: bool, d: impl Into<String>) -> Self {
|
||
self.clip = true;
|
||
self.clip_path_svg = Some((evenodd, d.into()));
|
||
self
|
||
}
|
||
|
||
/// Fija la caja de referencia del clip-path (`<geometry-box>`): el rect del
|
||
/// nodo se encoge por `insets` px `[top, right, bottom, left]` antes de
|
||
/// resolver la forma. Sin forma, recorta a ese rect. Activa el recorte.
|
||
pub fn clip_ref_inset(mut self, insets: [f32; 4]) -> Self {
|
||
self.clip = true;
|
||
self.clip_ref_inset = Some(insets);
|
||
self
|
||
}
|
||
|
||
pub fn children(mut self, children: Vec<View<Msg>>) -> Self {
|
||
self.children = children;
|
||
self
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod semantics_tests {
|
||
use super::*;
|
||
use llimphi_layout::Style;
|
||
|
||
#[test]
|
||
fn map_transforma_msg_y_recursa_hijos() {
|
||
// `View::map` eleva el Msg de todo el árbol (para embeber el view de un
|
||
// sub-app en un host). Verificamos el caso simple (on_click) en el nodo
|
||
// y en un hijo.
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
enum Sub {
|
||
Hi,
|
||
}
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
enum Host {
|
||
FromSub(Sub),
|
||
}
|
||
let child = View::<Sub>::new(Style::default()).on_click(Sub::Hi);
|
||
let parent = View::<Sub>::new(Style::default())
|
||
.on_click(Sub::Hi)
|
||
.children(vec![child]);
|
||
let mapped: View<Host> = parent.map(Host::FromSub);
|
||
assert_eq!(mapped.on_click, Some(Host::FromSub(Sub::Hi)));
|
||
assert_eq!(mapped.children.len(), 1);
|
||
assert_eq!(mapped.children[0].on_click, Some(Host::FromSub(Sub::Hi)));
|
||
}
|
||
|
||
#[test]
|
||
fn clip_inset_setea_campo_y_activa_clip() {
|
||
// `.clip_inset(...)` guarda los insets y activa el recorte (Fase 7.1219).
|
||
let v = View::<()>::new(Style::default()).clip_inset([1.0, 2.0, 3.0, 4.0]);
|
||
assert_eq!(v.clip_inset, Some([1.0, 2.0, 3.0, 4.0]));
|
||
assert!(v.clip, "clip_inset implica clip activo");
|
||
// `.clip(true)` solo (overflow:hidden) deja clip_inset en None.
|
||
let h = View::<()>::new(Style::default()).clip(true);
|
||
assert!(h.clip);
|
||
assert_eq!(h.clip_inset, None);
|
||
// Default: sin recorte.
|
||
let d = View::<()>::new(Style::default());
|
||
assert!(!d.clip);
|
||
assert_eq!(d.clip_inset, None);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_ellipse_setea_campo_y_activa_clip() {
|
||
// `.clip_ellipse(...)` guarda el spec de 14 floats y activa el recorte
|
||
// (Fase 7.1220 rect, 7.1221 radios %, 7.1222 lados).
|
||
let spec =
|
||
[0.0, 50.0, 0.0, 50.0, 30.0, 0.0, 0.0, 0.0, 0.0, 20.0, 0.0, 0.0, 0.0, 0.0];
|
||
let v = View::<()>::new(Style::default()).clip_ellipse(spec);
|
||
assert_eq!(v.clip_ellipse, Some(spec));
|
||
assert!(v.clip, "clip_ellipse implica clip activo");
|
||
// No interfiere con clip_inset (campos independientes).
|
||
assert_eq!(v.clip_inset, None);
|
||
// Default: sin elipse.
|
||
let d = View::<()>::new(Style::default());
|
||
assert_eq!(d.clip_ellipse, None);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_polygon_setea_campo_y_activa_clip() {
|
||
// `.clip_polygon(...)` guarda (evenodd, puntos) y activa el recorte
|
||
// (Fase 7.1223).
|
||
let pts = vec![[0.0, 0.0, 0.0, 0.0], [0.0, 100.0, 0.0, 0.0], [0.0, 50.0, 0.0, 100.0]];
|
||
let v = View::<()>::new(Style::default()).clip_polygon(true, pts.clone());
|
||
assert_eq!(v.clip_polygon, Some((true, pts)));
|
||
assert!(v.clip, "clip_polygon implica clip activo");
|
||
// No interfiere con elipse/inset.
|
||
assert_eq!(v.clip_ellipse, None);
|
||
assert_eq!(v.clip_inset, None);
|
||
// Default: sin polígono.
|
||
assert_eq!(View::<()>::new(Style::default()).clip_polygon, None);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_path_svg_setea_campo_y_activa_clip() {
|
||
// `.clip_path_svg(...)` guarda (evenodd, d) y activa el recorte
|
||
// (Fase 7.1224). El string SVG debe parsear con kurbo.
|
||
let v = View::<()>::new(Style::default()).clip_path_svg(false, "M0 0 L10 0 L10 10 Z");
|
||
assert_eq!(v.clip_path_svg, Some((false, "M0 0 L10 0 L10 10 Z".to_string())));
|
||
assert!(v.clip, "clip_path_svg implica clip activo");
|
||
// El path de muestra parsea a un BezPath no vacío.
|
||
let bez = vello::kurbo::BezPath::from_svg("M0 0 L10 0 L10 10 Z").unwrap();
|
||
assert!(!bez.elements().is_empty());
|
||
// Default: sin path.
|
||
assert_eq!(View::<()>::new(Style::default()).clip_path_svg, None);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_ref_inset_setea_campo_y_activa_clip() {
|
||
// `.clip_ref_inset(...)` guarda la caja de referencia y activa el
|
||
// recorte (Fase 7.1225).
|
||
let v = View::<()>::new(Style::default()).clip_ref_inset([5.0, 5.0, 5.0, 5.0]);
|
||
assert_eq!(v.clip_ref_inset, Some([5.0, 5.0, 5.0, 5.0]));
|
||
assert!(v.clip, "clip_ref_inset implica clip activo");
|
||
// Default: sin caja de referencia.
|
||
assert_eq!(View::<()>::new(Style::default()).clip_ref_inset, None);
|
||
}
|
||
|
||
#[test]
|
||
fn mask_image_setea_campo_sin_tocar_clip() {
|
||
// `.mask_image(img)` guarda la imagen-máscara para que el paint la
|
||
// aplique como luminancia sobre el subárbol. Es ORTOGONAL al recorte:
|
||
// NO activa `clip` (a diferencia de los `clip_*`). Fase 7.1226.
|
||
use vello::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
|
||
let data = ImageData {
|
||
data: Blob::from(vec![255u8, 255, 255, 255]),
|
||
format: ImageFormat::Rgba8,
|
||
alpha_type: ImageAlphaType::Alpha,
|
||
width: 1,
|
||
height: 1,
|
||
};
|
||
let v = View::<()>::new(Style::default()).mask_image(Image::new(data));
|
||
assert!(v.mask_image.is_some(), "mask_image queda seteada");
|
||
assert!(!v.clip, "mask_image NO activa clip (es ortogonal al recorte)");
|
||
// Sin `mask_placement`, el paint estira al border-box (Fase 7.1226).
|
||
assert!(v.mask_placement.is_none());
|
||
// Default: sin máscara.
|
||
assert!(View::<()>::new(Style::default()).mask_image.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mask_placement_setea_encaje() {
|
||
// `.mask_placement(...)` guarda el encaje (size/position/repeat/mode) que
|
||
// el paint resuelve contra el rect, igual que background-image. Fase
|
||
// 7.1227 (encaje), 7.1228 (mode).
|
||
let p = MaskPlacement {
|
||
size: MaskSize::Contain,
|
||
pos_x: MaskLen::Pct(50.0),
|
||
pos_y: MaskLen::Px(8.0),
|
||
repeat_x: true,
|
||
repeat_y: false,
|
||
mode: MaskMode::Alpha,
|
||
clip_inset: Some([2.0, 2.0, 2.0, 2.0]),
|
||
origin_inset: None,
|
||
};
|
||
let v = View::<()>::new(Style::default()).mask_placement(p);
|
||
assert_eq!(v.mask_placement, Some(p));
|
||
// No activa clip ni implica máscara por sí solo (es sólo el encaje).
|
||
assert!(!v.clip);
|
||
assert!(v.mask_image.is_none());
|
||
// Default: sin encaje (estira al border-box, modo luminancia).
|
||
assert!(View::<()>::new(Style::default()).mask_placement.is_none());
|
||
// El modo default del compositor es luminancia (el camino estirado de
|
||
// la Fase 7.1226 sin placement). Fase 7.1228.
|
||
assert_eq!(MaskMode::default(), MaskMode::Luminance);
|
||
}
|
||
|
||
#[test]
|
||
fn mask_extra_setea_capas_adicionales() {
|
||
// `.mask_extra(...)` guarda las capas extra `(imagen, operador)` que el
|
||
// paint combina con la capa 0 según el operador. Fase 7.1231.
|
||
use vello::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
|
||
let mk = || {
|
||
Image::new(ImageData {
|
||
data: Blob::from(vec![255u8, 255, 255, 255]),
|
||
format: ImageFormat::Rgba8,
|
||
alpha_type: ImageAlphaType::Alpha,
|
||
width: 1,
|
||
height: 1,
|
||
})
|
||
};
|
||
let v = View::<()>::new(Style::default())
|
||
.mask_extra(vec![(mk(), MaskCompose::Intersect), (mk(), MaskCompose::Add)]);
|
||
assert_eq!(v.mask_extra.len(), 2);
|
||
assert_eq!(v.mask_extra[0].1, MaskCompose::Intersect);
|
||
assert_eq!(v.mask_extra[1].1, MaskCompose::Add);
|
||
// Default: sin capas extra; el operador default es `add`.
|
||
assert!(View::<()>::new(Style::default()).mask_extra.is_empty());
|
||
assert_eq!(MaskCompose::default(), MaskCompose::Add);
|
||
}
|
||
|
||
#[test]
|
||
fn aria_label_sobre_role_preserva_role() {
|
||
let v = View::<()>::new(Style::default())
|
||
.role(Role::Button)
|
||
.aria_label("Guardar");
|
||
let s = v.semantics.expect("semantics");
|
||
assert_eq!(s.role, Some(Role::Button));
|
||
assert_eq!(s.label.as_deref(), Some("Guardar"));
|
||
}
|
||
|
||
#[test]
|
||
fn role_sobre_aria_label_preserva_label() {
|
||
// Orden invertido: el segundo setter no debe pisar lo del primero.
|
||
let v = View::<()>::new(Style::default())
|
||
.aria_label("Buscar")
|
||
.role(Role::TextInput);
|
||
let s = v.semantics.expect("semantics");
|
||
assert_eq!(s.role, Some(Role::TextInput));
|
||
assert_eq!(s.label.as_deref(), Some("Buscar"));
|
||
}
|
||
|
||
#[test]
|
||
fn flags_independientes_no_se_pisan() {
|
||
let v = View::<()>::new(Style::default())
|
||
.role(Role::Checkbox)
|
||
.aria_checked(true)
|
||
.aria_required(true);
|
||
let s = v.semantics.expect("semantics");
|
||
assert_eq!(s.flags.checked, Some(true));
|
||
assert_eq!(s.flags.required, Some(true));
|
||
assert!(s.flags.disabled.is_none(), "no se setea lo que no se pidió");
|
||
}
|
||
|
||
#[test]
|
||
fn semantics_spec_completo_reemplaza_lo_acumulado() {
|
||
// `.semantics(spec)` es el setter "todo o nada"; debe sobrescribir.
|
||
let v = View::<()>::new(Style::default())
|
||
.role(Role::Button)
|
||
.aria_label("Vieja")
|
||
.semantics(SemanticsSpec::role(Role::Link).with_label("Nueva"));
|
||
let s = v.semantics.expect("semantics");
|
||
assert_eq!(s.role, Some(Role::Link));
|
||
assert_eq!(s.label.as_deref(), Some("Nueva"));
|
||
}
|
||
|
||
#[test]
|
||
fn filter_setea_campo_sin_tocar_clip() {
|
||
// `.filter([Blur])` guarda la lista de filtros del propio subárbol. Es
|
||
// ORTOGONAL al recorte (NO activa clip) y al backdrop_blur. Fase 7.1232.
|
||
let v = View::<()>::new(Style::default()).filter(vec![FilterOp::Blur(4.0)]);
|
||
assert_eq!(v.filter, vec![FilterOp::Blur(4.0)]);
|
||
assert!(!v.clip, "filter NO activa clip (es ortogonal al recorte)");
|
||
assert!(v.backdrop_blur.is_none(), "filter NO es backdrop_blur");
|
||
// Default: sin filtro.
|
||
assert!(View::<()>::new(Style::default()).filter.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn blend_setea_campo_sin_tocar_clip_ni_filter() {
|
||
// `.blend(bm)` guarda el modo de mezcla del nodo entero (CSS
|
||
// `mix-blend-mode`). Es ORTOGONAL a clip/filter/alpha. Fase 7.1237.
|
||
let bm = BlendMode::from(vello::peniko::Mix::Multiply);
|
||
let v = View::<()>::new(Style::default()).blend(bm);
|
||
assert_eq!(v.blend, Some(bm));
|
||
assert!(!v.clip, "blend NO activa clip (es ortogonal al recorte)");
|
||
assert!(v.filter.is_empty(), "blend NO es filter");
|
||
assert!(v.alpha.is_none(), "blend NO es alpha");
|
||
// Default: sin blend (source-over).
|
||
assert!(View::<()>::new(Style::default()).blend.is_none());
|
||
// El campo sobrevive el mount (llega al MountedNode).
|
||
use llimphi_layout::LayoutTree;
|
||
let mut layout = LayoutTree::new();
|
||
let mounted = mount(&mut layout, View::<()>::new(Style::default()).blend(bm));
|
||
assert_eq!(mounted.nodes[0].blend, Some(bm), "blend llega al MountedNode");
|
||
}
|
||
|
||
#[test]
|
||
fn collect_filters_aplana_ops_con_rect_del_nodo() {
|
||
// `collect_filters` recolecta cada FilterOp con el rect computado del
|
||
// nodo, en orden de lista. Verificamos el camino mount → compute →
|
||
// collect sin GPU (la aplicación del blur en sí es GPU). Fase 7.1232.
|
||
use llimphi_layout::taffy::prelude::{length, Size};
|
||
use llimphi_layout::LayoutTree;
|
||
let root = View::<()>::new(Style {
|
||
size: Size { width: length(100.0), height: length(40.0) },
|
||
..Default::default()
|
||
})
|
||
.filter(vec![FilterOp::Blur(5.0)]);
|
||
let mut layout = LayoutTree::new();
|
||
let mounted = mount(&mut layout, root);
|
||
let computed = layout
|
||
.compute(mounted.root, (200.0, 200.0))
|
||
.expect("layout");
|
||
let passes = collect_filters(&mounted, &computed);
|
||
assert_eq!(passes.len(), 1, "un FilterOp → un FilterPass");
|
||
assert!(matches!(passes[0].op, FilterOp::Blur(s) if (s - 5.0).abs() < 1e-3));
|
||
assert_eq!(passes[0].rect.2, 100.0, "ancho del rect del nodo");
|
||
assert_eq!(passes[0].rect.3, 40.0, "alto del rect del nodo");
|
||
}
|
||
|
||
#[test]
|
||
fn collect_filters_vacio_sin_filtros() {
|
||
// Sin `.filter(...)` en ningún nodo, la recolección es vacía (coste cero
|
||
// en el runtime). Fase 7.1232.
|
||
use llimphi_layout::LayoutTree;
|
||
let root = View::<()>::new(Style::default());
|
||
let mut layout = LayoutTree::new();
|
||
let mounted = mount(&mut layout, root);
|
||
let computed = layout.compute(mounted.root, (50.0, 50.0)).expect("layout");
|
||
assert!(collect_filters(&mounted, &computed).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn no_wrap_setea_campo_del_texto_fase_7_1253() {
|
||
// `.no_wrap()` marca el TextSpec para shapear en una sola línea (CSS
|
||
// `white-space: nowrap`). Ortogonal al resto del estilo de texto;
|
||
// default false (wrap). No-op si el nodo no tiene texto.
|
||
let v = View::<()>::new(Style::default())
|
||
.text("hola mundo", 14.0, Color::BLACK)
|
||
.no_wrap();
|
||
let t = v.text.as_ref().expect("text");
|
||
assert!(t.no_wrap, "no_wrap=true tras el builder");
|
||
// Default: el texto envuelve (no_wrap=false).
|
||
let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
|
||
assert!(!def.text.as_ref().unwrap().no_wrap, "default es wrap");
|
||
// No-op sin texto: no panickea ni inventa un TextSpec.
|
||
let sin = View::<()>::new(Style::default()).no_wrap();
|
||
assert!(sin.text.is_none(), "no_wrap sin texto no crea TextSpec");
|
||
}
|
||
|
||
#[test]
|
||
fn overflow_wrap_setea_campo_del_texto_fase_7_1254() {
|
||
// `.overflow_wrap()` marca el TextSpec para partir la palabra larga (CSS
|
||
// `overflow-wrap: break-word`). Ortogonal al resto; default false (la
|
||
// palabra desborda). No-op si el nodo no tiene texto.
|
||
let v = View::<()>::new(Style::default())
|
||
.text("palabralarga", 14.0, Color::BLACK)
|
||
.overflow_wrap();
|
||
let t = v.text.as_ref().expect("text");
|
||
assert!(t.overflow_wrap, "overflow_wrap=true tras el builder");
|
||
// Default: la palabra larga desborda (overflow_wrap=false).
|
||
let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
|
||
assert!(
|
||
!def.text.as_ref().unwrap().overflow_wrap,
|
||
"default es desbordar"
|
||
);
|
||
// No-op sin texto: no panickea ni inventa un TextSpec.
|
||
let sin = View::<()>::new(Style::default()).overflow_wrap();
|
||
assert!(sin.text.is_none(), "overflow_wrap sin texto no crea TextSpec");
|
||
}
|
||
}
|