tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}