tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ main widgets/button/src/lib.rs Rust · 241 siq'ikuna · 9.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · 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
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}