tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definitions

⎇ main widgets/panel/src/lib.rs Rust · 269 lines · 9.4 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `llimphi-widget-panel` — firma visual transversal de los paneles tawasuyu.
2//!
3//! Aporta dos detalles que aplicados consistentemente vuelven al sistema
4//! reconocible sin que se note "diseñado":
5//!
6//! 1. **Gradiente vertical casi imperceptible** — el fondo del panel no
7//!    es un color sólido sino una interpolación lineal entre una versión
8//!    ligeramente más clara (top) y una ligeramente más oscura (bot) del
9//!    color base. La diferencia es ~4% en valor — invisible al primer
10//!    vistazo pero el ojo lo registra como "tallado" en vez de "pintado".
11//!
12//! 2. **Hairline accent en el top edge** — una línea horizontal de 1px
13//!    en el color accent del theme, al ~30% de alpha, justo en el borde
14//!    superior del panel. Funciona como "hilo de identidad" que cose
15//!    todos los paneles del sistema: aparece en modales, dropdowns,
16//!    cards, sidebars; siempre el mismo grosor, siempre el mismo color.
17//!
18//! ## API
19//!
20//! - [`PanelStyle`] — bundle de tokens (color base, accent, radio,
21//!   alpha del hairline, fuerza del gradiente).
22//! - [`panel_signature_painter`] — `Fn` para `View::paint_with`. Útil si
23//!   ya tienes un View configurado y quieres sumarle la firma sin envolver.
24//! - [`panel_view`] — convenience: arma el View completo con la firma
25//!   aplicada, recibe los hijos como `Vec<View<Msg>>`.
26//!
27//! ## Cuándo usarlo
28//!
29//! - SÍ: modales, dropdowns, cards prominentes, columnas de layout,
30//!   shortcuts-help, paneles flotantes.
31//! - NO: chips, badges, toasts, items de lista (la firma es para
32//!   superficies grandes; en piezas chiquitas es ruido).
33
34#![forbid(unsafe_code)]
35
36
37// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
38// `String`/`Vec`/`Arc`—, y declararlo es lo que le permite cruzar a una
39// jaula WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
40#![no_std]
41
42extern crate alloc;
43
44use alloc::vec::Vec;
45use llimphi_compositor::llimphi_layout::taffy::prelude::{percent, Size, Style};
46use llimphi_compositor::peniko::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect};
47use llimphi_compositor::peniko::{color::AlphaColor, Color, Fill, Gradient};
48use llimphi_compositor::{PaintRect, Shadow, View};
49use llimphi_theme::{alpha, elevation, radius, Theme};
50
51/// Token bundle de la firma visual.
52#[derive(Debug, Clone, Copy)]
53pub struct PanelStyle {
54    /// Color base del panel (típico: `theme.bg_panel`).
55    pub bg_base: Color,
56    /// Color del hairline (típico: `theme.accent`).
57    pub accent: Color,
58    /// Radio de las esquinas (típico: `radius::MD` para cards, `radius::LG`
59    /// para modales/overlays).
60    pub radius: f64,
61    /// Alpha del hairline (0.0–1.0). Por debajo de 0.20 se pierde; por
62    /// encima de 0.45 se vuelve dominante. Default 0.30.
63    pub hairline_alpha: f32,
64    /// Fuerza del gradiente — cada componente RGB se desplaza ±gradient
65    /// (en escala 0.0–1.0). 0.04 = 4% = imperceptible-pero-presente.
66    /// Subir más sólo si el theme es muy claro y el efecto no llega.
67    pub gradient_strength: f32,
68}
69
70impl PanelStyle {
71    /// Estilo estándar para cards / sidebars / paneles medianos.
72    pub fn from_theme(t: &Theme) -> Self {
73        Self {
74            bg_base: t.bg_panel,
75            accent: t.accent,
76            radius: radius::MD,
77            hairline_alpha: alpha::SCRIM as f32 / 255.0 * 1.2, // ~0.30
78            gradient_strength: 0.04,
79        }
80    }
81
82    /// Variante para superficies grandes — modales, splash, overlays.
83    /// Esquinas más generosas, gradiente y hairline un toque más marcados.
84    pub fn from_theme_large(t: &Theme) -> Self {
85        Self {
86            bg_base: t.bg_panel,
87            accent: t.accent,
88            radius: radius::LG,
89            hairline_alpha: 0.35,
90            gradient_strength: 0.05,
91        }
92    }
93
94    /// Variante neutra — sin hairline (panels que no deben llevar la
95    /// "firma" porque son piezas auxiliares). Mantiene el gradiente.
96    pub fn neutral(t: &Theme) -> Self {
97        Self {
98            bg_base: t.bg_panel,
99            accent: t.accent,
100            radius: radius::MD,
101            hairline_alpha: 0.0,
102            gradient_strength: 0.03,
103        }
104    }
105
106    /// Color del top del gradiente: base aclarada.
107    pub fn bg_top(&self) -> Color {
108        shift(self.bg_base, self.gradient_strength)
109    }
110
111    /// Color del bottom del gradiente: base oscurecida.
112    pub fn bg_bot(&self) -> Color {
113        shift(self.bg_base, -self.gradient_strength)
114    }
115}
116
117/// Devuelve la closure de pintura que aplica la firma sobre el rect del
118/// nodo. Pasarla a `View::paint_with` para sumar la firma a un View
119/// existente. El View NO debe tener `.fill(...)` setteado — el gradient
120/// reemplaza el fill sólido.
121///
122/// Nota: el View debe llamar `.radius(style.radius)` en sí mismo si quiere
123/// que clip/hit-test/borders respeten las esquinas. La firma pinta el
124/// gradiente como `RoundedRect` con el mismo `radius`, así que la
125/// silueta visual es consistente independientemente del clipping.
126pub fn panel_signature_painter(
127    style: PanelStyle,
128) -> impl Fn(&mut llimphi_compositor::Lienzo, &mut llimphi_compositor::llimphi_text::Typesetter, PaintRect)
129       + Send
130       + Sync
131       + 'static {
132    move |scene, _ts, rect| {
133        if rect.w <= 0.0 || rect.h <= 0.0 {
134            return;
135        }
136
137        // === 1) Gradiente vertical en RoundedRect ===
138        let x0 = rect.x as f64;
139        let y0 = rect.y as f64;
140        let x1 = (rect.x + rect.w) as f64;
141        let y1 = (rect.y + rect.h) as f64;
142        let rr = RoundedRect::new(x0, y0, x1, y1, style.radius);
143        let gradient = Gradient::new_linear(
144            Point::new(x0, y0),
145            Point::new(x0, y1),
146        )
147        .with_stops([style.bg_top(), style.bg_bot()].as_slice());
148        scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
149
150        // === 2) Hairline accent en el top edge ===
151        // Se acorta horizontalmente para no chocar con las esquinas
152        // redondeadas — queda inscrito en el "techo recto" del panel.
153        if style.hairline_alpha > 0.0 && rect.w > style.radius as f32 * 2.0 + 4.0 {
154            let hairline_color = with_alpha_mul(style.accent, style.hairline_alpha);
155            let hairline = KurboRect::new(
156                x0 + style.radius,
157                y0,
158                x1 - style.radius,
159                y0 + 1.0,
160            );
161            scene.fill(Fill::NonZero, Affine::IDENTITY, hairline_color, None, &hairline);
162        }
163    }
164}
165
166/// Convenience: arma un `View` con la firma aplicada y los `children`
167/// adentro. Equivalente a:
168///
169/// ```ignore
170/// View::new(Style { size: full, ..Default::default() })
171///     .paint_with(panel_signature_painter(style))
172///     .radius(style.radius)
173///     .clip(true)
174///     .children(children)
175/// ```
176///
177/// Para layouts custom (size específico, padding, flex direction), usar
178/// `panel_signature_painter` directamente y construir el View a mano.
179pub fn panel_view<Msg: Clone + 'static>(
180    children: Vec<View<Msg>>,
181    style: PanelStyle,
182) -> View<Msg> {
183    View::new(Style {
184        size: Size {
185            width: percent(1.0_f32),
186            height: percent(1.0_f32),
187        },
188        ..Default::default()
189    })
190    .paint_with(panel_signature_painter(style))
191    .radius(style.radius)
192    .clip(true)
193    .children(children)
194}
195
196/// Variante elevada: agrega una sombra del nivel `elev` (token de
197/// [`llimphi_theme::elevation`]) al `panel_view`. Para dropdowns,
198/// popovers y dashboards que necesitan separación clara del fondo.
199/// Pasar `elevation::E2` para cards, `E3` para menús contextuales,
200/// `E4` para modales.
201pub fn panel_elevated_view<Msg: Clone + 'static>(
202    children: Vec<View<Msg>>,
203    style: PanelStyle,
204    elev: elevation::Elev,
205) -> View<Msg> {
206    let (a, blur, dy) = elev;
207    let shadow = Shadow {
208        color: Color::from_rgba8(0, 0, 0, a),
209        blur,
210        dx: 0.0,
211        dy,
212        spread: 0.0,
213    };
214    panel_view(children, style).shadow(shadow)
215}
216
217// =====================================================================
218// Helpers internos
219// =====================================================================
220
221/// Desplaza cada componente RGB de `c` por `delta` (positivo aclara,
222/// negativo oscurece). Clampea en [0,1]. El alpha queda intacto.
223fn shift(c: Color, delta: f32) -> Color {
224    let [r, g, b, a] = c.components;
225    AlphaColor::new([
226        (r + delta).clamp(0.0, 1.0),
227        (g + delta).clamp(0.0, 1.0),
228        (b + delta).clamp(0.0, 1.0),
229        a,
230    ])
231}
232
233fn with_alpha_mul(c: Color, mult: f32) -> Color {
234    let [r, g, b, a] = c.components;
235    AlphaColor::new([r, g, b, a * mult])
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn bg_top_is_brighter_than_bg_bot() {
244        let t = Theme::dark();
245        let s = PanelStyle::from_theme(&t);
246        let top = s.bg_top();
247        let bot = s.bg_bot();
248        // El top debe tener cada canal RGB ≥ al del bot (es más claro).
249        for i in 0..3 {
250            assert!(top.components[i] >= bot.components[i],
251                "canal {i}: top {} < bot {}", top.components[i], bot.components[i]);
252        }
253    }
254
255    #[test]
256    fn neutral_style_has_no_hairline() {
257        let t = Theme::dark();
258        let s = PanelStyle::neutral(&t);
259        assert_eq!(s.hairline_alpha, 0.0);
260    }
261
262    #[test]
263    fn shift_clamps_to_unit() {
264        let c = Color::from_rgba8(250, 250, 250, 255);
265        let bright = shift(c, 0.5);
266        assert!(bright.components[0] <= 1.0);
267        assert!(bright.components[1] <= 1.0);
268    }
269}