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}