gioser

tawasuyu/llimphi

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

Rust 124Markdown 82TOML 78Shell 12149 Definiciones

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/fitted-box/src/lib.rs Rust · 241 líneas · 9.8 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

1//! `llimphi-widget-fitted-box` — escala un subárbol al slot disponible.
2//!
3//! Análogo a `FittedBox` de Flutter: el caller le pasa el **tamaño
4//! natural** del contenido y una **política de fit**, y el widget aplica
5//! un `transform` afín al subárbol para que entre en el slot real
6//! (medido por el seam [`LayoutBuilder`] del compositor). El subárbol
7//! queda **centrado** y, salvo `BoxFit::Fill`, **preserva su aspect
8//! ratio**.
9//!
10//! Por qué no sale solo: `taffy` dimensiona contenedores, pero el
11//! contenido (texto, imagen, painter custom) no escala con su contenedor
12//! — sólo se posiciona dentro. `FittedBox` aplica un escalado VISUAL
13//! sobre el subárbol completo, así un canvas `paint_with` o un texto
14//! grande caben en una celda chiquita sin que el caller los re-mida.
15//!
16//! El padre del widget tiene `clip(true)` para que ningún píxel se salga
17//! del slot (sólo importa cuando el aspect del contenido NO coincide con
18//! el slot bajo `BoxFit::None`, o nunca con `Contain`/`Fill`).
19//!
20//! ## API
21//!
22//! ```ignore
23//! use llimphi_widget_fitted_box::{fitted_box, BoxFit};
24//! // Una imagen 800×600 que tiene que caber en cualquier slot, preservando
25//! // aspect (mostrar entera, posibles bandas).
26//! fitted_box((800.0, 600.0), BoxFit::Contain, || my_image_view())
27//! ```
28//!
29//! ## Funciones puras
30//!
31//! [`compute_fit`] devuelve `(sx, sy, dx, dy)` para un `(slot, inner,
32//! fit)` dado y es testeable sin runtime. Útil para validar el algoritmo
33//! y para casos donde el caller ya tiene una transformación propia que
34//! quiere componer.
35
36#![forbid(unsafe_code)]
37
38use llimphi_ui::llimphi_layout::taffy::{
39    prelude::{length, percent, Size, Style},
40};
41use llimphi_ui::llimphi_raster::kurbo::Affine;
42use llimphi_ui::View;
43
44/// Política de encaje del contenido en el slot. Cubre los cinco modos
45/// canónicos (Flutter `BoxFit`, CSS `object-fit`).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum BoxFit {
48    /// Preserva aspect — el contenido cabe ENTERO en el slot, dejando
49    /// bandas si el aspect no coincide. Lo usual para mostrar imágenes
50    /// sin recortar.
51    Contain,
52    /// Preserva aspect — el contenido CUBRE todo el slot, recortando lo
53    /// que sobre. Lo usual para fondos.
54    Cover,
55    /// Estira para llenar el slot (puede deformar — no preserva aspect).
56    Fill,
57    /// No escala — el contenido se muestra a tamaño natural, centrado.
58    /// Si es más grande que el slot, se recorta por el `clip` del padre.
59    None,
60    /// Como `Contain` pero **nunca agranda** — sólo achica si el
61    /// contenido es más grande que el slot. Equivale a
62    /// `min(Contain, None)`. Útil para evitar pixelar imágenes chicas.
63    ScaleDown,
64}
65
66/// `(sx, sy, dx, dy)` para encajar un contenido de tamaño `inner` en un
67/// slot `slot` bajo la política `fit`. El factor `(sx, sy)` se aplica
68/// como escala (igual en x e y salvo en `Fill`), y `(dx, dy)` es el
69/// offset desde la esquina superior-izquierda del slot al borde
70/// superior-izquierdo del contenido **ya escalado**, que queda centrado.
71///
72/// Casos de borde:
73/// - `inner.0 <= 0.0 || inner.1 <= 0.0` o `slot.0 <= 0.0 || slot.1 <=
74///   0.0` ⇒ `(1.0, 1.0, 0.0, 0.0)` (identidad — defensa, no panic).
75pub fn compute_fit(slot: (f32, f32), inner: (f32, f32), fit: BoxFit) -> (f32, f32, f32, f32) {
76    let (sw, sh) = slot;
77    let (iw, ih) = inner;
78    if iw <= 0.0 || ih <= 0.0 || sw <= 0.0 || sh <= 0.0 {
79        return (1.0, 1.0, 0.0, 0.0);
80    }
81    let (sx, sy) = match fit {
82        BoxFit::Contain => {
83            let s = (sw / iw).min(sh / ih);
84            (s, s)
85        }
86        BoxFit::Cover => {
87            let s = (sw / iw).max(sh / ih);
88            (s, s)
89        }
90        BoxFit::Fill => (sw / iw, sh / ih),
91        BoxFit::None => (1.0, 1.0),
92        BoxFit::ScaleDown => {
93            let s = (sw / iw).min(sh / ih).min(1.0);
94            (s, s)
95        }
96    };
97    let scaled_w = iw * sx;
98    let scaled_h = ih * sy;
99    let dx = (sw - scaled_w) * 0.5;
100    let dy = (sh - scaled_h) * 0.5;
101    (sx, sy, dx, dy)
102}
103
104/// Vista que escala el subárbol `inner` (tamaño natural `inner_size`) al
105/// slot del padre bajo la política `fit`. `inner` es una closure porque
106/// `View<Msg>` no es `Clone` — el seam `LayoutBuilder` puede invocar el
107/// builder más de una vez en su resolución de dos pasadas.
108///
109/// El nodo retornado toma `width: 100%` y `height: 100%` por defecto —
110/// el caller decide el tamaño envolviéndolo en un padre con `Style`
111/// explícito.
112pub fn fitted_box<Msg, F>(inner_size: (f32, f32), fit: BoxFit, inner: F) -> View<Msg>
113where
114    Msg: Clone + 'static,
115    F: Fn() -> View<Msg> + Send + Sync + 'static,
116{
117    let (iw, ih) = inner_size;
118    View::<Msg>::new(Style {
119        size: Size { width: percent(1.0), height: percent(1.0) },
120        ..Default::default()
121    })
122    .clip(true)
123    .layout_builder(move |c| {
124        let slot = (c.max_width, c.max_height);
125        let (sx, sy, dx, dy) = compute_fit(slot, (iw, ih), fit);
126        // El runtime aplica el transform centrado en el nodo (convención
127        // CSS `transform-origin: 50% 50%`). Un nodo de tamaño natural
128        // (iw, ih) centrado en (iw/2, ih/2), tras `scale_non_uniform(sx,
129        // sy)` queda con tamaño visual (iw*sx, ih*sy) pero todavía
130        // centrado en (iw/2, ih/2). Para correrlo al centro del slot
131        // (sw/2, sh/2) trasladamos por la diferencia de centros, que en
132        // el caso de un nodo posicionado en (0,0) es exactamente `(dx,
133        // dy) + (scaled_w-iw)/2 + (scaled_h-ih)/2`. Después de hacer la
134        // cuenta: `delta = (sw - iw)/2`. (`dx + (scaled-iw)/2 =
135        // (sw-iw)/2`.) Así el offset que pasamos es `((sw-iw)/2,
136        // (sh-ih)/2)` antes del scale-around-center.
137        let delta_x = (c.max_width - iw) * 0.5;
138        let delta_y = (c.max_height - ih) * 0.5;
139        let xf = Affine::translate((delta_x as f64, delta_y as f64))
140            * Affine::scale_non_uniform(sx as f64, sy as f64);
141        // Suprimir warnings sobre dx/dy no usados — están en la doc + tests.
142        let _ = (dx, dy);
143
144        let inner_node = (inner)();
145        let inner_wrap = View::<Msg>::new(Style {
146            position: llimphi_ui::llimphi_layout::taffy::Position::Absolute,
147            inset: llimphi_ui::llimphi_layout::taffy::Rect {
148                top: length(0.0),
149                left: length(0.0),
150                right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
151                bottom: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
152            },
153            size: Size { width: length(iw), height: length(ih) },
154            ..Default::default()
155        })
156        .children(vec![inner_node])
157        .transform(xf);
158
159        View::<Msg>::new(Style {
160            position: llimphi_ui::llimphi_layout::taffy::Position::Relative,
161            size: Size {
162                width: length(c.max_width),
163                height: length(c.max_height),
164            },
165            ..Default::default()
166        })
167        .children(vec![inner_wrap])
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn contain_preserva_aspect_y_deja_bandas() {
177        // Slot 200×100, contenido 200×200 (cuadrado) — Contain achica al
178        // mínimo eje (100/200 = 0.5).
179        let (sx, sy, dx, dy) = compute_fit((200.0, 100.0), (200.0, 200.0), BoxFit::Contain);
180        assert!((sx - 0.5).abs() < 1e-3);
181        assert!((sy - 0.5).abs() < 1e-3);
182        // Contenido escalado 100×100 centrado en 200×100 → dx=50, dy=0.
183        assert!((dx - 50.0).abs() < 1e-3);
184        assert!(dy.abs() < 1e-3);
185    }
186
187    #[test]
188    fn cover_preserva_aspect_y_recorta() {
189        // Slot 200×200, contenido 200×100 (paisaje) — Cover toma el MÁXIMO
190        // eje (200/100 = 2.0 en y), el x sobra.
191        let (sx, sy, dx, dy) = compute_fit((200.0, 200.0), (200.0, 100.0), BoxFit::Cover);
192        assert!((sx - 2.0).abs() < 1e-3);
193        assert!((sy - 2.0).abs() < 1e-3);
194        // Contenido escalado 400×200 centrado en 200×200 → dx=-100, dy=0.
195        assert!((dx - (-100.0)).abs() < 1e-3);
196        assert!(dy.abs() < 1e-3);
197    }
198
199    #[test]
200    fn fill_estira_no_preserva_aspect() {
201        let (sx, sy, dx, dy) = compute_fit((200.0, 100.0), (100.0, 200.0), BoxFit::Fill);
202        assert!((sx - 2.0).abs() < 1e-3);
203        assert!((sy - 0.5).abs() < 1e-3);
204        // Sin offset — el contenido escalado cubre todo el slot.
205        assert!(dx.abs() < 1e-3);
206        assert!(dy.abs() < 1e-3);
207    }
208
209    #[test]
210    fn none_mantiene_natural_y_centra() {
211        // Slot 200×200, contenido 80×60 → sin escalar, centrado.
212        let (sx, sy, dx, dy) = compute_fit((200.0, 200.0), (80.0, 60.0), BoxFit::None);
213        assert!((sx - 1.0).abs() < 1e-6);
214        assert!((sy - 1.0).abs() < 1e-6);
215        assert!((dx - 60.0).abs() < 1e-3); // (200-80)/2
216        assert!((dy - 70.0).abs() < 1e-3); // (200-60)/2
217    }
218
219    #[test]
220    fn scale_down_no_agranda_solo_achica() {
221        // Contenido chico (40×30) en slot grande (200×100) → no agranda,
222        // queda 1.0 y centrado.
223        let (sx, sy, _, _) = compute_fit((200.0, 100.0), (40.0, 30.0), BoxFit::ScaleDown);
224        assert!((sx - 1.0).abs() < 1e-6);
225        assert!((sy - 1.0).abs() < 1e-6);
226        // Contenido grande (400×400) en slot chico (100×100) → achica como
227        // Contain (100/400 = 0.25).
228        let (sx2, sy2, _, _) =
229            compute_fit((100.0, 100.0), (400.0, 400.0), BoxFit::ScaleDown);
230        assert!((sx2 - 0.25).abs() < 1e-3);
231        assert!((sy2 - 0.25).abs() < 1e-3);
232    }
233
234    #[test]
235    fn entradas_invalidas_devuelven_identidad() {
236        // Cualquier dimensión ≤ 0 ⇒ identidad sin offset (defensa).
237        assert_eq!(compute_fit((0.0, 100.0), (50.0, 50.0), BoxFit::Contain), (1.0, 1.0, 0.0, 0.0));
238        assert_eq!(compute_fit((100.0, 100.0), (0.0, 50.0), BoxFit::Cover), (1.0, 1.0, 0.0, 0.0));
239        assert_eq!(compute_fit((100.0, 100.0), (50.0, -1.0), BoxFit::Fill), (1.0, 1.0, 0.0, 0.0));
240    }
241}