Skip to main content

llimphi_widget_panel/
lib.rs

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 tenés un View configurado y querés 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
36use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
37use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect};
38use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Color, Fill, Gradient};
39use llimphi_ui::{PaintRect, Shadow, View};
40use llimphi_theme::{alpha, elevation, radius, Theme};
41
42/// Token bundle de la firma visual.
43#[derive(Debug, Clone, Copy)]
44pub struct PanelStyle {
45    /// Color base del panel (típico: `theme.bg_panel`).
46    pub bg_base: Color,
47    /// Color del hairline (típico: `theme.accent`).
48    pub accent: Color,
49    /// Radio de las esquinas (típico: `radius::MD` para cards, `radius::LG`
50    /// para modales/overlays).
51    pub radius: f64,
52    /// Alpha del hairline (0.0–1.0). Por debajo de 0.20 se pierde; por
53    /// encima de 0.45 se vuelve dominante. Default 0.30.
54    pub hairline_alpha: f32,
55    /// Fuerza del gradiente — cada componente RGB se desplaza ±gradient
56    /// (en escala 0.0–1.0). 0.04 = 4% = imperceptible-pero-presente.
57    /// Subir más sólo si el theme es muy claro y el efecto no llega.
58    pub gradient_strength: f32,
59}
60
61impl PanelStyle {
62    /// Estilo estándar para cards / sidebars / paneles medianos.
63    pub fn from_theme(t: &Theme) -> Self {
64        Self {
65            bg_base: t.bg_panel,
66            accent: t.accent,
67            radius: radius::MD,
68            hairline_alpha: alpha::SCRIM as f32 / 255.0 * 1.2, // ~0.30
69            gradient_strength: 0.04,
70        }
71    }
72
73    /// Variante para superficies grandes — modales, splash, overlays.
74    /// Esquinas más generosas, gradiente y hairline un toque más marcados.
75    pub fn from_theme_large(t: &Theme) -> Self {
76        Self {
77            bg_base: t.bg_panel,
78            accent: t.accent,
79            radius: radius::LG,
80            hairline_alpha: 0.35,
81            gradient_strength: 0.05,
82        }
83    }
84
85    /// Variante neutra — sin hairline (panels que no deben llevar la
86    /// "firma" porque son piezas auxiliares). Mantiene el gradiente.
87    pub fn neutral(t: &Theme) -> Self {
88        Self {
89            bg_base: t.bg_panel,
90            accent: t.accent,
91            radius: radius::MD,
92            hairline_alpha: 0.0,
93            gradient_strength: 0.03,
94        }
95    }
96
97    /// Color del top del gradiente: base aclarada.
98    pub fn bg_top(&self) -> Color {
99        shift(self.bg_base, self.gradient_strength)
100    }
101
102    /// Color del bottom del gradiente: base oscurecida.
103    pub fn bg_bot(&self) -> Color {
104        shift(self.bg_base, -self.gradient_strength)
105    }
106}
107
108/// Devuelve la closure de pintura que aplica la firma sobre el rect del
109/// nodo. Pasarla a `View::paint_with` para sumar la firma a un View
110/// existente. El View NO debe tener `.fill(...)` setteado — el gradient
111/// reemplaza el fill sólido.
112///
113/// Nota: el View debe llamar `.radius(style.radius)` en sí mismo si quiere
114/// que clip/hit-test/borders respeten las esquinas. La firma pinta el
115/// gradiente como `RoundedRect` con el mismo `radius`, así que la
116/// silueta visual es consistente independientemente del clipping.
117pub fn panel_signature_painter(
118    style: PanelStyle,
119) -> impl Fn(&mut llimphi_ui::llimphi_raster::vello::Scene, &mut llimphi_ui::llimphi_text::Typesetter, PaintRect)
120       + Send
121       + Sync
122       + 'static {
123    move |scene, _ts, rect| {
124        if rect.w <= 0.0 || rect.h <= 0.0 {
125            return;
126        }
127
128        // === 1) Gradiente vertical en RoundedRect ===
129        let x0 = rect.x as f64;
130        let y0 = rect.y as f64;
131        let x1 = (rect.x + rect.w) as f64;
132        let y1 = (rect.y + rect.h) as f64;
133        let rr = RoundedRect::new(x0, y0, x1, y1, style.radius);
134        let gradient = Gradient::new_linear(
135            Point::new(x0, y0),
136            Point::new(x0, y1),
137        )
138        .with_stops([style.bg_top(), style.bg_bot()].as_slice());
139        scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
140
141        // === 2) Hairline accent en el top edge ===
142        // Se acorta horizontalmente para no chocar con las esquinas
143        // redondeadas — queda inscrito en el "techo recto" del panel.
144        if style.hairline_alpha > 0.0 && rect.w > style.radius as f32 * 2.0 + 4.0 {
145            let hairline_color = with_alpha_mul(style.accent, style.hairline_alpha);
146            let hairline = KurboRect::new(
147                x0 + style.radius,
148                y0,
149                x1 - style.radius,
150                y0 + 1.0,
151            );
152            scene.fill(Fill::NonZero, Affine::IDENTITY, hairline_color, None, &hairline);
153        }
154    }
155}
156
157/// Convenience: arma un `View` con la firma aplicada y los `children`
158/// adentro. Equivalente a:
159///
160/// ```ignore
161/// View::new(Style { size: full, ..Default::default() })
162///     .paint_with(panel_signature_painter(style))
163///     .radius(style.radius)
164///     .clip(true)
165///     .children(children)
166/// ```
167///
168/// Para layouts custom (size específico, padding, flex direction), usar
169/// `panel_signature_painter` directamente y construir el View a mano.
170pub fn panel_view<Msg: Clone + 'static>(
171    children: Vec<View<Msg>>,
172    style: PanelStyle,
173) -> View<Msg> {
174    View::new(Style {
175        size: Size {
176            width: percent(1.0_f32),
177            height: percent(1.0_f32),
178        },
179        ..Default::default()
180    })
181    .paint_with(panel_signature_painter(style))
182    .radius(style.radius)
183    .clip(true)
184    .children(children)
185}
186
187/// Variante elevada: agrega una sombra del nivel `elev` (token de
188/// [`llimphi_theme::elevation`]) al `panel_view`. Para dropdowns,
189/// popovers y dashboards que necesitan separación clara del fondo.
190/// Pasar `elevation::E2` para cards, `E3` para menús contextuales,
191/// `E4` para modales.
192pub fn panel_elevated_view<Msg: Clone + 'static>(
193    children: Vec<View<Msg>>,
194    style: PanelStyle,
195    elev: elevation::Elev,
196) -> View<Msg> {
197    let (a, blur, dy) = elev;
198    let shadow = Shadow {
199        color: Color::from_rgba8(0, 0, 0, a),
200        blur,
201        dx: 0.0,
202        dy,
203        spread: 0.0,
204    };
205    panel_view(children, style).shadow(shadow)
206}
207
208// =====================================================================
209// Helpers internos
210// =====================================================================
211
212/// Desplaza cada componente RGB de `c` por `delta` (positivo aclara,
213/// negativo oscurece). Clampea en [0,1]. El alpha queda intacto.
214fn shift(c: Color, delta: f32) -> Color {
215    let [r, g, b, a] = c.components;
216    AlphaColor::new([
217        (r + delta).clamp(0.0, 1.0),
218        (g + delta).clamp(0.0, 1.0),
219        (b + delta).clamp(0.0, 1.0),
220        a,
221    ])
222}
223
224fn with_alpha_mul(c: Color, mult: f32) -> Color {
225    let [r, g, b, a] = c.components;
226    AlphaColor::new([r, g, b, a * mult])
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn bg_top_is_brighter_than_bg_bot() {
235        let t = Theme::dark();
236        let s = PanelStyle::from_theme(&t);
237        let top = s.bg_top();
238        let bot = s.bg_bot();
239        // El top debe tener cada canal RGB ≥ al del bot (es más claro).
240        for i in 0..3 {
241            assert!(top.components[i] >= bot.components[i],
242                "canal {i}: top {} < bot {}", top.components[i], bot.components[i]);
243        }
244    }
245
246    #[test]
247    fn neutral_style_has_no_hairline() {
248        let t = Theme::dark();
249        let s = PanelStyle::neutral(&t);
250        assert_eq!(s.hairline_alpha, 0.0);
251    }
252
253    #[test]
254    fn shift_clamps_to_unit() {
255        let c = Color::from_rgba8(250, 250, 250, 255);
256        let bright = shift(c, 0.5);
257        assert!(bright.components[0] <= 1.0);
258        assert!(bright.components[1] <= 1.0);
259    }
260}