//! `llimphi-widget-button` — botón clicable con estado hover. //! //! Reusable entre apps Llimphi: `button_view(label, palette, on_click)` //! devuelve una vista que cambia de color cuando el cursor pasa por //! encima y emite `on_click` al ser apretada. El caller controla las //! dimensiones envolviendo el `View` retornado en un contenedor flex //! con el tamaño que necesite (botón ancho completo, chip 80×30, etc). //! //! No expone estado interno — todo el estado vive en el `Model` del App //! (el hover lo trackea llimphi-ui automáticamente vía `hover_fill`). #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`/`Arc`—, y declararlo es lo que le permite cruzar a una // jaula WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::string::String; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, Size, Style}, AlignItems, JustifyContent, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::View; /// Paleta del botón. Por default un chip dark con highlight tenue al /// hover — similar al patrón `bg_panel_alt` + `bg_row_hover` de /// `nahual-theme`. #[derive(Debug, Clone, Copy)] pub struct ButtonPalette { pub bg: Color, pub bg_hover: Color, pub fg: Color, pub radius: f64, } impl Default for ButtonPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } impl ButtonPalette { /// Construye la paleta desde un `Theme` semántico. pub fn from_theme(t: &llimphi_theme::Theme) -> Self { Self { bg: t.bg_button, bg_hover: t.bg_button_hover, fg: t.fg_text, radius: 5.0, } } } /// Compone un botón rectangular: bg + texto + on_click + hover. Por /// default ocupa ancho 100% del padre y alto 30 px; sobre-escribir /// pasando un `Style` propio vía [`button_styled`]. pub fn button_view( label: impl Into, palette: &ButtonPalette, on_click: Msg, ) -> View { button_styled( label, Style { size: Size { width: percent(1.0_f32), height: length(30.0_f32), }, padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }, Alignment::Center, palette, on_click, ) } /// Tinte de la onda de ripple derivado de la paleta: el color de texto /// (`fg`, normalmente claro sobre el botón dark) a alpha bajo, así contrasta /// con el fondo y se adapta al theme sin añadir un campo a [`ButtonPalette`]. fn ripple_ink(palette: &ButtonPalette) -> Color { let c = palette.fg.components; Color { components: [c[0], c[1], c[2], 0.22], ..palette.fg } } /// Mezcla lineal `a → b` por `t∈[0,1]` en el espacio de `components`, /// conservando el alpha de `a`. Base de los tintes derivados (sheen, borde). fn mix(a: Color, b: Color, t: f32) -> Color { let (ca, cb) = (a.components, b.components); Color { components: [ ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t, ca[3], ], ..a } } /// Luminancia percibida (Rec. 709) del color — decide si el botón es claro u /// oscuro para modular sheen/borde/sombra sin depender del nombre del theme. fn luminance(c: Color) -> f32 { let k = c.components; 0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2] } const WHITE: Color = Color::from_rgba8(255, 255, 255, 255); const BLACK: Color = Color::from_rgba8(0, 0, 0, 255); /// Como [`button_view`] pero con feedback **ripple/InkWell**: al presionarlo /// emite la salpicadura Material (círculo que se expande desde el punto del /// tap y se desvanece, recortado al contorno del botón). `key` debe ser /// **estable y único** entre los botones vivos del frame (índice del botón, /// hash de su acción) — es lo que enlaza la onda retenida con este botón entre /// frames. El tinte sale de la paleta ([`ripple_ink`]). pub fn button_ripple( label: impl Into, key: u64, palette: &ButtonPalette, on_click: Msg, ) -> View { button_view(label, palette, on_click).ripple(key, ripple_ink(palette)) } /// Variante con `Style` y alineación de texto explícitos — útil cuando /// la app necesita un botón con dimensiones particulares o el texto a /// la izquierda. pub fn button_styled( label: impl Into, style: Style, text_alignment: Alignment, palette: &ButtonPalette, on_click: Msg, ) -> View { let label: String = label.into(); // Semántica accesible: rol Button + el texto visible como nombre. Si el // caller le pasó un label vacío (botones puramente icónicos), igual sale // como Button — lo correcto es agregarle un aria_label propio desde fuera. let aria = label.clone(); let radius = palette.radius; let bg = palette.bg; // Luminancia del botón: gobierna la intensidad y la dirección del relieve // para que se vea igual de refinado en themes claros y oscuros (sin mirar // el nombre del theme). `dark` alto ⇒ botón oscuro. let lum = luminance(bg); let dark = 1.0 - lum; // --- Capa 1: sombra suave y ceñida (elevación discreta) --------------- // `spread: -1` la deja un pelo más chica que el botón: levanta sin flotar. // Sobre fondo claro es la que da la profundidad; sobre fondo oscuro casi no // se ve (una sombra negra sobre negro no lee) y el relieve lo carga el // borde-luz de abajo. Alpha modesto para no ensuciar toolbars densas. let shadow = llimphi_compositor::Shadow { color: Color::from_rgba8(0, 0, 0, 38), blur: 5.0, dx: 0.0, dy: 1.5, spread: -1.0, }; // --- Capa 2: borde hairline (contorno/bisel) -------------------------- // En botón oscuro, un borde un toque más CLARO lee como rim-light/bisel; // en botón claro, uno más OSCURO da un canto nítido. 1 px, sutil. let border_col = if lum < 0.5 { mix(bg, WHITE, 0.16) } else { mix(bg, BLACK, 0.14) }; // --- Capa 4: sheen vertical translúcido (dimensión) ------------------- // Va en `paint_with` (entre el fill —que respeta hover_fill— y el texto), // así el relieve se SUMA al color base y el hover sigue intacto. 3 stops: // luz arriba → neutro al medio → sombra abajo. La luz sube cuanto más // oscuro el botón (`dark`); la sombra de abajo es constante y tenue. let top_a = (0.10 + 0.12 * dark).min(0.22); let sheen_top = Color { components: [1.0, 1.0, 1.0, top_a], ..WHITE }; let sheen_mid = Color::from_rgba8(255, 255, 255, 0); let sheen_bot = Color::from_rgba8(0, 0, 0, 26); View::new(style) .fill(palette.bg) .hover_fill(palette.bg_hover) .radius(radius) .shadow(shadow) .border(1.0, border_col) .paint_with(move |scene, _ts, rect| { use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect}; use llimphi_compositor::peniko::{Fill, Gradient}; if rect.w <= 0.0 || rect.h <= 0.0 { return; } let x0 = rect.x as f64; let y0 = rect.y as f64; let x1 = (rect.x + rect.w) as f64; let y1 = (rect.y + rect.h) as f64; let rr = RoundedRect::new(x0, y0, x1, y1, radius); // Sheen de arriba a abajo sobre todo el alto. let sheen = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1)) .with_stops([sheen_top, sheen_mid, sheen_bot].as_slice()); scene.fill(Fill::NonZero, Affine::IDENTITY, &sheen, None, &rr); // Specular: hairline brillante pegado al borde superior interno — // el catch-light que hace que el canto "prenda". 1.2 px de alto. let inset = radius.min(1.0).max(0.5); let cap = RoundedRect::new(x0 + inset, y0 + 0.6, x1 - inset, y0 + 1.8, radius * 0.5); let spec = Color { components: [1.0, 1.0, 1.0, (0.22 + 0.20 * dark).min(0.5)], ..WHITE }; scene.fill(Fill::NonZero, Affine::IDENTITY, &spec, None, &cap); }) .text_aligned(label, 13.0, palette.fg, text_alignment) .role(llimphi_compositor::Role::Button) .aria_label(aria) .on_click(on_click) .cursor(llimphi_compositor::Cursor::Pointer) } /// Como [`button_styled`] pero con feedback **ripple/InkWell** (ver /// [`button_ripple`] para la semántica de `key`). pub fn button_styled_ripple( label: impl Into, key: u64, style: Style, text_alignment: Alignment, palette: &ButtonPalette, on_click: Msg, ) -> View { button_styled(label, style, text_alignment, palette, on_click) .ripple(key, ripple_ink(palette)) }