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-button` — botón clicable con estado hover.
2//!
3//! Reusable entre apps Llimphi: `button_view(label, palette, on_click)`
4//! devuelve una vista que cambia de color cuando el cursor pasa por
5//! encima y emite `on_click` al ser apretada. El caller controla las
6//! dimensiones envolviendo el `View` retornado en un contenedor flex
7//! con el tamaño que necesite (botón ancho completo, chip 80×30, etc).
8//!
9//! No expone estado interno — todo el estado vive en el `Model` del App
10//! (el hover lo trackea llimphi-ui automáticamente vía `hover_fill`).
11
12#![forbid(unsafe_code)]
13
14
15// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
16// `String`/`Vec`/`Arc`—, y declararlo es lo que le permite cruzar a una
17// jaula WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
18#![no_std]
19
20extern crate alloc;
21
22use alloc::string::String;
23use llimphi_compositor::llimphi_layout::taffy::{
24 prelude::{length, percent, Size, Style},
25 AlignItems, JustifyContent, Rect,
26};
27use llimphi_compositor::peniko::Color;
28use llimphi_compositor::llimphi_text::Alignment;
29use llimphi_compositor::View;
30
31/// Paleta del botón. Por default un chip dark con highlight tenue al
32/// hover — similar al patrón `bg_panel_alt` + `bg_row_hover` de
33/// `nahual-theme`.
34#[derive(Debug, Clone, Copy)]
35pub struct ButtonPalette {
36 pub bg: Color,
37 pub bg_hover: Color,
38 pub fg: Color,
39 pub radius: f64,
40}
41
42impl Default for ButtonPalette {
43 fn default() -> Self {
44 Self::from_theme(&llimphi_theme::Theme::dark())
45 }
46}
47
48impl ButtonPalette {
49 /// Construye la paleta desde un `Theme` semántico.
50 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
51 Self {
52 bg: t.bg_button,
53 bg_hover: t.bg_button_hover,
54 fg: t.fg_text,
55 radius: 5.0,
56 }
57 }
58}
59
60/// Compone un botón rectangular: bg + texto + on_click + hover. Por
61/// default ocupa ancho 100% del padre y alto 30 px; sobre-escribir
62/// pasando un `Style` propio vía [`button_styled`].
63pub fn button_view<Msg: Clone + 'static>(
64 label: impl Into<String>,
65 palette: &ButtonPalette,
66 on_click: Msg,
67) -> View<Msg> {
68 button_styled(
69 label,
70 Style {
71 size: Size {
72 width: percent(1.0_f32),
73 height: length(30.0_f32),
74 },
75 padding: Rect {
76 left: length(10.0_f32),
77 right: length(10.0_f32),
78 top: length(0.0_f32),
79 bottom: length(0.0_f32),
80 },
81 align_items: Some(AlignItems::CENTER),
82 justify_content: Some(JustifyContent::CENTER),
83 ..Default::default()
84 },
85 Alignment::Center,
86 palette,
87 on_click,
88 )
89}
90
91/// Tinte de la onda de ripple derivado de la paleta: el color de texto
92/// (`fg`, normalmente claro sobre el botón dark) a alpha bajo, así contrasta
93/// con el fondo y se adapta al theme sin añadir un campo a [`ButtonPalette`].
94fn ripple_ink(palette: &ButtonPalette) -> Color {
95 let c = palette.fg.components;
96 Color { components: [c[0], c[1], c[2], 0.22], ..palette.fg }
97}
98
99/// Mezcla lineal `a → b` por `t∈[0,1]` en el espacio de `components`,
100/// conservando el alpha de `a`. Base de los tintes derivados (sheen, borde).
101fn mix(a: Color, b: Color, t: f32) -> Color {
102 let (ca, cb) = (a.components, b.components);
103 Color {
104 components: [
105 ca[0] + (cb[0] - ca[0]) * t,
106 ca[1] + (cb[1] - ca[1]) * t,
107 ca[2] + (cb[2] - ca[2]) * t,
108 ca[3],
109 ],
110 ..a
111 }
112}
113
114/// Luminancia percibida (Rec. 709) del color — decide si el botón es claro u
115/// oscuro para modular sheen/borde/sombra sin depender del nombre del theme.
116fn luminance(c: Color) -> f32 {
117 let k = c.components;
118 0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2]
119}
120
121const WHITE: Color = Color::from_rgba8(255, 255, 255, 255);
122const BLACK: Color = Color::from_rgba8(0, 0, 0, 255);
123
124/// Como [`button_view`] pero con feedback **ripple/InkWell**: al presionarlo
125/// emite la salpicadura Material (círculo que se expande desde el punto del
126/// tap y se desvanece, recortado al contorno del botón). `key` debe ser
127/// **estable y único** entre los botones vivos del frame (índice del botón,
128/// hash de su acción) — es lo que enlaza la onda retenida con este botón entre
129/// frames. El tinte sale de la paleta ([`ripple_ink`]).
130pub fn button_ripple<Msg: Clone + 'static>(
131 label: impl Into<String>,
132 key: u64,
133 palette: &ButtonPalette,
134 on_click: Msg,
135) -> View<Msg> {
136 button_view(label, palette, on_click).ripple(key, ripple_ink(palette))
137}
138
139/// Variante con `Style` y alineación de texto explícitos — útil cuando
140/// la app necesita un botón con dimensiones particulares o el texto a
141/// la izquierda.
142pub fn button_styled<Msg: Clone + 'static>(
143 label: impl Into<String>,
144 style: Style,
145 text_alignment: Alignment,
146 palette: &ButtonPalette,
147 on_click: Msg,
148) -> View<Msg> {
149 let label: String = label.into();
150 // Semántica accesible: rol Button + el texto visible como nombre. Si el
151 // caller le pasó un label vacío (botones puramente icónicos), igual sale
152 // como Button — lo correcto es agregarle un aria_label propio desde fuera.
153 let aria = label.clone();
154 let radius = palette.radius;
155 let bg = palette.bg;
156 // Luminancia del botón: gobierna la intensidad y la dirección del relieve
157 // para que se vea igual de refinado en themes claros y oscuros (sin mirar
158 // el nombre del theme). `dark` alto ⇒ botón oscuro.
159 let lum = luminance(bg);
160 let dark = 1.0 - lum;
161
162 // --- Capa 1: sombra suave y ceñida (elevación discreta) ---------------
163 // `spread: -1` la deja un pelo más chica que el botón: levanta sin flotar.
164 // Sobre fondo claro es la que da la profundidad; sobre fondo oscuro casi no
165 // se ve (una sombra negra sobre negro no lee) y el relieve lo carga el
166 // borde-luz de abajo. Alpha modesto para no ensuciar toolbars densas.
167 let shadow = llimphi_compositor::Shadow {
168 color: Color::from_rgba8(0, 0, 0, 38),
169 blur: 5.0,
170 dx: 0.0,
171 dy: 1.5,
172 spread: -1.0,
173 };
174
175 // --- Capa 2: borde hairline (contorno/bisel) --------------------------
176 // En botón oscuro, un borde un toque más CLARO lee como rim-light/bisel;
177 // en botón claro, uno más OSCURO da un canto nítido. 1 px, sutil.
178 let border_col = if lum < 0.5 {
179 mix(bg, WHITE, 0.16)
180 } else {
181 mix(bg, BLACK, 0.14)
182 };
183
184 // --- Capa 4: sheen vertical translúcido (dimensión) -------------------
185 // Va en `paint_with` (entre el fill —que respeta hover_fill— y el texto),
186 // así el relieve se SUMA al color base y el hover sigue intacto. 3 stops:
187 // luz arriba → neutro al medio → sombra abajo. La luz sube cuanto más
188 // oscuro el botón (`dark`); la sombra de abajo es constante y tenue.
189 let top_a = (0.10 + 0.12 * dark).min(0.22);
190 let sheen_top = Color { components: [1.0, 1.0, 1.0, top_a], ..WHITE };
191 let sheen_mid = Color::from_rgba8(255, 255, 255, 0);
192 let sheen_bot = Color::from_rgba8(0, 0, 0, 26);
193
194 View::new(style)
195 .fill(palette.bg)
196 .hover_fill(palette.bg_hover)
197 .radius(radius)
198 .shadow(shadow)
199 .border(1.0, border_col)
200 .paint_with(move |scene, _ts, rect| {
201 use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
202 use llimphi_compositor::peniko::{Fill, Gradient};
203 if rect.w <= 0.0 || rect.h <= 0.0 {
204 return;
205 }
206 let x0 = rect.x as f64;
207 let y0 = rect.y as f64;
208 let x1 = (rect.x + rect.w) as f64;
209 let y1 = (rect.y + rect.h) as f64;
210 let rr = RoundedRect::new(x0, y0, x1, y1, radius);
211 // Sheen de arriba a abajo sobre todo el alto.
212 let sheen = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1))
213 .with_stops([sheen_top, sheen_mid, sheen_bot].as_slice());
214 scene.fill(Fill::NonZero, Affine::IDENTITY, &sheen, None, &rr);
215 // Specular: hairline brillante pegado al borde superior interno —
216 // el catch-light que hace que el canto "prenda". 1.2 px de alto.
217 let inset = radius.min(1.0).max(0.5);
218 let cap = RoundedRect::new(x0 + inset, y0 + 0.6, x1 - inset, y0 + 1.8, radius * 0.5);
219 let spec = Color { components: [1.0, 1.0, 1.0, (0.22 + 0.20 * dark).min(0.5)], ..WHITE };
220 scene.fill(Fill::NonZero, Affine::IDENTITY, &spec, None, &cap);
221 })
222 .text_aligned(label, 13.0, palette.fg, text_alignment)
223 .role(llimphi_compositor::Role::Button)
224 .aria_label(aria)
225 .on_click(on_click)
226 .cursor(llimphi_compositor::Cursor::Pointer)
227}
228
229/// Como [`button_styled`] pero con feedback **ripple/InkWell** (ver
230/// [`button_ripple`] para la semántica de `key`).
231pub fn button_styled_ripple<Msg: Clone + 'static>(
232 label: impl Into<String>,
233 key: u64,
234 style: Style,
235 text_alignment: Alignment,
236 palette: &ButtonPalette,
237 on_click: Msg,
238) -> View<Msg> {
239 button_styled(label, style, text_alignment, palette, on_click)
240 .ripple(key, ripple_ink(palette))
241}