cosmic_time/widget/
toggler.rs

1//! Show toggle controls using togglers.
2use iced_native::alignment;
3use iced_native::event;
4use iced_native::layout;
5use iced_native::mouse;
6use iced_native::renderer;
7use iced_native::text;
8use iced_native::time::Duration;
9use iced_native::widget::{self, Row, Text, Tree};
10use iced_native::{
11    color, Alignment, Clipboard, Color, Element, Event, Layout, Length, Pixels, Point, Rectangle,
12    Shell, Widget,
13};
14
15use crate::keyframes::{self, toggler::Chain};
16use crate::lerp;
17
18pub use iced_style::toggler::{Appearance, StyleSheet};
19
20/// The default animation duration. Change here for custom widgets. Or at runtime with `.anim_multiplier`
21const ANIM_DURATION: f32 = 100.;
22
23/// A toggler widget.
24///
25/// # Example
26///
27/// ```
28/// # type Toggler<'a, Message> = iced_native::widget::Toggler<'a, Message, iced_native::renderer::Null>;
29/// #
30/// pub enum Message {
31///     TogglerToggled(bool),
32/// }
33///
34/// let is_toggled = true;
35///
36/// Toggler::new(String::from("Toggle me!"), is_toggled, |b| Message::TogglerToggled(b));
37/// ```
38#[allow(missing_debug_implementations)]
39pub struct Toggler<'a, Message, Renderer>
40where
41    Renderer: text::Renderer,
42    Renderer::Theme: StyleSheet,
43{
44    id: crate::keyframes::toggler::Id,
45    is_toggled: bool,
46    on_toggle: Box<dyn Fn(Chain, bool) -> Message + 'a>,
47    label: Option<String>,
48    width: Length,
49    size: f32,
50    text_size: Option<f32>,
51    text_alignment: alignment::Horizontal,
52    spacing: f32,
53    font: Renderer::Font,
54    style: <Renderer::Theme as StyleSheet>::Style,
55    percent: f32,
56    anim_multiplier: f32,
57}
58
59impl<'a, Message, Renderer> Toggler<'a, Message, Renderer>
60where
61    Renderer: text::Renderer,
62    Renderer::Theme: StyleSheet,
63{
64    /// The default size of a [`Toggler`].
65    pub const DEFAULT_SIZE: f32 = 20.0;
66
67    /// Creates a new [`Toggler`].
68    ///
69    /// It expects:
70    ///   * a boolean describing whether the [`Toggler`] is checked or not
71    ///   * An optional label for the [`Toggler`]
72    ///   * a function that will be called when the [`Toggler`] is toggled. It
73    ///     will receive the new state of the [`Toggler`] and must produce a
74    ///     `Message`.
75    pub fn new<F>(
76        id: crate::keyframes::toggler::Id,
77        label: impl Into<Option<String>>,
78        is_toggled: bool,
79        f: F,
80    ) -> Self
81    where
82        F: 'a + Fn(Chain, bool) -> Message,
83    {
84        Toggler {
85            id,
86            is_toggled,
87            on_toggle: Box::new(f),
88            label: label.into(),
89            width: Length::Fill,
90            size: Self::DEFAULT_SIZE,
91            text_size: None,
92            text_alignment: alignment::Horizontal::Left,
93            spacing: 0.0,
94            font: Renderer::Font::default(),
95            style: Default::default(),
96            percent: if is_toggled { 1.0 } else { 0.0 },
97            anim_multiplier: 1.0,
98        }
99    }
100
101    /// Sets the size of the [`Toggler`].
102    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
103        self.size = size.into().0;
104        self
105    }
106
107    /// Sets the width of the [`Toggler`].
108    pub fn width(mut self, width: impl Into<Length>) -> Self {
109        self.width = width.into();
110        self
111    }
112
113    /// Sets the text size o the [`Toggler`].
114    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
115        self.text_size = Some(text_size.into().0);
116        self
117    }
118
119    /// Sets the horizontal alignment of the text of the [`Toggler`]
120    pub fn text_alignment(mut self, alignment: alignment::Horizontal) -> Self {
121        self.text_alignment = alignment;
122        self
123    }
124
125    /// Sets the spacing between the [`Toggler`] and the text.
126    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
127        self.spacing = spacing.into().0;
128        self
129    }
130
131    /// Sets the [`Font`] of the text of the [`Toggler`]
132    ///
133    /// [`Font`]: iced_native::text::Renderer::Font
134    pub fn font(mut self, font: Renderer::Font) -> Self {
135        self.font = font;
136        self
137    }
138
139    /// Sets the style of the [`Toggler`].
140    pub fn style(mut self, style: impl Into<<Renderer::Theme as StyleSheet>::Style>) -> Self {
141        self.style = style.into();
142        self
143    }
144
145    /// The percent completion of the toggler animation.
146    /// This is indented to automated cosmic-time use, and shouldn't
147    /// need to be called manually.
148    pub fn percent(mut self, percent: f32) -> Self {
149        self.percent = percent;
150        self
151    }
152
153    /// The default animation time is 100ms, to speed up the toggle
154    /// animation use a value less than 1.0, and to slow down the
155    /// animation use a value greater than 1.0.
156    pub fn anim_multiplier(mut self, multiplier: f32) -> Self {
157        self.anim_multiplier = multiplier;
158        self
159    }
160}
161
162impl<'a, Message, Renderer> Widget<Message, Renderer> for Toggler<'a, Message, Renderer>
163where
164    Renderer: text::Renderer,
165    Renderer::Theme: StyleSheet + widget::text::StyleSheet,
166{
167    fn width(&self) -> Length {
168        self.width
169    }
170
171    fn height(&self) -> Length {
172        Length::Shrink
173    }
174
175    fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
176        let mut row = Row::<(), Renderer>::new()
177            .width(self.width)
178            .spacing(self.spacing)
179            .align_items(Alignment::Center);
180
181        if let Some(label) = &self.label {
182            row = row.push(
183                Text::new(label)
184                    .horizontal_alignment(self.text_alignment)
185                    .font(self.font.clone())
186                    .width(self.width)
187                    .size(self.text_size.unwrap_or_else(|| renderer.default_size())),
188            );
189        }
190
191        row = row.push(Row::new().width(2.0 * self.size).height(self.size));
192
193        row.layout(renderer, limits)
194    }
195
196    fn on_event(
197        &mut self,
198        _state: &mut Tree,
199        event: Event,
200        layout: Layout<'_>,
201        cursor_position: Point,
202        _renderer: &Renderer,
203        _clipboard: &mut dyn Clipboard,
204        shell: &mut Shell<'_, Message>,
205    ) -> event::Status {
206        match event {
207            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
208                let mouse_over = layout.bounds().contains(cursor_position);
209
210                // To prevent broken animations, only send message if
211                // toggler is clicked after animation is finished.
212                // TODO this should be possible to fix once redirectable
213                // animations are implemented.
214                if mouse_over && (self.percent == 0.0 || self.percent == 1.0) {
215                    if self.is_toggled {
216                        let off_animation = Chain::new(self.id.clone())
217                            .link(keyframes::toggler::Toggler::new(Duration::ZERO).percent(1.0))
218                            .link(
219                                keyframes::toggler::Toggler::new(Duration::from_millis(
220                                    (ANIM_DURATION * self.anim_multiplier.round()) as u64,
221                                ))
222                                .percent(0.0),
223                            );
224                        shell.publish((self.on_toggle)(off_animation, !self.is_toggled));
225                    } else {
226                        let on_animation = Chain::new(self.id.clone())
227                            .link(keyframes::toggler::Toggler::new(Duration::ZERO).percent(0.0))
228                            .link(
229                                keyframes::toggler::Toggler::new(Duration::from_millis(
230                                    (ANIM_DURATION * self.anim_multiplier.round()) as u64,
231                                ))
232                                .percent(1.0),
233                            );
234                        shell.publish((self.on_toggle)(on_animation, !self.is_toggled));
235                    }
236
237                    event::Status::Captured
238                } else {
239                    event::Status::Ignored
240                }
241            }
242            _ => event::Status::Ignored,
243        }
244    }
245
246    fn mouse_interaction(
247        &self,
248        _state: &Tree,
249        layout: Layout<'_>,
250        cursor_position: Point,
251        _viewport: &Rectangle,
252        _renderer: &Renderer,
253    ) -> mouse::Interaction {
254        if layout.bounds().contains(cursor_position) {
255            mouse::Interaction::Pointer
256        } else {
257            mouse::Interaction::default()
258        }
259    }
260
261    fn draw(
262        &self,
263        _state: &Tree,
264        renderer: &mut Renderer,
265        theme: &Renderer::Theme,
266        style: &renderer::Style,
267        layout: Layout<'_>,
268        cursor_position: Point,
269        _viewport: &Rectangle,
270    ) {
271        /// Makes sure that the border radius of the toggler looks good at every size.
272        const BORDER_RADIUS_RATIO: f32 = 32.0 / 13.0;
273
274        /// The space ratio between the background Quad and the Toggler bounds, and
275        /// between the background Quad and foreground Quad.
276        const SPACE_RATIO: f32 = 0.05;
277
278        let mut children = layout.children();
279
280        if let Some(label) = &self.label {
281            let label_layout = children.next().unwrap();
282
283            iced_native::widget::text::draw(
284                renderer,
285                style,
286                label_layout,
287                label,
288                self.text_size,
289                self.font.clone(),
290                Default::default(),
291                self.text_alignment,
292                alignment::Vertical::Center,
293            );
294        }
295
296        let toggler_layout = children.next().unwrap();
297        let bounds = toggler_layout.bounds();
298
299        let is_mouse_over = bounds.contains(cursor_position);
300
301        let style = if is_mouse_over {
302            blend_appearances(
303                theme.hovered(&self.style, false),
304                theme.hovered(&self.style, true),
305                self.percent,
306            )
307        } else {
308            blend_appearances(
309                theme.active(&self.style, false),
310                theme.active(&self.style, true),
311                self.percent,
312            )
313        };
314
315        let border_radius = bounds.height / BORDER_RADIUS_RATIO;
316        let space = SPACE_RATIO * bounds.height;
317
318        let toggler_background_bounds = Rectangle {
319            x: bounds.x + space,
320            y: bounds.y + space,
321            width: bounds.width - (2.0 * space),
322            height: bounds.height - (2.0 * space),
323        };
324
325        renderer.fill_quad(
326            renderer::Quad {
327                bounds: toggler_background_bounds,
328                border_radius: border_radius.into(),
329                border_width: 1.0,
330                border_color: style.background_border.unwrap_or(style.background),
331            },
332            style.background,
333        );
334
335        let toggler_foreground_bounds = Rectangle {
336            x: bounds.x
337                + lerp(
338                    2.0 * space,
339                    bounds.width - 2.0 * space - (bounds.height - (4.0 * space)),
340                    self.percent,
341                ),
342            y: bounds.y + (2.0 * space),
343            width: bounds.height - (4.0 * space),
344            height: bounds.height - (4.0 * space),
345        };
346
347        renderer.fill_quad(
348            renderer::Quad {
349                bounds: toggler_foreground_bounds,
350                border_radius: border_radius.into(),
351                border_width: 1.0,
352                border_color: style.foreground_border.unwrap_or(style.foreground),
353            },
354            style.foreground,
355        );
356    }
357}
358
359impl<'a, Message, Renderer> From<Toggler<'a, Message, Renderer>> for Element<'a, Message, Renderer>
360where
361    Message: 'a,
362    Renderer: 'a + text::Renderer,
363    Renderer::Theme: StyleSheet + widget::text::StyleSheet,
364{
365    fn from(toggler: Toggler<'a, Message, Renderer>) -> Element<'a, Message, Renderer> {
366        Element::new(toggler)
367    }
368}
369
370fn blend_appearances(
371    one: iced_style::toggler::Appearance,
372    mut two: iced_style::toggler::Appearance,
373    percent: f32,
374) -> iced_style::toggler::Appearance {
375    let background: [f32; 4] = one
376        .background
377        .into_linear()
378        .iter()
379        .zip(two.background.into_linear().iter())
380        .map(|(o, t)| lerp(*o, *t, percent))
381        .collect::<Vec<f32>>()
382        .try_into()
383        .unwrap();
384
385    let border_one: Color = one.background_border.unwrap_or(color!(0, 0, 0));
386    let border_two: Color = two.background_border.unwrap_or(color!(0, 0, 0));
387    let border: [f32; 4] = border_one
388        .into_linear()
389        .iter()
390        .zip(border_two.into_linear().iter())
391        .map(|(o, t)| lerp(*o, *t, percent))
392        .collect::<Vec<f32>>()
393        .try_into()
394        .unwrap();
395    let new_border: Color = border.into();
396
397    let foreground: [f32; 4] = one
398        .foreground
399        .into_linear()
400        .iter()
401        .zip(two.foreground.into_linear().iter())
402        .map(|(o, t)| lerp(*o, *t, percent))
403        .collect::<Vec<f32>>()
404        .try_into()
405        .unwrap();
406
407    let f_border_one: Color = one.foreground_border.unwrap_or(color!(0, 0, 0));
408    let f_border_two: Color = two.foreground_border.unwrap_or(color!(0, 0, 0));
409    let f_border: [f32; 4] = f_border_one
410        .into_linear()
411        .iter()
412        .zip(f_border_two.into_linear().iter())
413        .map(|(o, t)| lerp(*o, *t, percent))
414        .collect::<Vec<f32>>()
415        .try_into()
416        .unwrap();
417    let new_f_border: Color = f_border.into();
418
419    two.background = background.into();
420    two.background_border = Some(new_border);
421    two.foreground = foreground.into();
422    two.foreground_border = Some(new_f_border);
423    two
424}