tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 widgets/rating/src/lib.rs Rust · 147 lines · 4.4 KB History Who touched what raw

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-rating` — N estrellas clicables.
2//!
3//! Render: estrellas dibujadas a mano con `paint_with` (polígono de 10
4//! vértices). El caller pasa `value` (0..=max) y `max` (típicamente 5).
5//! Cada estrella es clickable: emite `on_change(idx + 1)`.
6//!
7//! Sober, no chillón: las estrellas inactivas son `border` (gris),
8//! las activas `accent`. Tamaño configurable, default 18 px.
9
10#![forbid(unsafe_code)]
11
12use llimphi_ui::llimphi_layout::taffy::{
13    prelude::{length, FlexDirection, Size, Style},
14    AlignItems,
15};
16use llimphi_ui::llimphi_raster::peniko::Color;
17use llimphi_ui::View;
18use llimphi_theme::Theme;
19
20/// Paleta del rating.
21#[derive(Debug, Clone, Copy)]
22pub struct RatingPalette {
23    pub on: Color,
24    pub off: Color,
25}
26
27impl RatingPalette {
28    pub fn from_theme(t: &Theme) -> Self {
29        Self {
30            on: t.accent,
31            off: t.border,
32        }
33    }
34}
35
36/// Compone `max` estrellas en fila; las primeras `value` están "on",
37/// el resto "off". Cada estrella dispara `on_change(idx + 1)` al click.
38pub fn rating_view<Msg, F>(
39    value: u32,
40    max: u32,
41    star_size: f32,
42    palette: &RatingPalette,
43    on_change: F,
44) -> View<Msg>
45where
46    Msg: Clone + 'static,
47    F: Fn(u32) -> Msg + Clone + Send + Sync + 'static,
48{
49    let mut children: Vec<View<Msg>> = Vec::with_capacity(max as usize);
50    for i in 0..max {
51        let filled = i < value;
52        let color = if filled { palette.on } else { palette.off };
53        let star = View::new(Style {
54            size: Size { width: length(star_size), height: length(star_size) },
55            ..Default::default()
56        })
57        .paint_with(move |scene, _ts, rect| {
58            paint_star(scene, rect, color);
59        })
60        .on_click(on_change(i + 1))
61        .cursor(llimphi_ui::Cursor::Pointer);
62        children.push(star);
63    }
64
65    View::new(Style {
66        flex_direction: FlexDirection::Row,
67        align_items: Some(AlignItems::Center),
68        gap: Size {
69            width: length(2.0_f32),
70            height: length(0.0_f32),
71        },
72        ..Default::default()
73    })
74    .children(children)
75}
76
77/// Nuevo valor tras un paso de teclado (←/→): `value + dir`, clampeado a
78/// `[0, max]`. El host mapea las flechas a esto y dispatcha `on_change`.
79pub fn step_rating(value: u32, direction: i32, max: u32) -> u32 {
80    let v = value as i32 + direction;
81    v.clamp(0, max as i32) as u32
82}
83
84/// Valor tras clickear la estrella `clicked` (1-based). Si se clickea la
85/// que ya marca el tope actual, **limpia a 0** (toggle — como los ratings
86/// que permiten desmarcar); si no, fija `clicked`.
87pub fn click_value(value: u32, clicked: u32) -> u32 {
88    if value == clicked {
89        0
90    } else {
91        clicked
92    }
93}
94
95/// Polígono de estrella de 5 puntas centrado en `rect`. Radios `R`
96/// (exterior) y `R·0.42` (interior).
97fn paint_star(
98    scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
99    rect: llimphi_ui::PaintRect,
100    color: Color,
101) {
102    use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point};
103    use llimphi_ui::llimphi_raster::peniko::Fill;
104    if rect.w <= 0.0 || rect.h <= 0.0 {
105        return;
106    }
107    let cx = (rect.x + rect.w * 0.5) as f64;
108    let cy = (rect.y + rect.h * 0.5) as f64;
109    let r_out = (rect.w.min(rect.h) as f64) * 0.5;
110    let r_in = r_out * 0.42;
111    let mut p = BezPath::new();
112    let n_points = 5;
113    let start_angle = -std::f64::consts::FRAC_PI_2; // primera punta arriba
114    for i in 0..(n_points * 2) {
115        let r = if i % 2 == 0 { r_out } else { r_in };
116        let theta = start_angle + (i as f64) * std::f64::consts::PI / (n_points as f64);
117        let x = cx + r * theta.cos();
118        let y = cy + r * theta.sin();
119        if i == 0 {
120            p.move_to(Point::new(x, y));
121        } else {
122            p.line_to(Point::new(x, y));
123        }
124    }
125    p.close_path();
126    scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p);
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn step_rating_clampa_en_0_y_max() {
135        assert_eq!(step_rating(3, 1, 5), 4);
136        assert_eq!(step_rating(3, -1, 5), 2);
137        assert_eq!(step_rating(5, 1, 5), 5); // tope
138        assert_eq!(step_rating(0, -1, 5), 0); // piso
139    }
140
141    #[test]
142    fn click_value_togglea_a_cero_en_la_misma() {
143        assert_eq!(click_value(3, 5), 5); // subir
144        assert_eq!(click_value(5, 3), 3); // bajar
145        assert_eq!(click_value(4, 4), 0); // click en la actual → limpia
146    }
147}