tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}