tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 widgets/button/src/lib.rs Rust · 232 líneas · 8.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}