1use tessera_ui::{
8 AccessibilityActionHandler, AccessibilityNode, AxisConstraint, Callback, Color, ComputedData,
9 Constraint, Dp, FocusDirection, FocusManager, FocusState, FocusTraversalPolicy, LayoutResult,
10 MeasurementError, Modifier, Px, PxPosition, RenderSlotWith, SemanticsModifierNode,
11 accesskit::{Orientation, Role, Toggled},
12 layout::{MeasureScope, layout},
13 modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
14 provide_context, remember, tessera, use_context,
15};
16
17use crate::{
18 alignment::{Alignment, CrossAxisAlignment},
19 icon::icon as icon_component,
20 layout_flex::cross_axis_child_constraint,
21 material_icons::filled,
22 modifier::{InteractionState, ModifierExt as _, Padding},
23 painter::Painter,
24 row::row,
25 shape_def::{RoundedCorner, Shape},
26 spacer::spacer,
27 surface::{SurfaceStyle, surface},
28 text::text,
29 theme::{MaterialAlpha, MaterialColorScheme, MaterialTheme, TextStyle},
30};
31
32const SEGMENTED_ICON_SPACING: Dp = Dp(8.0);
33
34#[cfg(all(test, not(target_family = "wasm")))]
35mod tests;
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub enum SegmentedButtonSelectionMode {
40 Single,
42 #[default]
44 Multiple,
45}
46
47struct SegmentedGroupSemantics {
48 single_choice: bool,
49}
50
51impl SemanticsModifierNode for SegmentedGroupSemantics {
52 fn apply(
53 &self,
54 node: &mut AccessibilityNode,
55 _handler: &mut Option<AccessibilityActionHandler>,
56 ) {
57 node.role = Some(if self.single_choice {
58 Role::RadioGroup
59 } else {
60 Role::Group
61 });
62 node.orientation = Some(Orientation::Horizontal);
63 node.merge_descendants = true;
64 }
65}
66
67#[derive(Clone, PartialEq, Copy, Debug)]
69pub struct SegmentedButtonColors {
70 pub active_container_color: Color,
72 pub active_content_color: Color,
74 pub active_border_color: Color,
76 pub inactive_container_color: Color,
78 pub inactive_content_color: Color,
80 pub inactive_border_color: Color,
82 pub disabled_active_container_color: Color,
84 pub disabled_active_content_color: Color,
86 pub disabled_active_border_color: Color,
88 pub disabled_inactive_container_color: Color,
90 pub disabled_inactive_content_color: Color,
92 pub disabled_inactive_border_color: Color,
94}
95
96impl SegmentedButtonColors {
97 fn container_color(self, enabled: bool, active: bool) -> Color {
98 match (enabled, active) {
99 (true, true) => self.active_container_color,
100 (true, false) => self.inactive_container_color,
101 (false, true) => self.disabled_active_container_color,
102 (false, false) => self.disabled_inactive_container_color,
103 }
104 }
105
106 fn content_color(self, enabled: bool, active: bool) -> Color {
107 match (enabled, active) {
108 (true, true) => self.active_content_color,
109 (true, false) => self.inactive_content_color,
110 (false, true) => self.disabled_active_content_color,
111 (false, false) => self.disabled_inactive_content_color,
112 }
113 }
114
115 fn border_color(self, enabled: bool, active: bool) -> Color {
116 match (enabled, active) {
117 (true, true) => self.active_border_color,
118 (true, false) => self.inactive_border_color,
119 (false, true) => self.disabled_active_border_color,
120 (false, false) => self.disabled_inactive_border_color,
121 }
122 }
123}
124
125pub struct SegmentedButtonDefaults;
127
128impl SegmentedButtonDefaults {
129 pub const HEIGHT: Dp = Dp(40.0);
131 pub const BORDER_WIDTH: Dp = Dp(1.0);
133 pub const ICON_SIZE: Dp = Dp(18.0);
135 pub const CONTENT_PADDING: Padding = Padding::symmetric(Dp(12.0), Dp(8.0));
137
138 pub fn shape() -> Shape {
140 Shape::CAPSULE
141 }
142
143 pub fn item_shape(index: usize, count: usize, base_shape: Shape) -> Shape {
145 if count <= 1 {
146 return base_shape;
147 }
148
149 let Shape::RoundedRectangle {
150 top_left,
151 top_right,
152 bottom_right,
153 bottom_left,
154 } = base_shape
155 else {
156 return base_shape;
157 };
158
159 let is_first = index == 0;
160 let is_last = index + 1 == count;
161
162 Shape::RoundedRectangle {
163 top_left: if is_first {
164 top_left
165 } else {
166 RoundedCorner::ZERO
167 },
168 top_right: if is_last {
169 top_right
170 } else {
171 RoundedCorner::ZERO
172 },
173 bottom_right: if is_last {
174 bottom_right
175 } else {
176 RoundedCorner::ZERO
177 },
178 bottom_left: if is_first {
179 bottom_left
180 } else {
181 RoundedCorner::ZERO
182 },
183 }
184 }
185
186 pub fn colors(scheme: &MaterialColorScheme) -> SegmentedButtonColors {
188 let scheme = scheme.clone();
189 let disabled_content = scheme
190 .on_surface
191 .with_alpha(MaterialAlpha::DISABLED_CONTENT);
192 let disabled_border = scheme.outline.with_alpha(MaterialAlpha::DISABLED_CONTAINER);
193
194 SegmentedButtonColors {
195 active_container_color: scheme.secondary_container,
196 active_content_color: scheme.on_secondary_container,
197 active_border_color: scheme.outline,
198 inactive_container_color: Color::TRANSPARENT,
199 inactive_content_color: scheme.on_surface,
200 inactive_border_color: scheme.outline,
201 disabled_active_container_color: scheme.secondary_container,
202 disabled_active_content_color: disabled_content,
203 disabled_active_border_color: disabled_border,
204 disabled_inactive_container_color: Color::TRANSPARENT,
205 disabled_inactive_content_color: disabled_content,
206 disabled_inactive_border_color: disabled_border,
207 }
208 }
209}
210
211#[tessera]
282pub fn segmented_button(
283 selection_mode: Option<SegmentedButtonSelectionMode>,
284 selected: Option<bool>,
285 enabled: Option<bool>,
286 #[prop(into)] label: Option<String>,
287 #[prop(into)] icon: Option<Painter>,
288 #[prop(into)] selected_icon: Option<Painter>,
289 show_selected_icon: Option<bool>,
290 modifier: Option<Modifier>,
291 shape: Option<Shape>,
292 colors: Option<SegmentedButtonColors>,
293 border_width: Option<Dp>,
294 min_height: Option<Dp>,
295 content_padding: Option<Padding>,
296 on_click: Option<Callback>,
297 #[prop(into)] accessibility_label: Option<String>,
298 #[prop(into)] accessibility_description: Option<String>,
299) {
300 let selected = selected.unwrap_or(false);
301 let label = label.unwrap_or_default();
302 let enabled = enabled.unwrap_or(true);
303 let modifier = modifier.unwrap_or_default();
304 let shape = shape.unwrap_or_else(SegmentedButtonDefaults::shape);
305 let single_choice = selection_mode.unwrap_or_default() == SegmentedButtonSelectionMode::Single;
306 let icon = if selected && show_selected_icon.unwrap_or(true) {
307 Some(selected_icon.unwrap_or_else(|| filled::CHECK_SVG.into()))
308 } else {
309 icon
310 };
311 let interaction_state = remember(InteractionState::new);
312 if (!enabled || on_click.is_none()) && interaction_state.get() != InteractionState::default() {
313 interaction_state.set(InteractionState::default());
314 }
315 let theme = use_context::<MaterialTheme>()
316 .expect("MaterialTheme must be provided")
317 .get();
318 let typography = theme.typography;
319 let colors = colors.unwrap_or_else(|| SegmentedButtonDefaults::colors(&theme.color_scheme));
320 let container_color = colors.container_color(enabled, selected);
321 let content_color = colors.content_color(enabled, selected);
322 let border_color = colors.border_color(enabled, selected);
323 let border_width = border_width.unwrap_or(SegmentedButtonDefaults::BORDER_WIDTH);
324 let content_padding = content_padding.unwrap_or(SegmentedButtonDefaults::CONTENT_PADDING);
325
326 let surface_style = if border_width.0 > 0.0 {
327 SurfaceStyle::FilledOutlined {
328 fill_color: container_color,
329 border_color,
330 border_width,
331 }
332 } else {
333 SurfaceStyle::Filled {
334 color: container_color,
335 }
336 };
337
338 let mut modifier = modifier.size_in(
339 Some(Dp(58.0)),
340 None,
341 Some(min_height.unwrap_or(SegmentedButtonDefaults::HEIGHT)),
342 None,
343 );
344 if enabled
345 && single_choice
346 && let Some(on_click) = on_click
347 {
348 let is_selected = selected;
349 modifier = modifier
350 .focusable()
351 .preferred_tab_stop(selected)
352 .on_focus_event(move |focus_state: FocusState| {
353 let directional = matches!(
354 FocusManager::current().focus_change_direction(),
355 Some(FocusDirection::Left | FocusDirection::Right)
356 );
357 if focus_state.is_focused()
358 && directional
359 && !is_selected
360 && !interaction_state.with(|state| state.is_pressed())
361 {
362 on_click.call();
363 }
364 });
365 }
366
367 let has_on_click = on_click.is_some();
368 let accessibility_label =
369 accessibility_label.or_else(|| (!label.is_empty()).then(|| label.clone()));
370
371 surface()
372 .modifier(modifier)
373 .style(surface_style)
374 .shape(shape)
375 .content_alignment(Alignment::Center)
376 .content_color(content_color)
377 .enabled(enabled)
378 .interaction_state(interaction_state)
379 .ripple_color(content_color)
380 .on_click_optional(on_click)
381 .accessibility_role(if single_choice {
382 Role::RadioButton
383 } else {
384 Role::CheckBox
385 })
386 .accessibility_toggled(if selected {
387 Toggled::True
388 } else {
389 Toggled::False
390 })
391 .accessibility_focusable(has_on_click && enabled)
392 .accessibility_label_optional(accessibility_label)
393 .accessibility_description_optional(accessibility_description)
394 .child(move || {
395 let leading_icon = icon.clone();
396 let padding = content_padding;
397 let label_outer = label.clone();
398 provide_context(
399 || typography.label_large,
400 move || {
401 let inherited =
402 use_context::<TextStyle>().expect("segmented button text style");
403 if inherited.get() != typography.label_large {
404 inherited.set(typography.label_large);
405 }
406 let leading_icon = leading_icon.clone();
407 let label_outer = label_outer.clone();
408 layout()
409 .modifier(Modifier::new().padding(padding))
410 .child(move || {
411 let leading_icon = leading_icon.clone();
412 let label = label_outer.clone();
413 let label_offset = if leading_icon.is_none() {
414 Dp::from(
415 Px::ZERO
416 - (Px::from(SegmentedButtonDefaults::ICON_SIZE)
417 + Px::from(SEGMENTED_ICON_SPACING))
418 / 2,
419 )
420 } else {
421 Dp(0.0)
422 };
423 row()
424 .cross_axis_alignment(CrossAxisAlignment::Center)
425 .children(move || {
426 if let Some(icon_content) = leading_icon.clone() {
427 icon_component()
428 .painter(icon_content)
429 .size(SegmentedButtonDefaults::ICON_SIZE);
430 } else {
431 spacer().modifier(
432 Modifier::new()
433 .width(SegmentedButtonDefaults::ICON_SIZE),
434 );
435 }
436
437 if !label.is_empty() {
438 spacer().modifier(
439 Modifier::new().width(SEGMENTED_ICON_SPACING),
440 );
441 {
442 text(label.clone()).modifier(
443 Modifier::new().offset(label_offset, Dp(0.0)),
444 );
445 };
446 }
447 });
448 });
449 },
450 );
451 });
452}
453
454#[tessera]
511pub fn single_choice_segmented_button_row(
512 modifier: Option<Modifier>,
513 overlap: Option<Dp>,
514 cross_axis_alignment: Option<CrossAxisAlignment>,
515 equal_width: Option<bool>,
516 content: Option<RenderSlotWith<SegmentedButtonSelectionMode>>,
517) {
518 let (modifier, layout_policy, content) = segmented_button_row_parts(
519 modifier,
520 overlap,
521 cross_axis_alignment,
522 equal_width,
523 content,
524 );
525 let modifier = modifier
526 .focus_group()
527 .push_semantics(SegmentedGroupSemantics {
528 single_choice: true,
529 })
530 .focus_traversal_policy(
531 FocusTraversalPolicy::horizontal()
532 .wrap(true)
533 .single_tab_stop(true),
534 );
535 layout().modifier(modifier).child(move || {
536 let content = content;
537 layout().policy(layout_policy.clone()).child(move || {
538 let content = content;
539 if let Some(content) = content {
540 content.render(SegmentedButtonSelectionMode::Single);
541 }
542 });
543 });
544}
545
546#[tessera]
603pub fn multi_choice_segmented_button_row(
604 modifier: Option<Modifier>,
605 overlap: Option<Dp>,
606 cross_axis_alignment: Option<CrossAxisAlignment>,
607 equal_width: Option<bool>,
608 content: Option<RenderSlotWith<SegmentedButtonSelectionMode>>,
609) {
610 let (modifier, layout_policy, content) = segmented_button_row_parts(
611 modifier,
612 overlap,
613 cross_axis_alignment,
614 equal_width,
615 content,
616 );
617 let modifier = modifier
618 .focus_group()
619 .push_semantics(SegmentedGroupSemantics {
620 single_choice: false,
621 })
622 .focus_traversal_policy(FocusTraversalPolicy::horizontal().wrap(true));
623 layout().modifier(modifier).child(move || {
624 let content = content;
625 layout().policy(layout_policy.clone()).child(move || {
626 let content = content;
627 if let Some(content) = content {
628 content.render(SegmentedButtonSelectionMode::Multiple);
629 }
630 });
631 });
632}
633
634fn segmented_button_row_parts(
635 modifier: Option<Modifier>,
636 overlap: Option<Dp>,
637 cross_axis_alignment: Option<CrossAxisAlignment>,
638 equal_width: Option<bool>,
639 content: Option<RenderSlotWith<SegmentedButtonSelectionMode>>,
640) -> (
641 Modifier,
642 SegmentedButtonRowLayout,
643 Option<RenderSlotWith<SegmentedButtonSelectionMode>>,
644) {
645 let modifier = modifier.unwrap_or_default();
646 let overlap = Px::from(overlap.unwrap_or(SegmentedButtonDefaults::BORDER_WIDTH)).max(Px::ZERO);
647 let cross_axis_alignment = cross_axis_alignment.unwrap_or(CrossAxisAlignment::Center);
648 let equal_width = equal_width.unwrap_or(true);
649 let modifier = modifier.size_in(None, None, Some(SegmentedButtonDefaults::HEIGHT), None);
650 let layout_policy = SegmentedButtonRowLayout {
651 overlap,
652 cross_axis_alignment,
653 equal_width,
654 };
655 (modifier, layout_policy, content)
656}
657
658#[derive(Clone, PartialEq)]
659struct SegmentedButtonRowLayout {
660 overlap: Px,
661 cross_axis_alignment: CrossAxisAlignment,
662 equal_width: bool,
663}
664
665impl tessera_ui::LayoutModifierNode for SegmentedButtonRowLayout {
666 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
667 let mut result = LayoutResult::default();
668 let children = input.children();
669 if children.is_empty() {
670 return Ok(result.with_size(ComputedData::ZERO));
671 }
672
673 let row_constraint = *input.parent_constraint().as_ref();
674 let child_constraint = Constraint::new(
675 AxisConstraint::new(Px::ZERO, row_constraint.width.resolve_max()),
676 cross_axis_child_constraint(row_constraint.height, self.cross_axis_alignment),
677 );
678
679 let mut children_sizes = vec![None; children.len()];
680 let mut total_width = Px::ZERO;
681 let mut max_width = Px::ZERO;
682 let mut max_height = Px::ZERO;
683 for (index, &child) in children.iter().enumerate() {
684 let child_result = child.measure(input, &child_constraint)?;
685 children_sizes[index] = Some(child_result.size());
686 total_width += child_result.width;
687 max_width = max_width.max(child_result.width);
688 max_height = max_height.max(child_result.height);
689 }
690
691 if self.equal_width && max_width > Px::ZERO {
692 let count = children.len() as i32;
693 let available = row_constraint.width.resolve_max();
694 let overlap = self
695 .overlap
696 .min(max_width)
697 .min(available.unwrap_or(max_width));
698 let target = row_constraint
699 .width
700 .clamp(max_width * count - overlap * (count - 1));
701 let budget = target + overlap * (count - 1);
702 let (width, remainder) = (budget / count, budget.0 % count);
703 total_width = Px::ZERO;
704 max_height = Px::ZERO;
705
706 for (index, &child) in children.iter().enumerate() {
707 let child_width = width + Px(i32::from((index as i32) < remainder));
708 let equal_constraint = Constraint::new(
709 child_width,
710 cross_axis_child_constraint(row_constraint.height, self.cross_axis_alignment),
711 );
712 let child_result = child.measure(input, &equal_constraint)?;
713 children_sizes[index] = Some(child_result.size());
714 total_width += child_result.width;
715 max_height = max_height.max(child_result.height);
716 }
717 }
718
719 let min_child_width = children_sizes
720 .iter()
721 .filter_map(|size| size.map(|size| size.width))
722 .min()
723 .unwrap_or(Px::ZERO);
724 let overlap = if children.len() > 1 {
725 self.overlap.min(min_child_width)
726 } else {
727 Px::ZERO
728 };
729 let total_width_with_overlap = if children.len() > 1 {
730 total_width - overlap * (children.len() as i32 - 1)
731 } else {
732 total_width
733 };
734 let total_width_with_overlap = total_width_with_overlap.max(Px::ZERO);
735 let final_width = row_constraint.width.clamp(total_width_with_overlap);
736 let final_height = row_constraint.height.clamp(max_height);
737
738 let mut current_x = Px::ZERO;
739 for (index, child_size_opt) in children_sizes.iter().enumerate() {
740 if let Some(child_size) = child_size_opt {
741 let child = children[index];
742 let y_offset = crate::layout_flex::cross_axis_offset(
743 child_size.height,
744 final_height,
745 self.cross_axis_alignment,
746 );
747 result.place_child(child, PxPosition::from_f32_xy(current_x.0 as f32, y_offset));
748 current_x += child_size.width;
749 if index < children.len() - 1 {
750 current_x -= overlap;
751 }
752 }
753 }
754
755 Ok(result.with_size(ComputedData {
756 width: final_width,
757 height: final_height,
758 }))
759 }
760}