widgetry/widgets/
spinner.rs

1use std::ops;
2
3use geom::{trim_f64, CornerRadii, Distance, Polygon, Pt2D};
4
5use crate::{
6    include_labeled_bytes, Button, Drawable, EdgeInsets, EventCtx, GeomBatch, GfxCtx, Outcome,
7    OutlineStyle, Prerender, ScreenDims, ScreenPt, ScreenRectangle, Style, Text, Widget,
8    WidgetImpl, WidgetOutput,
9};
10
11// Manually tuned
12const TEXT_WIDTH: f64 = 100.0;
13
14pub trait SpinnerValue:
15    Copy
16    + PartialOrd
17    + std::fmt::Display
18    + std::ops::Add<Output = Self>
19    + std::ops::AddAssign
20    + std::ops::Sub<Output = Self>
21    + std::ops::SubAssign
22where
23    Self: std::marker::Sized,
24{
25}
26
27impl<T> SpinnerValue for T where
28    T: Copy
29        + PartialOrd
30        + std::fmt::Display
31        + std::ops::Add<Output = Self>
32        + std::ops::AddAssign
33        + std::ops::Sub<Output = Self>
34        + std::ops::SubAssign
35{
36}
37
38// TODO Allow text entry
39// TODO Allow click and hold
40// TODO Grey out the buttons when we're maxed out
41pub struct Spinner<T> {
42    low: T,
43    high: T,
44    step_size: T,
45    pub current: T,
46    label: String,
47    render_value: Box<dyn Fn(T) -> String>,
48
49    up: Button,
50    down: Button,
51    outline: OutlineStyle,
52    drawable: Drawable,
53
54    top_left: ScreenPt,
55    dims: ScreenDims,
56}
57
58impl<T: 'static + SpinnerValue> Spinner<T> {
59    /// Creates a spinner using the `SpinnerValue`'s default `to_string` implementation for
60    /// rendering.
61    pub fn widget(
62        ctx: &EventCtx,
63        label: impl Into<String>,
64        (low, high): (T, T),
65        current: T,
66        step_size: T,
67    ) -> Widget {
68        Spinner::widget_with_custom_rendering(
69            ctx,
70            label,
71            (low, high),
72            current,
73            step_size,
74            Box::new(|x| x.to_string()),
75        )
76    }
77
78    /// Creates a spinner using a custom method for rendering the value as text.
79    pub fn widget_with_custom_rendering(
80        ctx: &EventCtx,
81        label: impl Into<String>,
82        (low, high): (T, T),
83        current: T,
84        step_size: T,
85        render_value: Box<dyn Fn(T) -> String>,
86    ) -> Widget {
87        let label = label.into();
88        Widget::new(Box::new(Self::new(
89            ctx,
90            label.clone(),
91            (low, high),
92            current,
93            step_size,
94            render_value,
95        )))
96        .named(label)
97    }
98
99    fn new(
100        ctx: &EventCtx,
101        label: String,
102        (low, high): (T, T),
103        mut current: T,
104        step_size: T,
105        render_value: Box<dyn Fn(T) -> String>,
106    ) -> Self {
107        let button_builder = ctx
108            .style()
109            .btn_plain
110            .btn()
111            .padding(EdgeInsets {
112                top: 2.0,
113                bottom: 2.0,
114                left: 4.0,
115                right: 4.0,
116            })
117            .image_dims(17.0);
118
119        let up = button_builder
120            .clone()
121            .image_bytes(include_labeled_bytes!("../../icons/arrow_up.svg"))
122            .corner_rounding(CornerRadii {
123                top_left: 0.0,
124                top_right: 5.0,
125                bottom_right: 0.0,
126                bottom_left: 5.0,
127            })
128            .build(ctx, "increase value");
129
130        let down = button_builder
131            .image_bytes(include_labeled_bytes!("../../icons/arrow_down.svg"))
132            .corner_rounding(CornerRadii {
133                top_left: 5.0,
134                top_right: 0.0,
135                bottom_right: 5.0,
136                bottom_left: 0.0,
137            })
138            .build(ctx, "decrease value");
139
140        let outline = ctx.style().btn_outline.outline;
141        let dims = ScreenDims::new(
142            TEXT_WIDTH + up.get_dims().width,
143            up.get_dims().height + down.get_dims().height + 1.0,
144        );
145        if current < low {
146            warn!(
147                "Spinner's initial value is out of bounds! {}, bounds ({}, {})",
148                current, low, high
149            );
150            current = low;
151        } else if high < current {
152            warn!(
153                "Spinner's initial value is out of bounds! {}, bounds ({}, {})",
154                current, low, high
155            );
156            current = high;
157        }
158
159        let mut spinner = Spinner {
160            low,
161            high,
162            current,
163            step_size,
164            label,
165            render_value,
166
167            up,
168            down,
169            drawable: Drawable::empty(ctx),
170            outline,
171            top_left: ScreenPt::new(0.0, 0.0),
172            dims,
173        };
174        spinner.drawable = spinner.drawable(ctx.prerender, ctx.style());
175        spinner
176    }
177
178    pub fn modify(&mut self, ctx: &EventCtx, delta: T) {
179        self.current += delta;
180        self.clamp();
181        self.drawable = self.drawable(ctx.prerender, ctx.style());
182    }
183
184    fn clamp(&mut self) {
185        if self.current > self.high {
186            self.current = self.high;
187        }
188        if self.current < self.low {
189            self.current = self.low;
190        }
191    }
192
193    fn drawable(&self, prerender: &Prerender, style: &Style) -> Drawable {
194        let mut batch = GeomBatch::from(vec![(
195            style.field_bg,
196            Polygon::rounded_rectangle(self.dims.width, self.dims.height, 5.0),
197        )]);
198        batch.append(
199            Text::from((self.render_value)(self.current))
200                .render_autocropped(prerender)
201                .centered_on(Pt2D::new(TEXT_WIDTH / 2.0, self.dims.height / 2.0)),
202        );
203        batch.push(
204            self.outline.1,
205            Polygon::rounded_rectangle(self.dims.width, self.dims.height, 5.0)
206                .to_outline(Distance::meters(self.outline.0)),
207        );
208        prerender.upload(batch)
209    }
210}
211
212impl<T: 'static + SpinnerValue> WidgetImpl for Spinner<T> {
213    fn get_dims(&self) -> ScreenDims {
214        self.dims
215    }
216
217    fn set_pos(&mut self, top_left: ScreenPt) {
218        // TODO This works, but it'd be kind of cool if we could construct a tiny little Panel
219        // here and use that. Wait, why can't we? ...
220        self.top_left = top_left;
221        self.up
222            .set_pos(ScreenPt::new(top_left.x + TEXT_WIDTH, top_left.y));
223        self.down.set_pos(ScreenPt::new(
224            top_left.x + TEXT_WIDTH,
225            top_left.y + self.up.get_dims().height,
226        ));
227    }
228
229    fn event(&mut self, ctx: &mut EventCtx, output: &mut WidgetOutput) {
230        self.up.event(ctx, output);
231        if let Outcome::Clicked(_) = output.outcome {
232            output.outcome = Outcome::Changed(self.label.clone());
233            self.current += self.step_size;
234            self.clamp();
235            self.drawable = self.drawable(ctx.prerender, ctx.style());
236            ctx.no_op_event(true, |ctx| self.up.event(ctx, output));
237            return;
238        }
239
240        self.down.event(ctx, output);
241        if let Outcome::Clicked(_) = output.outcome {
242            output.outcome = Outcome::Changed(self.label.clone());
243            self.current -= self.step_size;
244            self.clamp();
245            self.drawable = self.drawable(ctx.prerender, ctx.style());
246            ctx.no_op_event(true, |ctx| self.down.event(ctx, output));
247            return;
248        }
249
250        if let Some(pt) = ctx.canvas.get_cursor_in_screen_space() {
251            if ScreenRectangle::top_left(self.top_left, self.dims).contains(pt) {
252                if let Some((_, dy)) = ctx.input.get_mouse_scroll() {
253                    if dy > 0.0 && self.current < self.high {
254                        self.current += self.step_size;
255                        self.clamp();
256                        output.outcome = Outcome::Changed(self.label.clone());
257                        self.drawable = self.drawable(ctx.prerender, ctx.style());
258                    }
259                    if dy < 0.0 && self.current > self.low {
260                        self.current -= self.step_size;
261                        self.clamp();
262                        output.outcome = Outcome::Changed(self.label.clone());
263                        self.drawable = self.drawable(ctx.prerender, ctx.style());
264                    }
265                }
266            }
267        }
268    }
269
270    fn draw(&self, g: &mut GfxCtx) {
271        g.redraw_at(self.top_left, &self.drawable);
272
273        self.up.draw(g);
274        self.down.draw(g);
275    }
276
277    fn can_restore(&self) -> bool {
278        true
279    }
280    fn restore(&mut self, ctx: &mut EventCtx, prev: &dyn WidgetImpl) {
281        let prev = prev.downcast_ref::<Spinner<T>>().unwrap();
282        self.current = prev.current;
283        self.drawable = self.drawable(ctx.prerender, ctx.style());
284    }
285}
286
287/// An f64 rounded to 4 decimal places. Useful with Spinners, to avoid values accumulating small
288/// drift.
289#[derive(Clone, Copy, PartialEq, PartialOrd)]
290pub struct RoundedF64(pub f64);
291
292impl ops::Add for RoundedF64 {
293    type Output = RoundedF64;
294
295    fn add(self, other: RoundedF64) -> RoundedF64 {
296        RoundedF64(trim_f64(self.0 + other.0))
297    }
298}
299
300impl ops::AddAssign for RoundedF64 {
301    fn add_assign(&mut self, other: RoundedF64) {
302        *self = *self + other;
303    }
304}
305
306impl ops::Sub for RoundedF64 {
307    type Output = RoundedF64;
308
309    fn sub(self, other: RoundedF64) -> RoundedF64 {
310        RoundedF64(trim_f64(self.0 - other.0))
311    }
312}
313
314impl ops::SubAssign for RoundedF64 {
315    fn sub_assign(&mut self, other: RoundedF64) {
316        *self = *self - other;
317    }
318}
319
320impl std::fmt::Display for RoundedF64 {
321    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
322        write!(f, "{}", self.0)
323    }
324}
325
326impl Spinner<RoundedF64> {
327    /// A spinner for f64s should prefer using this, which will round to 4 decimal places.
328    pub fn f64_widget(
329        ctx: &EventCtx,
330        label: impl Into<String>,
331        (low, high): (f64, f64),
332        current: f64,
333        step_size: f64,
334    ) -> Widget {
335        Spinner::widget(
336            ctx,
337            label,
338            (RoundedF64(low), RoundedF64(high)),
339            RoundedF64(current),
340            RoundedF64(step_size),
341        )
342    }
343}