Skip to main content

egui_components/
rating.rs

1//! `Rating` — a row of clickable stars bound to a `&mut u32`.
2//!
3//! ```ignore
4//! ui.add(sc::Rating::new(&mut self.stars).max(5));
5//! ui.add(sc::Rating::new(&mut fixed).read_only());  // display only
6//! ```
7
8use egui::{pos2, vec2, Color32, Pos2, Response, Sense, Stroke, Ui, Widget};
9use egui_components_theme::{mix, Theme};
10
11pub struct Rating<'a> {
12    value: &'a mut u32,
13    max: u32,
14    star_size: f32,
15    gap: f32,
16    read_only: bool,
17    color: Option<Color32>,
18}
19
20impl<'a> Rating<'a> {
21    pub fn new(value: &'a mut u32) -> Self {
22        Self {
23            value,
24            max: 5,
25            star_size: 20.0,
26            gap: 4.0,
27            read_only: false,
28            color: None,
29        }
30    }
31    pub fn max(mut self, m: u32) -> Self {
32        self.max = m.max(1);
33        self
34    }
35    pub fn star_size(mut self, s: f32) -> Self {
36        self.star_size = s;
37        self
38    }
39    pub fn read_only(mut self) -> Self {
40        self.read_only = true;
41        self
42    }
43    pub fn color(mut self, c: Color32) -> Self {
44        self.color = Some(c);
45        self
46    }
47}
48
49impl<'a> Widget for Rating<'a> {
50    fn ui(self, ui: &mut Ui) -> Response {
51        let theme = Theme::get(ui.ctx());
52        let c = theme.colors;
53        let fill = self.color.unwrap_or(c.warning_background);
54        let empty = mix(c.muted_foreground, c.background, 0.4);
55
56        let n = self.max as f32;
57        let total = vec2(n * self.star_size + (n - 1.0) * self.gap, self.star_size);
58        let sense = if self.read_only {
59            Sense::hover()
60        } else {
61            Sense::click()
62        };
63        let (rect, mut response) = ui.allocate_exact_size(total, sense);
64
65        // Which star is under the pointer (1-based), if hovering interactively.
66        let hover_index = if !self.read_only && response.hovered() {
67            response.hover_pos().map(|p| {
68                let rel = (p.x - rect.left()) / (self.star_size + self.gap);
69                (rel.floor() as i64 + 1).clamp(1, self.max as i64) as u32
70            })
71        } else {
72            None
73        };
74
75        if let Some(h) = hover_index {
76            if response.clicked() {
77                // Click the already-selected single star to clear it.
78                *self.value = if *self.value == h { 0 } else { h };
79                response.mark_changed();
80            }
81        }
82
83        let shown = hover_index.unwrap_or(*self.value);
84
85        if ui.is_rect_visible(rect) {
86            for i in 0..self.max {
87                let cx = rect.left() + self.star_size * 0.5 + i as f32 * (self.star_size + self.gap);
88                let center = pos2(cx, rect.center().y);
89                let filled = i < shown;
90                draw_star(
91                    ui.painter(),
92                    center,
93                    self.star_size * 0.5,
94                    if filled { fill } else { Color32::TRANSPARENT },
95                    Stroke::new(1.4, if filled { fill } else { empty }),
96                );
97            }
98            if !self.read_only && response.hovered() {
99                ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
100            }
101        }
102
103        response
104    }
105}
106
107fn draw_star(painter: &egui::Painter, center: Pos2, radius: f32, fill: Color32, stroke: Stroke) {
108    let inner = radius * 0.4;
109    let mut pts = Vec::with_capacity(10);
110    let start = -std::f32::consts::FRAC_PI_2;
111    for k in 0..10 {
112        let r = if k % 2 == 0 { radius } else { inner };
113        let a = start + std::f32::consts::PI * (k as f32) / 5.0;
114        let (s, co) = a.sin_cos();
115        pts.push(center + vec2(co, s) * r);
116    }
117    painter.add(egui::Shape::convex_polygon(pts, fill, stroke));
118}