widgetry/widgets/
slider.rs

1use geom::{Circle, Distance, Polygon, Pt2D};
2
3use crate::{
4    Color, Drawable, EdgeInsets, EventCtx, GeomBatch, GfxCtx, Outcome, ScreenDims, ScreenPt,
5    ScreenRectangle, Widget, WidgetImpl, WidgetOutput,
6};
7
8pub struct Slider {
9    current_percent: f64,
10    mouse_on_slider: bool,
11    pub(crate) dragging: bool,
12
13    style: Style,
14    label: Option<String>,
15
16    draw: Drawable,
17
18    top_left: ScreenPt,
19    dims: ScreenDims,
20}
21
22enum Style {
23    Horizontal { main_bg_len: f64, dragger_len: f64 },
24    Vertical { main_bg_len: f64, dragger_len: f64 },
25    Area { width: f64 },
26}
27
28pub const SCROLLBAR_BG_WIDTH: f64 = 8.0;
29pub const AREA_SLIDER_BG_WIDTH: f64 = 10.0;
30
31impl Style {
32    fn padding(&self) -> EdgeInsets {
33        match self {
34            Style::Horizontal { .. } | Style::Vertical { .. } => EdgeInsets::zero(),
35            Style::Area { .. } => EdgeInsets {
36                top: 10.0,
37                bottom: 10.0,
38                left: 20.0,
39                right: 20.0,
40            },
41        }
42    }
43
44    fn inner_dims(&self) -> ScreenDims {
45        match self {
46            Style::Horizontal { main_bg_len, .. } => {
47                ScreenDims::new(*main_bg_len, SCROLLBAR_BG_WIDTH)
48            }
49            Style::Vertical { main_bg_len, .. } => {
50                ScreenDims::new(SCROLLBAR_BG_WIDTH, *main_bg_len)
51            }
52            Style::Area { width } => ScreenDims::new(*width, AREA_SLIDER_BG_WIDTH),
53        }
54    }
55}
56
57impl Slider {
58    pub(crate) fn horizontal_scrollbar(
59        ctx: &EventCtx,
60        width: f64,
61        dragger_len: f64,
62        current_percent: f64,
63    ) -> Widget {
64        Slider::new_widget(
65            ctx,
66            Style::Horizontal {
67                main_bg_len: width,
68                dragger_len,
69            },
70            current_percent,
71            // Don't emit Outcome::Changed for scollbars
72            None,
73        )
74    }
75
76    pub(crate) fn vertical_scrollbar(
77        ctx: &EventCtx,
78        height: f64,
79        dragger_len: f64,
80        current_percent: f64,
81    ) -> Widget {
82        Slider::new_widget(
83            ctx,
84            Style::Vertical {
85                main_bg_len: height,
86                dragger_len,
87            },
88            current_percent,
89            // Don't emit Outcome::Changed for scollbars
90            None,
91        )
92    }
93
94    pub fn area(ctx: &EventCtx, width: f64, current_percent: f64, label: &str) -> Widget {
95        Slider::new_widget(
96            ctx,
97            Style::Area { width },
98            current_percent,
99            Some(label.to_string()),
100        )
101        .named(label)
102    }
103
104    fn new_widget(
105        ctx: &EventCtx,
106        style: Style,
107        current_percent: f64,
108        label: Option<String>,
109    ) -> Widget {
110        let mut s = Slider {
111            current_percent,
112            mouse_on_slider: false,
113            dragging: false,
114            style,
115            draw: Drawable::empty(ctx),
116            label,
117
118            top_left: ScreenPt::new(0.0, 0.0),
119            dims: ScreenDims::new(0.0, 0.0),
120        };
121        s.recalc(ctx);
122        Widget::new(Box::new(s))
123    }
124
125    fn recalc(&mut self, ctx: &EventCtx) {
126        let mut batch = GeomBatch::new();
127
128        match self.style {
129            Style::Horizontal { .. } | Style::Vertical { .. } => {
130                let inner_dims = self.style.inner_dims();
131                // The background
132                batch.push(
133                    ctx.style.field_bg,
134                    Polygon::rectangle(inner_dims.width, inner_dims.height),
135                );
136
137                // The draggy thing
138                batch.push(
139                    if self.mouse_on_slider {
140                        ctx.style.btn_solid.bg_hover
141                    } else {
142                        ctx.style.btn_solid.bg
143                    },
144                    self.button_geom(),
145                );
146            }
147            Style::Area { .. } => {
148                // Full dims
149                let inner_dims = self.style.inner_dims();
150                // The background
151                batch.push(
152                    ctx.style.field_bg.dull(0.5),
153                    Polygon::pill(inner_dims.width, inner_dims.height),
154                );
155
156                // So far
157                batch.push(
158                    Color::hex("#F4DF4D"),
159                    Polygon::pill(self.current_percent * inner_dims.width, inner_dims.height),
160                );
161
162                // The circle dragger
163                batch.push(
164                    if self.mouse_on_slider {
165                        ctx.style.btn_solid.bg_hover
166                    } else {
167                        // we don't want to use `ctx.style.btn_solid.bg` because it achieves it's
168                        // "dulling" with opacity, which causes the slider to "peak through" and
169                        // looks weird.
170                        ctx.style.btn_solid.bg_hover.dull(0.2)
171                    },
172                    self.button_geom(),
173                );
174            }
175        }
176
177        let padding = self.style.padding();
178        batch = batch.translate(padding.left, padding.top);
179        self.dims = self.style.inner_dims().pad(padding);
180        self.draw = ctx.upload(batch);
181    }
182
183    // Doesn't touch self.top_left
184    fn button_geom(&self) -> Polygon {
185        match self.style {
186            Style::Horizontal {
187                main_bg_len,
188                dragger_len,
189            } => Polygon::pill(dragger_len, SCROLLBAR_BG_WIDTH)
190                .translate(self.current_percent * (main_bg_len - dragger_len), 0.0),
191            Style::Vertical {
192                main_bg_len,
193                dragger_len,
194            } => Polygon::pill(SCROLLBAR_BG_WIDTH, dragger_len)
195                .translate(0.0, self.current_percent * (main_bg_len - dragger_len)),
196            Style::Area { width } => Circle::new(
197                Pt2D::new(self.current_percent * width, AREA_SLIDER_BG_WIDTH / 2.0),
198                Distance::meters(16.0),
199            )
200            .to_polygon(),
201        }
202    }
203
204    fn pt_to_percent(&self, pt: ScreenPt) -> f64 {
205        let padding = self.style.padding();
206        let pt = pt.translated(
207            -self.top_left.x - padding.left,
208            -self.top_left.y - padding.top,
209        );
210
211        match self.style {
212            Style::Horizontal {
213                main_bg_len,
214                dragger_len,
215            } => (pt.x - (dragger_len / 2.0)) / (main_bg_len - dragger_len),
216            Style::Vertical {
217                main_bg_len,
218                dragger_len,
219            } => (pt.y - (dragger_len / 2.0)) / (main_bg_len - dragger_len),
220            Style::Area { width } => pt.x / width,
221        }
222    }
223
224    pub fn get_percent(&self) -> f64 {
225        self.current_percent
226    }
227
228    pub fn get_value(&self, num_items: usize) -> usize {
229        (self.current_percent * (num_items as f64 - 1.0)) as usize
230    }
231
232    pub fn set_percent(&mut self, ctx: &EventCtx, percent: f64) {
233        assert!((0.0..=1.0).contains(&percent));
234        self.current_percent = percent;
235        self.recalc(ctx);
236        if let Some(pt) = ctx.canvas.get_cursor_in_screen_space() {
237            self.mouse_on_slider = self
238                .button_geom()
239                .translate(self.top_left.x, self.top_left.y)
240                .contains_pt(pt.to_pt());
241        } else {
242            self.mouse_on_slider = false;
243        }
244    }
245
246    // True if anything changed
247    fn inner_event(&mut self, ctx: &mut EventCtx) -> bool {
248        if self.dragging {
249            if ctx.input.get_moved_mouse().is_some() {
250                self.current_percent = self
251                    .pt_to_percent(ctx.canvas.get_cursor())
252                    .min(1.0)
253                    .max(0.0);
254                return true;
255            }
256            if ctx.input.left_mouse_button_released() {
257                self.dragging = false;
258                return true;
259            }
260            return false;
261        }
262        let padding = self.style.padding();
263        if ctx.redo_mouseover() {
264            let old = self.mouse_on_slider;
265            if let Some(pt) = ctx.canvas.get_cursor_in_screen_space() {
266                self.mouse_on_slider = self
267                    .button_geom()
268                    .translate(
269                        self.top_left.x + padding.left,
270                        self.top_left.y + padding.top,
271                    )
272                    .contains_pt(pt.to_pt());
273            } else {
274                self.mouse_on_slider = false;
275            }
276            return self.mouse_on_slider != old;
277        }
278        if ctx.input.left_mouse_button_pressed() {
279            if self.mouse_on_slider {
280                self.dragging = true;
281                return true;
282            }
283
284            // Did we click somewhere else on the bar?
285            if let Some(pt) = ctx.canvas.get_cursor_in_screen_space() {
286                if Polygon::rectangle(self.dims.width, self.dims.height)
287                    .translate(
288                        self.top_left.x + padding.left,
289                        self.top_left.y + padding.top,
290                    )
291                    .contains_pt(pt.to_pt())
292                {
293                    self.current_percent = self
294                        .pt_to_percent(ctx.canvas.get_cursor())
295                        .min(1.0)
296                        .max(0.0);
297                    self.mouse_on_slider = true;
298                    self.dragging = true;
299                    return true;
300                }
301            }
302        }
303        false
304    }
305}
306
307impl WidgetImpl for Slider {
308    fn get_dims(&self) -> ScreenDims {
309        self.dims
310    }
311
312    fn set_pos(&mut self, top_left: ScreenPt) {
313        self.top_left = top_left;
314    }
315
316    fn event(&mut self, ctx: &mut EventCtx, output: &mut WidgetOutput) {
317        if self.inner_event(ctx) {
318            self.recalc(ctx);
319            if let Some(ref label) = self.label {
320                output.outcome = Outcome::Changed(label.clone());
321            }
322        }
323    }
324
325    fn draw(&self, g: &mut GfxCtx) {
326        g.redraw_at(self.top_left, &self.draw);
327        // TODO Since the sliders in Panels are scrollbars outside of the clipping rectangle,
328        // this stays for now. It has no effect for other sliders.
329        g.canvas
330            .mark_covered_area(ScreenRectangle::top_left(self.top_left, self.dims));
331    }
332}