Skip to main content

maolan_widgets/
multi_toggler.rs

1use iced::advanced::Shell;
2use iced::advanced::layout::{self, Layout};
3use iced::advanced::renderer;
4use iced::advanced::text;
5use iced::advanced::widget::{self, Tree, Widget};
6use iced::alignment;
7use iced::mouse;
8use iced::{Border, Color, Element, Event, Font, Length, Pixels, Point, Rectangle, Size};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Orientation {
12    Horizontal,
13    Vertical,
14}
15
16/// Where the selected-option label is drawn relative to the option track.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum LabelPosition {
19    /// Label above the track (default).
20    #[default]
21    Above,
22    /// Label below the track.
23    Below,
24    /// Label left of the track.
25    Left,
26    /// Label right of the track.
27    Right,
28}
29
30pub struct MultiToggler<'a, Message> {
31    labels: Vec<String>,
32    selected: usize,
33    on_change: Box<dyn Fn(usize) -> Message + 'a>,
34    orientation: Orientation,
35    label_position: LabelPosition,
36    width: Length,
37    height: Length,
38    text_size: Pixels,
39    background_color: Color,
40    border_color: Color,
41    selected_color: Color,
42    text_color: Color,
43    selected_text_color: Color,
44    size: f32,
45    spacing: f32,
46    label_extent: f32,
47}
48
49impl<'a, Message> MultiToggler<'a, Message> {
50    pub fn new<F, L, S>(labels: L, selected: usize, orientation: Orientation, on_change: F) -> Self
51    where
52        F: Fn(usize) -> Message + 'a,
53        L: IntoIterator<Item = S>,
54        S: Into<String>,
55    {
56        let label_position = match orientation {
57            Orientation::Horizontal => LabelPosition::Above,
58            Orientation::Vertical => LabelPosition::Right,
59        };
60        Self {
61            labels: labels.into_iter().map(Into::into).collect(),
62            selected,
63            on_change: Box::new(on_change),
64            orientation,
65            label_position,
66            width: Length::Shrink,
67            height: Length::Shrink,
68            text_size: Pixels(12.0),
69            background_color: Color::from_rgb(
70                0x42 as f32 / 255.0,
71                0x46 as f32 / 255.0,
72                0x4D as f32 / 255.0,
73            ),
74            border_color: Color::from_rgb(
75                0x30 as f32 / 255.0,
76                0x33 as f32 / 255.0,
77                0x3C as f32 / 255.0,
78            ),
79            selected_color: Color::from_rgb(
80                0x29 as f32 / 255.0,
81                0x66 as f32 / 255.0,
82                0xA3 as f32 / 255.0,
83            ),
84            text_color: Color::from_rgb(
85                0xD7 as f32 / 255.0,
86                0xDA as f32 / 255.0,
87                0xE0 as f32 / 255.0,
88            ),
89            selected_text_color: Color::WHITE,
90            size: 16.0,
91            spacing: 8.0,
92            label_extent: 72.0,
93        }
94    }
95
96    pub fn label_position(mut self, label_position: LabelPosition) -> Self {
97        self.label_position = label_position;
98        self
99    }
100
101    pub fn width(mut self, width: Length) -> Self {
102        self.width = width;
103        self
104    }
105
106    pub fn height(mut self, height: Length) -> Self {
107        self.height = height;
108        self
109    }
110
111    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
112        self.text_size = text_size.into();
113        self
114    }
115
116    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
117        self.size = size.into().0.max(1.0);
118        self
119    }
120
121    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
122        self.spacing = spacing.into().0.max(0.0);
123        self
124    }
125
126    pub fn label_extent(mut self, label_extent: impl Into<Pixels>) -> Self {
127        self.label_extent = label_extent.into().0.max(0.0);
128        self
129    }
130
131    pub fn background_color(mut self, color: Color) -> Self {
132        self.background_color = color;
133        self
134    }
135
136    pub fn border_color(mut self, color: Color) -> Self {
137        self.border_color = color;
138        self
139    }
140
141    pub fn selected_color(mut self, color: Color) -> Self {
142        self.selected_color = color;
143        self
144    }
145
146    pub fn text_color(mut self, color: Color) -> Self {
147        self.text_color = color;
148        self
149    }
150
151    pub fn selected_text_color(mut self, color: Color) -> Self {
152        self.selected_text_color = color;
153        self
154    }
155
156    fn selected(&self) -> usize {
157        self.selected.min(self.labels.len().saturating_sub(1))
158    }
159
160    fn option_count(&self) -> usize {
161        self.labels.len().max(1)
162    }
163
164    fn label_height(&self) -> f32 {
165        self.text_size.0 * 1.4
166    }
167
168    fn track_size(&self) -> Size {
169        let count = self.option_count() as f32;
170        match self.orientation {
171            Orientation::Horizontal => Size::new(self.size * count, self.size),
172            Orientation::Vertical => Size::new(self.size, self.size * count),
173        }
174    }
175
176    fn track_bounds(&self, bounds: Rectangle) -> Rectangle {
177        let track = self.track_size();
178        let label_height = self.label_height();
179        let (offset_x, offset_y) = match self.label_position {
180            LabelPosition::Above => (0.0, label_height + self.spacing),
181            LabelPosition::Below => (0.0, 0.0),
182            LabelPosition::Left => (self.label_extent + self.spacing, 0.0),
183            LabelPosition::Right => (0.0, 0.0),
184        };
185        Rectangle {
186            x: bounds.x + offset_x,
187            y: bounds.y + offset_y,
188            width: track.width,
189            height: track.height,
190        }
191    }
192
193    fn option_center(&self, track: Rectangle, index: usize) -> Point {
194        match self.orientation {
195            Orientation::Horizontal => Point::new(
196                track.x + self.size * (index as f32 + 0.5),
197                track.y + track.height * 0.5,
198            ),
199            Orientation::Vertical => Point::new(
200                track.x + track.width * 0.5,
201                track.y + self.size * (index as f32 + 0.5),
202            ),
203        }
204    }
205
206    fn label_bounds(&self, bounds: Rectangle) -> Rectangle {
207        let track = self.track_bounds(bounds);
208        let label_height = self.label_height();
209        match self.label_position {
210            LabelPosition::Above => Rectangle {
211                x: track.x,
212                y: bounds.y,
213                width: self.label_extent,
214                height: label_height,
215            },
216            LabelPosition::Below => Rectangle {
217                x: track.x,
218                y: track.y + track.height + self.spacing,
219                width: self.label_extent,
220                height: label_height,
221            },
222            LabelPosition::Left => Rectangle {
223                x: bounds.x,
224                y: track.center_y() - label_height * 0.5,
225                width: self.label_extent,
226                height: label_height,
227            },
228            LabelPosition::Right => Rectangle {
229                x: track.x + track.width + self.spacing,
230                y: track.center_y() - label_height * 0.5,
231                width: self.label_extent,
232                height: label_height,
233            },
234        }
235    }
236
237    fn index_at(&self, cursor_position: Point, bounds: Rectangle) -> Option<usize> {
238        if self.labels.is_empty() {
239            return None;
240        }
241
242        let track = self.track_bounds(bounds);
243        if !track.contains(cursor_position) {
244            return None;
245        }
246        let count = self.labels.len();
247        let raw = match self.orientation {
248            Orientation::Horizontal => (cursor_position.x - track.x) / self.size,
249            Orientation::Vertical => (cursor_position.y - track.y) / self.size,
250        };
251
252        Some((raw.floor() as isize).clamp(0, count as isize - 1) as usize)
253    }
254
255    fn publish_index(&self, state: &mut State, index: usize, shell: &mut Shell<'_, Message>) {
256        if state.active_index != Some(index) {
257            state.active_index = Some(index);
258            shell.publish((self.on_change)(index));
259        }
260    }
261}
262
263pub fn horizontal_multi_toggler<'a, Message, F, L, S>(
264    labels: L,
265    selected: usize,
266    on_change: F,
267) -> MultiToggler<'a, Message>
268where
269    F: Fn(usize) -> Message + 'a,
270    L: IntoIterator<Item = S>,
271    S: Into<String>,
272{
273    MultiToggler::new(labels, selected, Orientation::Horizontal, on_change)
274}
275
276pub fn vertical_multi_toggler<'a, Message, F, L, S>(
277    labels: L,
278    selected: usize,
279    on_change: F,
280) -> MultiToggler<'a, Message>
281where
282    F: Fn(usize) -> Message + 'a,
283    L: IntoIterator<Item = S>,
284    S: Into<String>,
285{
286    MultiToggler::new(labels, selected, Orientation::Vertical, on_change)
287}
288
289#[derive(Default)]
290struct State {
291    is_dragging: bool,
292    active_index: Option<usize>,
293}
294
295impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer> for MultiToggler<'a, Message>
296where
297    Renderer: renderer::Renderer + text::Renderer<Font = Font>,
298{
299    fn size(&self) -> Size<Length> {
300        Size {
301            width: self.width,
302            height: self.height,
303        }
304    }
305
306    fn layout(
307        &mut self,
308        _tree: &mut Tree,
309        _renderer: &Renderer,
310        limits: &layout::Limits,
311    ) -> layout::Node {
312        let track = self.track_size();
313        let label_height = self.label_height();
314        let default_size = match self.label_position {
315            LabelPosition::Above | LabelPosition::Below => Size::new(
316                track.width.max(self.label_extent),
317                label_height + self.spacing + track.height,
318            ),
319            LabelPosition::Left | LabelPosition::Right => Size::new(
320                track.width + self.spacing + self.label_extent,
321                track.height.max(label_height),
322            ),
323        };
324        let size = limits.width(self.width).height(self.height).resolve(
325            self.width,
326            self.height,
327            default_size,
328        );
329
330        layout::Node::new(size)
331    }
332
333    fn draw(
334        &self,
335        _tree: &Tree,
336        renderer: &mut Renderer,
337        _theme: &Theme,
338        _style: &renderer::Style,
339        layout: Layout<'_>,
340        _cursor: mouse::Cursor,
341        viewport: &Rectangle,
342    ) {
343        let bounds = layout.bounds();
344        let track = self.track_bounds(bounds);
345        let border_radius = track.width.min(track.height) / 2.0;
346
347        renderer.fill_quad(
348            renderer::Quad {
349                bounds: track,
350                border: Border {
351                    radius: border_radius.into(),
352                    width: 0.0,
353                    color: Color::TRANSPARENT,
354                },
355                ..Default::default()
356            },
357            self.background_color,
358        );
359
360        if !self.labels.is_empty() {
361            let padding = (0.1 * self.size).round();
362            let diameter = (self.size - 2.0 * padding).max(1.0);
363            let center = self.option_center(track, self.selected());
364            renderer.fill_quad(
365                renderer::Quad {
366                    bounds: Rectangle {
367                        x: center.x - diameter * 0.5,
368                        y: center.y - diameter * 0.5,
369                        width: diameter,
370                        height: diameter,
371                    },
372                    border: Border {
373                        radius: (diameter * 0.5).into(),
374                        width: 0.0,
375                        color: Color::TRANSPARENT,
376                    },
377                    ..Default::default()
378                },
379                self.selected_color,
380            );
381        }
382
383        if let Some(label) = self.labels.get(self.selected()) {
384            let label_bounds = self.label_bounds(bounds);
385            renderer.fill_text(
386                text::Text {
387                    content: label.clone(),
388                    bounds: label_bounds.size(),
389                    size: self.text_size,
390                    line_height: text::LineHeight::default(),
391                    font: renderer.default_font(),
392                    align_x: text::Alignment::Left,
393                    align_y: alignment::Vertical::Center,
394                    shaping: text::Shaping::default(),
395                    wrapping: text::Wrapping::None,
396                },
397                Point::new(label_bounds.x, label_bounds.center_y()),
398                self.text_color,
399                *viewport,
400            );
401        }
402    }
403
404    fn tag(&self) -> widget::tree::Tag {
405        widget::tree::Tag::of::<State>()
406    }
407
408    fn state(&self) -> widget::tree::State {
409        widget::tree::State::new(State::default())
410    }
411
412    fn mouse_interaction(
413        &self,
414        _tree: &Tree,
415        layout: Layout<'_>,
416        cursor: mouse::Cursor,
417        _viewport: &Rectangle,
418        _renderer: &Renderer,
419    ) -> mouse::Interaction {
420        if !self.labels.is_empty() && cursor.is_over(layout.bounds()) {
421            mouse::Interaction::Pointer
422        } else {
423            mouse::Interaction::default()
424        }
425    }
426
427    fn update(
428        &mut self,
429        tree: &mut Tree,
430        event: &Event,
431        layout: Layout<'_>,
432        cursor: mouse::Cursor,
433        _renderer: &Renderer,
434        _clipboard: &mut dyn iced::advanced::Clipboard,
435        shell: &mut Shell<'_, Message>,
436        _viewport: &Rectangle,
437    ) {
438        let state = tree.state.downcast_mut::<State>();
439        let bounds = layout.bounds();
440
441        match event {
442            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
443                if cursor.is_over(bounds)
444                    && let Some(cursor_position) = cursor.position()
445                    && let Some(index) = self.index_at(cursor_position, bounds)
446                {
447                    state.is_dragging = true;
448                    state.active_index = Some(self.selected());
449                    self.publish_index(state, index, shell);
450                    shell.capture_event();
451                }
452            }
453            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
454                state.is_dragging = false;
455                state.active_index = None;
456            }
457            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
458                if state.is_dragging
459                    && let Some(cursor_position) = cursor.position()
460                    && let Some(index) = self.index_at(cursor_position, bounds)
461                {
462                    self.publish_index(state, index, shell);
463                    shell.capture_event();
464                }
465            }
466            _ => {}
467        }
468    }
469}
470
471impl<'a, Message, Theme, Renderer> From<MultiToggler<'a, Message>>
472    for Element<'a, Message, Theme, Renderer>
473where
474    Message: 'a,
475    Theme: 'a,
476    Renderer: renderer::Renderer + text::Renderer<Font = Font> + 'a,
477{
478    fn from(toggler: MultiToggler<'a, Message>) -> Self {
479        Self::new(toggler)
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use iced::Event;
487    use iced::advanced::{
488        Layout, Shell, clipboard, layout,
489        widget::{self, Tree, Widget},
490    };
491
492    fn test_tree() -> Tree {
493        Tree {
494            tag: widget::tree::Tag::of::<State>(),
495            state: widget::tree::State::new(State::default()),
496            children: Vec::new(),
497        }
498    }
499
500    fn test_layout(width: f32, height: f32) -> Layout<'static> {
501        let node = Box::leak(Box::new(layout::Node::new(Size::new(width, height))));
502        Layout::new(node)
503    }
504
505    #[test]
506    fn horizontal_index_at_uses_segments() {
507        let toggler = horizontal_multi_toggler(["A", "B", "C"], 0, |index| index)
508            .label_position(LabelPosition::Below);
509        let bounds = Rectangle {
510            x: 10.0,
511            y: 20.0,
512            width: 90.0,
513            height: 24.0,
514        };
515
516        assert_eq!(toggler.index_at(Point::new(10.0, 30.0), bounds), Some(0));
517        assert_eq!(toggler.index_at(Point::new(30.0, 30.0), bounds), Some(1));
518        assert_eq!(toggler.index_at(Point::new(57.0, 30.0), bounds), Some(2));
519        assert_eq!(toggler.index_at(Point::new(59.0, 30.0), bounds), None);
520    }
521
522    #[test]
523    fn horizontal_label_above_track_by_default() {
524        let toggler = horizontal_multi_toggler(["A", "B", "C"], 0, |index| index);
525        let bounds = Rectangle {
526            x: 10.0,
527            y: 20.0,
528            width: 90.0,
529            height: 48.0,
530        };
531
532        let track = toggler.track_bounds(bounds);
533        assert_eq!(track.x, bounds.x);
534        assert_eq!(track.width, 48.0);
535        assert_eq!(track.y, bounds.y + toggler.label_height() + toggler.spacing);
536
537        let label = toggler.label_bounds(bounds);
538        assert_eq!(label.x, track.x);
539        assert_eq!(label.width, 72.0);
540        assert!(label.y < track.y, "label should sit above the track");
541    }
542
543    #[test]
544    fn horizontal_label_below_places_track_at_top() {
545        let toggler = horizontal_multi_toggler(["A", "B", "C"], 0, |index| index)
546            .label_position(LabelPosition::Below);
547        let bounds = Rectangle {
548            x: 10.0,
549            y: 20.0,
550            width: 90.0,
551            height: 48.0,
552        };
553
554        let track = toggler.track_bounds(bounds);
555        assert_eq!(track.y, bounds.y);
556
557        let label = toggler.label_bounds(bounds);
558        assert!(label.y > track.y, "label should sit below the track");
559        assert_eq!(label.x, track.x);
560    }
561
562    #[test]
563    fn label_left_pushes_track_right_and_centers_label() {
564        let toggler = horizontal_multi_toggler(["A", "B", "C"], 0, |index| index)
565            .label_position(LabelPosition::Left);
566        let bounds = Rectangle {
567            x: 10.0,
568            y: 20.0,
569            width: 160.0,
570            height: 24.0,
571        };
572
573        let track = toggler.track_bounds(bounds);
574        assert_eq!(track.x, bounds.x + toggler.label_extent + toggler.spacing);
575        assert_eq!(track.y, bounds.y);
576
577        let label = toggler.label_bounds(bounds);
578        assert_eq!(label.x, bounds.x);
579        assert!((label.center_y() - track.center_y()).abs() < 1e-6);
580    }
581
582    #[test]
583    fn label_right_places_label_beside_track() {
584        let toggler = horizontal_multi_toggler(["A", "B", "C"], 0, |index| index)
585            .label_position(LabelPosition::Right);
586        let bounds = Rectangle {
587            x: 10.0,
588            y: 20.0,
589            width: 160.0,
590            height: 24.0,
591        };
592
593        let track = toggler.track_bounds(bounds);
594        assert_eq!(track.x, bounds.x);
595
596        let label = toggler.label_bounds(bounds);
597        assert_eq!(label.x, track.x + track.width + toggler.spacing);
598        assert!((label.center_y() - track.center_y()).abs() < 1e-6);
599    }
600
601    #[test]
602    fn vertical_index_at_uses_segments() {
603        let toggler = vertical_multi_toggler(["A", "B", "C"], 0, |index| index);
604        let bounds = Rectangle {
605            x: 10.0,
606            y: 20.0,
607            width: 72.0,
608            height: 90.0,
609        };
610
611        assert_eq!(toggler.index_at(Point::new(20.0, 20.0), bounds), Some(0));
612        assert_eq!(toggler.index_at(Point::new(20.0, 45.0), bounds), Some(1));
613        assert_eq!(toggler.index_at(Point::new(20.0, 67.0), bounds), Some(2));
614    }
615
616    #[cfg(debug_assertions)]
617    #[test]
618    fn click_publishes_selected_index() {
619        let mut toggler = horizontal_multi_toggler(["A", "B", "C"], 0, |index| index)
620            .label_position(LabelPosition::Below);
621        let mut tree = test_tree();
622        let layout = test_layout(90.0, 24.0);
623        let mut messages = Vec::new();
624        let mut shell = Shell::new(&mut messages);
625        let renderer = ();
626        let mut clipboard = clipboard::Null;
627        let viewport = Rectangle::new(Point::ORIGIN, Size::new(90.0, 24.0));
628
629        <MultiToggler<'_, usize> as Widget<usize, iced::Theme, ()>>::update(
630            &mut toggler,
631            &mut tree,
632            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
633            layout,
634            mouse::Cursor::Available(Point::new(30.0, 12.0)),
635            &renderer,
636            &mut clipboard,
637            &mut shell,
638            &viewport,
639        );
640
641        assert_eq!(messages, vec![1]);
642    }
643
644    #[cfg(debug_assertions)]
645    #[test]
646    fn drag_can_publish_return_to_starting_index() {
647        let mut toggler = horizontal_multi_toggler(["A", "B", "C"], 0, |index| index)
648            .label_position(LabelPosition::Below);
649        let mut tree = test_tree();
650        let layout = test_layout(90.0, 24.0);
651        let renderer = ();
652        let mut clipboard = clipboard::Null;
653        let viewport = Rectangle::new(Point::ORIGIN, Size::new(90.0, 24.0));
654        let mut messages = Vec::new();
655
656        {
657            let mut shell = Shell::new(&mut messages);
658            <MultiToggler<'_, usize> as Widget<usize, iced::Theme, ()>>::update(
659                &mut toggler,
660                &mut tree,
661                &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
662                layout,
663                mouse::Cursor::Available(Point::new(30.0, 12.0)),
664                &renderer,
665                &mut clipboard,
666                &mut shell,
667                &viewport,
668            );
669        }
670
671        {
672            let mut shell = Shell::new(&mut messages);
673            <MultiToggler<'_, usize> as Widget<usize, iced::Theme, ()>>::update(
674                &mut toggler,
675                &mut tree,
676                &Event::Mouse(mouse::Event::CursorMoved {
677                    position: Point::new(15.0, 12.0),
678                }),
679                layout,
680                mouse::Cursor::Available(Point::new(15.0, 12.0)),
681                &renderer,
682                &mut clipboard,
683                &mut shell,
684                &viewport,
685            );
686        }
687
688        assert_eq!(messages, vec![1, 0]);
689    }
690
691    #[cfg(debug_assertions)]
692    #[test]
693    fn click_on_current_index_does_not_publish() {
694        let mut toggler = horizontal_multi_toggler(["A", "B", "C"], 1, |index| index)
695            .label_position(LabelPosition::Below);
696        let mut tree = test_tree();
697        let layout = test_layout(90.0, 24.0);
698        let mut messages = Vec::new();
699        let mut shell = Shell::new(&mut messages);
700        let renderer = ();
701        let mut clipboard = clipboard::Null;
702        let viewport = Rectangle::new(Point::ORIGIN, Size::new(90.0, 24.0));
703
704        <MultiToggler<'_, usize> as Widget<usize, iced::Theme, ()>>::update(
705            &mut toggler,
706            &mut tree,
707            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
708            layout,
709            mouse::Cursor::Available(Point::new(30.0, 12.0)),
710            &renderer,
711            &mut clipboard,
712            &mut shell,
713            &viewport,
714        );
715
716        assert!(messages.is_empty());
717    }
718}