1use std::hash::Hash;
11use std::sync::Arc;
12
13use egui::{
14 pos2, Color32, CornerRadius, FontId, FontSelection, Galley, Rect, Response, Sense, Stroke,
15 StrokeKind, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
16};
17
18use crate::theme::{placeholder_galley, with_alpha, Theme};
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
26pub enum SegmentedSize {
27 Small,
29 #[default]
31 Medium,
32 Large,
35}
36
37impl SegmentedSize {
38 fn font_size(self, theme: &Theme) -> f32 {
39 let t = &theme.typography;
40 match self {
41 Self::Small => t.small,
42 Self::Medium => t.label,
43 Self::Large => t.button,
44 }
45 }
46 fn icon_size(self, theme: &Theme) -> f32 {
47 self.font_size(theme)
48 }
49 fn pad_x(self) -> f32 {
50 match self {
51 Self::Small => 10.0,
52 Self::Medium => 12.0,
53 Self::Large => 16.0,
54 }
55 }
56 fn pad_y(self) -> f32 {
57 match self {
58 Self::Small => 3.0,
59 Self::Medium => 5.0,
60 Self::Large => 7.0,
61 }
62 }
63 fn track_pad(self) -> f32 {
64 match self {
65 Self::Small => 2.0,
66 Self::Medium => 3.0,
67 Self::Large => 4.0,
68 }
69 }
70 fn track_radius(self) -> u8 {
71 match self {
72 Self::Small => 6,
73 Self::Medium => 7,
74 Self::Large => 8,
75 }
76 }
77 fn segment_radius(self) -> u8 {
78 match self {
79 Self::Small => 4,
80 Self::Medium => 5,
81 Self::Large => 6,
82 }
83 }
84 fn count_height(self) -> f32 {
85 match self {
86 Self::Small => 16.0,
87 Self::Medium => 18.0,
88 Self::Large => 20.0,
89 }
90 }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
99pub enum SegmentDot {
100 Neutral,
102 Sky,
104 Amber,
106 Red,
108 Green,
110}
111
112#[must_use = "Use with SegmentedControl::from_segments(...)"]
125pub struct Segment {
126 label: Option<WidgetText>,
127 icon: Option<WidgetText>,
128 count: Option<WidgetText>,
129 dot: Option<SegmentDot>,
130 enabled: bool,
131}
132
133impl std::fmt::Debug for Segment {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("Segment")
136 .field("label", &self.label.as_ref().map(|l| l.text().to_string()))
137 .field("icon", &self.icon.as_ref().map(|i| i.text().to_string()))
138 .field("count", &self.count.as_ref().map(|c| c.text().to_string()))
139 .field("dot", &self.dot)
140 .field("enabled", &self.enabled)
141 .finish()
142 }
143}
144
145impl Default for Segment {
146 fn default() -> Self {
147 Self {
148 label: None,
149 icon: None,
150 count: None,
151 dot: None,
152 enabled: true,
153 }
154 }
155}
156
157impl Segment {
158 pub fn text(label: impl Into<WidgetText>) -> Self {
160 Self {
161 label: Some(label.into()),
162 ..Self::default()
163 }
164 }
165
166 pub fn icon(icon: impl Into<WidgetText>) -> Self {
170 Self {
171 icon: Some(icon.into()),
172 ..Self::default()
173 }
174 }
175
176 pub fn icon_text(icon: impl Into<WidgetText>, label: impl Into<WidgetText>) -> Self {
178 Self {
179 icon: Some(icon.into()),
180 label: Some(label.into()),
181 ..Self::default()
182 }
183 }
184
185 #[inline]
187 pub fn count(mut self, count: impl Into<WidgetText>) -> Self {
188 self.count = Some(count.into());
189 self
190 }
191
192 #[inline]
194 pub fn dot(mut self, dot: SegmentDot) -> Self {
195 self.dot = Some(dot);
196 self
197 }
198
199 #[inline]
202 pub fn enabled(mut self, enabled: bool) -> Self {
203 self.enabled = enabled;
204 self
205 }
206
207 fn debug_label(&self) -> String {
208 if let Some(l) = &self.label {
209 l.text().to_string()
210 } else if let Some(i) = &self.icon {
211 i.text().to_string()
212 } else {
213 String::new()
214 }
215 }
216}
217
218#[must_use = "Add with `ui.add(...)`."]
259pub struct SegmentedControl<'a> {
260 selection: Selection<'a>,
261 segments: Vec<Segment>,
262 size: SegmentedSize,
263 fill: bool,
264}
265
266enum Selection<'a> {
276 Single(&'a mut usize),
277 Multi(&'a mut [bool]),
278}
279
280impl<'a> Selection<'a> {
281 fn is_active(&self, i: usize) -> bool {
282 match self {
283 Selection::Single(idx) => **idx == i,
284 Selection::Multi(states) => states.get(i).copied().unwrap_or(false),
285 }
286 }
287
288 fn click(&mut self, i: usize) {
289 match self {
290 Selection::Single(idx) => {
291 if **idx != i {
292 **idx = i;
293 }
294 }
295 Selection::Multi(states) => {
296 if let Some(s) = states.get_mut(i) {
297 *s = !*s;
298 }
299 }
300 }
301 }
302}
303
304impl<'a> std::fmt::Debug for SegmentedControl<'a> {
305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 let mut d = f.debug_struct("SegmentedControl");
307 match &self.selection {
308 Selection::Single(idx) => {
309 d.field("mode", &"single");
310 d.field("selected", &**idx);
311 }
312 Selection::Multi(states) => {
313 d.field("mode", &"multi");
314 d.field("states", states);
315 }
316 }
317 d.field("segments", &self.segments)
318 .field("size", &self.size)
319 .field("fill", &self.fill)
320 .finish()
321 }
322}
323
324impl<'a> SegmentedControl<'a> {
325 pub fn new<I, S>(selected: &'a mut usize, items: I) -> Self
329 where
330 I: IntoIterator<Item = S>,
331 S: Into<WidgetText>,
332 {
333 Self {
334 selection: Selection::Single(selected),
335 segments: items.into_iter().map(Segment::text).collect(),
336 size: SegmentedSize::default(),
337 fill: false,
338 }
339 }
340
341 pub fn from_segments(
344 selected: &'a mut usize,
345 segments: impl IntoIterator<Item = Segment>,
346 ) -> Self {
347 Self {
348 selection: Selection::Single(selected),
349 segments: segments.into_iter().collect(),
350 size: SegmentedSize::default(),
351 fill: false,
352 }
353 }
354
355 pub fn toggles<I, S>(states: &'a mut [bool], items: I) -> Self
370 where
371 I: IntoIterator<Item = S>,
372 S: Into<WidgetText>,
373 {
374 Self {
375 selection: Selection::Multi(states),
376 segments: items.into_iter().map(Segment::text).collect(),
377 size: SegmentedSize::default(),
378 fill: false,
379 }
380 }
381
382 #[inline]
384 pub fn size(mut self, size: SegmentedSize) -> Self {
385 self.size = size;
386 self
387 }
388
389 #[inline]
392 pub fn fill(mut self) -> Self {
393 self.fill = true;
394 self
395 }
396}
397
398struct Prepared {
399 icon: Option<Arc<Galley>>,
400 label: Option<Arc<Galley>>,
401 count: Option<Arc<Galley>>,
402 dot: Option<SegmentDot>,
403 enabled: bool,
404 a11y: String,
405 natural_w: f32,
406 natural_h: f32,
407}
408
409const INNER_GAP: f32 = 6.0;
410const DOT_SIZE: f32 = 6.0;
411
412fn count_galley(ui: &Ui, text: &str, size: f32) -> Arc<Galley> {
413 let rt = egui::RichText::new(text)
414 .color(Color32::PLACEHOLDER)
415 .size(size)
416 .strong();
417 egui::WidgetText::from(rt).into_galley(
418 ui,
419 Some(TextWrapMode::Extend),
420 f32::INFINITY,
421 FontSelection::FontId(FontId::monospace(size)),
422 )
423}
424
425fn dot_color(dot: SegmentDot, theme: &Theme, active: bool) -> Color32 {
426 let p = &theme.palette;
427 match dot {
428 SegmentDot::Neutral => {
429 if active {
430 p.sky
431 } else {
432 p.text_faint
433 }
434 }
435 SegmentDot::Sky => p.sky,
436 SegmentDot::Amber => p.warning,
437 SegmentDot::Red => p.danger,
438 SegmentDot::Green => p.success,
439 }
440}
441
442impl<'a> Widget for SegmentedControl<'a> {
443 fn ui(mut self, ui: &mut Ui) -> Response {
444 let theme = Theme::current(ui.ctx());
445 let p = &theme.palette;
446
447 let size = self.size;
448 let track_pad = size.track_pad();
449 let pad_x = size.pad_x();
450 let pad_y = size.pad_y();
451 let font_size = size.font_size(&theme);
452 let icon_size = size.icon_size(&theme);
453 let count_size = (font_size - 1.5).max(10.0);
454 let count_h = size.count_height();
455
456 let mut prepared: Vec<Prepared> = Vec::with_capacity(self.segments.len());
458 for seg in &self.segments {
459 let icon = seg
460 .icon
461 .as_ref()
462 .map(|t| placeholder_galley(ui, t.text(), icon_size, false, f32::INFINITY));
463 let label = seg
464 .label
465 .as_ref()
466 .map(|t| placeholder_galley(ui, t.text(), font_size, true, f32::INFINITY));
467 let count = seg
468 .count
469 .as_ref()
470 .map(|t| count_galley(ui, t.text(), count_size));
471
472 let mut content_w = 0.0_f32;
473 let mut content_h = font_size;
474 if seg.dot.is_some() {
475 content_w += DOT_SIZE;
476 content_h = content_h.max(DOT_SIZE);
477 }
478 if let Some(g) = &icon {
479 if content_w > 0.0 {
480 content_w += INNER_GAP;
481 }
482 content_w += g.size().x;
483 content_h = content_h.max(g.size().y);
484 }
485 if let Some(g) = &label {
486 if content_w > 0.0 {
487 content_w += INNER_GAP;
488 }
489 content_w += g.size().x;
490 content_h = content_h.max(g.size().y);
491 }
492 if let Some(g) = &count {
493 if content_w > 0.0 {
494 content_w += INNER_GAP;
495 }
496 let pill_w = (g.size().x + 10.0).max(count_h);
497 content_w += pill_w;
498 content_h = content_h.max(count_h);
499 }
500
501 prepared.push(Prepared {
502 icon,
503 label,
504 count,
505 dot: seg.dot,
506 enabled: seg.enabled,
507 a11y: seg.debug_label(),
508 natural_w: pad_x * 2.0 + content_w,
509 natural_h: pad_y * 2.0 + content_h,
510 });
511 }
512
513 let segment_h = prepared
515 .iter()
516 .map(|s| s.natural_h)
517 .fold(font_size + pad_y * 2.0, f32::max);
518
519 let cell_widths: Vec<f32> = if self.fill && !prepared.is_empty() {
520 let avail = (ui.available_width() - track_pad * 2.0).max(0.0);
521 let max_natural = prepared.iter().map(|s| s.natural_w).fold(0.0_f32, f32::max);
522 let cell_w = (avail / prepared.len() as f32).max(max_natural);
523 prepared.iter().map(|_| cell_w).collect()
524 } else {
525 prepared.iter().map(|s| s.natural_w).collect()
526 };
527
528 let total_w = track_pad * 2.0 + cell_widths.iter().sum::<f32>();
529 let total_h = track_pad * 2.0 + segment_h;
530
531 let (track_rect, response) =
535 ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
536 let base_id = response.id;
537
538 let mut x = track_rect.min.x + track_pad;
540 let segment_y = track_rect.min.y + track_pad;
541 let mut cell_rects: Vec<Rect> = Vec::with_capacity(prepared.len());
542 let mut cell_responses: Vec<Response> = Vec::with_capacity(prepared.len());
543 for (i, prep) in prepared.iter().enumerate() {
544 let cell_rect =
545 Rect::from_min_size(pos2(x, segment_y), Vec2::new(cell_widths[i], segment_h));
546 x += cell_widths[i];
547 let sense = if prep.enabled {
548 Sense::click()
549 } else {
550 Sense::hover()
551 };
552 let cell_resp = ui.interact(cell_rect, base_id.with(("seg", i)), sense);
553 if prep.enabled && cell_resp.clicked() {
554 self.selection.click(i);
555 }
556 cell_rects.push(cell_rect);
557 cell_responses.push(cell_resp);
558 }
559
560 let is_active = |i: usize| -> bool {
564 i < prepared.len() && prepared[i].enabled && self.selection.is_active(i)
565 };
566 let hovered_idx = cell_responses
567 .iter()
568 .zip(prepared.iter())
569 .position(|(r, prep)| prep.enabled && r.hovered());
570
571 if ui.is_rect_visible(track_rect) {
573 let track_radius = CornerRadius::same(size.track_radius());
574 ui.painter().rect(
575 track_rect,
576 track_radius,
577 p.input_bg,
578 Stroke::new(1.0, p.border),
579 StrokeKind::Inside,
580 );
581
582 for (i, cell) in cell_rects.iter().enumerate().skip(1) {
584 let left_busy = is_active(i - 1) || hovered_idx == Some(i - 1);
585 let right_busy = is_active(i) || hovered_idx == Some(i);
586 if left_busy || right_busy {
587 continue;
588 }
589 let div_x = cell.min.x.round() - 0.5;
590 let dy = (segment_h * 0.30).min(8.0);
591 ui.painter().line_segment(
592 [pos2(div_x, cell.min.y + dy), pos2(div_x, cell.max.y - dy)],
593 Stroke::new(1.0, with_alpha(p.border, 200)),
594 );
595 }
596
597 let segment_radius = CornerRadius::same(size.segment_radius());
598
599 if let Some(h) = hovered_idx {
601 if !is_active(h) {
602 let hover_fill = with_alpha(p.text, if p.is_dark { 14 } else { 18 });
603 ui.painter().rect(
604 cell_rects[h].shrink(0.5),
605 segment_radius,
606 hover_fill,
607 Stroke::NONE,
608 StrokeKind::Inside,
609 );
610 }
611 }
612
613 for (i, cell_rect) in cell_rects.iter().enumerate().take(prepared.len()) {
616 if !is_active(i) {
617 continue;
618 }
619 let cell = cell_rect.shrink(0.5);
620 let shadow = cell.translate(Vec2::new(0.0, 1.0));
621 ui.painter().rect(
622 shadow,
623 segment_radius,
624 with_alpha(Color32::BLACK, if p.is_dark { 70 } else { 28 }),
625 Stroke::NONE,
626 StrokeKind::Inside,
627 );
628 ui.painter().rect(
629 cell,
630 segment_radius,
631 p.card,
632 Stroke::new(1.0, p.border),
633 StrokeKind::Inside,
634 );
635 }
636
637 for (i, prep) in prepared.iter().enumerate() {
639 let cell_rect = cell_rects[i];
640 let active = is_active(i);
641 let hovered = hovered_idx == Some(i) && !active;
642
643 let text_color = if !prep.enabled {
644 with_alpha(p.text_faint, 160)
645 } else if active || hovered {
646 p.text
647 } else {
648 p.text_muted
649 };
650
651 let count_pill_w = prep
653 .count
654 .as_ref()
655 .map(|g| (g.size().x + 10.0).max(count_h));
656 let mut content_w = 0.0_f32;
657 if prep.dot.is_some() {
658 content_w += DOT_SIZE;
659 }
660 if let Some(g) = &prep.icon {
661 if content_w > 0.0 {
662 content_w += INNER_GAP;
663 }
664 content_w += g.size().x;
665 }
666 if let Some(g) = &prep.label {
667 if content_w > 0.0 {
668 content_w += INNER_GAP;
669 }
670 content_w += g.size().x;
671 }
672 if let Some(w) = count_pill_w {
673 if content_w > 0.0 {
674 content_w += INNER_GAP;
675 }
676 content_w += w;
677 }
678
679 let mut cx = cell_rect.center().x - content_w * 0.5;
680 let cy = cell_rect.center().y;
681
682 if let Some(dot) = prep.dot {
683 let mut col = dot_color(dot, &theme, active);
684 if !prep.enabled {
685 col = with_alpha(col, 120);
686 }
687 ui.painter()
688 .circle_filled(pos2(cx + DOT_SIZE * 0.5, cy), DOT_SIZE * 0.5, col);
689 cx += DOT_SIZE;
690 }
691 if let Some(icon) = &prep.icon {
692 if cx > cell_rect.center().x - content_w * 0.5 {
693 cx += INNER_GAP;
694 }
695 let pos = pos2(cx, cy - icon.size().y * 0.5);
696 ui.painter().galley(pos, icon.clone(), text_color);
697 cx += icon.size().x;
698 }
699 if let Some(label) = &prep.label {
700 if cx > cell_rect.center().x - content_w * 0.5 {
701 cx += INNER_GAP;
702 }
703 let pos = pos2(cx, cy - label.size().y * 0.5);
704 ui.painter().galley(pos, label.clone(), text_color);
705 cx += label.size().x;
706 }
707 if let (Some(g), Some(pill_w)) = (&prep.count, count_pill_w) {
708 if cx > cell_rect.center().x - content_w * 0.5 {
709 cx += INNER_GAP;
710 }
711 let pill_rect = Rect::from_min_size(
712 pos2(cx, cy - count_h * 0.5),
713 Vec2::new(pill_w, count_h),
714 );
715 let (pill_bg, pill_fg) = if active {
716 (with_alpha(p.sky, 50), p.sky)
717 } else if !prep.enabled {
718 (with_alpha(p.text_faint, 35), with_alpha(p.text_faint, 200))
719 } else {
720 (with_alpha(p.text_muted, 45), p.text_muted)
721 };
722 ui.painter().rect(
723 pill_rect,
724 CornerRadius::same(99),
725 pill_bg,
726 Stroke::NONE,
727 StrokeKind::Inside,
728 );
729 let text_pos = pos2(
730 pill_rect.center().x - g.size().x * 0.5,
731 pill_rect.center().y - g.size().y * 0.5,
732 );
733 ui.painter().galley(text_pos, g.clone(), pill_fg);
734 }
735 }
736 }
737
738 let multi = matches!(self.selection, Selection::Multi(_));
742 let segment_role = if multi {
743 WidgetType::Checkbox
744 } else {
745 WidgetType::RadioButton
746 };
747 let group_role = if multi {
748 WidgetType::Other
749 } else {
750 WidgetType::RadioGroup
751 };
752 for (i, (cell_resp, prep)) in cell_responses.iter().zip(prepared.iter()).enumerate() {
753 let label = prep.a11y.clone();
754 let enabled = prep.enabled;
755 let selected = is_active(i);
756 cell_resp.widget_info(|| WidgetInfo::selected(segment_role, enabled, selected, &label));
757 }
758 response.widget_info(|| WidgetInfo::labeled(group_role, true, "segmented control"));
759 response
760 }
761}