use std::rc::Rc;
use std::time::Duration;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
PointerPhase, Role, ScrollDelta, SemanticsCtx, View, Widget,
};
use frust::input::{TOUCH_SLOP, WHEEL_LINE_PX};
use frust::{AnimationController, SpringDesc, Theme};
use kurbo::{Point, RoundedRectRadii, Size};
use crate::interaction::HapticSignal;
use crate::menu::{MenuNode, MenuSelection, menu, menu_toggleable};
use crate::overlay::{OverlayAnchor, overlay_anchor};
use crate::press::presses;
use crate::{ButtonVariant, ToggleButtonSize};
const STANDARD_SPACING: f64 = 8.0;
const CONNECTED_GAP: f64 = 2.0;
const COMPACT_SCALE: f64 = 0.75;
const DEFAULT_EXPANDED_RATIO: f64 = 0.15;
const SQUISH_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 1200.0,
damping_ratio: 0.8,
};
const SQUISH_ANIM_PERIOD: Duration = Duration::from_millis(300);
const FLING_VELOCITY: f64 = 4.0;
const SQUISH_REST_EPSILON: f64 = 1e-3;
const FIT_EPSILON: f64 = 0.5;
pub const OUTER_RADIUS: f64 = 16.0;
pub const INNER_RADIUS: f64 = 4.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonGroupType {
#[default]
Standard,
Connected,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonGroupDensity {
#[default]
Regular,
Compact,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonGroupDirection {
#[default]
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonGroupOverflow {
None,
#[default]
Scroll,
BottomSheet,
Popup,
}
impl ButtonGroupDensity {
fn scale(self, raw: f64) -> f64 {
match self {
ButtonGroupDensity::Regular => raw,
ButtonGroupDensity::Compact => (raw * COMPACT_SCALE).floor(),
}
}
}
pub fn resolve_spacing(
explicit: Option<f64>,
group_type: ButtonGroupType,
density: ButtonGroupDensity,
) -> f64 {
match explicit {
Some(spacing) => spacing,
None => match group_type {
ButtonGroupType::Connected => CONNECTED_GAP,
ButtonGroupType::Standard => density.scale(STANDARD_SPACING),
},
}
}
pub fn squish_sizes(
natural: &[f64],
pressed: Option<usize>,
expanded_ratio: f64,
anim: f64,
) -> Vec<f64> {
let mut sizes = natural.to_vec();
let count = sizes.len();
let Some(index) = pressed.filter(|i| *i < count) else {
return sizes;
};
if anim <= 0.0 {
return sizes;
}
let growth = natural[index] * expanded_ratio * anim;
sizes[index] += growth;
if count < 2 {
return sizes;
}
if index > 0 && index + 1 < count {
let half = growth / 2.0;
sizes[index - 1] = (sizes[index - 1] - half).max(0.0);
sizes[index + 1] = (sizes[index + 1] - half).max(0.0);
} else if index == 0 {
sizes[1] = (sizes[1] - growth).max(0.0);
} else {
sizes[index - 1] = (sizes[index - 1] - growth).max(0.0);
}
sizes
}
pub fn visible_count_for_overflow(
max_main: f64,
item_extents: &[f64],
trigger_extent: f64,
separator: f64,
) -> usize {
let available = max_main.floor();
let trigger = trigger_extent.ceil();
let separator = separator.ceil();
let mut current = 0.0_f64;
let mut visible = 0usize;
for (i, extent) in item_extents.iter().enumerate() {
let gap_before = if i == 0 { 0.0 } else { separator };
let remaining_after = item_extents.len() - i - 1;
let reserved = if remaining_after > 0 {
separator + trigger
} else {
0.0
};
let next = current + gap_before + extent.ceil();
if next + reserved < available {
current = next;
visible = i + 1;
} else {
break;
}
}
visible
}
pub fn member_radii(index: usize, count: usize, outer: f64, inner: f64) -> RoundedRectRadii {
let is_first = index == 0;
let is_last = index + 1 == count;
let left = if is_first { outer } else { inner };
let right = if is_last { outer } else { inner };
RoundedRectRadii::new(left, right, right, left)
}
type IconFactory<State> = Rc<dyn Fn() -> AnyView<State>>;
pub struct ButtonGroupAction<State: 'static> {
icon: Option<IconFactory<State>>,
checked_icon: Option<IconFactory<State>>,
label: Option<String>,
checked_label: Option<String>,
checked: Option<bool>,
enabled: bool,
width: Option<f64>,
}
pub fn button_group_action<State: 'static>(label: impl Into<String>) -> ButtonGroupAction<State> {
ButtonGroupAction {
icon: None,
checked_icon: None,
label: Some(label.into()),
checked_label: None,
checked: None,
enabled: true,
width: None,
}
}
pub fn button_group_icon_action<State: 'static, F>(icon: F) -> ButtonGroupAction<State>
where
F: Fn() -> AnyView<State> + 'static,
{
ButtonGroupAction {
icon: Some(Rc::new(icon)),
checked_icon: None,
label: None,
checked_label: None,
checked: None,
enabled: true,
width: None,
}
}
impl<State: 'static> ButtonGroupAction<State> {
pub fn icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
self.icon = Some(Rc::new(icon));
self
}
pub fn checked_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
self.checked_icon = Some(Rc::new(icon));
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn checked_label(mut self, label: impl Into<String>) -> Self {
self.checked_label = Some(label.into());
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = Some(checked);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn width(mut self, width: f64) -> Self {
self.width = Some(width);
self
}
fn accessible_label(&self) -> String {
self.checked_label
.clone()
.or_else(|| self.label.clone())
.unwrap_or_default()
}
}
type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
type OnIndexChanged<State> = Rc<dyn Fn(&mut State, Option<usize>)>;
type OnIndicesChanged<State> = Rc<dyn Fn(&mut State, Vec<usize>)>;
type OnOverflow<State> = Rc<dyn Fn(&mut State, usize)>;
pub struct ButtonGroupView<State: 'static> {
actions: Vec<ButtonGroupAction<State>>,
group_type: ButtonGroupType,
density: ButtonGroupDensity,
direction: ButtonGroupDirection,
spacing: Option<f64>,
variant: ButtonVariant,
size: ToggleButtonSize,
haptic: HapticSignal,
selected: Option<usize>,
selected_indices: Option<Vec<usize>>,
on_select: Option<OnSelect<State>>,
on_index_changed: Option<OnIndexChanged<State>>,
on_indices_changed: Option<OnIndicesChanged<State>>,
neighbor_squish: bool,
expanded_ratio: f64,
overflow: ButtonGroupOverflow,
overflow_icon: Option<IconFactory<State>>,
on_overflow: Option<OnOverflow<State>>,
overflow_anchor: Option<OverlayAnchor>,
semantic_label: Option<String>,
}
pub fn button_group_actions<State: 'static>(
actions: impl IntoIterator<Item = ButtonGroupAction<State>>,
) -> ButtonGroupView<State> {
ButtonGroupView {
actions: actions.into_iter().collect(),
group_type: ButtonGroupType::default(),
density: ButtonGroupDensity::default(),
direction: ButtonGroupDirection::default(),
spacing: None,
variant: ButtonVariant::default(),
size: ToggleButtonSize::default(),
haptic: HapticSignal::None,
selected: None,
selected_indices: None,
on_select: None,
on_index_changed: None,
on_indices_changed: None,
neighbor_squish: true,
expanded_ratio: DEFAULT_EXPANDED_RATIO,
overflow: ButtonGroupOverflow::default(),
overflow_icon: None,
on_overflow: None,
overflow_anchor: None,
semantic_label: None,
}
}
pub fn button_group<State: 'static, F: Fn(&mut State, usize) + 'static>(
labels: impl IntoIterator<Item = impl Into<String>>,
selected: usize,
on_select: F,
) -> ButtonGroupView<State> {
let actions = labels
.into_iter()
.map(|label| button_group_action::<State>(label));
button_group_actions(actions)
.group_type(ButtonGroupType::Connected)
.selected_index(Some(selected))
.on_select(on_select)
}
#[allow(non_snake_case)]
pub fn ButtonGroup<State: 'static, F: Fn(&mut State, usize) + 'static>(
labels: impl IntoIterator<Item = impl Into<String>>,
selected: usize,
on_select: F,
) -> ButtonGroupView<State> {
button_group(labels, selected, on_select)
}
impl<State: 'static> ButtonGroupView<State> {
pub fn group_type(mut self, group_type: ButtonGroupType) -> Self {
self.group_type = group_type;
self
}
pub fn density(mut self, density: ButtonGroupDensity) -> Self {
self.density = density;
self
}
pub fn direction(mut self, direction: ButtonGroupDirection) -> Self {
self.direction = direction;
self
}
pub fn spacing(mut self, spacing: f64) -> Self {
self.spacing = Some(spacing);
self
}
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn size(mut self, size: ToggleButtonSize) -> Self {
self.size = size;
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
pub fn selected_index(mut self, selected: Option<usize>) -> Self {
self.selected = selected;
self
}
pub fn selected_indices(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
let mut indices: Vec<usize> = indices.into_iter().collect();
indices.sort_unstable();
indices.dedup();
self.selected_indices = Some(indices);
self
}
pub fn on_select<F: Fn(&mut State, usize) + 'static>(mut self, on_select: F) -> Self {
self.on_select = Some(Rc::new(on_select));
self
}
pub fn on_selected_index_changed<F: Fn(&mut State, Option<usize>) + 'static>(
mut self,
on_change: F,
) -> Self {
self.on_index_changed = Some(Rc::new(on_change));
self
}
pub fn on_selected_indices_changed<F: Fn(&mut State, Vec<usize>) + 'static>(
mut self,
on_change: F,
) -> Self {
self.on_indices_changed = Some(Rc::new(on_change));
self
}
pub fn neighbor_squish(mut self, squish: bool) -> Self {
self.neighbor_squish = squish;
self
}
pub fn expanded_ratio(mut self, ratio: f64) -> Self {
self.expanded_ratio = ratio;
self
}
pub fn overflow(mut self, overflow: ButtonGroupOverflow) -> Self {
self.overflow = overflow;
self
}
pub fn overflow_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
self.overflow_icon = Some(Rc::new(icon));
self
}
pub fn on_overflow<F: Fn(&mut State, usize) + 'static>(mut self, on_overflow: F) -> Self {
self.on_overflow = Some(Rc::new(on_overflow));
self
}
pub fn overflow_anchor(mut self, anchor: &OverlayAnchor) -> Self {
self.overflow_anchor = Some(anchor.clone());
self
}
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
fn is_selected(&self, index: usize) -> bool {
if let Some(set) = &self.selected_indices {
return set.contains(&index);
}
if self.on_index_changed.is_some() || self.selected.is_some() {
return self.selected == Some(index);
}
self.actions[index].checked.unwrap_or(false)
}
fn member_view(&self, index: usize) -> AnyView<State> {
let action = &self.actions[index];
let count = self.actions.len();
let connected = self.group_type == ButtonGroupType::Connected;
let on_indices = self.on_indices_changed.clone();
let on_index = self.on_index_changed.clone();
let on_select = self.on_select.clone();
let current = self.selected_indices.clone().unwrap_or_default();
let mut member = crate::toggle_button::toggle_button::<State, _>(
self.is_selected(index),
move |state: &mut State, checked: bool| {
if let Some(callback) = &on_indices {
let mut next = current.clone();
if checked {
if !next.contains(&index) {
next.push(index);
next.sort_unstable();
}
} else {
next.retain(|i| *i != index);
}
callback(state, next);
} else if let Some(callback) = &on_index {
callback(state, checked.then_some(index));
} else if let Some(callback) = &on_select {
callback(state, index);
}
},
)
.variant(self.variant)
.size(self.size)
.enabled(action.enabled)
.haptic(self.haptic)
.group_connected(connected)
.first_in_group(index == 0)
.last_in_group(index + 1 == count);
if let Some(icon) = &action.icon {
member = member.icon(icon());
}
if let Some(icon) = &action.checked_icon {
member = member.checked_icon(icon());
}
if let Some(label) = &action.label {
member = member.label(label.clone());
}
if let Some(label) = &action.checked_label {
member = member.checked_label(label.clone());
}
frust::authoring::any::<State, _>(member)
}
fn trigger_view(&self) -> AnyView<State> {
let icon = match &self.overflow_icon {
Some(factory) => factory(),
None => frust::authoring::any::<State, _>(frust::icon(crate::icons::MORE_HORIZ)),
};
let trigger =
crate::toggle_button::toggle_button::<State, _>(false, |_state: &mut State, _v| {})
.variant(self.variant)
.size(self.size)
.haptic(self.haptic)
.group_connected(self.group_type == ButtonGroupType::Connected)
.first_in_group(false)
.last_in_group(true)
.icon(icon);
match (&self.overflow_anchor, self.overflow) {
(Some(anchor), ButtonGroupOverflow::Popup) => {
frust::authoring::any::<State, _>(overlay_anchor(anchor, trigger))
}
_ => frust::authoring::any::<State, _>(trigger),
}
}
fn member_views(&self) -> Vec<AnyView<State>> {
(0..self.actions.len())
.map(|i| self.member_view(i))
.collect()
}
fn maybe_trigger_view(&self) -> Option<AnyView<State>> {
matches!(
self.overflow,
ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup
)
.then(|| self.trigger_view())
}
pub fn overflow_menu(&self, first_hidden: usize, open: bool) -> AnyView<State> {
let anchor = self.overflow_anchor.clone().unwrap_or_default();
let start = first_hidden.min(self.actions.len());
let nodes: Vec<MenuNode> = self.actions[start..]
.iter()
.enumerate()
.map(|(offset, action)| {
menu_toggleable(action.accessible_label(), self.is_selected(start + offset))
.enabled(action.enabled)
.into()
})
.collect();
let on_indices = self.on_indices_changed.clone();
let on_index = self.on_index_changed.clone();
let on_select = self.on_select.clone();
let current = self.selected_indices.clone().unwrap_or_default();
frust::authoring::any::<State, _>(
menu(nodes, move |state: &mut State, selection: MenuSelection| {
let index = start + selection.index;
let checked = selection.checked().unwrap_or(true);
if let Some(callback) = &on_indices {
let mut next = current.clone();
if checked {
if !next.contains(&index) {
next.push(index);
next.sort_unstable();
}
} else {
next.retain(|i| *i != index);
}
callback(state, next);
} else if let Some(callback) = &on_index {
callback(state, checked.then_some(index));
} else if let Some(callback) = &on_select {
callback(state, index);
}
})
.anchor(&anchor)
.open(open),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Target {
Member(usize),
Trigger,
}
pub struct ButtonGroupWidget {
members: Vec<ChildPod>,
trigger: Option<ChildPod>,
labels: Vec<String>,
widths: Vec<Option<f64>>,
group_type: ButtonGroupType,
density: ButtonGroupDensity,
direction: ButtonGroupDirection,
spacing: Option<f64>,
neighbor_squish: bool,
expanded_ratio: f64,
overflow: ButtonGroupOverflow,
semantic_label: Option<String>,
on_overflow: Option<ErasedArgCallback<usize>>,
visible_count: usize,
trigger_visible: bool,
member_bases: Vec<Point>,
trigger_base: Point,
content_main: f64,
viewport_main: f64,
scroll_offset: f64,
active: Option<Target>,
panning: bool,
down_pos: Point,
last_drag: Point,
pressed_index: Option<usize>,
squish: AnimationController,
laid_out_squish: SquishState,
}
type SquishState = (f64, Option<usize>);
impl ButtonGroupWidget {
pub fn supports_squish(&self) -> bool {
self.direction == ButtonGroupDirection::Horizontal
&& self.group_type != ButtonGroupType::Connected
&& self.neighbor_squish
}
fn squish_state(&self) -> SquishState {
let anim = if self.supports_squish() {
self.squish.value_clamped()
} else {
0.0
};
(anim, self.pressed_index)
}
fn max_scroll(&self) -> f64 {
(self.content_main - self.viewport_main).max(0.0)
}
fn can_scroll(&self) -> bool {
self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON
}
fn main_of(&self, p: Point) -> f64 {
match self.direction {
ButtonGroupDirection::Horizontal => p.x,
ButtonGroupDirection::Vertical => p.y,
}
}
fn pod_mut(&mut self, target: Target) -> Option<&mut ChildPod> {
match target {
Target::Member(i) => self.members.get_mut(i),
Target::Trigger => self.trigger.as_mut(),
}
}
fn hit(&self, pos: Point) -> Option<Target> {
if self.trigger_visible
&& let Some(trigger) = &self.trigger
&& trigger.contains(pos)
{
return Some(Target::Trigger);
}
let visible = self.visible_count.min(self.members.len());
self.members[..visible]
.iter()
.position(|pod| pod.contains(pos))
.map(Target::Member)
}
fn press_in(&mut self) {
self.squish.fling(FLING_VELOCITY, SQUISH_SPRING);
}
fn press_out(&mut self) {
self.squish.fling(-FLING_VELOCITY, SQUISH_SPRING);
}
fn sync_origins(&mut self) {
let horizontal = self.direction == ButtonGroupDirection::Horizontal;
let offset = self.scroll_offset;
let visible = self.visible_count.min(self.members.len());
for (pod, base) in self.members[..visible].iter_mut().zip(&self.member_bases) {
pod.set_origin(shift_main(*base, -offset, horizontal));
}
if self.trigger_visible
&& let Some(trigger) = &mut self.trigger
{
trigger.set_origin(shift_main(self.trigger_base, -offset, horizontal));
}
}
fn pan_by(&mut self, delta: f64) -> bool {
let max = self.max_scroll();
let next = (self.scroll_offset - delta).clamp(0.0, max);
if (next - self.scroll_offset).abs() < f64::EPSILON {
return false;
}
self.scroll_offset = next;
self.sync_origins();
true
}
fn send_cancel(&mut self, ctx: &mut EventCtx, target: Target, pos: Point) {
let cancel = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Cancel,
position: pos,
button: PointerButton::Primary,
});
if let Some(pod) = self.pod_mut(target) {
pod.event_child(ctx, &cancel);
pod.set_active(false);
}
}
fn broadcast(&mut self, ctx: &mut EventCtx, event: &InputEvent) {
for pod in self.members.iter_mut() {
pod.event_child(ctx, event);
}
if let Some(trigger) = &mut self.trigger {
trigger.event_child(ctx, event);
}
}
fn route_focused(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let Some(pod) = self.members.iter_mut().find(|pod| pod.is_focused()) {
return pod.event_child(ctx, event);
}
if let Some(trigger) = self.trigger.as_mut().filter(|pod| pod.is_focused()) {
return trigger.event_child(ctx, event);
}
EventResult::Ignored
}
fn apply_config<State: 'static>(&mut self, view: &ButtonGroupView<State>) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if self.group_type != view.group_type
|| self.density != view.density
|| self.direction != view.direction
|| self.spacing != view.spacing
|| self.overflow != view.overflow
|| self.neighbor_squish != view.neighbor_squish
|| self.expanded_ratio != view.expanded_ratio
{
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
self.group_type = view.group_type;
self.density = view.density;
self.direction = view.direction;
self.spacing = view.spacing;
self.overflow = view.overflow;
self.neighbor_squish = view.neighbor_squish;
self.expanded_ratio = view.expanded_ratio;
self.semantic_label = view.semantic_label.clone();
self.labels = view
.actions
.iter()
.map(ButtonGroupAction::accessible_label)
.collect();
self.widths = view.actions.iter().map(|a| a.width).collect();
self.on_overflow = view
.on_overflow
.as_ref()
.map(frust::authoring::erase_callback_arg);
flags
}
}
fn shift_main(base: Point, delta: f64, horizontal: bool) -> Point {
if horizontal {
Point::new(base.x + delta, base.y)
} else {
Point::new(base.x, base.y + delta)
}
}
impl<State: 'static> View<State> for ButtonGroupView<State> {
type Element = ButtonGroupWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonGroupWidget {
let members: Vec<ChildPod> = self
.member_views()
.iter()
.map(|view| frust::authoring::build_child(view, ctx))
.collect();
let trigger = self
.maybe_trigger_view()
.map(|view| frust::authoring::build_child(&view, ctx));
let mut widget = ButtonGroupWidget {
visible_count: members.len(),
members,
trigger,
labels: Vec::new(),
widths: Vec::new(),
group_type: self.group_type,
density: self.density,
direction: self.direction,
spacing: self.spacing,
neighbor_squish: self.neighbor_squish,
expanded_ratio: self.expanded_ratio,
overflow: self.overflow,
semantic_label: None,
on_overflow: None,
trigger_visible: false,
member_bases: Vec::new(),
trigger_base: Point::ZERO,
content_main: 0.0,
viewport_main: 0.0,
scroll_offset: 0.0,
active: None,
panning: false,
down_pos: Point::ZERO,
last_drag: Point::ZERO,
pressed_index: None,
squish: AnimationController::new(SQUISH_ANIM_PERIOD),
laid_out_squish: (0.0, None),
};
widget.apply_config(self);
widget
}
fn rebuild(
&self,
prev: &Self,
element: &mut ButtonGroupWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let prev_views = prev.member_views();
let next_views = self.member_views();
let mut flags = frust::authoring::rebuild_children(
&prev_views,
&next_views,
&mut element.members,
ctx,
|view| view,
|_| None,
);
match (prev.maybe_trigger_view(), self.maybe_trigger_view()) {
(Some(prev_view), Some(next_view)) => {
if let Some(pod) = element.trigger.as_mut() {
flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
}
}
(Some(prev_view), None) => {
if let Some(pod) = element.trigger.as_mut() {
frust::authoring::teardown_child(&prev_view, pod, ctx);
}
element.trigger = None;
element.trigger_visible = false;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(None, Some(next_view)) => {
element.trigger = Some(frust::authoring::build_child(&next_view, ctx));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(None, None) => {}
}
if prev.actions.len() != self.actions.len() {
element.active = None;
element.panning = false;
element.pressed_index = None;
element.scroll_offset = 0.0;
element.visible_count = element.members.len();
}
flags |= element.apply_config(self);
flags
}
fn teardown(&self, element: &mut ButtonGroupWidget, ctx: &mut BuildCtx<'_>) {
for (view, pod) in self.member_views().iter().zip(element.members.iter_mut()) {
frust::authoring::teardown_child(view, pod, ctx);
}
if let (Some(view), Some(pod)) = (self.maybe_trigger_view(), element.trigger.as_mut()) {
frust::authoring::teardown_child(&view, pod, ctx);
}
}
}
impl Widget for ButtonGroupWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let horizontal = self.direction == ButtonGroupDirection::Horizontal;
let max = bc.max();
let (max_main, max_cross) = if horizontal {
(max.width, max.height)
} else {
(max.height, max.width)
};
self.member_bases.clear();
if self.members.is_empty() {
self.visible_count = 0;
self.trigger_visible = false;
self.content_main = 0.0;
self.viewport_main = 0.0;
return bc.constrain(Size::ZERO);
}
let spacing = resolve_spacing(self.spacing, self.group_type, self.density);
let natural_bc = if horizontal {
BoxConstraints::loose(Size::new(f64::INFINITY, max_cross))
} else {
BoxConstraints::loose(Size::new(max_cross, f64::INFINITY))
};
let mut natural = Vec::with_capacity(self.members.len());
for (i, pod) in self.members.iter_mut().enumerate() {
let size = pod.layout_child(ctx, &natural_bc);
let measured = if horizontal { size.width } else { size.height };
natural.push(self.widths.get(i).copied().flatten().unwrap_or(measured));
}
let trigger_natural = self.trigger.as_mut().map(|pod| {
let size = pod.layout_child(ctx, &natural_bc);
if horizontal { size.width } else { size.height }
});
let (visible_count, trigger_visible) = match (self.overflow, trigger_natural) {
(ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup, Some(trigger))
if max_main.is_finite() =>
{
let fit = visible_count_for_overflow(max_main, &natural, trigger, spacing);
if fit >= natural.len() {
(natural.len(), false)
} else {
(fit, true)
}
}
_ => (natural.len(), false),
};
self.visible_count = visible_count;
self.trigger_visible = trigger_visible;
let state = self.squish_state();
self.laid_out_squish = state;
let (anim, pressed) = state;
let pressed = pressed.filter(|i| *i < visible_count);
let mut sizes = squish_sizes(
&natural[..visible_count],
pressed,
self.expanded_ratio,
anim,
);
if let Some(trigger) = trigger_natural.filter(|_| trigger_visible) {
sizes.push(trigger);
}
let mut cross = 0.0_f64;
let mut child_sizes = Vec::with_capacity(sizes.len());
{
let pods = self.members[..visible_count]
.iter_mut()
.chain(self.trigger.iter_mut().filter(|_| trigger_visible));
for (pod, main) in pods.zip(sizes.iter()) {
let child_bc = if horizontal {
BoxConstraints::new(Size::new(*main, 0.0), Size::new(*main, max_cross))
} else {
BoxConstraints::new(Size::new(0.0, *main), Size::new(max_cross, *main))
};
let size = pod.layout_child(ctx, &child_bc);
cross = cross.max(if horizontal { size.height } else { size.width });
child_sizes.push(size);
}
}
let laid_out = sizes.len();
let total_main: f64 = sizes.iter().sum::<f64>()
+ if laid_out > 0 {
spacing * (laid_out - 1) as f64
} else {
0.0
};
let mut main_offset = 0.0_f64;
let mut bases = Vec::with_capacity(laid_out);
for (main, size) in sizes.iter().zip(child_sizes.iter()) {
let child_cross = if horizontal { size.height } else { size.width };
let cross_offset = ((cross - child_cross) / 2.0).max(0.0);
bases.push(if horizontal {
Point::new(main_offset, cross_offset)
} else {
Point::new(cross_offset, main_offset)
});
main_offset += main + spacing;
}
self.trigger_base = if trigger_visible {
bases.pop().unwrap_or(Point::ZERO)
} else {
Point::ZERO
};
self.member_bases = bases;
let size = bc.constrain(if horizontal {
Size::new(total_main, cross)
} else {
Size::new(cross, total_main)
});
self.content_main = total_main;
self.viewport_main = if horizontal { size.width } else { size.height };
self.scroll_offset = self.scroll_offset.clamp(0.0, self.max_scroll());
self.sync_origins();
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
if reduce_motion {
if self.squish.is_animating() {
self.squish.stop();
}
self.pressed_index = None;
} else {
if self.squish.advance(ctx.frame_time()) {
ctx.request_layout();
} else if self.active.is_none()
&& self.squish.value().abs() < SQUISH_REST_EPSILON
&& self.pressed_index.is_some()
{
self.pressed_index = None;
}
}
if self.squish_state() != self.laid_out_squish {
ctx.request_layout();
}
let clipped =
self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON;
if clipped {
scene.push_clip(ctx.origin(), ctx.size());
}
let visible = self.visible_count.min(self.members.len());
for pod in self.members[..visible].iter_mut() {
pod.paint_child(ctx, scene);
}
if self.trigger_visible
&& let Some(trigger) = &mut self.trigger
{
trigger.paint_child(ctx, scene);
}
if clipped {
scene.pop_clip();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
self.broadcast(ctx, event);
return EventResult::Ignored;
}
if event.is_focus_routed() {
return self.route_focused(ctx, event);
}
match event {
InputEvent::Scroll { delta, .. } => {
if !self.can_scroll() {
return EventResult::Ignored;
}
let (dx, dy) = match delta {
ScrollDelta::Lines(x, y) => (x * WHEEL_LINE_PX, y * WHEEL_LINE_PX),
ScrollDelta::Pixels(x, y) => (*x, *y),
};
let main = match self.direction {
ButtonGroupDirection::Horizontal if dx != 0.0 => dx,
ButtonGroupDirection::Horizontal => dy,
ButtonGroupDirection::Vertical => dy,
};
if self.pan_by(-main) {
ctx.request_redraw();
}
EventResult::Handled
}
InputEvent::Pointer(p) => match p.phase {
PointerPhase::Down => {
let Some(target) = self.hit(p.position) else {
return EventResult::Ignored;
};
if !presses(p) {
return self
.pod_mut(target)
.map(|pod| pod.event_child(ctx, event))
.unwrap_or(EventResult::Ignored);
}
self.active = Some(target);
self.panning = false;
self.down_pos = p.position;
self.last_drag = p.position;
if let Target::Member(i) = target {
self.pressed_index = Some(i);
self.press_in();
}
ctx.capture_pointer();
if let Some(pod) = self.pod_mut(target) {
pod.event_child(ctx, event);
}
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
let Some(target) = self.active else {
return match self.hit(p.position) {
Some(hit) => self
.pod_mut(hit)
.map(|pod| pod.event_child(ctx, event))
.unwrap_or(EventResult::Ignored),
None => EventResult::Ignored,
};
};
if self.panning {
let delta = self.main_of(p.position) - self.main_of(self.last_drag);
self.last_drag = p.position;
if self.pan_by(delta) {
ctx.request_redraw();
}
return EventResult::Handled;
}
let travelled = self.main_of(p.position) - self.main_of(self.down_pos);
if self.can_scroll() && travelled.abs() > TOUCH_SLOP {
self.panning = true;
self.last_drag = p.position;
self.send_cancel(ctx, target, p.position);
self.press_out();
ctx.request_redraw();
return EventResult::Handled;
}
if let Some(pod) = self.pod_mut(target) {
pod.event_child(ctx, event);
}
EventResult::Handled
}
PointerPhase::Up => {
let Some(target) = self.active.take() else {
return EventResult::Ignored;
};
let panned = self.panning;
self.panning = false;
if !panned {
if let Some(pod) = self.pod_mut(target) {
pod.event_child(ctx, event);
}
if target == Target::Trigger
&& self
.trigger
.as_ref()
.is_some_and(|pod| pod.contains(p.position))
{
let first_hidden = self.visible_count;
if let Some(callback) = self.on_overflow.as_mut() {
callback(ctx, first_hidden);
}
}
}
if let Some(pod) = self.pod_mut(target) {
pod.set_active(false);
}
self.press_out();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
let Some(target) = self.active.take() else {
return EventResult::Ignored;
};
if !self.panning
&& let Some(pod) = self.pod_mut(target)
{
pod.event_child(ctx, event);
}
if let Some(pod) = self.pod_mut(target) {
pod.set_active(false);
}
self.panning = false;
self.press_out();
ctx.request_redraw();
EventResult::Handled
}
},
_ => EventResult::Ignored,
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let label = self.semantic_label.clone();
ctx.push_container(
Role::Group,
|node| {
if let Some(label) = &label {
node.set_label(label.as_str());
}
},
|ctx| {
let visible = self.visible_count.min(self.members.len());
for pod in &self.members[..visible] {
pod.semantics_child(ctx);
}
if self.trigger_visible
&& let Some(trigger) = &self.trigger
{
trigger.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(members, trigger);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use frust::authoring::text::TextContext;
use frust_widgets::test_support::leaf_any;
use kurbo::Rect;
use std::any::Any;
use std::cell::RefCell;
const ICON_MEMBER_W: f64 = 36.0;
const MEMBER_H: f64 = 40.0;
fn bare_icon_actions(count: usize) -> Vec<ButtonGroupAction<()>> {
(0..count)
.map(|_| button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)))
.collect()
}
fn build<S: 'static>(view: &ButtonGroupView<S>) -> ButtonGroupWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(widget: &mut ButtonGroupWidget, max: Size) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
widget.layout(&mut lctx, &BoxConstraints::loose(max))
}
fn layout_themed(widget: &mut ButtonGroupWidget, max: Size, theme: &Theme) -> Size {
let mut tcx = TextContext::new();
let mut lctx =
LayoutCtx::with_text_context(&mut tcx as &mut dyn Any).with_theme(theme as &dyn Any);
widget.layout(&mut lctx, &BoxConstraints::loose(max))
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn dispatch<S: 'static>(
widget: &mut ButtonGroupWidget,
state: &mut S,
size: Size,
event: &InputEvent,
) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
widget.event(&mut ctx, event);
}
fn advance_squish(widget: &mut ButtonGroupWidget, secs: f64) {
widget.squish.advance(FrameTime::ZERO);
widget
.squish
.advance(FrameTime::from_nanos((secs * 1e9) as u64));
}
#[derive(Default)]
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
fn paint(widget: &mut ButtonGroupWidget, size: Size, theme: Option<&Theme>) {
let mut scene = NullScene;
let mut ctx = PaintCtx::new(Point::ZERO, size);
if let Some(theme) = theme {
ctx = ctx.with_theme(theme as &dyn Any);
}
widget.paint(&mut ctx, &mut scene);
}
#[test]
fn single_member_group_is_all_outer_corners() {
let r = member_radii(0, 1, OUTER_RADIUS, INNER_RADIUS);
assert_eq!(r.top_left, 16.0);
assert_eq!(r.top_right, 16.0);
assert_eq!(r.bottom_right, 16.0);
assert_eq!(r.bottom_left, 16.0);
}
#[test]
fn end_members_get_outer_on_their_outer_side_only() {
let first = member_radii(0, 3, 16.0, 4.0);
assert_eq!((first.top_left, first.bottom_left), (16.0, 16.0));
assert_eq!((first.top_right, first.bottom_right), (4.0, 4.0));
let last = member_radii(2, 3, 16.0, 4.0);
assert_eq!((last.top_left, last.bottom_left), (4.0, 4.0));
assert_eq!((last.top_right, last.bottom_right), (16.0, 16.0));
}
#[test]
fn middle_members_are_all_inner_corners() {
let mid = member_radii(1, 3, 16.0, 4.0);
assert_eq!(mid.top_left, 4.0);
assert_eq!(mid.top_right, 4.0);
assert_eq!(mid.bottom_right, 4.0);
assert_eq!(mid.bottom_left, 4.0);
}
#[test]
fn resolved_gap_pins_every_type_density_pair() {
use ButtonGroupDensity::{Compact, Regular};
use ButtonGroupType::{Connected, Standard};
assert_eq!(resolve_spacing(None, Standard, Regular), 8.0);
assert_eq!(resolve_spacing(None, Standard, Compact), 6.0);
assert_eq!(resolve_spacing(None, Connected, Regular), 2.0);
assert_eq!(resolve_spacing(None, Connected, Compact), 2.0);
assert_eq!(resolve_spacing(Some(3.5), Connected, Compact), 3.5);
}
#[test]
fn squish_middle_splits_the_growth_between_both_neighbours() {
let natural = [100.0, 200.0, 100.0];
let sizes = squish_sizes(&natural, Some(1), 0.15, 1.0);
assert_eq!(sizes, vec![85.0, 230.0, 85.0]);
}
#[test]
fn squish_at_the_first_index_moves_all_growth_to_its_only_neighbour() {
let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(0), 0.15, 1.0);
assert_eq!(sizes, vec![115.0, 85.0, 100.0]);
}
#[test]
fn squish_at_the_last_index_mirrors_the_first() {
let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(2), 0.15, 1.0);
assert_eq!(sizes, vec![100.0, 85.0, 115.0]);
}
#[test]
fn squish_preserves_the_total_main_extent() {
for pressed in 0..3 {
let natural = [80.0, 120.0, 60.0];
let sizes = squish_sizes(&natural, Some(pressed), 0.15, 0.6);
let before: f64 = natural.iter().sum();
let after: f64 = sizes.iter().sum();
assert!(
(before - after).abs() < 1e-9,
"pressing {pressed} changed the total: {before} -> {after}"
);
}
}
#[test]
fn squish_scales_with_the_spring_progress_and_the_ratio() {
let half = squish_sizes(&[100.0, 100.0], Some(0), 0.15, 0.5);
assert_eq!(half, vec![107.5, 92.5]);
let wider = squish_sizes(&[100.0, 100.0], Some(0), 0.30, 1.0);
assert_eq!(wider, vec![130.0, 70.0]);
}
#[test]
fn squish_is_inert_without_a_press_or_progress() {
let natural = [100.0, 100.0];
assert_eq!(squish_sizes(&natural, None, 0.15, 1.0), natural.to_vec());
assert_eq!(squish_sizes(&natural, Some(0), 0.15, 0.0), natural.to_vec());
assert_eq!(squish_sizes(&natural, Some(9), 0.15, 1.0), natural.to_vec());
}
#[test]
fn squish_of_a_one_member_group_grows_with_nothing_to_absorb_it() {
assert_eq!(squish_sizes(&[100.0], Some(0), 0.15, 1.0), vec![115.0]);
}
#[test]
fn squish_shrink_is_floored_at_zero() {
let sizes = squish_sizes(&[400.0, 10.0], Some(0), 0.15, 1.0);
assert_eq!(sizes, vec![460.0, 0.0]);
}
#[test]
fn squish_applies_only_to_a_horizontal_unconnected_group() {
let cases = [
(
ButtonGroupDirection::Horizontal,
ButtonGroupType::Standard,
true,
true,
),
(
ButtonGroupDirection::Horizontal,
ButtonGroupType::Connected,
true,
false,
),
(
ButtonGroupDirection::Vertical,
ButtonGroupType::Standard,
true,
false,
),
(
ButtonGroupDirection::Horizontal,
ButtonGroupType::Standard,
false,
false,
),
];
for (direction, group_type, squish, expected) in cases {
let view = button_group_actions(bare_icon_actions(3))
.direction(direction)
.group_type(group_type)
.neighbor_squish(squish);
let widget = build(&view);
assert_eq!(
widget.supports_squish(),
expected,
"{direction:?}/{group_type:?}/squish={squish}"
);
}
}
#[test]
fn visible_count_is_every_item_when_they_all_fit() {
assert_eq!(visible_count_for_overflow(200.0, &[36.0; 3], 36.0, 8.0), 3);
}
#[test]
fn visible_count_reserves_the_trigger_and_its_separator() {
assert_eq!(visible_count_for_overflow(100.0, &[36.0; 4], 36.0, 8.0), 1);
}
#[test]
fn visible_count_rounds_consumed_up_and_available_down() {
assert_eq!(
visible_count_for_overflow(99.9, &[36.2, 36.2, 36.2], 36.0, 8.0),
1
);
}
#[test]
fn visible_count_is_zero_when_even_one_item_cannot_fit() {
assert_eq!(visible_count_for_overflow(40.0, &[36.0; 3], 36.0, 8.0), 0);
}
#[test]
fn build_creates_one_pod_per_action() {
let view = button_group::<u32, _>(vec!["Day", "Week", "Month"], 0, |s: &mut u32, i| {
*s = i as u32
});
let widget = build(&view);
assert_eq!(widget.members.len(), 3);
assert_eq!(widget.labels, vec!["Day", "Week", "Month"]);
assert!(widget.trigger.is_none(), "no trigger without BottomSheet");
}
#[test]
fn the_legacy_constructor_stays_connected_while_the_action_one_is_standard() {
let legacy = build(&button_group::<(), _>(vec!["A", "B"], 0, |_s, _i| {}));
assert_eq!(legacy.group_type, ButtonGroupType::Connected);
let ported = build(&button_group_actions(bare_icon_actions(2)));
assert_eq!(ported.group_type, ButtonGroupType::Standard);
assert_eq!(ported.overflow, ButtonGroupOverflow::Scroll);
}
#[test]
fn a_bottom_sheet_group_builds_a_trigger_pod() {
let view =
button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet);
let widget = build(&view);
assert!(widget.trigger.is_some());
}
#[test]
fn members_abut_with_the_resolved_gap() {
let mut widget = build(&button_group_actions(bare_icon_actions(3)));
let size = layout(&mut widget, Size::new(500.0, 200.0));
assert_eq!(size, Size::new(3.0 * ICON_MEMBER_W + 16.0, MEMBER_H));
assert_eq!(widget.member_bases[0].x, 0.0);
assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + 8.0);
assert_eq!(widget.member_bases[2].x, 2.0 * (ICON_MEMBER_W + 8.0));
}
#[test]
fn a_connected_group_uses_the_hairline_seam() {
let mut widget = build(
&button_group_actions(bare_icon_actions(3)).group_type(ButtonGroupType::Connected),
);
layout(&mut widget, Size::new(500.0, 200.0));
assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + CONNECTED_GAP);
}
#[test]
fn a_vertical_group_stacks_along_the_cross_axis() {
let mut widget = build(
&button_group_actions(bare_icon_actions(2)).direction(ButtonGroupDirection::Vertical),
);
let size = layout(&mut widget, Size::new(200.0, 500.0));
assert_eq!(size, Size::new(ICON_MEMBER_W, 2.0 * MEMBER_H + 8.0));
assert_eq!(widget.member_bases[1].y, MEMBER_H + 8.0);
}
#[test]
fn a_pinned_action_width_overrides_the_natural_extent() {
let actions = vec![
button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).width(90.0),
button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
];
let mut widget = build(&button_group_actions(actions));
let size = layout(&mut widget, Size::new(500.0, 200.0));
assert_eq!(size.width, 90.0 + 8.0 + ICON_MEMBER_W);
assert_eq!(widget.member_bases[1].x, 98.0);
}
#[test]
fn pressing_a_member_squishes_its_neighbour_in_layout() {
let mut widget = build(&button_group_actions(bare_icon_actions(2)));
let unpressed = layout(&mut widget, Size::new(500.0, 200.0));
let gap_start = widget.member_bases[1].x;
let mut state = ();
dispatch(
&mut widget,
&mut state,
unpressed,
&ev(PointerPhase::Down, 4.0, 20.0),
);
advance_squish(&mut widget, 0.05);
let pressed = layout(&mut widget, Size::new(500.0, 200.0));
assert!(
widget.member_bases[1].x > gap_start,
"member 0 grew, pushing member 1 along ({} -> {})",
gap_start,
widget.member_bases[1].x
);
assert!(
(pressed.width - unpressed.width).abs() < 1e-6,
"the squish is extent-preserving: {unpressed:?} -> {pressed:?}"
);
}
fn labelled_actions() -> Vec<ButtonGroupAction<()>> {
vec![
button_group_action("Every day"),
button_group_action("Days per week"),
button_group_action("Selected days"),
]
}
#[derive(Default)]
struct GlyphCounter {
runs: Vec<usize>,
}
impl PaintScene for GlyphCounter {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
self.runs.push(run.glyphs.len());
}
}
struct Frames {
widget: ButtonGroupWidget,
text: TextContext,
theme: Theme,
max: Size,
size: Size,
needs_layout: bool,
}
impl Frames {
fn new(view: &ButtonGroupView<()>, max: Size) -> Self {
Frames {
widget: build(view),
text: TextContext::new(),
theme: crate::baseline(),
max,
size: Size::ZERO,
needs_layout: true,
}
}
fn run(&mut self, t_ms: u64) -> Vec<usize> {
if self.needs_layout {
let mut lctx = LayoutCtx::with_text_context(&mut self.text as &mut dyn Any)
.with_theme(&self.theme as &dyn Any);
self.size = self
.widget
.layout(&mut lctx, &BoxConstraints::loose(self.max));
}
let mut scene = GlyphCounter::default();
let mut ctx = PaintCtx::for_test(
Point::ZERO,
self.size,
FrameTime::from_nanos(t_ms * 1_000_000),
)
.with_theme(&self.theme as &dyn Any);
self.widget.paint(&mut ctx, &mut scene);
self.needs_layout = ctx.needs_layout();
scene.runs
}
fn run_until(&mut self, from_ms: u64, to_ms: u64) -> Vec<usize> {
let mut painted = Vec::new();
let mut t = from_ms;
while t <= to_ms {
painted = self.run(t);
t += 16;
}
painted
}
fn member_mains(&self) -> Vec<f64> {
self.widget.members.iter().map(|p| p.size().width).collect()
}
fn pointer(&mut self, phase: PointerPhase, x: f64) {
let mut state = ();
let size = self.size;
dispatch(&mut self.widget, &mut state, size, &ev(phase, x, 16.0));
}
}
#[test]
fn a_settled_squish_restores_every_member_to_its_natural_shape() {
let view = button_group_actions(labelled_actions())
.density(ButtonGroupDensity::Compact)
.spacing(8.0)
.size(ToggleButtonSize::Sm);
let mut frames = Frames::new(&view, Size::new(1000.0, 200.0));
let resting_runs = frames.run(0);
let resting_mains = frames.member_mains();
assert_eq!(resting_runs.len(), 3, "one label run per member");
let mut t = 0u64;
for pressed in [1usize, 0] {
let x = frames.widget.member_bases[pressed].x + 4.0;
frames.pointer(PointerPhase::Down, x);
let mid_squish = frames.run_until(t + 16, t + 16 * 4);
assert_eq!(
mid_squish, resting_runs,
"a squished neighbour of {pressed} covers its label, never re-shapes it"
);
frames.run_until(t + 16 * 5, t + 16 * 60);
frames.pointer(PointerPhase::Up, x);
let settled_runs = frames.run_until(t + 16 * 61, t + 16 * 160);
t += 16 * 160;
assert!(
!frames.widget.squish.is_animating(),
"the squish spring settled within the driven frames (pressed {pressed})"
);
assert_eq!(
frames.widget.pressed_index, None,
"the settled spring released member {pressed}"
);
assert_eq!(
settled_runs, resting_runs,
"every label paints its full glyph run again once member {pressed}'s squish settles"
);
assert_eq!(
frames.member_mains(),
resting_mains,
"every member is back at its natural main extent after pressing {pressed}"
);
}
}
#[test]
fn reduce_motion_freezes_the_squish() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let mut widget = build(&button_group_actions(bare_icon_actions(2)));
let size = layout_themed(&mut widget, Size::new(500.0, 200.0), &theme);
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 4.0, 20.0),
);
assert!(widget.squish.is_animating());
paint(&mut widget, size, Some(&theme));
assert!(
!widget.squish.is_animating(),
"reduce_motion stops the spring"
);
assert_eq!(widget.pressed_index, None);
}
#[test]
fn tap_reports_the_tapped_member_index() {
let mut widget = build(&button_group::<u32, _>(
vec!["A", "B", "C"],
0,
|s: &mut u32, i| *s = i as u32,
));
let size = layout(&mut widget, Size::new(500.0, 200.0));
let mid = widget.member_bases[1].x + 4.0;
let mut state = 0u32;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, mid, 20.0),
);
assert_eq!(widget.pressed_index, Some(1));
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, mid, 20.0),
);
assert_eq!(state, 1, "on_select fired with the tapped index");
}
#[test]
fn the_legacy_callback_still_fires_on_the_already_selected_member() {
let mut widget = build(&button_group::<u32, _>(
vec!["A", "B"],
1,
|s: &mut u32, i| *s = 40 + i as u32,
));
let size = layout(&mut widget, Size::new(500.0, 200.0));
let x = widget.member_bases[1].x + 4.0;
let mut state = 0u32;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, x, 20.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, x, 20.0),
);
assert_eq!(state, 41);
}
#[test]
fn single_select_reports_none_when_deselecting_the_current_member() {
let seen: Rc<RefCell<Vec<Option<usize>>>> = Rc::new(RefCell::new(Vec::new()));
let sink = seen.clone();
let view = button_group_actions(bare_icon_actions(2))
.selected_index(Some(1))
.on_selected_index_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
let mut widget = build(&view);
let size = layout(&mut widget, Size::new(500.0, 200.0));
let x = widget.member_bases[1].x + 4.0;
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, x, 20.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, x, 20.0),
);
assert_eq!(*seen.borrow(), vec![None]);
}
#[test]
fn multi_select_reports_the_requested_index_set() {
let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
let sink = seen.clone();
let view = button_group_actions(bare_icon_actions(3))
.selected_indices([2])
.on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
let mut widget = build(&view);
let size = layout(&mut widget, Size::new(500.0, 200.0));
let x = widget.member_bases[0].x + 4.0;
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, x, 20.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, x, 20.0),
);
assert_eq!(*seen.borrow(), vec![vec![0, 2]], "added, kept ascending");
}
#[test]
fn multi_select_removes_an_already_selected_index() {
let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
let sink = seen.clone();
let view = button_group_actions(bare_icon_actions(3))
.selected_indices([0, 2])
.on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
let mut widget = build(&view);
let size = layout(&mut widget, Size::new(500.0, 200.0));
let x = widget.member_bases[2].x + 4.0;
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, x, 20.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, x, 20.0),
);
assert_eq!(*seen.borrow(), vec![vec![0]]);
}
#[test]
fn selection_resolution_follows_the_documented_precedence() {
let view = button_group_actions(bare_icon_actions(3))
.selected_index(Some(0))
.selected_indices([1, 2]);
assert!(!view.is_selected(0));
assert!(view.is_selected(1) && view.is_selected(2));
let actions = vec![
button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).checked(true),
button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
];
let view = button_group_actions(actions).selected_index(Some(1));
assert!(!view.is_selected(0));
assert!(view.is_selected(1));
let actions = vec![
button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).checked(true),
button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
];
let view = button_group_actions(actions);
assert!(view.is_selected(0));
assert!(!view.is_selected(1));
}
#[test]
fn a_selected_index_outside_the_action_range_selects_nothing() {
let view = button_group::<(), _>(vec!["A", "B"], 7, |_s, _i| {});
assert!(!view.is_selected(0));
assert!(!view.is_selected(1));
}
#[test]
fn is_controlled_the_view_never_self_mutates_its_selection() {
let mut widget = build(&button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {}));
let size = layout(&mut widget, Size::new(500.0, 200.0));
let x = widget.member_bases[1].x + 4.0;
let mut state = 0u32;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, x, 20.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, x, 20.0),
);
let prev = button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {});
let next = button_group::<u32, _>(vec!["A", "B"], 1, |_s, _i| {});
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(!flags.is_empty(), "the selection flip reaches the members");
}
#[test]
fn a_release_outside_the_pressed_member_does_not_fire() {
let mut widget = build(&button_group::<u32, _>(
vec!["A", "B"],
0,
|s: &mut u32, i| *s = i as u32,
));
let size = layout(&mut widget, Size::new(500.0, 200.0));
let mut state = 7u32;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 4.0, 20.0),
);
let away = widget.member_bases[1].x + 4.0;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Move, away, 20.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, away, 20.0),
);
assert_eq!(state, 7, "the member's own up-inside rule still governs");
}
#[test]
fn a_down_outside_every_member_is_ignored() {
let mut widget = build(&button_group_actions(bare_icon_actions(2)));
let size = layout(&mut widget, Size::new(500.0, 200.0));
let mut state = ();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
let result = widget.event(&mut ctx, &ev(PointerPhase::Down, 400.0, 20.0));
assert!(matches!(result, EventResult::Ignored));
assert_eq!(widget.pressed_index, None);
}
#[test]
fn cancel_clears_the_press_without_firing() {
let mut widget = build(&button_group::<u32, _>(
vec!["A", "B"],
0,
|s: &mut u32, i| *s = i as u32,
));
let size = layout(&mut widget, Size::new(500.0, 200.0));
let mut state = 3u32;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 4.0, 20.0),
);
assert!(widget.active.is_some());
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Cancel, 4.0, 20.0),
);
assert!(widget.active.is_none());
assert_eq!(state, 3);
}
#[test]
fn overflow_none_lets_the_content_exceed_the_box() {
let mut widget =
build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::None));
layout(&mut widget, Size::new(100.0, 200.0));
assert_eq!(widget.visible_count, 6, "nothing is hidden");
assert!(widget.content_main > widget.viewport_main);
assert!(!widget.can_scroll(), "None never pans");
}
#[test]
fn overflow_scroll_pans_after_the_touch_slop() {
let mut widget = build(&button_group_actions(bare_icon_actions(6)));
let size = layout(&mut widget, Size::new(100.0, 200.0));
assert!(widget.can_scroll());
let start_x = widget.member_bases[0].x;
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 4.0, 20.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Move, 4.0, 20.0),
);
assert!(!widget.panning);
assert_eq!(widget.scroll_offset, 0.0);
let far = 4.0 - TOUCH_SLOP - 20.0;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Move, far, 20.0),
);
assert!(widget.panning);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Move, far - 15.0, 20.0),
);
assert!(widget.scroll_offset > 0.0, "the row panned");
assert!(
widget.members[0].origin().x < start_x,
"member origins follow the pan"
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, far - 15.0, 20.0),
);
assert!(!widget.panning);
}
#[test]
fn overflow_scroll_clamps_the_pan_to_the_content() {
let mut widget = build(&button_group_actions(bare_icon_actions(6)));
layout(&mut widget, Size::new(100.0, 200.0));
let max = widget.max_scroll();
widget.pan_by(-10_000.0);
assert_eq!(widget.scroll_offset, max);
widget.pan_by(10_000.0);
assert_eq!(widget.scroll_offset, 0.0);
}
#[test]
fn overflow_scroll_does_not_pan_when_the_content_fits() {
let mut widget = build(&button_group_actions(bare_icon_actions(2)));
layout(&mut widget, Size::new(500.0, 200.0));
assert!(!widget.can_scroll());
assert!(!widget.pan_by(-50.0));
}
#[test]
fn overflow_bottom_sheet_splits_and_shows_a_trigger() {
let mut widget = build(
&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
);
layout(&mut widget, Size::new(140.0, 200.0));
assert_eq!(widget.visible_count, 2);
assert!(widget.trigger_visible);
assert_eq!(widget.member_bases.len(), 2);
}
#[test]
fn overflow_bottom_sheet_hides_nothing_when_everything_fits() {
let mut widget = build(
&button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet),
);
layout(&mut widget, Size::new(500.0, 200.0));
assert_eq!(widget.visible_count, 3);
assert!(!widget.trigger_visible);
}
#[test]
fn tapping_the_overflow_trigger_reports_the_first_hidden_index() {
let seen: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let sink = seen.clone();
let view = button_group_actions(bare_icon_actions(6))
.overflow(ButtonGroupOverflow::BottomSheet)
.on_overflow(move |_s: &mut (), first| sink.borrow_mut().push(first));
let mut widget = build(&view);
let size = layout(&mut widget, Size::new(140.0, 200.0));
let trigger_x = widget.trigger_base.x + 4.0;
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, trigger_x, 20.0),
);
assert_eq!(widget.active, Some(Target::Trigger));
assert_eq!(widget.pressed_index, None, "the trigger never squishes");
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, trigger_x, 20.0),
);
assert_eq!(*seen.borrow(), vec![2]);
}
#[test]
fn overflow_popup_group_builds_a_trigger_pod() {
let view = button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::Popup);
let widget = build(&view);
assert!(widget.trigger.is_some());
}
#[test]
fn overflow_popup_splits_and_shows_a_trigger() {
let mut widget =
build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::Popup));
layout(&mut widget, Size::new(140.0, 200.0));
assert_eq!(widget.visible_count, 2);
assert!(widget.trigger_visible);
}
#[test]
fn the_trigger_captures_its_window_rect_into_the_overflow_anchor_when_wired() {
let anchor = OverlayAnchor::new();
let cell = anchor.clone();
let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
let mut logic = move |_s: &mut ()| {
button_group_actions(bare_icon_actions(6))
.overflow(ButtonGroupOverflow::Popup)
.overflow_anchor(&cell)
};
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(140.0, 200.0), &mut tcx as &mut dyn Any);
let mut scene = NullScene;
root.paint(&mut scene, FrameTime::ZERO);
let rect = anchor.rect();
assert!(
rect.width() > 0.0 && rect.height() > 0.0,
"the trigger's window rect was captured into the overflow anchor on paint: {rect:?}"
);
}
#[derive(Default)]
struct OverflowMenuState {
selected: Option<usize>,
}
fn overflow_menu_actions() -> Vec<ButtonGroupAction<OverflowMenuState>> {
(0..6)
.map(|i| button_group_action(format!("Item {i}")))
.collect()
}
#[test]
fn overflow_menu_shows_hidden_members_checked_state_per_upstream() {
let anchor = OverlayAnchor::new();
anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
let cell = anchor.clone();
let mut root: frust_core::RenderRoot<
OverflowMenuState,
frust::StackView<OverflowMenuState>,
> = frust_core::RenderRoot::new();
let mut state = OverflowMenuState { selected: Some(4) };
let mut logic = move |s: &mut OverflowMenuState| {
let group = button_group_actions(overflow_menu_actions())
.overflow(ButtonGroupOverflow::Popup)
.overflow_anchor(&cell)
.selected_index(s.selected);
frust::Stack(vec![group.overflow_menu(4, true)])
};
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let boxes: Vec<_> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::MenuItemCheckBox)
.collect();
assert_eq!(boxes.len(), 2, "only the two hidden members (indices 4, 5)");
let checked = boxes
.iter()
.find(|(_, n)| n.label() == Some("Item 4"))
.expect("Item 4 is present");
assert_eq!(
checked.1.toggled(),
Some(frust::authoring::Toggled::True),
"the group's own selected index shows checked in the popup"
);
let unchecked = boxes
.iter()
.find(|(_, n)| n.label() == Some("Item 5"))
.expect("Item 5 is present");
assert_eq!(
unchecked.1.toggled(),
Some(frust::authoring::Toggled::False)
);
}
#[test]
fn overflow_menu_selection_routes_through_the_groups_own_fan_out() {
let anchor_rect = Rect::new(40.0, 100.0, 160.0, 140.0);
let anchor = OverlayAnchor::new();
anchor.set(anchor_rect);
let cell = anchor.clone();
let mut root: frust_core::RenderRoot<
OverflowMenuState,
frust::StackView<OverflowMenuState>,
> = frust_core::RenderRoot::new();
let mut state = OverflowMenuState { selected: None };
let mut logic = move |s: &mut OverflowMenuState| {
let group = button_group_actions(overflow_menu_actions())
.overflow(ButtonGroupOverflow::Popup)
.overflow_anchor(&cell)
.selected_index(s.selected)
.on_selected_index_changed(|s: &mut OverflowMenuState, i| s.selected = i);
frust::Stack(vec![group.overflow_menu(5, true)])
};
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let row = Point::new(
anchor_rect.x0 + crate::menu::MENU_MIN_WIDTH / 2.0,
anchor_rect.y1
+ crate::overlay::OVERLAY_ANCHOR_GAP
+ crate::menu::MENU_SURFACE_V_PADDING
+ (crate::menu::MENU_ITEM_GAP + crate::menu::MENU_ROW_MIN_HEIGHT) / 2.0,
);
root.event(&mut state, &ev(PointerPhase::Down, row.x, row.y));
root.event(&mut state, &ev(PointerPhase::Up, row.x, row.y));
assert_eq!(
state.selected,
Some(5),
"activating the only hidden member's popup row reports through \
`on_selected_index_changed`, exactly as a visible member's own tap does"
);
}
#[test]
fn semantics_yields_a_group_of_toggle_members() {
fn logic(_s: &mut ()) -> ButtonGroupView<()> {
button_group::<(), _>(vec!["One", "Two", "Three"], 2, |_s: &mut (), _i| {})
.semantic_label("Range")
}
let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let group = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Group)
.expect("a Group container node is contributed");
assert_eq!(group.1.children().len(), 3);
assert_eq!(group.1.label(), Some("Range"));
let members: Vec<_> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::Button)
.collect();
assert_eq!(members.len(), 3);
let selected = members
.iter()
.find(|(_, n)| n.label() == Some("Three"))
.expect("the Three member is present");
assert_eq!(selected.1.toggled(), Some(frust::authoring::Toggled::True));
let unselected = members
.iter()
.find(|(_, n)| n.label() == Some("One"))
.expect("the One member is present");
assert_eq!(
unselected.1.toggled(),
Some(frust::authoring::Toggled::False)
);
}
#[test]
fn semantics_omit_the_members_the_overflow_split_hid() {
let mut widget = build(
&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
);
layout(&mut widget, Size::new(140.0, 200.0));
assert_eq!(widget.visible_count, 2);
assert!(widget.hit(Point::new(300.0, 20.0)).is_none());
}
#[test]
fn this_module_no_longer_uses_the_legacy_radial_shape_model() {
let src = include_str!("button_group.rs");
let import = format!("use super::{}_morph", "shape");
let call = format!("{}_path(", "morph");
assert!(
!src.contains(&import),
"the legacy radial polygon module must not be imported here"
);
assert!(
!src.contains(&call),
"the legacy radial morph primitive must not be called here"
);
}
}