1use std::rc::Rc;
169
170use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
171use frust::authoring::{
172 Action, BoxConstraints, BuildCtx, ChangeFlags, CornerRadii, ErasedCallback, EventCtx,
173 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
174 Toggled, TypedArgCallback, View, Widget,
175};
176use frust::{IconData, IconSource, ShapeScale, Theme};
177use kurbo::{Affine, BezPath, Point, Rect, RoundedRect, Shape, Size, Vec2};
178use peniko::{Brush, Color};
179
180use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
181use crate::press::presses;
182
183pub const MIN_SEGMENTS: usize = 2;
186pub const MAX_SEGMENTS: usize = 5;
191
192const HEIGHT: f64 = 40.0;
197const ICON_SIZE: f64 = 18.0;
199const SEGMENT_H_PADDING: f64 = 12.0;
202const ICON_LABEL_GAP: f64 = 8.0;
205const BORDER_WIDTH: f64 = 1.0;
208const PATH_TOLERANCE: f64 = 0.1;
211
212const SHAPING_INK: Color = Color::BLACK;
216
217const LABEL_LARGE: (f32, f32, f32, FontWeight) = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
221
222const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
224const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
226const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
228const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
230
231fn next_selection<T: PartialEq + Clone>(current: &[T], multi_select: bool, tapped: &T) -> Vec<T> {
237 if multi_select {
238 let mut next = current.to_vec();
239 if let Some(pos) = next.iter().position(|v| v == tapped) {
240 next.remove(pos);
241 } else {
242 next.push(tapped.clone());
243 }
244 next
245 } else {
246 vec![tapped.clone()]
247 }
248}
249
250fn bind_on_tap<State: 'static, T: PartialEq + Clone + 'static>(
254 on_selection_changed: &TypedArgCallback<State, Vec<T>>,
255 current_selected: &[T],
256 multi_select: bool,
257 tapped_value: T,
258) -> ErasedCallback {
259 let on_selection_changed = on_selection_changed.clone();
260 let next = next_selection(current_selected, multi_select, &tapped_value);
261 Box::new(move |ctx: &mut EventCtx| {
262 MaterialHaptics::fire(HapticSignal::None);
263 let state = ctx.state_mut::<State>();
264 on_selection_changed(state, next.clone());
265 })
266}
267
268fn segment_radii(index: usize, count: usize, outer: f64) -> CornerRadii {
279 let is_first = index == 0;
280 let is_last = index + 1 == count;
281 let left = if is_first { outer } else { 0.0 };
282 let right = if is_last { outer } else { 0.0 };
283 CornerRadii::new(left, right, right, left)
284}
285
286fn outer_radius(theme: Option<&Theme>, width: f64, height: f64) -> f64 {
292 match theme {
293 Some(theme) => ShapeScale::resolve(theme.shape.full, width, height),
294 None => width.min(height) / 2.0,
295 }
296}
297
298fn with_alpha(color: Color, alpha: f32) -> Color {
300 let c = color.components;
301 Color::new([c[0], c[1], c[2], alpha])
302}
303
304fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
308 match (a, b) {
309 (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
310 (None, None) => true,
311 _ => false,
312 }
313}
314
315struct Colors {
322 outline: Color,
323 divider: Color,
324 selected_bg: Color,
325 selected_fg: Color,
326 unselected_fg: Color,
327}
328
329fn resolve_colors(theme: Option<&Theme>) -> Colors {
330 match theme {
331 Some(theme) => {
332 let s = theme.scheme();
333 Colors {
334 outline: s.outline,
335 divider: s.outline,
336 selected_bg: s.secondary_container,
337 selected_fg: s.on_secondary_container,
338 unselected_fg: s.on_surface,
339 }
340 }
341 None => Colors {
342 outline: OUTLINE,
343 divider: OUTLINE,
344 selected_bg: SECONDARY_CONTAINER,
345 selected_fg: ON_SECONDARY_CONTAINER,
346 unselected_fg: ON_SURFACE,
347 },
348 }
349}
350
351fn label_style(theme: Option<&Theme>) -> TextStyle {
355 let mut style = match theme {
356 Some(theme) => theme.type_scale.label_large.clone(),
357 None => {
358 let (size, line_height, letter_spacing, weight) = LABEL_LARGE;
359 let mut style = TextStyle::new(size, SHAPING_INK);
360 style.line_height = LineHeight::Absolute(line_height);
361 style.letter_spacing = letter_spacing;
362 style.weight = weight;
363 style
364 }
365 };
366 style.color = SHAPING_INK;
367 style
368}
369
370struct LabelRun {
378 content: String,
379 layout: Option<TextLayout>,
380 shaped_for: Option<TextStyle>,
381}
382
383impl LabelRun {
384 fn new(content: String) -> Self {
385 Self {
386 content,
387 layout: None,
388 shaped_for: None,
389 }
390 }
391
392 fn set_content(&mut self, content: &str) {
393 if self.content != content {
394 self.content = content.to_string();
395 self.layout = None;
396 self.shaped_for = None;
397 }
398 }
399
400 fn content(&self) -> &str {
401 &self.content
402 }
403
404 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
405 if let Some(cached) = &self.layout
406 && self.shaped_for.as_ref() == Some(style)
407 {
408 return cached.size();
409 }
410 let text_ctx = ctx.text_context::<TextContext>();
411 let laid = text_ctx.layout(&self.content, style, None);
412 let size = laid.size();
413 self.layout = Some(laid);
414 self.shaped_for = Some(style.clone());
415 size
416 }
417
418 fn size(&self) -> Size {
419 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
420 }
421
422 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
423 let Some(layout) = &self.layout else {
424 return;
425 };
426 for mut run in layout.to_scene_runs(origin) {
427 run.brush = Brush::Solid(color);
428 scene.draw_glyph_run(run);
429 }
430 }
431}
432
433pub struct Segment<T> {
438 value: T,
439 label: Option<String>,
440 icon: Option<IconSource>,
441}
442
443pub fn segment<T>(value: T) -> Segment<T> {
448 Segment {
449 value,
450 label: None,
451 icon: None,
452 }
453}
454
455impl<T> Segment<T> {
456 pub fn label(mut self, label: impl Into<String>) -> Self {
458 self.label = Some(label.into());
459 self
460 }
461
462 pub fn icon(mut self, icon: IconSource) -> Self {
466 self.icon = Some(icon);
467 self
468 }
469}
470
471type OnSelectionChanged<State, T> = TypedArgCallback<State, Vec<T>>;
474
475pub struct SegmentedButtonView<State: 'static, T: PartialEq + Clone + 'static> {
477 segments: Vec<Segment<T>>,
478 selected: Vec<T>,
479 multi_select: bool,
480 show_selected_icon: bool,
481 on_selection_changed: OnSelectionChanged<State, T>,
482}
483
484pub fn segmented_button<
492 State: 'static,
493 T: PartialEq + Clone + 'static,
494 F: Fn(&mut State, Vec<T>) + 'static,
495>(
496 segments: impl IntoIterator<Item = Segment<T>>,
497 selected: impl IntoIterator<Item = T>,
498 on_selection_changed: F,
499) -> SegmentedButtonView<State, T> {
500 let segments: Vec<Segment<T>> = segments.into_iter().collect();
501 debug_assert!(
502 (MIN_SEGMENTS..=MAX_SEGMENTS).contains(&segments.len()),
503 "a segmented button needs {MIN_SEGMENTS}-{MAX_SEGMENTS} segments \
504 (m3e_segmented_buttons.dart:26 asserts >= 2 in code; its class doc \
505 pins the upper bound at 5), got {}",
506 segments.len()
507 );
508 for s in &segments {
509 debug_assert!(
510 s.label.is_some() || s.icon.is_some(),
511 "a segment needs at least a label or an icon (m3e_segment.dart:8-11)"
512 );
513 }
514 SegmentedButtonView {
515 segments,
516 selected: selected.into_iter().collect(),
517 multi_select: false,
518 show_selected_icon: true,
519 on_selection_changed: Rc::new(on_selection_changed),
520 }
521}
522
523#[allow(non_snake_case)]
526pub fn SegmentedButton<
527 State: 'static,
528 T: PartialEq + Clone + 'static,
529 F: Fn(&mut State, Vec<T>) + 'static,
530>(
531 segments: impl IntoIterator<Item = Segment<T>>,
532 selected: impl IntoIterator<Item = T>,
533 on_selection_changed: F,
534) -> SegmentedButtonView<State, T> {
535 segmented_button(segments, selected, on_selection_changed)
536}
537
538impl<State: 'static, T: PartialEq + Clone + 'static> SegmentedButtonView<State, T> {
539 pub fn multi_select(mut self, multi_select: bool) -> Self {
543 self.multi_select = multi_select;
544 self
545 }
546
547 pub fn show_selected_icon(mut self, show: bool) -> Self {
551 self.show_selected_icon = show;
552 self
553 }
554}
555
556pub struct SegmentedButtonWidget {
561 labels: Vec<Option<LabelRun>>,
563 icon_geoms: Vec<Option<(BezPath, f64)>>,
567 check_icon_geom: (BezPath, f64),
569 selected_flags: Vec<bool>,
572 show_selected_icon: bool,
573 multi_select: bool,
574 segment_rects: Vec<Rect>,
577 interactions: Vec<InteractionState>,
580 armed: bool,
582 active_segment: Option<usize>,
584 pressed_inside: bool,
586 on_taps: Vec<ErasedCallback>,
588}
589
590impl SegmentedButtonWidget {
591 fn icon_geom(&self, i: usize) -> Option<&(BezPath, f64)> {
596 if self.selected_flags[i] && self.show_selected_icon {
597 Some(&self.check_icon_geom)
598 } else {
599 self.icon_geoms[i].as_ref()
600 }
601 }
602
603 fn has_icon(&self, i: usize) -> bool {
605 self.icon_geom(i).is_some()
606 }
607
608 fn segment_at(&self, pos: Point) -> Option<usize> {
610 self.segment_rects.iter().position(|r| r.contains(pos))
611 }
612}
613
614type SegmentRuntime = (Vec<Option<LabelRun>>, Vec<Option<(BezPath, f64)>>);
617
618fn build_segment_runtime<T>(segments: &[Segment<T>]) -> SegmentRuntime {
621 let labels = segments
622 .iter()
623 .map(|s| s.label.as_ref().map(|l| LabelRun::new(l.clone())))
624 .collect();
625 let icon_geoms = segments
626 .iter()
627 .map(|s| s.icon.map(|src| IconData::from(src).resolve()))
628 .collect();
629 (labels, icon_geoms)
630}
631
632fn compute_selected_flags<T: PartialEq>(segments: &[Segment<T>], selected: &[T]) -> Vec<bool> {
633 segments
634 .iter()
635 .map(|s| selected.iter().any(|v| v == &s.value))
636 .collect()
637}
638
639fn bind_on_taps<State: 'static, T: PartialEq + Clone + 'static>(
640 segments: &[Segment<T>],
641 on_selection_changed: &OnSelectionChanged<State, T>,
642 selected: &[T],
643 multi_select: bool,
644) -> Vec<ErasedCallback> {
645 segments
646 .iter()
647 .map(|s| {
648 bind_on_tap::<State, T>(
649 on_selection_changed,
650 selected,
651 multi_select,
652 s.value.clone(),
653 )
654 })
655 .collect()
656}
657
658impl<State: 'static, T: PartialEq + Clone + 'static> View<State> for SegmentedButtonView<State, T> {
659 type Element = SegmentedButtonWidget;
660
661 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SegmentedButtonWidget {
662 let (labels, icon_geoms) = build_segment_runtime(&self.segments);
663 let selected_flags = compute_selected_flags(&self.segments, &self.selected);
664 let on_taps = bind_on_taps::<State, T>(
665 &self.segments,
666 &self.on_selection_changed,
667 &self.selected,
668 self.multi_select,
669 );
670 let count = self.segments.len();
671 SegmentedButtonWidget {
672 labels,
673 icon_geoms,
674 check_icon_geom: IconData::from(crate::icons::CHECK).resolve(),
675 selected_flags,
676 show_selected_icon: self.show_selected_icon,
677 multi_select: self.multi_select,
678 segment_rects: vec![Rect::ZERO; count],
679 interactions: vec![InteractionState::new(); count],
680 armed: false,
681 active_segment: None,
682 pressed_inside: false,
683 on_taps,
684 }
685 }
686
687 fn rebuild(
688 &self,
689 prev: &Self,
690 element: &mut SegmentedButtonWidget,
691 _ctx: &mut BuildCtx<'_>,
692 ) -> ChangeFlags {
693 let mut flags = ChangeFlags::NONE;
694
695 if prev.segments.len() != self.segments.len() {
696 let (labels, icon_geoms) = build_segment_runtime(&self.segments);
698 element.labels = labels;
699 element.icon_geoms = icon_geoms;
700 element.segment_rects = vec![Rect::ZERO; self.segments.len()];
701 element.interactions = vec![InteractionState::new(); self.segments.len()];
702 element.armed = false;
703 element.active_segment = None;
704 element.pressed_inside = false;
705 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
706 } else {
707 for (i, (prev_s, next_s)) in prev.segments.iter().zip(self.segments.iter()).enumerate()
708 {
709 match (&prev_s.label, &next_s.label) {
710 (None, None) => {}
711 (Some(p), Some(n)) if p == n => {}
712 (_, Some(n)) => {
713 let run =
714 element.labels[i].get_or_insert_with(|| LabelRun::new(String::new()));
715 run.set_content(n);
716 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
717 }
718 (Some(_), None) => {
719 element.labels[i] = None;
720 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
721 }
722 }
723 if !same_icon_source(prev_s.icon, next_s.icon) {
724 element.icon_geoms[i] = next_s.icon.map(|src| IconData::from(src).resolve());
725 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
726 }
727 }
728 }
729
730 let selected_flags = compute_selected_flags(&self.segments, &self.selected);
734 if selected_flags != element.selected_flags {
735 element.selected_flags = selected_flags;
736 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
737 }
738
739 if element.show_selected_icon != self.show_selected_icon {
740 element.show_selected_icon = self.show_selected_icon;
741 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
742 }
743 if element.multi_select != self.multi_select {
744 element.multi_select = self.multi_select;
745 flags |= ChangeFlags::PAINT;
746 }
747
748 element.on_taps = bind_on_taps::<State, T>(
751 &self.segments,
752 &self.on_selection_changed,
753 &self.selected,
754 self.multi_select,
755 );
756
757 flags
758 }
759}
760
761impl Widget for SegmentedButtonWidget {
762 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
763 let count = self.labels.len();
764 let style = {
765 let theme = Theme::from_layout_ctx(ctx);
766 label_style(theme)
767 };
768
769 let mut content_widths = Vec::with_capacity(count);
770 for i in 0..count {
771 let has_icon = self.has_icon(i);
772 let has_label = self.labels[i].is_some();
773 let label_w = if let Some(run) = self.labels[i].as_mut() {
774 run.shape(ctx, &style).width
775 } else {
776 0.0
777 };
778 let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
779 let gap = if has_icon && has_label {
780 ICON_LABEL_GAP
781 } else {
782 0.0
783 };
784 content_widths.push(icon_w + gap + label_w + SEGMENT_H_PADDING * 2.0);
785 }
786
787 let dividers = count.saturating_sub(1) as f64 * BORDER_WIDTH;
788 let total_w: f64 = content_widths.iter().sum::<f64>() + dividers;
789 let size = bc.constrain(Size::new(total_w, HEIGHT));
790
791 self.segment_rects.clear();
792 let mut x = 0.0_f64;
793 for w in &content_widths {
794 self.segment_rects.push(Rect::new(x, 0.0, x + w, HEIGHT));
795 x += w + BORDER_WIDTH;
796 }
797
798 size
799 }
800
801 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
802 let origin = ctx.origin();
803 let size = ctx.size();
804 let count = self.segment_rects.len();
805
806 let (colors, outer) = {
807 let theme = Theme::from_paint_ctx(ctx);
808 (
809 resolve_colors(theme),
810 outer_radius(theme, size.width, size.height),
811 )
812 };
813
814 for i in 0..count {
816 let rect = self.segment_rects[i];
817 let radii = segment_radii(i, count, outer);
818 let selected = self.selected_flags[i];
819 let fg = if selected {
820 colors.selected_fg
821 } else {
822 colors.unselected_fg
823 };
824 let seg_origin = Point::new(origin.x + rect.x0, origin.y + rect.y0);
825
826 if selected {
827 scene.fill_rounded_rect_radii(seg_origin, rect.size(), radii, colors.selected_bg);
828 }
829
830 let opacity = self.interactions[i].resolve_opacity();
831 if opacity > 0.0 {
832 scene.fill_rounded_rect_radii(
833 seg_origin,
834 rect.size(),
835 radii,
836 with_alpha(fg, opacity),
837 );
838 }
839
840 let has_icon = self.has_icon(i);
841 let has_label = self.labels[i].is_some();
842 let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
843 let gap = if has_icon && has_label {
844 ICON_LABEL_GAP
845 } else {
846 0.0
847 };
848 let label_w = self.labels[i].as_ref().map_or(0.0, |run| run.size().width);
849 let content_w = icon_w + gap + label_w;
850 let content_x = rect.x0 + (rect.width() - content_w) / 2.0;
851
852 if let Some((path, design)) = self.icon_geom(i) {
853 let scale = if *design > 0.0 {
854 ICON_SIZE / *design
855 } else {
856 1.0
857 };
858 let icon_x = content_x;
859 let icon_y = rect.y0 + (rect.height() - ICON_SIZE) / 2.0;
860 let transform = Affine::translate(Vec2::new(icon_x, icon_y)) * Affine::scale(scale);
861 let scaled = transform * path.clone();
862 scene.fill_path(origin, &scaled, &Brush::Solid(fg));
863 }
864
865 if let Some(run) = &self.labels[i] {
866 let label_x = content_x + icon_w + gap;
867 let label_y = rect.y0 + (rect.height() - run.size().height) / 2.0;
868 run.paint(
869 Point::new(origin.x + label_x, origin.y + label_y),
870 fg,
871 scene,
872 );
873 }
874 }
875
876 let ring_rect = Rect::from_origin_size(Point::ORIGIN, size);
878 let ring_path = RoundedRect::from_rect(ring_rect, outer).to_path(PATH_TOLERANCE);
879 scene.stroke_path(
880 origin,
881 &ring_path,
882 BORDER_WIDTH,
883 &Brush::Solid(colors.outline),
884 );
885
886 for i in 1..count {
888 let x = self.segment_rects[i - 1].x1 + BORDER_WIDTH / 2.0;
889 scene.stroke_line(
890 Point::new(origin.x + x, origin.y),
891 Point::new(origin.x + x, origin.y + size.height),
892 BORDER_WIDTH,
893 colors.divider,
894 );
895 }
896 }
897
898 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
899 let InputEvent::Pointer(p) = event else {
900 return EventResult::Ignored;
901 };
902 match p.phase {
903 PointerPhase::Down => {
904 if !presses(p) {
905 return EventResult::Ignored;
906 }
907 let Some(i) = self.segment_at(p.position) else {
908 return EventResult::Ignored;
909 };
910 self.armed = true;
911 self.active_segment = Some(i);
912 self.pressed_inside = true;
913 self.interactions[i].set_pressed(true);
914 ctx.capture_pointer();
915 ctx.request_redraw();
916 EventResult::Handled
917 }
918 PointerPhase::Move => {
919 if !self.armed {
920 return EventResult::Ignored;
921 }
922 let Some(i) = self.active_segment else {
923 return EventResult::Ignored;
924 };
925 let inside = self
926 .segment_rects
927 .get(i)
928 .is_some_and(|r| r.contains(p.position));
929 if inside != self.pressed_inside {
930 self.pressed_inside = inside;
931 self.interactions[i].set_pressed(inside);
932 ctx.request_redraw();
933 }
934 EventResult::Handled
935 }
936 PointerPhase::Up => {
937 if !self.armed {
938 return EventResult::Ignored;
939 }
940 if let Some(i) = self.active_segment {
941 self.interactions[i].set_pressed(false);
942 if self.pressed_inside {
943 (self.on_taps[i])(ctx);
944 }
945 }
946 self.armed = false;
947 self.pressed_inside = false;
948 self.active_segment = None;
949 ctx.request_redraw();
950 EventResult::Handled
951 }
952 PointerPhase::Cancel => {
953 if !self.armed {
954 return EventResult::Ignored;
955 }
956 if let Some(i) = self.active_segment {
957 self.interactions[i].set_pressed(false);
958 }
959 self.armed = false;
960 self.pressed_inside = false;
961 self.active_segment = None;
962 ctx.request_redraw();
963 EventResult::Handled
964 }
965 }
966 }
967
968 fn semantics(&self, ctx: &mut SemanticsCtx) {
969 let group_role = if self.multi_select {
973 Role::Group
974 } else {
975 Role::RadioGroup
976 };
977 let multi_select = self.multi_select;
978 let labels = &self.labels;
979 let selected_flags = &self.selected_flags;
980 ctx.push_container(
981 group_role,
982 |_| {},
983 |ctx| {
984 for (i, selected) in selected_flags.iter().enumerate() {
985 let child_role = if multi_select {
986 Role::CheckBox
987 } else {
988 Role::RadioButton
989 };
990 let label = labels[i].as_ref().map(LabelRun::content);
991 ctx.push_node(child_role, |node| {
992 if let Some(l) = label {
993 node.set_label(l);
994 }
995 if multi_select {
996 node.set_toggled(if *selected {
997 Toggled::True
998 } else {
999 Toggled::False
1000 });
1001 } else {
1002 node.set_selected(*selected);
1003 }
1004 node.add_action(Action::Click);
1005 });
1006 }
1007 },
1008 );
1009 }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015 use frust::authoring::{PointerButton, PointerEvent};
1016 use std::any::Any;
1017
1018 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019 enum Density {
1020 Compact,
1021 Cozy,
1022 Comfortable,
1023 }
1024
1025 fn build_view(selected: Vec<Density>) -> SegmentedButtonView<Vec<Density>, Density> {
1026 segmented_button(
1027 [
1028 segment(Density::Compact).label("Compact"),
1029 segment(Density::Cozy).label("Cozy"),
1030 segment(Density::Comfortable).label("Comfortable"),
1031 ],
1032 selected,
1033 |s: &mut Vec<Density>, next| *s = next,
1034 )
1035 }
1036
1037 fn build_widget(selected: Vec<Density>) -> SegmentedButtonWidget {
1038 let view = build_view(selected);
1039 let mut counter = 0u64;
1040 View::<Vec<Density>>::build(&view, &mut BuildCtx::new(&mut counter))
1041 }
1042
1043 fn with_rects(w: &mut SegmentedButtonWidget, widths: &[f64]) {
1044 w.segment_rects.clear();
1045 let mut x = 0.0;
1046 for width in widths {
1047 w.segment_rects.push(Rect::new(x, 0.0, x + width, HEIGHT));
1048 x += width + BORDER_WIDTH;
1049 }
1050 }
1051
1052 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1053 InputEvent::Pointer(PointerEvent {
1054 phase,
1055 position: Point::new(x, y),
1056 button: PointerButton::Primary,
1057 })
1058 }
1059
1060 #[allow(clippy::ptr_arg)]
1065 fn dispatch(w: &mut SegmentedButtonWidget, state: &mut Vec<Density>, event: &InputEvent) {
1066 let state_any: &mut dyn Any = state;
1067 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT));
1068 w.event(&mut ctx, event);
1069 }
1070
1071 #[test]
1074 fn single_select_replaces_the_whole_selection() {
1075 let next = next_selection(&[Density::Compact], false, &Density::Cozy);
1076 assert_eq!(next, vec![Density::Cozy]);
1077 }
1078
1079 #[test]
1080 fn single_select_tapping_the_already_selected_member_cannot_deselect() {
1081 let next = next_selection(&[Density::Cozy], false, &Density::Cozy);
1084 assert_eq!(
1085 next,
1086 vec![Density::Cozy],
1087 "a single-select tap on the already-selected member must not clear it"
1088 );
1089 }
1090
1091 #[test]
1092 fn multi_select_toggles_an_absent_value_in() {
1093 let next = next_selection(&[Density::Compact], true, &Density::Cozy);
1094 assert_eq!(next, vec![Density::Compact, Density::Cozy]);
1095 }
1096
1097 #[test]
1098 fn multi_select_toggles_a_present_value_out() {
1099 let next = next_selection(&[Density::Compact, Density::Cozy], true, &Density::Cozy);
1100 assert_eq!(
1101 next,
1102 vec![Density::Compact],
1103 "multi-select can reach a reduced (even empty) selection"
1104 );
1105 }
1106
1107 #[test]
1108 fn multi_select_can_reach_zero_selected_members() {
1109 let next = next_selection(&[Density::Compact], true, &Density::Compact);
1110 assert!(next.is_empty());
1111 }
1112
1113 #[test]
1116 fn single_segment_group_is_all_outer_corners() {
1117 let r = segment_radii(0, 1, 20.0);
1118 assert_eq!(r.top_left, 20.0);
1119 assert_eq!(r.top_right, 20.0);
1120 assert_eq!(r.bottom_right, 20.0);
1121 assert_eq!(r.bottom_left, 20.0);
1122 }
1123
1124 #[test]
1125 fn end_segments_get_outer_on_their_outer_side_and_square_elsewhere() {
1126 let first = segment_radii(0, 3, 20.0);
1127 assert_eq!((first.top_left, first.bottom_left), (20.0, 20.0));
1128 assert_eq!((first.top_right, first.bottom_right), (0.0, 0.0));
1129 let last = segment_radii(2, 3, 20.0);
1130 assert_eq!((last.top_left, last.bottom_left), (0.0, 0.0));
1131 assert_eq!((last.top_right, last.bottom_right), (20.0, 20.0));
1132 }
1133
1134 #[test]
1135 fn middle_segments_are_entirely_square() {
1136 let mid = segment_radii(1, 3, 20.0);
1137 assert_eq!(mid.top_left, 0.0);
1138 assert_eq!(mid.top_right, 0.0);
1139 assert_eq!(mid.bottom_right, 0.0);
1140 assert_eq!(mid.bottom_left, 0.0);
1141 }
1142
1143 #[test]
1146 #[should_panic(expected = "needs 2-5 segments")]
1147 fn fewer_than_two_segments_panics() {
1148 let _ = segmented_button::<(), u32, _>(
1149 [Segment {
1150 value: 0,
1151 label: Some("Only".into()),
1152 icon: None,
1153 }],
1154 [],
1155 |_s: &mut (), _v| {},
1156 );
1157 }
1158
1159 #[test]
1160 #[should_panic(expected = "needs 2-5 segments")]
1161 fn more_than_five_segments_panics() {
1162 let segs: Vec<Segment<u32>> = (0..6).map(|i| segment(i).label(i.to_string())).collect();
1163 let _ = segmented_button::<(), u32, _>(segs, [], |_s: &mut (), _v| {});
1164 }
1165
1166 #[test]
1167 fn exactly_two_and_exactly_five_segments_are_accepted() {
1168 let two: Vec<Segment<u32>> = (0..2).map(|i| segment(i).label(i.to_string())).collect();
1169 let _ = segmented_button::<(), u32, _>(two, [], |_s: &mut (), _v| {});
1170 let five: Vec<Segment<u32>> = (0..5).map(|i| segment(i).label(i.to_string())).collect();
1171 let _ = segmented_button::<(), u32, _>(five, [], |_s: &mut (), _v| {});
1172 }
1173
1174 #[test]
1175 #[should_panic(expected = "label or an icon")]
1176 fn a_segment_with_neither_label_nor_icon_panics() {
1177 let _ = segmented_button::<(), u32, _>(
1178 [
1179 Segment {
1180 value: 0,
1181 label: None,
1182 icon: None,
1183 },
1184 segment(1u32).label("B"),
1185 ],
1186 [],
1187 |_s: &mut (), _v| {},
1188 );
1189 }
1190
1191 #[test]
1194 fn build_creates_one_run_per_segment_for_a_generic_enum_value() {
1195 let w = build_widget(vec![Density::Cozy]);
1196 assert_eq!(w.labels.len(), 3);
1197 assert_eq!(w.selected_flags, vec![false, true, false]);
1198 }
1199
1200 #[test]
1201 fn is_controlled_selection_only_moves_via_rebuild() {
1202 let mut w = build_widget(vec![Density::Compact]);
1203 with_rects(&mut w, &[80.0, 80.0, 80.0]);
1204 let mut state = vec![Density::Compact];
1205 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0, 20.0));
1206 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1207 assert_eq!(
1208 w.selected_flags,
1209 vec![true, false, false],
1210 "widget did not self-mutate — its own flags still reflect the original selection"
1211 );
1212
1213 let next = build_view(vec![Density::Cozy]);
1214 let prev = build_view(vec![Density::Compact]);
1215 let mut counter = 0u64;
1216 View::<Vec<Density>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1217 assert_eq!(w.selected_flags, vec![false, true, false]);
1218 }
1219
1220 #[test]
1221 fn tap_reports_the_tapped_segments_value_single_select() {
1222 let mut w = build_widget(vec![Density::Compact]);
1223 with_rects(&mut w, &[80.0, 80.0, 80.0]);
1224 let mut state = vec![Density::Compact];
1225 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0, 20.0));
1227 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1228 assert_eq!(state, vec![Density::Cozy]);
1229 }
1230
1231 #[test]
1232 fn tap_on_the_selected_single_select_member_does_not_clear_it() {
1233 let mut w = build_widget(vec![Density::Compact]);
1234 with_rects(&mut w, &[80.0, 80.0, 80.0]);
1235 let mut state = vec![Density::Compact];
1236 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1237 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 20.0));
1238 assert_eq!(state, vec![Density::Compact]);
1239 }
1240
1241 #[test]
1242 fn multi_select_tap_toggles_membership() {
1243 let view = segmented_button(
1244 [
1245 segment(Density::Compact).label("Compact"),
1246 segment(Density::Cozy).label("Cozy"),
1247 segment(Density::Comfortable).label("Comfortable"),
1248 ],
1249 vec![Density::Compact],
1250 |s: &mut Vec<Density>, next| *s = next,
1251 )
1252 .multi_select(true);
1253 let mut counter = 0u64;
1254 let mut w = View::<Vec<Density>>::build(&view, &mut BuildCtx::new(&mut counter));
1255 with_rects(&mut w, &[80.0, 80.0, 80.0]);
1256 let mut state = vec![Density::Compact];
1257
1258 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0, 20.0));
1260 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1261 assert_eq!(state, vec![Density::Compact, Density::Cozy]);
1262 }
1263
1264 #[test]
1265 fn up_outside_the_pressed_segment_does_not_fire() {
1266 let mut w = build_widget(vec![Density::Compact]);
1267 with_rects(&mut w, &[80.0, 80.0, 80.0]);
1268 let mut state = vec![Density::Compact];
1269 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1270 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0, 20.0));
1271 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1272 assert_eq!(state, vec![Density::Compact]);
1273 }
1274
1275 #[test]
1276 fn cancel_clears_the_press_without_firing() {
1277 let mut w = build_widget(vec![Density::Compact]);
1278 with_rects(&mut w, &[80.0, 80.0, 80.0]);
1279 let mut state = vec![Density::Compact];
1280 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1281 assert!(w.armed);
1282 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 20.0));
1283 assert!(!w.armed);
1284 assert_eq!(state, vec![Density::Compact]);
1285 }
1286
1287 #[test]
1288 fn down_outside_all_segments_is_ignored() {
1289 let mut w = build_widget(vec![Density::Compact]);
1290 with_rects(&mut w, &[80.0, 80.0, 80.0]);
1291 let mut state = vec![Density::Compact];
1292 let state_any: &mut dyn Any = &mut state;
1293 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT));
1294 let result = w.event(&mut ctx, &ev(PointerPhase::Down, 1000.0, 20.0));
1295 assert!(matches!(result, EventResult::Ignored));
1296 assert!(w.active_segment.is_none());
1297 }
1298
1299 #[test]
1302 fn checkmark_swaps_in_only_for_the_selected_segment() {
1303 let w = build_widget(vec![Density::Cozy]);
1304 assert!(w.has_icon(1));
1307 let (check_path, check_design) = &w.check_icon_geom;
1308 let (resolved_path, resolved_design) = w.icon_geom(1).expect("selected slot has an icon");
1309 assert_eq!(resolved_design, check_design);
1310 assert_eq!(resolved_path.elements().len(), check_path.elements().len());
1311 assert!(!w.has_icon(0));
1313 assert!(!w.has_icon(2));
1314 }
1315
1316 #[test]
1317 fn show_selected_icon_false_suppresses_the_checkmark() {
1318 let view = segmented_button(
1319 [
1320 segment(Density::Compact).label("Compact"),
1321 segment(Density::Cozy).label("Cozy"),
1322 ],
1323 vec![Density::Cozy],
1324 |_s: &mut (), _v: Vec<Density>| {},
1325 )
1326 .show_selected_icon(false);
1327 let mut counter = 0u64;
1328 let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1329 assert!(
1330 !w.has_icon(1),
1331 "checkmark suppressed by show_selected_icon(false)"
1332 );
1333 }
1334
1335 #[test]
1336 fn a_segments_own_icon_survives_while_unselected() {
1337 let view = segmented_button::<(), u32, _>(
1338 [
1339 segment(0u32).icon(crate::icons::SEARCH),
1340 segment(1u32).label("Text only"),
1341 ],
1342 [],
1343 |_s: &mut (), _v| {},
1344 );
1345 let mut counter = 0u64;
1346 let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1347 assert!(
1348 w.has_icon(0),
1349 "unselected segment 0 still shows its own icon"
1350 );
1351 assert!(
1352 !w.has_icon(1),
1353 "label-only, unselected segment 1 has no icon"
1354 );
1355 }
1356
1357 #[derive(Default)]
1362 struct PaintRecorder {
1363 radii_fills: Vec<(Point, Size, CornerRadii, Color)>,
1364 lines: usize,
1365 strokes: usize,
1366 }
1367
1368 impl PaintScene for PaintRecorder {
1369 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1370 fn draw_text(&mut self, _o: Point, _t: &str) {}
1371 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1372 self.radii_fills.push((o, s, radii, color));
1373 }
1374 fn stroke_line(&mut self, _p0: Point, _p1: Point, _w: f64, _c: Color) {
1375 self.lines += 1;
1376 }
1377 fn stroke_path(&mut self, _o: Point, _p: &BezPath, _w: f64, _b: &Brush) {
1378 self.strokes += 1;
1379 }
1380 fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {}
1381 }
1382
1383 fn layout_widget(w: &mut SegmentedButtonWidget, width: f64) -> Size {
1384 let bc = BoxConstraints::loose(Size::new(width, f64::INFINITY));
1385 let mut tcx = TextContext::new();
1386 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1387 w.layout(&mut ctx, &bc)
1388 }
1389
1390 #[test]
1391 fn divider_count_is_segment_count_minus_one() {
1392 let mut w = build_widget(vec![Density::Compact]);
1393 layout_widget(&mut w, 400.0);
1394 let mut rec = PaintRecorder::default();
1395 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1396 w.paint(&mut ctx, &mut rec);
1397 assert_eq!(rec.lines, 2, "3 segments have 2 internal dividers");
1398 assert_eq!(rec.strokes, 1, "exactly one outline ring stroke");
1399 }
1400
1401 #[test]
1402 fn only_the_selected_segment_paints_a_background_fill() {
1403 let mut w = build_widget(vec![Density::Cozy]);
1404 layout_widget(&mut w, 400.0);
1405 let mut rec = PaintRecorder::default();
1406 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1407 w.paint(&mut ctx, &mut rec);
1408 assert_eq!(
1409 rec.radii_fills.len(),
1410 1,
1411 "only the selected member fills a background"
1412 );
1413 }
1414
1415 #[test]
1416 fn themed_paint_resolves_container_and_outline_tokens() {
1417 let theme = crate::baseline();
1418 let mut w = build_widget(vec![Density::Compact]);
1419 layout_widget(&mut w, 400.0);
1420
1421 #[derive(Default)]
1422 struct ColorRec {
1423 fills: Vec<Color>,
1424 }
1425 impl PaintScene for ColorRec {
1426 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1427 fn draw_text(&mut self, _o: Point, _t: &str) {}
1428 fn fill_rounded_rect_radii(
1429 &mut self,
1430 _o: Point,
1431 _s: Size,
1432 _r: CornerRadii,
1433 color: Color,
1434 ) {
1435 self.fills.push(color);
1436 }
1437 }
1438 let mut rec = ColorRec::default();
1439 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
1440 w.paint(&mut ctx, &mut rec);
1441 let scheme = theme.scheme();
1442 assert_eq!(rec.fills[0], scheme.secondary_container);
1443 }
1444
1445 #[test]
1448 fn semantics_yields_a_radiogroup_for_single_select() {
1449 fn logic(_s: &mut ()) -> SegmentedButtonView<(), u32> {
1450 segmented_button::<(), u32, _>(
1451 [segment(0u32).label("A"), segment(1u32).label("B")],
1452 [1u32],
1453 |_s: &mut (), _v| {},
1454 )
1455 }
1456 let mut root: frust_core::RenderRoot<(), SegmentedButtonView<(), u32>> =
1457 frust_core::RenderRoot::new();
1458 let mut state = ();
1459 root.rebuild(&mut logic, &mut state);
1460 let mut tcx = TextContext::new();
1461 root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
1462 let update = root.semantics();
1463
1464 let group = update
1465 .nodes
1466 .iter()
1467 .find(|(_, n)| n.role() == Role::RadioGroup)
1468 .expect("a RadioGroup container node is contributed");
1469 assert_eq!(group.1.children().len(), 2);
1470
1471 let selected = update
1472 .nodes
1473 .iter()
1474 .find(|(_, n)| n.role() == Role::RadioButton && n.label() == Some("B"))
1475 .expect("segment B is present");
1476 assert_eq!(selected.1.is_selected(), Some(true));
1477 }
1478
1479 #[test]
1480 fn semantics_yields_a_group_of_checkboxes_for_multi_select() {
1481 fn logic(_s: &mut ()) -> SegmentedButtonView<(), u32> {
1482 segmented_button::<(), u32, _>(
1483 [segment(0u32).label("A"), segment(1u32).label("B")],
1484 [0u32, 1u32],
1485 |_s: &mut (), _v| {},
1486 )
1487 .multi_select(true)
1488 }
1489 let mut root: frust_core::RenderRoot<(), SegmentedButtonView<(), u32>> =
1490 frust_core::RenderRoot::new();
1491 let mut state = ();
1492 root.rebuild(&mut logic, &mut state);
1493 let mut tcx = TextContext::new();
1494 root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
1495 let update = root.semantics();
1496
1497 let group = update
1498 .nodes
1499 .iter()
1500 .find(|(_, n)| n.role() == Role::Group)
1501 .expect("a Group container node is contributed");
1502 assert_eq!(group.1.children().len(), 2);
1503
1504 let boxes: Vec<_> = update
1505 .nodes
1506 .iter()
1507 .filter(|(_, n)| n.role() == Role::CheckBox)
1508 .collect();
1509 assert_eq!(boxes.len(), 2);
1510 assert!(
1511 boxes
1512 .iter()
1513 .all(|(_, n)| n.toggled() == Some(Toggled::True))
1514 );
1515 }
1516}