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