cosmic/widget/
spin_button.rs

1// Copyright 2022 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4//! A control for incremental adjustments of a value.
5
6use crate::{
7    Element, theme,
8    widget::{button, column, container, icon, row, text},
9};
10use apply::Apply;
11use iced::{Alignment, Length};
12use iced::{Border, Shadow};
13use std::borrow::Cow;
14use std::ops::{Add, Sub};
15
16/// Horizontal spin button widget.
17pub fn spin_button<'a, T, M>(
18    label: impl Into<Cow<'a, str>>,
19    #[cfg(feature = "a11y")] name: impl Into<Cow<'a, str>>,
20    value: T,
21    step: T,
22    min: T,
23    max: T,
24    on_press: impl Fn(T) -> M + 'static,
25) -> SpinButton<'a, T, M>
26where
27    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
28{
29    let mut button = SpinButton::new(
30        label,
31        value,
32        step,
33        min,
34        max,
35        Orientation::Horizontal,
36        on_press,
37    );
38
39    #[cfg(feature = "a11y")]
40    {
41        button = button.name(name.into());
42    }
43
44    button
45}
46
47/// Vertical spin button widget.
48pub fn vertical<'a, T, M>(
49    label: impl Into<Cow<'a, str>>,
50    #[cfg(feature = "a11y")] name: impl Into<Cow<'a, str>>,
51    value: T,
52    step: T,
53    min: T,
54    max: T,
55    on_press: impl Fn(T) -> M + 'static,
56) -> SpinButton<'a, T, M>
57where
58    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
59{
60    let mut button = SpinButton::new(
61        label,
62        value,
63        step,
64        min,
65        max,
66        Orientation::Horizontal,
67        on_press,
68    );
69
70    #[cfg(feature = "a11y")]
71    {
72        button = button.name(name.into());
73    }
74
75    button
76}
77
78#[derive(Clone, Copy)]
79enum Orientation {
80    Horizontal,
81    Vertical,
82}
83
84pub struct SpinButton<'a, T, M>
85where
86    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
87{
88    /// The formatted value of the spin button.
89    label: Cow<'a, str>,
90    /// A name for screen reader support.
91    #[cfg(feature = "a11y")]
92    name: Cow<'a, str>,
93    /// The current value of the spin button.
94    value: T,
95    /// The amount to increment or decrement the value.
96    step: T,
97    /// The minimum value permitted.
98    min: T,
99    /// The maximum value permitted.
100    max: T,
101    orientation: Orientation,
102    on_press: Box<dyn Fn(T) -> M>,
103}
104
105impl<'a, T, M> SpinButton<'a, T, M>
106where
107    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
108{
109    /// Create a new new button
110    fn new(
111        label: impl Into<Cow<'a, str>>,
112        value: T,
113        step: T,
114        min: T,
115        max: T,
116        orientation: Orientation,
117        on_press: impl Fn(T) -> M + 'static,
118    ) -> Self {
119        Self {
120            label: label.into(),
121            #[cfg(feature = "a11y")]
122            name: Cow::Borrowed(""),
123            step,
124            value: if value < min {
125                min
126            } else if value > max {
127                max
128            } else {
129                value
130            },
131            min,
132            max,
133            orientation,
134            on_press: Box::from(on_press),
135        }
136    }
137
138    #[cfg(feature = "a11y")]
139    pub(self) fn name(mut self, name: Cow<'a, str>) -> Self {
140        self.name = name;
141        self
142    }
143}
144
145fn increment<T>(value: T, step: T, _min: T, max: T) -> T
146where
147    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
148{
149    if value > max - step {
150        max
151    } else {
152        value + step
153    }
154}
155
156fn decrement<T>(value: T, step: T, min: T, _max: T) -> T
157where
158    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
159{
160    if value < min + step {
161        min
162    } else {
163        value - step
164    }
165}
166
167impl<'a, T, Message> From<SpinButton<'a, T, Message>> for Element<'a, Message>
168where
169    Message: Clone + 'static,
170    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
171{
172    fn from(this: SpinButton<'a, T, Message>) -> Self {
173        match this.orientation {
174            Orientation::Horizontal => horizontal_variant(this),
175            Orientation::Vertical => vertical_variant(this),
176        }
177    }
178}
179
180fn make_button<'a, T, Message>(
181    spin_button: &SpinButton<'a, T, Message>,
182    icon: &'static str,
183    #[cfg(feature = "a11y")] name: String,
184    operation: Option<fn(T, T, T, T) -> T>,
185) -> Element<'a, Message>
186where
187    Message: Clone + 'static,
188    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
189{
190    let mut button = icon::from_name(icon).apply(button::icon);
191
192    if let Some(f) = operation {
193        button = button.on_press((spin_button.on_press)(f(
194            spin_button.value,
195            spin_button.step,
196            spin_button.min,
197            spin_button.max,
198        )))
199    };
200
201    #[cfg(feature = "a11y")]
202    {
203        button = button.name(name.clone());
204    }
205
206    button.into()
207}
208
209fn horizontal_variant<T, Message>(spin_button: SpinButton<'_, T, Message>) -> Element<'_, Message>
210where
211    Message: Clone + 'static,
212    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
213{
214    let decrement_button = make_button(
215        &spin_button,
216        "list-remove-symbolic",
217        #[cfg(feature = "a11y")]
218        [&spin_button.name, " decrease"].concat(),
219        match spin_button.value == spin_button.min {
220            true => None,
221            false => Some(decrement),
222        },
223    );
224    let increment_button = make_button(
225        &spin_button,
226        "list-add-symbolic",
227        #[cfg(feature = "a11y")]
228        [&spin_button.name, " increase"].concat(),
229        match spin_button.value == spin_button.max {
230            true => None,
231            false => Some(increment),
232        },
233    );
234    let label = text::body(spin_button.label)
235        .apply(container)
236        .center_x(Length::Fixed(48.0))
237        .align_y(Alignment::Center);
238
239    row::with_capacity(3)
240        .push(decrement_button)
241        .push(label)
242        .push(increment_button)
243        .align_y(Alignment::Center)
244        .apply(container)
245        .class(theme::Container::custom(container_style))
246        .into()
247}
248
249fn vertical_variant<T, Message>(spin_button: SpinButton<'_, T, Message>) -> Element<'_, Message>
250where
251    Message: Clone + 'static,
252    T: Copy + Sub<Output = T> + Add<Output = T> + PartialOrd,
253{
254    let decrement_button = make_button(
255        &spin_button,
256        "list-remove-symbolic",
257        #[cfg(feature = "a11y")]
258        [&spin_button.label, " decrease"].concat(),
259        match spin_button.value == spin_button.min {
260            true => None,
261            false => Some(decrement),
262        },
263    );
264    let increment_button = make_button(
265        &spin_button,
266        "list-add-symbolic",
267        #[cfg(feature = "a11y")]
268        [&spin_button.label, " increase"].concat(),
269        match spin_button.value == spin_button.max {
270            true => None,
271            false => Some(increment),
272        },
273    );
274
275    let label = text::body(spin_button.label)
276        .apply(container)
277        .center_x(Length::Fixed(48.0))
278        .align_y(Alignment::Center);
279
280    column::with_capacity(3)
281        .push(increment_button)
282        .push(label)
283        .push(decrement_button)
284        .align_x(Alignment::Center)
285        .apply(container)
286        .class(theme::Container::custom(container_style))
287        .into()
288}
289
290#[allow(clippy::trivially_copy_pass_by_ref)]
291fn container_style(theme: &crate::Theme) -> iced_widget::container::Style {
292    let cosmic_theme = &theme.cosmic();
293    let accent = &cosmic_theme.accent;
294    let corners = &cosmic_theme.corner_radii;
295    let current_container = theme.current_container();
296    let border = if theme.theme_type.is_high_contrast() {
297        Border {
298            radius: corners.radius_s.into(),
299            width: 1.,
300            color: current_container.component.border.into(),
301        }
302    } else {
303        Border {
304            radius: corners.radius_s.into(),
305            width: 0.0,
306            color: accent.base.into(),
307        }
308    };
309
310    iced_widget::container::Style {
311        icon_color: Some(current_container.on.into()),
312        text_color: Some(current_container.on.into()),
313        background: None,
314        border,
315        shadow: Shadow::default(),
316        snap: true,
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    #[test]
323    fn decrement() {
324        assert_eq!(super::decrement(0i32, 10, 15, 35), 15);
325    }
326}