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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum LabelPosition {
19 #[default]
21 Above,
22 Below,
24 Left,
26 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}