tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main widgets/fab/src/lib.rs Rust · 222 siq'ikuna · 6.8 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-fab` — Floating Action Button.
2//!
3//! Botón circular elevado pensado para la **acción primaria** de una
4//! pantalla (componer, nuevo, +, capturar). Heredamos el patrón
5//! Material/Flutter: rest sobre sombra E3, círculo del color `accent`,
6//! glyph blanco centrado, y **feedback de hover** (el fill se aviva al
7//! pasar el cursor, vía `hover_fill` del compositor — sin estado en el host).
8//!
9//! La **entrada** usa `animated_pop_in` para el "pop" canónico de Material
10//! (scale 0.6 → 1.0 + fade-in) en vez de aparecer de golpe.
11
12#![forbid(unsafe_code)]
13
14
15// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
16// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
17// 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 alloc::vec::Vec;
24use llimphi_compositor::Shadow;
25use llimphi_compositor::llimphi_layout::taffy::{
26    prelude::{length, Size, Style},
27    AlignItems, JustifyContent,
28};
29use llimphi_compositor::peniko::Color;
30use llimphi_compositor::llimphi_text::Alignment;
31use llimphi_compositor::View;
32use llimphi_theme::{elevation, motion, Theme};
33
34/// Tamaño del FAB. El estándar Material es 56 px; el "mini" 40 px;
35/// el "extended" (texto + glyph, píldora ancha) es [`fab_extended`] /
36/// [`fab_extended_icon`], no un `FabSize`.
37#[derive(Debug, Clone, Copy)]
38pub enum FabSize {
39    Regular,
40    Mini,
41}
42
43/// Aviva un color hacia el blanco en `t` (0..1) — feedback de hover.
44fn brighten(c: Color, t: f32) -> Color {
45    let [r, g, b, a] = c.components;
46    Color {
47        components: [
48            r + (1.0 - r) * t,
49            g + (1.0 - g) * t,
50            b + (1.0 - b) * t,
51            a,
52        ],
53        ..c
54    }
55}
56
57impl FabSize {
58    pub fn px(self) -> f32 {
59        match self {
60            FabSize::Regular => 56.0,
61            FabSize::Mini => 40.0,
62        }
63    }
64}
65
66/// Paleta del FAB.
67#[derive(Debug, Clone, Copy)]
68pub struct FabPalette {
69    /// Fill del círculo (idle).
70    pub bg: Color,
71    /// Color del glyph.
72    pub fg: Color,
73}
74
75impl FabPalette {
76    pub fn from_theme(t: &Theme) -> Self {
77        Self {
78            bg: t.accent,
79            // Texto sobre accent: blanco (los accents del repo son todos
80            // suficientemente saturados para hacer contraste).
81            fg: Color::from_rgba8(255, 255, 255, 255),
82        }
83    }
84}
85
86/// Compone el FAB. `key` debe ser estable para que la anim de hover
87/// quede vinculada al mismo nodo entre frames.
88pub fn fab_view<Msg: Clone + 'static>(
89    glyph: impl Into<String>,
90    size: FabSize,
91    key: u64,
92    palette: &FabPalette,
93    on_click: Msg,
94) -> View<Msg> {
95    let s = size.px();
96    let (a, blur, dy) = elevation::E3;
97    let shadow = Shadow {
98        color: Color::from_rgba8(0, 0, 0, a),
99        blur,
100        dx: 0.0,
101        dy,
102        spread: 0.0,
103    };
104    let glyph: String = glyph.into();
105    let aria = glyph.clone();
106    View::new(Style {
107        size: Size { width: length(s), height: length(s) },
108        align_items: Some(AlignItems::CENTER),
109        justify_content: Some(JustifyContent::CENTER),
110        ..Default::default()
111    })
112    .fill(palette.bg)
113    .hover_fill(brighten(palette.bg, 0.12))
114    .radius((s as f64) * 0.5)
115    .shadow(shadow)
116    .animated_pop_in(key, motion::FAST)
117    .text_aligned(
118        glyph,
119        (s * 0.42).round(),
120        palette.fg,
121        Alignment::Center,
122    )
123    // El glyph (+, ✎, etc.) no siempre es un buen nombre para el lector — el
124    // caller suele querer overridear con `.aria_label("Crear nota")`. Lo dejamos
125    // como fallback igual: mejor decir "más" que nada.
126    .role(llimphi_compositor::Role::Button)
127    .aria_label(aria)
128    .on_click(on_click)
129    .cursor(llimphi_compositor::Cursor::Pointer)
130}
131
132/// Extended FAB (Material): píldora ancha con **texto**. Para texto + glyph
133/// líder, usar [`fab_extended_icon`].
134pub fn fab_extended<Msg: Clone + 'static>(
135    label: impl Into<String>,
136    key: u64,
137    palette: &FabPalette,
138    on_click: Msg,
139) -> View<Msg> {
140    fab_extended_icon(None::<String>, label, key, palette, on_click)
141}
142
143/// Extended FAB con **glyph líder + texto** (el "texto + ícono" que promete
144/// el patrón Material). `glyph` `None` = sólo texto (equivale a
145/// [`fab_extended`]).
146pub fn fab_extended_icon<Msg: Clone + 'static>(
147    glyph: Option<impl Into<String>>,
148    label: impl Into<String>,
149    key: u64,
150    palette: &FabPalette,
151    on_click: Msg,
152) -> View<Msg> {
153    let label: String = label.into();
154    let aria = label.clone();
155    let h = 48.0_f32;
156    let (a, blur, dy) = elevation::E3;
157    let shadow = Shadow {
158        color: Color::from_rgba8(0, 0, 0, a),
159        blur,
160        dx: 0.0,
161        dy,
162        spread: 0.0,
163    };
164
165    let mut children: Vec<View<Msg>> = Vec::with_capacity(2);
166    if let Some(g) = glyph {
167        children.push(
168            View::new(Style {
169                size: Size { width: length(20.0_f32), height: length(h) },
170                align_items: Some(AlignItems::CENTER),
171                justify_content: Some(JustifyContent::CENTER),
172                margin: llimphi_compositor::llimphi_layout::taffy::Rect {
173                    left: length(0.0_f32),
174                    right: length(8.0_f32),
175                    top: length(0.0_f32),
176                    bottom: length(0.0_f32),
177                },
178                ..Default::default()
179            })
180            .text_aligned(g.into(), 18.0, palette.fg, Alignment::Center),
181        );
182    }
183    children.push(
184        View::new(Style {
185            size: Size {
186                width: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
187                height: length(h),
188            },
189            align_items: Some(AlignItems::CENTER),
190            justify_content: Some(JustifyContent::CENTER),
191            ..Default::default()
192        })
193        .text_aligned(label, 14.0, palette.fg, Alignment::Center),
194    );
195
196    View::new(Style {
197        size: Size {
198            width: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
199            height: length(h),
200        },
201        flex_direction: llimphi_compositor::llimphi_layout::taffy::prelude::FlexDirection::Row,
202        align_items: Some(AlignItems::CENTER),
203        justify_content: Some(JustifyContent::CENTER),
204        padding: llimphi_compositor::llimphi_layout::taffy::Rect {
205            left: length(20.0_f32),
206            right: length(20.0_f32),
207            top: length(0.0_f32),
208            bottom: length(0.0_f32),
209        },
210        ..Default::default()
211    })
212    .fill(palette.bg)
213    .hover_fill(brighten(palette.bg, 0.12))
214    .radius((h as f64) * 0.5)
215    .shadow(shadow)
216    .animated_pop_in(key, motion::FAST)
217    .role(llimphi_compositor::Role::Button)
218    .aria_label(aria)
219    .on_click(on_click)
220    .cursor(llimphi_compositor::Cursor::Pointer)
221    .children(children)
222}