Skip to main content

gpui_component/
rating.rs

1use crate::theme::ActiveTheme;
2use crate::{Disableable, Icon, IconName, Sizable, Size, StyledExt, h_flex};
3use std::rc::Rc;
4
5use gpui::{
6    App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, StyleRefinement,
7    Styled, Window, div, prelude::FluentBuilder as _,
8};
9use gpui::{ClickEvent, Hsla, StatefulInteractiveElement};
10
11/// A simple star Rating element.
12#[derive(IntoElement)]
13pub struct Rating {
14    id: ElementId,
15    style: StyleRefinement,
16    size: Size,
17    disabled: bool,
18    value: usize,
19    max: usize,
20    color: Option<Hsla>,
21    on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
22}
23
24impl Rating {
25    /// Create a new Rating with an `ElementId`.
26    pub fn new(id: impl Into<ElementId>) -> Self {
27        Self {
28            id: id.into(),
29            style: StyleRefinement::default(),
30            size: Size::Medium,
31            disabled: false,
32            value: 0,
33            max: 5,
34            color: None,
35            on_click: None,
36        }
37    }
38
39    /// Set the star size.
40    pub fn with_size(mut self, size: impl Into<Size>) -> Self {
41        self.size = size.into();
42        self
43    }
44
45    /// Disable interaction.
46    pub fn disabled(mut self, disabled: bool) -> Self {
47        self.disabled = disabled;
48        self
49    }
50
51    /// Set active color, default will use `yellow` from theme colors.
52    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
53        self.color = Some(color.into());
54        self
55    }
56
57    /// Set initial value (0..=max).
58    pub fn value(mut self, value: usize) -> Self {
59        self.value = value;
60        if self.value > self.max {
61            self.value = self.max;
62        }
63        self
64    }
65
66    /// Set maximum number of stars.
67    pub fn max(mut self, max: usize) -> Self {
68        self.max = max;
69        if self.value > self.max {
70            self.value = self.max;
71        }
72        self
73    }
74
75    /// Add on_click handler when the rating changes.
76    ///
77    /// The `&usize` parameter is the new rating value.
78    pub fn on_click(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
79        self.on_click = Some(Rc::new(handler));
80        self
81    }
82}
83
84impl Styled for Rating {
85    fn style(&mut self) -> &mut gpui::StyleRefinement {
86        &mut self.style
87    }
88}
89
90impl Sizable for Rating {
91    fn with_size(mut self, size: impl Into<Size>) -> Self {
92        self.size = size.into();
93        self
94    }
95}
96
97impl Disableable for Rating {
98    fn disabled(mut self, disabled: bool) -> Self {
99        self.disabled = disabled;
100        self
101    }
102}
103
104struct RaitingState {
105    /// To save the default value on init state, to detect external value changes.
106    default_value: usize,
107    /// To store the current selected value.
108    value: usize,
109    /// To store the currently hovered value.
110    hovered_value: usize,
111}
112
113impl RenderOnce for Rating {
114    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
115        let id = self.id;
116        let size = self.size;
117        let disabled = self.disabled;
118        let max = self.max;
119        let default_value = self.value;
120        let active_color = self.color.unwrap_or(cx.theme().yellow);
121        let on_click = self.on_click.clone();
122
123        let state = window.use_keyed_state(id.clone(), cx, |_, _| RaitingState {
124            default_value,
125            value: default_value,
126            hovered_value: 0,
127        });
128
129        // Reset state if outside has changed `value` prop.
130        if state.read(cx).default_value != default_value {
131            state.update(cx, |state, _| {
132                state.default_value = default_value;
133                state.value = default_value;
134            });
135        }
136        let value = state.read(cx).value;
137
138        h_flex()
139            .id(id)
140            .flex_nowrap()
141            .refine_style(&self.style)
142            .on_hover(window.listener_for(&state, move |state, hovered, _, cx| {
143                if !hovered {
144                    state.hovered_value = 0;
145                    cx.notify();
146                }
147            }))
148            .map(|mut this| {
149                for ix in 1..=max {
150                    let filled = ix <= value;
151                    let hovered = state.read(cx).hovered_value >= ix;
152
153                    this = this.child(
154                        div()
155                            .id(ix)
156                            .p_0p5()
157                            .flex_none()
158                            .flex_shrink_0()
159                            .when(filled || hovered, |this| this.text_color(active_color))
160                            .child(
161                                Icon::new(if filled {
162                                    IconName::StarFill
163                                } else {
164                                    IconName::Star
165                                })
166                                .with_size(size),
167                            )
168                            .when(!disabled, |this| {
169                                this.on_mouse_move(window.listener_for(
170                                    &state,
171                                    move |state, _, _, cx| {
172                                        state.hovered_value = ix;
173                                        cx.notify();
174                                    },
175                                ))
176                                .on_click({
177                                    let state = state.clone();
178                                    let on_click = on_click.clone();
179                                    move |_: &ClickEvent, window, cx| {
180                                        let new = if value >= ix {
181                                            ix.saturating_sub(1)
182                                        } else {
183                                            ix
184                                        };
185
186                                        state.update(cx, |state, cx| {
187                                            state.value = new;
188                                            cx.notify();
189                                        });
190
191                                        if let Some(on_click) = &on_click {
192                                            on_click(&new, window, cx);
193                                        }
194                                    }
195                                })
196                            }),
197                    );
198                }
199
200                this
201            })
202    }
203}