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 selected: &'a mut usize,
261 segments: Vec<Segment>,
262 size: SegmentedSize,
263 fill: bool,
264}
265
266impl<'a> std::fmt::Debug for SegmentedControl<'a> {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 f.debug_struct("SegmentedControl")
269 .field("selected", &*self.selected)
270 .field("segments", &self.segments)
271 .field("size", &self.size)
272 .field("fill", &self.fill)
273 .finish()
274 }
275}
276
277impl<'a> SegmentedControl<'a> {
278 pub fn new<I, S>(selected: &'a mut usize, items: I) -> Self
281 where
282 I: IntoIterator<Item = S>,
283 S: Into<WidgetText>,
284 {
285 Self {
286 selected,
287 segments: items.into_iter().map(Segment::text).collect(),
288 size: SegmentedSize::default(),
289 fill: false,
290 }
291 }
292
293 pub fn from_segments(
296 selected: &'a mut usize,
297 segments: impl IntoIterator<Item = Segment>,
298 ) -> Self {
299 Self {
300 selected,
301 segments: segments.into_iter().collect(),
302 size: SegmentedSize::default(),
303 fill: false,
304 }
305 }
306
307 #[inline]
309 pub fn size(mut self, size: SegmentedSize) -> Self {
310 self.size = size;
311 self
312 }
313
314 #[inline]
317 pub fn fill(mut self) -> Self {
318 self.fill = true;
319 self
320 }
321}
322
323struct Prepared {
324 icon: Option<Arc<Galley>>,
325 label: Option<Arc<Galley>>,
326 count: Option<Arc<Galley>>,
327 dot: Option<SegmentDot>,
328 enabled: bool,
329 a11y: String,
330 natural_w: f32,
331 natural_h: f32,
332}
333
334const INNER_GAP: f32 = 6.0;
335const DOT_SIZE: f32 = 6.0;
336
337fn count_galley(ui: &Ui, text: &str, size: f32) -> Arc<Galley> {
338 let rt = egui::RichText::new(text)
339 .color(Color32::PLACEHOLDER)
340 .size(size)
341 .strong();
342 egui::WidgetText::from(rt).into_galley(
343 ui,
344 Some(TextWrapMode::Extend),
345 f32::INFINITY,
346 FontSelection::FontId(FontId::monospace(size)),
347 )
348}
349
350fn dot_color(dot: SegmentDot, theme: &Theme, active: bool) -> Color32 {
351 let p = &theme.palette;
352 match dot {
353 SegmentDot::Neutral => {
354 if active {
355 p.sky
356 } else {
357 p.text_faint
358 }
359 }
360 SegmentDot::Sky => p.sky,
361 SegmentDot::Amber => p.warning,
362 SegmentDot::Red => p.danger,
363 SegmentDot::Green => p.success,
364 }
365}
366
367impl<'a> Widget for SegmentedControl<'a> {
368 fn ui(self, ui: &mut Ui) -> Response {
369 let theme = Theme::current(ui.ctx());
370 let p = &theme.palette;
371
372 let size = self.size;
373 let track_pad = size.track_pad();
374 let pad_x = size.pad_x();
375 let pad_y = size.pad_y();
376 let font_size = size.font_size(&theme);
377 let icon_size = size.icon_size(&theme);
378 let count_size = (font_size - 1.5).max(10.0);
379 let count_h = size.count_height();
380
381 let mut prepared: Vec<Prepared> = Vec::with_capacity(self.segments.len());
383 for seg in &self.segments {
384 let icon = seg
385 .icon
386 .as_ref()
387 .map(|t| placeholder_galley(ui, t.text(), icon_size, false, f32::INFINITY));
388 let label = seg
389 .label
390 .as_ref()
391 .map(|t| placeholder_galley(ui, t.text(), font_size, true, f32::INFINITY));
392 let count = seg
393 .count
394 .as_ref()
395 .map(|t| count_galley(ui, t.text(), count_size));
396
397 let mut content_w = 0.0_f32;
398 let mut content_h = font_size;
399 if seg.dot.is_some() {
400 content_w += DOT_SIZE;
401 content_h = content_h.max(DOT_SIZE);
402 }
403 if let Some(g) = &icon {
404 if content_w > 0.0 {
405 content_w += INNER_GAP;
406 }
407 content_w += g.size().x;
408 content_h = content_h.max(g.size().y);
409 }
410 if let Some(g) = &label {
411 if content_w > 0.0 {
412 content_w += INNER_GAP;
413 }
414 content_w += g.size().x;
415 content_h = content_h.max(g.size().y);
416 }
417 if let Some(g) = &count {
418 if content_w > 0.0 {
419 content_w += INNER_GAP;
420 }
421 let pill_w = (g.size().x + 10.0).max(count_h);
422 content_w += pill_w;
423 content_h = content_h.max(count_h);
424 }
425
426 prepared.push(Prepared {
427 icon,
428 label,
429 count,
430 dot: seg.dot,
431 enabled: seg.enabled,
432 a11y: seg.debug_label(),
433 natural_w: pad_x * 2.0 + content_w,
434 natural_h: pad_y * 2.0 + content_h,
435 });
436 }
437
438 let segment_h = prepared
440 .iter()
441 .map(|s| s.natural_h)
442 .fold(font_size + pad_y * 2.0, f32::max);
443
444 let cell_widths: Vec<f32> = if self.fill && !prepared.is_empty() {
445 let avail = (ui.available_width() - track_pad * 2.0).max(0.0);
446 let max_natural = prepared.iter().map(|s| s.natural_w).fold(0.0_f32, f32::max);
447 let cell_w = (avail / prepared.len() as f32).max(max_natural);
448 prepared.iter().map(|_| cell_w).collect()
449 } else {
450 prepared.iter().map(|s| s.natural_w).collect()
451 };
452
453 let total_w = track_pad * 2.0 + cell_widths.iter().sum::<f32>();
454 let total_h = track_pad * 2.0 + segment_h;
455
456 let (track_rect, response) =
460 ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
461 let base_id = response.id;
462
463 let mut x = track_rect.min.x + track_pad;
465 let segment_y = track_rect.min.y + track_pad;
466 let mut cell_rects: Vec<Rect> = Vec::with_capacity(prepared.len());
467 let mut cell_responses: Vec<Response> = Vec::with_capacity(prepared.len());
468 for (i, prep) in prepared.iter().enumerate() {
469 let cell_rect =
470 Rect::from_min_size(pos2(x, segment_y), Vec2::new(cell_widths[i], segment_h));
471 x += cell_widths[i];
472 let sense = if prep.enabled {
473 Sense::click()
474 } else {
475 Sense::hover()
476 };
477 let cell_resp = ui.interact(cell_rect, base_id.with(("seg", i)), sense);
478 if prep.enabled && cell_resp.clicked() && *self.selected != i {
479 *self.selected = i;
480 }
481 cell_rects.push(cell_rect);
482 cell_responses.push(cell_resp);
483 }
484
485 let active_idx = if *self.selected < prepared.len() && prepared[*self.selected].enabled {
486 Some(*self.selected)
487 } else {
488 None
489 };
490 let hovered_idx = cell_responses
491 .iter()
492 .zip(prepared.iter())
493 .position(|(r, prep)| prep.enabled && r.hovered());
494
495 if ui.is_rect_visible(track_rect) {
497 let track_radius = CornerRadius::same(size.track_radius());
498 ui.painter().rect(
499 track_rect,
500 track_radius,
501 p.input_bg,
502 Stroke::new(1.0, p.border),
503 StrokeKind::Inside,
504 );
505
506 for (i, cell) in cell_rects.iter().enumerate().skip(1) {
508 let left_busy = active_idx == Some(i - 1) || hovered_idx == Some(i - 1);
509 let right_busy = active_idx == Some(i) || hovered_idx == Some(i);
510 if left_busy || right_busy {
511 continue;
512 }
513 let div_x = cell.min.x.round() - 0.5;
514 let dy = (segment_h * 0.30).min(8.0);
515 ui.painter().line_segment(
516 [pos2(div_x, cell.min.y + dy), pos2(div_x, cell.max.y - dy)],
517 Stroke::new(1.0, with_alpha(p.border, 200)),
518 );
519 }
520
521 let segment_radius = CornerRadius::same(size.segment_radius());
522
523 if let Some(h) = hovered_idx {
525 if active_idx != Some(h) {
526 let hover_fill = with_alpha(p.text, if p.is_dark { 14 } else { 18 });
527 ui.painter().rect(
528 cell_rects[h].shrink(0.5),
529 segment_radius,
530 hover_fill,
531 Stroke::NONE,
532 StrokeKind::Inside,
533 );
534 }
535 }
536
537 if let Some(a) = active_idx {
539 let cell = cell_rects[a].shrink(0.5);
540 let shadow = cell.translate(Vec2::new(0.0, 1.0));
541 ui.painter().rect(
542 shadow,
543 segment_radius,
544 with_alpha(Color32::BLACK, if p.is_dark { 70 } else { 28 }),
545 Stroke::NONE,
546 StrokeKind::Inside,
547 );
548 ui.painter().rect(
549 cell,
550 segment_radius,
551 p.card,
552 Stroke::new(1.0, p.border),
553 StrokeKind::Inside,
554 );
555 }
556
557 for (i, prep) in prepared.iter().enumerate() {
559 let cell_rect = cell_rects[i];
560 let is_active = active_idx == Some(i);
561 let is_hovered = hovered_idx == Some(i) && !is_active;
562
563 let text_color = if !prep.enabled {
564 with_alpha(p.text_faint, 160)
565 } else if is_active || is_hovered {
566 p.text
567 } else {
568 p.text_muted
569 };
570
571 let count_pill_w = prep
573 .count
574 .as_ref()
575 .map(|g| (g.size().x + 10.0).max(count_h));
576 let mut content_w = 0.0_f32;
577 if prep.dot.is_some() {
578 content_w += DOT_SIZE;
579 }
580 if let Some(g) = &prep.icon {
581 if content_w > 0.0 {
582 content_w += INNER_GAP;
583 }
584 content_w += g.size().x;
585 }
586 if let Some(g) = &prep.label {
587 if content_w > 0.0 {
588 content_w += INNER_GAP;
589 }
590 content_w += g.size().x;
591 }
592 if let Some(w) = count_pill_w {
593 if content_w > 0.0 {
594 content_w += INNER_GAP;
595 }
596 content_w += w;
597 }
598
599 let mut cx = cell_rect.center().x - content_w * 0.5;
600 let cy = cell_rect.center().y;
601
602 if let Some(dot) = prep.dot {
603 let mut col = dot_color(dot, &theme, is_active);
604 if !prep.enabled {
605 col = with_alpha(col, 120);
606 }
607 ui.painter()
608 .circle_filled(pos2(cx + DOT_SIZE * 0.5, cy), DOT_SIZE * 0.5, col);
609 cx += DOT_SIZE;
610 }
611 if let Some(icon) = &prep.icon {
612 if cx > cell_rect.center().x - content_w * 0.5 {
613 cx += INNER_GAP;
614 }
615 let pos = pos2(cx, cy - icon.size().y * 0.5);
616 ui.painter().galley(pos, icon.clone(), text_color);
617 cx += icon.size().x;
618 }
619 if let Some(label) = &prep.label {
620 if cx > cell_rect.center().x - content_w * 0.5 {
621 cx += INNER_GAP;
622 }
623 let pos = pos2(cx, cy - label.size().y * 0.5);
624 ui.painter().galley(pos, label.clone(), text_color);
625 cx += label.size().x;
626 }
627 if let (Some(g), Some(pill_w)) = (&prep.count, count_pill_w) {
628 if cx > cell_rect.center().x - content_w * 0.5 {
629 cx += INNER_GAP;
630 }
631 let pill_rect = Rect::from_min_size(
632 pos2(cx, cy - count_h * 0.5),
633 Vec2::new(pill_w, count_h),
634 );
635 let (pill_bg, pill_fg) = if is_active {
636 (with_alpha(p.sky, 50), p.sky)
637 } else if !prep.enabled {
638 (with_alpha(p.text_faint, 35), with_alpha(p.text_faint, 200))
639 } else {
640 (with_alpha(p.text_muted, 45), p.text_muted)
641 };
642 ui.painter().rect(
643 pill_rect,
644 CornerRadius::same(99),
645 pill_bg,
646 Stroke::NONE,
647 StrokeKind::Inside,
648 );
649 let text_pos = pos2(
650 pill_rect.center().x - g.size().x * 0.5,
651 pill_rect.center().y - g.size().y * 0.5,
652 );
653 ui.painter().galley(text_pos, g.clone(), pill_fg);
654 }
655 }
656 }
657
658 for (i, (cell_resp, prep)) in cell_responses.iter().zip(prepared.iter()).enumerate() {
660 let label = prep.a11y.clone();
661 let enabled = prep.enabled;
662 let selected = active_idx == Some(i);
663 cell_resp.widget_info(|| {
664 WidgetInfo::selected(WidgetType::RadioButton, enabled, selected, &label)
665 });
666 }
667 response
668 .widget_info(|| WidgetInfo::labeled(WidgetType::RadioGroup, true, "segmented control"));
669 response
670 }
671}