use std::rc::Rc;
use std::time::Duration;
use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
use frust::authoring::{
Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, CursorIcon,
EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role,
SemanticsCtx, Toggled, View, Widget,
};
use frust::input::TOUCH_SLOP;
use frust::{AnimationController, FrameTime, SpringDesc, Theme};
use kurbo::{Point, Rect, RoundedRect, RoundedRectRadii, Shape, Size};
use peniko::{Brush, Color};
use crate::ButtonVariant;
use crate::interaction::{
DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState,
MaterialHaptics,
};
use crate::press::presses;
const OUTLINE_WIDTH: f64 = 1.0;
const PATH_TOLERANCE: f64 = 0.1;
const SHAPING_INK: Color = Color::BLACK;
#[derive(Clone, Copy, Debug, PartialEq)]
struct SizeMetrics {
height: f64,
h_padding: f64,
icon_size: f64,
icon_gap: f64,
}
type TypeToken = (f32, f32, f32, FontWeight);
const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
const TITLE_MEDIUM: TypeToken = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToggleButtonSize {
#[default]
Sm,
Md,
Lg,
}
impl ToggleButtonSize {
fn metrics(self) -> SizeMetrics {
match self {
ToggleButtonSize::Sm => SizeMetrics {
height: 40.0,
h_padding: 16.0,
icon_size: 20.0,
icon_gap: 8.0,
},
ToggleButtonSize::Md => SizeMetrics {
height: 56.0,
h_padding: 24.0,
icon_size: 24.0,
icon_gap: 8.0,
},
ToggleButtonSize::Lg => SizeMetrics {
height: 96.0,
h_padding: 48.0,
icon_size: 32.0,
icon_gap: 12.0,
},
}
}
fn square_radius(self, theme: Option<&Theme>) -> f64 {
match theme {
Some(theme) => match self {
ToggleButtonSize::Sm => theme.shape.medium,
ToggleButtonSize::Md => theme.shape.large,
ToggleButtonSize::Lg => theme.shape.extra_large,
},
None => match self {
ToggleButtonSize::Sm => 12.0,
ToggleButtonSize::Md => 16.0,
ToggleButtonSize::Lg => 28.0,
},
}
}
fn pressed_radius(self, theme: Option<&Theme>) -> f64 {
match theme {
Some(theme) => match self {
ToggleButtonSize::Sm => theme.shape.small,
ToggleButtonSize::Md => theme.shape.medium,
ToggleButtonSize::Lg => theme.shape.large,
},
None => match self {
ToggleButtonSize::Sm => 8.0,
ToggleButtonSize::Md => 12.0,
ToggleButtonSize::Lg => 16.0,
},
}
}
fn hovered_radius(self) -> f64 {
match self {
ToggleButtonSize::Sm => 10.0,
ToggleButtonSize::Md => 14.0,
ToggleButtonSize::Lg => 22.0,
}
}
fn label_style(self, theme: Option<&Theme>) -> TextStyle {
let mut style = match theme {
Some(theme) => match self {
ToggleButtonSize::Sm => theme.type_scale.label_medium.clone(),
ToggleButtonSize::Md => theme.type_scale.label_large.clone(),
ToggleButtonSize::Lg => theme.type_scale.title_medium.clone(),
},
None => {
let (size, line_height, letter_spacing, weight) = match self {
ToggleButtonSize::Sm => LABEL_MEDIUM,
ToggleButtonSize::Md => LABEL_LARGE,
ToggleButtonSize::Lg => TITLE_MEDIUM,
};
let mut style = TextStyle::new(size, SHAPING_INK);
style.line_height = LineHeight::Absolute(line_height);
style.letter_spacing = letter_spacing;
style.weight = weight;
style
}
};
style.color = SHAPING_INK;
style
}
}
const CONNECTED_INNER_RADIUS: f64 = 6.0;
const CONNECTED_PRESSED_INNER_RADIUS: f64 = 2.0;
const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const SURFACE_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
const SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
#[derive(Clone, Copy, Debug, PartialEq)]
struct Roles {
primary: Color,
on_primary: Color,
on_surface_variant: Color,
surface_container_highest: Color,
surface_container_low: Color,
secondary_container: Color,
on_secondary_container: Color,
outline: Color,
on_surface: Color,
}
impl Roles {
fn resolve(theme: Option<&Theme>) -> Self {
match theme {
Some(theme) => {
let s = theme.scheme();
Self {
primary: s.primary,
on_primary: s.on_primary,
on_surface_variant: s.on_surface_variant,
surface_container_highest: s.surface_container_highest,
surface_container_low: s.surface_container_low,
secondary_container: s.secondary_container,
on_secondary_container: s.on_secondary_container,
outline: s.outline,
on_surface: s.on_surface,
}
}
None => Self {
primary: PRIMARY,
on_primary: ON_PRIMARY,
on_surface_variant: ON_SURFACE_VARIANT,
surface_container_highest: SURFACE_CONTAINER_HIGHEST,
surface_container_low: SURFACE_CONTAINER_LOW,
secondary_container: SECONDARY_CONTAINER,
on_secondary_container: ON_SECONDARY_CONTAINER,
outline: OUTLINE,
on_surface: ON_SURFACE,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ToggleColors {
container: Color,
content: Color,
outline: Option<Color>,
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn is_transparent(variant: ButtonVariant) -> bool {
matches!(variant, ButtonVariant::Outlined | ButtonVariant::Text)
}
fn resolve_colors(
theme: Option<&Theme>,
variant: ButtonVariant,
checked: bool,
enabled: bool,
) -> ToggleColors {
let roles = Roles::resolve(theme);
if !enabled {
return ToggleColors {
container: if is_transparent(variant) {
Color::TRANSPARENT
} else {
with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)
},
content: with_alpha(roles.on_surface, DISABLED_CONTENT_OPACITY),
outline: (variant == ButtonVariant::Outlined)
.then(|| with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)),
};
}
let (content, container) = match variant {
ButtonVariant::Filled => (
if checked {
roles.on_primary
} else {
roles.on_surface_variant
},
if checked {
roles.primary
} else {
roles.surface_container_highest
},
),
ButtonVariant::Elevated => (
if checked {
roles.on_primary
} else {
roles.primary
},
if checked {
roles.primary
} else {
roles.surface_container_low
},
),
ButtonVariant::Tonal => (
if checked {
roles.on_secondary_container
} else {
roles.on_surface_variant
},
if checked {
roles.secondary_container
} else {
roles.surface_container_highest
},
),
ButtonVariant::Outlined => (
if checked {
roles.on_secondary_container
} else {
roles.on_surface
},
if checked {
roles.secondary_container
} else {
Color::TRANSPARENT
},
),
ButtonVariant::Text => (
if checked {
roles.primary
} else {
roles.on_surface
},
Color::TRANSPARENT,
),
};
ToggleColors {
container,
content,
outline: (variant == ButtonVariant::Outlined).then_some(roles.outline),
}
}
fn elevation_dp(variant: ButtonVariant, enabled: bool, pressed: bool, hovered: bool) -> f64 {
if !enabled {
return 0.0;
}
match variant {
ButtonVariant::Elevated => {
if pressed {
0.0
} else if hovered {
3.0
} else {
1.0
}
}
ButtonVariant::Filled | ButtonVariant::Tonal => {
if !pressed && hovered {
1.0
} else {
0.0
}
}
ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
}
}
fn resolve_shadow(theme: Option<&Theme>, dp: f64) -> Option<(f64, f64, Color)> {
if dp <= 0.0 {
return None;
}
match theme {
Some(theme) => {
let level = if dp >= theme.elevation.level2.dp {
theme.elevation.level2
} else {
theme.elevation.level1
};
let shadow = level.shadow(theme.brightness);
let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
Some((shadow.blur_std_dev, shadow.y_offset, color))
}
None => Some((dp, dp / 2.0 + 1.0, FALLBACK_SHADOW_COLOR)),
}
}
const SHAPE_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 380.0,
damping_ratio: 0.55,
};
const ANIM_PERIOD: Duration = Duration::from_millis(300);
const FLING_VELOCITY: f64 = 4.0;
const RETARGET_TOLERANCE: f64 = 0.1;
const OVERSHOOT_LIMIT: f64 = 1.5;
fn lerp(from: f64, to: f64, t: f64) -> f64 {
from + (to - from) * t
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ContentPadding {
left: f64,
right: f64,
top: f64,
bottom: f64,
}
impl ContentPadding {
const fn symmetric(horizontal: f64) -> Self {
Self {
left: horizontal,
right: horizontal,
top: 0.0,
bottom: 0.0,
}
}
fn lerp(self, to: Self, t: f64) -> Self {
Self {
left: lerp(self.left, to.left, t),
right: lerp(self.right, to.right, t),
top: lerp(self.top, to.top, t),
bottom: lerp(self.bottom, to.bottom, t),
}
}
fn matches(self, other: Self) -> bool {
(self.left - other.left).abs() <= RETARGET_TOLERANCE
&& (self.right - other.right).abs() <= RETARGET_TOLERANCE
&& (self.top - other.top).abs() <= RETARGET_TOLERANCE
&& (self.bottom - other.bottom).abs() <= RETARGET_TOLERANCE
}
}
fn corners_match(a: CornerRadii, b: CornerRadii) -> bool {
(a.top_left - b.top_left).abs() <= RETARGET_TOLERANCE
&& (a.top_right - b.top_right).abs() <= RETARGET_TOLERANCE
&& (a.bottom_right - b.bottom_right).abs() <= RETARGET_TOLERANCE
&& (a.bottom_left - b.bottom_left).abs() <= RETARGET_TOLERANCE
}
fn lerp_corners(from: CornerRadii, to: CornerRadii, t: f64) -> CornerRadii {
CornerRadii::new(
lerp(from.top_left, to.top_left, t).max(0.0),
lerp(from.top_right, to.top_right, t).max(0.0),
lerp(from.bottom_right, to.bottom_right, t).max(0.0),
lerp(from.bottom_left, to.bottom_left, t).max(0.0),
)
}
#[derive(Clone, Copy, Debug)]
struct ShapeMotion {
from_radii: CornerRadii,
to_radii: CornerRadii,
from_padding: ContentPadding,
to_padding: ContentPadding,
anim: AnimationController,
seeded: bool,
}
impl ShapeMotion {
fn new() -> Self {
let zero_pad = ContentPadding {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
};
Self {
from_radii: CornerRadii::default(),
to_radii: CornerRadii::default(),
from_padding: zero_pad,
to_padding: zero_pad,
anim: AnimationController::new(ANIM_PERIOD),
seeded: false,
}
}
fn retarget(&mut self, radii: CornerRadii, padding: ContentPadding) -> bool {
if !self.seeded {
self.snap_to(radii, padding);
return false;
}
if corners_match(self.to_radii, radii) && self.to_padding.matches(padding) {
return false;
}
self.from_radii = self.radii();
self.from_padding = self.padding();
self.to_radii = radii;
self.to_padding = padding;
self.anim = AnimationController::new(ANIM_PERIOD);
self.anim.fling(FLING_VELOCITY, SHAPE_SPRING);
true
}
fn snap_to(&mut self, radii: CornerRadii, padding: ContentPadding) {
self.from_radii = radii;
self.to_radii = radii;
self.from_padding = padding;
self.to_padding = padding;
self.anim = AnimationController::new(ANIM_PERIOD);
self.seeded = true;
}
fn advance(&mut self, now: FrameTime) -> bool {
self.anim.advance(now)
}
fn is_seeded(&self) -> bool {
self.seeded
}
fn factor(&self) -> f64 {
let raw = self.anim.value();
if raw.is_finite() {
raw.clamp(0.0, OVERSHOOT_LIMIT)
} else {
0.0
}
}
fn radii(&self) -> CornerRadii {
lerp_corners(self.from_radii, self.to_radii, self.factor())
}
fn padding(&self) -> ContentPadding {
self.from_padding.lerp(self.to_padding, self.factor())
}
#[cfg(test)]
fn target_radii(&self) -> CornerRadii {
self.to_radii
}
}
struct LabelRun {
content: String,
layout: Option<TextLayout>,
shaped_for: Option<TextStyle>,
}
impl LabelRun {
fn new(content: String) -> Self {
Self {
content,
layout: None,
shaped_for: None,
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
self.shaped_for = None;
}
}
fn content(&self) -> &str {
&self.content
}
fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if let Some(cached) = &self.layout
&& self.shaped_for.as_ref() == Some(style)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, style, None);
let size = laid.size();
self.layout = Some(laid);
self.shaped_for = Some(style.clone());
size
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
fn horizontal_radii(is_first: bool, is_last: bool, outer: f64, inner: f64) -> CornerRadii {
let left = if is_first { outer } else { inner };
let right = if is_last { outer } else { inner };
CornerRadii::new(left, right, right, left)
}
fn inset_stroke_rrect(size: Size, radii: CornerRadii, width: f64) -> RoundedRect {
let half = width / 2.0;
let r = RoundedRectRadii::new(
(radii.top_left - half).max(0.0),
(radii.top_right - half).max(0.0),
(radii.bottom_right - half).max(0.0),
(radii.bottom_left - half).max(0.0),
);
RoundedRect::from_rect(
Rect::new(half, half, size.width - half, size.height - half),
r,
)
}
type OnCheckedChange<State> = Rc<dyn Fn(&mut State, bool)>;
type OnLongPress<State> = Rc<dyn Fn(&mut State)>;
pub struct ToggleButtonView<State: 'static> {
checked: bool,
on_checked_change: OnCheckedChange<State>,
variant: ButtonVariant,
size: ToggleButtonSize,
enabled: bool,
icon: Option<AnyView<State>>,
checked_icon: Option<AnyView<State>>,
label: Option<String>,
checked_label: Option<String>,
is_group_connected: bool,
is_first_in_group: bool,
is_last_in_group: bool,
haptic: HapticSignal,
on_long_press: Option<OnLongPress<State>>,
}
impl<State: 'static> ToggleButtonView<State> {
fn effective_icon_view(&self) -> Option<&AnyView<State>> {
if self.checked {
self.checked_icon.as_ref().or(self.icon.as_ref())
} else {
self.icon.as_ref()
}
}
fn effective_label_text(&self) -> Option<&str> {
if self.checked {
self.checked_label.as_deref().or(self.label.as_deref())
} else {
self.label.as_deref()
}
}
}
pub fn toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_checked_change: F,
) -> ToggleButtonView<State> {
ToggleButtonView {
checked,
on_checked_change: Rc::new(on_checked_change),
variant: ButtonVariant::default(),
size: ToggleButtonSize::default(),
enabled: true,
icon: None,
checked_icon: None,
label: None,
checked_label: None,
is_group_connected: false,
is_first_in_group: true,
is_last_in_group: true,
haptic: HapticSignal::None,
on_long_press: None,
}
}
#[allow(non_snake_case)]
pub fn ToggleButton<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_checked_change: F,
) -> ToggleButtonView<State> {
toggle_button(checked, on_checked_change)
}
pub fn filled_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_checked_change: F,
) -> ToggleButtonView<State> {
toggle_button(checked, on_checked_change)
}
pub fn tonal_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_checked_change: F,
) -> ToggleButtonView<State> {
toggle_button(checked, on_checked_change).variant(ButtonVariant::Tonal)
}
pub fn elevated_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_checked_change: F,
) -> ToggleButtonView<State> {
toggle_button(checked, on_checked_change).variant(ButtonVariant::Elevated)
}
pub fn outlined_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_checked_change: F,
) -> ToggleButtonView<State> {
toggle_button(checked, on_checked_change).variant(ButtonVariant::Outlined)
}
pub fn text_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
checked: bool,
on_checked_change: F,
) -> ToggleButtonView<State> {
toggle_button(checked, on_checked_change).variant(ButtonVariant::Text)
}
impl<State: 'static> ToggleButtonView<State> {
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 enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn icon(mut self, icon: AnyView<State>) -> Self {
self.icon = Some(icon);
self
}
pub fn checked_icon(mut self, icon: AnyView<State>) -> Self {
self.checked_icon = Some(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 group_connected(mut self, connected: bool) -> Self {
self.is_group_connected = connected;
self
}
pub fn first_in_group(mut self, first: bool) -> Self {
self.is_first_in_group = first;
self
}
pub fn last_in_group(mut self, last: bool) -> Self {
self.is_last_in_group = last;
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
pub fn on_long_press<F: Fn(&mut State) + 'static>(mut self, on_long_press: F) -> Self {
self.on_long_press = Some(Rc::new(on_long_press));
self
}
}
#[derive(Debug, Default, Clone, Copy)]
struct LongPressState {
press_start: Option<FrameTime>,
elapsed: bool,
fired: bool,
}
impl LongPressState {
fn clear(&mut self) {
*self = Self::default();
}
}
const LONG_PRESS_MS: f64 = 500.0;
pub struct ToggleButtonWidget {
variant: ButtonVariant,
size: ToggleButtonSize,
enabled: bool,
checked: bool,
label: LabelRun,
label_origin: Point,
content_clipped: bool,
icon: Option<ChildPod>,
is_group_connected: bool,
is_first_in_group: bool,
is_last_in_group: bool,
haptic: HapticSignal,
state: InteractionState,
captured: bool,
down_pos: Point,
motion: ShapeMotion,
long_press: LongPressState,
on_checked_change: frust::authoring::ErasedArgCallback<bool>,
on_long_press: Option<frust::authoring::ErasedCallback>,
}
impl ToggleButtonWidget {
fn has_label(&self) -> bool {
!self.label.content().is_empty()
}
fn h_padding(&self, metrics: &SizeMetrics) -> f64 {
if self.has_label() {
metrics.h_padding
} else {
metrics.h_padding / 2.0
}
}
fn target_radii(&self, theme: Option<&Theme>, height: f64) -> CornerRadii {
let pill = height / 2.0;
if self.is_group_connected {
self.connected_target_radii(theme, pill)
} else {
self.standalone_target_radii(theme, pill)
}
}
fn standalone_target_radii(&self, theme: Option<&Theme>, pill: f64) -> CornerRadii {
if self.enabled && self.state.pressed {
return CornerRadii::uniform(self.size.pressed_radius(theme));
}
if self.enabled && self.state.hovered {
return CornerRadii::uniform(self.size.hovered_radius());
}
if self.checked {
return CornerRadii::uniform(self.size.square_radius(theme));
}
CornerRadii::uniform(pill)
}
fn connected_target_radii(&self, _theme: Option<&Theme>, outer: f64) -> CornerRadii {
if self.enabled && self.state.pressed {
return horizontal_radii(
self.is_first_in_group,
self.is_last_in_group,
outer,
CONNECTED_PRESSED_INNER_RADIUS,
);
}
if self.enabled && self.state.hovered {
return horizontal_radii(
self.is_first_in_group,
self.is_last_in_group,
outer,
self.size.hovered_radius(),
);
}
if self.checked {
return CornerRadii::uniform(outer);
}
horizontal_radii(
self.is_first_in_group,
self.is_last_in_group,
outer,
CONNECTED_INNER_RADIUS,
)
}
}
impl<State: 'static> View<State> for ToggleButtonView<State> {
type Element = ToggleButtonWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ToggleButtonWidget {
let icon = self
.effective_icon_view()
.map(|icon| frust::authoring::build_child(icon, ctx));
let mut label = LabelRun::new(String::new());
if let Some(text) = self.effective_label_text() {
label.set_content(text);
}
ToggleButtonWidget {
variant: self.variant,
size: self.size,
enabled: self.enabled,
checked: self.checked,
label,
label_origin: Point::ZERO,
content_clipped: false,
icon,
is_group_connected: self.is_group_connected,
is_first_in_group: self.is_first_in_group,
is_last_in_group: self.is_last_in_group,
haptic: self.haptic,
state: InteractionState::new(),
captured: false,
down_pos: Point::ZERO,
motion: ShapeMotion::new(),
long_press: LongPressState::default(),
on_checked_change: frust::authoring::erase_callback_arg(&self.on_checked_change),
on_long_press: self
.on_long_press
.as_ref()
.map(frust::authoring::erase_callback),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ToggleButtonWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_checked_change = frust::authoring::erase_callback_arg(&self.on_checked_change);
element.on_long_press = self
.on_long_press
.as_ref()
.map(frust::authoring::erase_callback);
element.haptic = self.haptic;
let mut flags = ChangeFlags::NONE;
if prev.checked != self.checked {
element.checked = self.checked;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.variant != self.variant {
element.variant = self.variant;
flags |= ChangeFlags::PAINT;
}
if prev.size != self.size {
element.size = self.size;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled {
element.state.set_pressed(false);
element.captured = false;
element.long_press.clear();
}
flags |= ChangeFlags::PAINT;
}
if prev.is_group_connected != self.is_group_connected {
element.is_group_connected = self.is_group_connected;
flags |= ChangeFlags::PAINT;
}
if prev.is_first_in_group != self.is_first_in_group {
element.is_first_in_group = self.is_first_in_group;
flags |= ChangeFlags::PAINT;
}
if prev.is_last_in_group != self.is_last_in_group {
element.is_last_in_group = self.is_last_in_group;
flags |= ChangeFlags::PAINT;
}
let prev_label = prev.effective_label_text();
let next_label = self.effective_label_text();
if prev_label != next_label {
element.label.set_content(next_label.unwrap_or(""));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
match (prev.effective_icon_view(), self.effective_icon_view()) {
(None, None) => {}
(Some(p), Some(n)) => {
let pod = element.icon.as_mut().expect("icon pod present");
flags |= frust::authoring::rebuild_child(p, n, pod, ctx);
}
(None, Some(n)) => {
element.icon = Some(frust::authoring::build_child(n, ctx));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(Some(p), None) => {
let mut pod = element.icon.take().expect("icon pod present");
frust::authoring::teardown_child(p, &mut pod, ctx);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
flags
}
fn teardown(&self, element: &mut ToggleButtonWidget, ctx: &mut BuildCtx<'_>) {
if let (Some(icon_view), Some(pod)) = (self.effective_icon_view(), element.icon.as_mut()) {
frust::authoring::teardown_child(icon_view, pod, ctx);
}
}
}
impl Widget for ToggleButtonWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let metrics = self.size.metrics();
let (label_style, h_padding) = {
let theme = Theme::from_layout_ctx(ctx);
(self.size.label_style(theme), self.h_padding(&metrics))
};
if !self.motion.is_seeded() {
let theme = Theme::from_layout_ctx(ctx);
let resting = self.target_radii(theme, metrics.height);
self.motion
.snap_to(resting, ContentPadding::symmetric(h_padding));
}
let padding = ContentPadding::symmetric(h_padding);
let icon_size = self.icon.as_mut().map(|pod| {
pod.layout_child(
ctx,
&BoxConstraints::tight(Size::new(metrics.icon_size, metrics.icon_size)),
)
});
let icon_width = icon_size.map_or(0.0, |s| s.width);
let has_label = self.has_label();
let gap = if icon_size.is_some() && has_label {
metrics.icon_gap
} else {
0.0
};
let label_size = self.label.shape(ctx, &label_style);
let content_width = icon_width + gap + label_size.width;
let width = content_width + padding.left + padding.right;
let size = bc.constrain(Size::new(width, metrics.height));
let content_x = (size.width - content_width) / 2.0;
self.content_clipped = content_width > size.width;
let icon_x = content_x;
let label_x = content_x + icon_width + gap;
if let Some(pod) = self.icon.as_mut() {
let icon_height = icon_size.map_or(0.0, |s| s.height);
pod.set_origin(Point::new(icon_x, (size.height - icon_height) / 2.0));
}
self.label_origin = Point::new(label_x, (size.height - label_size.height) / 2.0);
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let origin = ctx.origin();
let size = ctx.size();
self.state.set_hovered(self.enabled && ctx.is_hovered());
self.state.set_focused(self.enabled && ctx.has_focus());
let (colors, shadow, radii_target, h_padding) = {
let theme = Theme::from_paint_ctx(ctx);
let dp = elevation_dp(
self.variant,
self.enabled,
self.state.pressed,
self.state.hovered,
);
(
resolve_colors(theme, self.variant, self.checked, self.enabled),
resolve_shadow(theme, dp),
self.target_radii(theme, size.height),
self.h_padding(&self.size.metrics()),
)
};
let padding = ContentPadding::symmetric(h_padding);
if self.motion.retarget(radii_target, padding) {
ctx.request_frame();
}
if self.motion.advance(ctx.frame_time()) {
ctx.request_frame();
}
let radii = self.motion.radii();
if let Some((blur, y_offset, shadow_color)) = shadow {
scene.draw_shadow(
Point::new(origin.x, origin.y + y_offset),
size,
radii.largest(),
blur,
shadow_color,
);
}
if colors.container.components[3] > 0.0 {
scene.fill_rounded_rect_radii(origin, size, radii, colors.container);
}
if self.enabled {
let opacity = self.state.resolve_opacity();
if opacity > 0.0 {
scene.fill_rounded_rect_radii(
origin,
size,
radii,
with_alpha(colors.content, opacity),
);
}
}
if let Some(outline) = colors.outline {
let path = inset_stroke_rrect(size, radii, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
scene.stroke_path(origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
}
if self.content_clipped {
scene.push_clip(origin, size);
}
if let Some(pod) = self.icon.as_mut() {
pod.paint_child(ctx, scene);
}
self.label.paint(
Point::new(
origin.x + self.label_origin.x,
origin.y + self.label_origin.y,
),
colors.content,
scene,
);
if self.content_clipped {
scene.pop_clip();
}
if self.enabled && self.captured && self.on_long_press.is_some() && !self.long_press.elapsed
{
let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
if elapsed_ms >= LONG_PRESS_MS {
self.long_press.elapsed = true;
} else {
ctx.request_frame();
}
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if !self.enabled {
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.state.set_pressed(true);
self.captured = true;
self.down_pos = p.position;
self.long_press.clear();
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
let over = inside(p.position, ctx.size());
if !self.captured {
if over {
ctx.claim_hover();
ctx.set_cursor(CursorIcon::Pointer);
}
if self.state.set_hovered(over) {
ctx.request_redraw();
}
return EventResult::Ignored;
}
ctx.set_cursor(CursorIcon::Pointer);
if self.state.set_pressed(over) {
ctx.request_redraw();
}
if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
self.long_press.clear();
} else if self.long_press.elapsed && !self.long_press.fired {
self.long_press.fired = true;
if let Some(cb) = self.on_long_press.as_mut() {
cb(ctx);
}
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
if self.long_press.elapsed {
if !self.long_press.fired {
self.long_press.fired = true;
if let Some(cb) = self.on_long_press.as_mut() {
cb(ctx);
}
}
} else if inside(p.position, ctx.size()) {
if self.haptic != HapticSignal::None {
MaterialHaptics::fire(self.haptic);
}
let requested = !self.checked;
(self.on_checked_change)(ctx, requested);
}
self.state.set_pressed(false);
self.captured = false;
self.long_press.clear();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.state.set_pressed(false);
self.captured = false;
self.long_press.clear();
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Button, |node| {
if !self.label.content().is_empty() {
node.set_label(self.label.content());
}
node.set_toggled(Toggled::from(self.checked));
if self.enabled {
node.add_action(Action::Click);
} else {
node.set_disabled();
}
});
}
frust::authoring::visit_children!(icon);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::scene::GlyphRun;
use frust::authoring::{PointerButton, PointerEvent};
use frust_widgets::test_support::leaf_any;
use std::any::Any;
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
fn build<S: 'static>(view: &ToggleButtonView<S>) -> ToggleButtonWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn settle_morph(widget: &mut ToggleButtonWidget) {
let mut t = 0.0;
for _ in 0..600 {
t += 1.0 / 60.0;
if !widget.motion.advance(ft_secs(t)) {
return;
}
}
panic!("the shape morph never settled");
}
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 ToggleButtonWidget,
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);
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, CornerRadii, Color)>,
strokes: Vec<(Point, f64, Color)>,
runs: Vec<(Point, Color)>,
run_glyphs: Vec<usize>,
clips: Vec<(Point, Size)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
self.rrects.push((o, s, radii, color));
}
fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
self.strokes.push((o, width, solid(brush)));
}
fn push_clip(&mut self, o: Point, s: Size) {
self.clips.push((o, s));
}
fn draw_glyph_run(&mut self, run: GlyphRun) {
let t = run.transform.translation();
self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
self.run_glyphs.push(run.glyphs.len());
}
}
fn solid(brush: &Brush) -> Color {
match brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
}
}
fn layout_with(widget: &mut ToggleButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
if let Some(theme) = theme {
lctx = lctx.with_theme(theme as &dyn Any);
}
widget.layout(
&mut lctx,
&BoxConstraints::loose(Size::new(max_width, 500.0)),
)
}
fn layout_tight_main(widget: &mut ToggleButtonWidget, main: f64) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
widget.layout(
&mut lctx,
&BoxConstraints::new(Size::new(main, 0.0), Size::new(main, 500.0)),
)
}
fn paint_at(widget: &mut ToggleButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, size);
if let Some(theme) = theme {
pctx = pctx.with_theme(theme as &dyn Any);
}
widget.paint(&mut pctx, &mut rec);
rec
}
const ALL_SIZES: [ToggleButtonSize; 3] = [
ToggleButtonSize::Sm,
ToggleButtonSize::Md,
ToggleButtonSize::Lg,
];
const ALL_VARIANTS: [ButtonVariant; 5] = [
ButtonVariant::Filled,
ButtonVariant::Outlined,
ButtonVariant::Tonal,
ButtonVariant::Elevated,
ButtonVariant::Text,
];
#[test]
fn measurements_match_the_plain_buttons_sm_md_lg_rows() {
let expected: [(ToggleButtonSize, f64, f64, f64, f64); 3] = [
(ToggleButtonSize::Sm, 40.0, 16.0, 20.0, 8.0),
(ToggleButtonSize::Md, 56.0, 24.0, 24.0, 8.0),
(ToggleButtonSize::Lg, 96.0, 48.0, 32.0, 12.0),
];
for (size, height, h_padding, icon_size, icon_gap) in expected {
assert_eq!(
size.metrics(),
SizeMetrics {
height,
h_padding,
icon_size,
icon_gap
},
"{size:?}"
);
}
}
#[test]
fn radius_tables_match_the_reference_themed_and_unthemed() {
let theme = crate::baseline();
let expected: [(ToggleButtonSize, f64, f64, f64); 3] = [
(ToggleButtonSize::Sm, 12.0, 8.0, 10.0),
(ToggleButtonSize::Md, 16.0, 12.0, 14.0),
(ToggleButtonSize::Lg, 28.0, 16.0, 22.0),
];
for (size, square, pressed, hovered) in expected {
assert_eq!(size.square_radius(None), square, "{size:?} square unthemed");
assert_eq!(
size.square_radius(Some(&theme)),
square,
"{size:?} square themed"
);
assert_eq!(
size.pressed_radius(None),
pressed,
"{size:?} pressed unthemed"
);
assert_eq!(
size.pressed_radius(Some(&theme)),
pressed,
"{size:?} pressed themed"
);
assert_eq!(size.hovered_radius(), hovered, "{size:?} hovered");
}
}
#[test]
fn label_roles_match_the_shared_base_button_state_switch() {
let theme = crate::baseline();
let expected = [
(ToggleButtonSize::Sm, theme.type_scale.label_medium.clone()),
(ToggleButtonSize::Md, theme.type_scale.label_large.clone()),
(ToggleButtonSize::Lg, theme.type_scale.title_medium.clone()),
];
for (size, role) in expected {
let style = size.label_style(Some(&theme));
assert_eq!(style.size, role.size, "{size:?} size");
assert_eq!(style.weight, role.weight, "{size:?} weight");
}
}
#[test]
fn enabled_colors_match_the_reference_variant_x_checked_table() {
let theme = crate::baseline();
let s = theme.scheme();
let expected: [(ButtonVariant, Color, Color, Color, Color, bool); 5] = [
(
ButtonVariant::Filled,
s.on_surface_variant,
s.on_primary,
s.surface_container_highest,
s.primary,
false,
),
(
ButtonVariant::Outlined,
s.on_surface,
s.on_secondary_container,
Color::TRANSPARENT,
s.secondary_container,
true,
),
(
ButtonVariant::Tonal,
s.on_surface_variant,
s.on_secondary_container,
s.surface_container_highest,
s.secondary_container,
false,
),
(
ButtonVariant::Elevated,
s.primary,
s.on_primary,
s.surface_container_low,
s.primary,
false,
),
(
ButtonVariant::Text,
s.on_surface,
s.primary,
Color::TRANSPARENT,
Color::TRANSPARENT,
false,
),
];
for (variant, fg_u, fg_c, bg_u, bg_c, has_outline) in expected {
let unchecked = resolve_colors(Some(&theme), variant, false, true);
let checked = resolve_colors(Some(&theme), variant, true, true);
assert_eq!(unchecked.content, fg_u, "{variant:?} unchecked content");
assert_eq!(unchecked.container, bg_u, "{variant:?} unchecked container");
assert_eq!(checked.content, fg_c, "{variant:?} checked content");
assert_eq!(checked.container, bg_c, "{variant:?} checked container");
assert_eq!(
unchecked.outline.is_some(),
has_outline,
"{variant:?} unchecked outline presence"
);
assert_eq!(
checked.outline.is_some(),
has_outline,
"{variant:?} checked outline presence"
);
if has_outline {
assert_eq!(unchecked.outline, Some(s.outline));
assert_eq!(checked.outline, Some(s.outline));
}
}
}
#[test]
fn unthemed_colors_fall_back_to_the_baseline_light_roles() {
let unchecked = resolve_colors(None, ButtonVariant::Filled, false, true);
assert_eq!(unchecked.content, ON_SURFACE_VARIANT);
assert_eq!(unchecked.container, SURFACE_CONTAINER_HIGHEST);
let checked = resolve_colors(None, ButtonVariant::Filled, true, true);
assert_eq!(checked.content, ON_PRIMARY);
assert_eq!(checked.container, PRIMARY);
}
#[test]
fn every_variant_x_size_x_checked_combination_builds_lays_out_and_paints() {
let theme = crate::baseline();
for variant in ALL_VARIANTS {
for size in ALL_SIZES {
for checked in [false, true] {
let view = toggle_button::<u32, _>(checked, |_, _| {})
.variant(variant)
.size(size)
.label("Toggle");
let mut widget = build(&view);
let laid = layout_with(&mut widget, 200.0, Some(&theme));
assert!(
laid.width.is_finite() && laid.width >= 0.0,
"{variant:?} {size:?} {checked}"
);
assert_eq!(laid.height, size.metrics().height);
let target = widget.target_radii(Some(&theme), laid.height);
for r in [
target.top_left,
target.top_right,
target.bottom_left,
target.bottom_right,
] {
assert!(r.is_finite() && r >= 0.0, "{variant:?} {size:?} {checked}");
}
let _ = paint_at(&mut widget, laid, Some(&theme));
}
}
}
}
#[test]
fn disabled_colors_are_the_38_and_12_percent_on_surface_roles_checked_independent() {
let theme = crate::baseline();
let on_surface = theme.scheme().on_surface;
for variant in ALL_VARIANTS {
for checked in [false, true] {
let colors = resolve_colors(Some(&theme), variant, checked, false);
assert_eq!(
colors.content,
with_alpha(on_surface, DISABLED_CONTENT_OPACITY),
"{variant:?} checked={checked} content"
);
if is_transparent(variant) {
assert_eq!(colors.container, Color::TRANSPARENT, "{variant:?}");
} else {
assert_eq!(
colors.container,
with_alpha(on_surface, DISABLED_CONTAINER_OPACITY),
"{variant:?} checked={checked} container"
);
}
}
}
}
#[test]
fn elevation_is_not_checked_aware_and_matches_the_plain_buttons_table() {
for variant in ALL_VARIANTS {
for pressed in [false, true] {
for hovered in [false, true] {
let expected = match variant {
ButtonVariant::Elevated => {
if pressed {
0.0
} else if hovered {
3.0
} else {
1.0
}
}
ButtonVariant::Filled | ButtonVariant::Tonal => {
if !pressed && hovered {
1.0
} else {
0.0
}
}
ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
};
assert_eq!(elevation_dp(variant, true, pressed, hovered), expected);
}
}
}
}
#[test]
fn shape_spring_matches_expressive_spatial_press() {
let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
assert_eq!(SHAPE_SPRING.stiffness, token.stiffness);
assert_eq!(SHAPE_SPRING.damping_ratio, token.damping_ratio);
assert_eq!(SHAPE_SPRING.mass, 1.0);
assert_eq!(SHAPE_SPRING.stiffness, 380.0);
assert_eq!(SHAPE_SPRING.damping_ratio, 0.55);
}
#[test]
fn the_first_target_snaps_rather_than_springing() {
let mut motion = ShapeMotion::new();
let pad = ContentPadding::symmetric(16.0);
assert!(!motion.retarget(CornerRadii::uniform(20.0), pad));
assert_eq!(motion.radii(), CornerRadii::uniform(20.0));
assert!(!motion.advance(ft_secs(0.0)));
}
#[test]
fn a_target_inside_the_dead_band_is_ignored() {
let mut motion = ShapeMotion::new();
let pad = ContentPadding::symmetric(16.0);
motion.retarget(CornerRadii::uniform(20.0), pad);
assert!(!motion.retarget(CornerRadii::uniform(20.0 + RETARGET_TOLERANCE / 2.0), pad));
assert_eq!(motion.target_radii(), CornerRadii::uniform(20.0));
assert!(motion.retarget(CornerRadii::uniform(20.0 + RETARGET_TOLERANCE * 2.0), pad));
}
#[test]
fn checked_toggle_springs_from_round_rest_to_the_square_radius_and_settles_exactly() {
let mut motion = ShapeMotion::new();
let pad = ContentPadding::symmetric(16.0);
motion.retarget(CornerRadii::uniform(20.0), pad); assert!(motion.retarget(CornerRadii::uniform(12.0), pad)); assert_eq!(
motion.radii(),
CornerRadii::uniform(20.0),
"starts where it was"
);
settle_at(&mut motion);
assert_eq!(motion.radii(), CornerRadii::uniform(12.0));
}
fn settle_at(motion: &mut ShapeMotion) {
let mut t = 0.0;
for _ in 0..600 {
t += 1.0 / 60.0;
if !motion.advance(ft_secs(t)) {
return;
}
}
panic!("never settled");
}
#[test]
fn rapid_toggle_retarget_mid_flight_is_continuous() {
let mut motion = ShapeMotion::new();
let pad = ContentPadding::symmetric(16.0);
motion.retarget(CornerRadii::uniform(20.0), pad);
motion.retarget(CornerRadii::uniform(12.0), pad);
motion.advance(ft_secs(0.0));
motion.advance(ft_secs(0.05));
let mid = motion.radii();
assert!(
mid.top_left < 20.0 && mid.top_left > 12.0,
"sampled mid-flight (got {mid:?})"
);
assert!(motion.retarget(CornerRadii::uniform(20.0), pad));
assert_eq!(motion.radii(), mid);
settle_at(&mut motion);
assert_eq!(motion.radii(), CornerRadii::uniform(20.0));
}
#[test]
fn overshoot_never_drives_a_corner_negative() {
let mut motion = ShapeMotion::new();
let pad = ContentPadding::symmetric(16.0);
motion.retarget(CornerRadii::uniform(28.0), pad);
motion.retarget(CornerRadii::uniform(0.0), pad);
let mut t = 0.0;
for _ in 0..600 {
t += 1.0 / 60.0;
let animating = motion.advance(ft_secs(t));
let r = motion.radii();
assert!(
r.top_left >= 0.0
&& r.top_right >= 0.0
&& r.bottom_left >= 0.0
&& r.bottom_right >= 0.0,
"a corner went negative at t={t}"
);
if !animating {
break;
}
}
assert_eq!(motion.radii(), CornerRadii::uniform(0.0));
}
#[test]
fn widget_paint_retargets_the_morph_on_a_checked_flip() {
let theme = crate::baseline();
let mut widget = build(&toggle_button::<u32, _>(false, |_, _| {}));
layout_with(&mut widget, 200.0, Some(&theme));
paint_at(&mut widget, Size::new(40.0, 40.0), Some(&theme));
settle_morph(&mut widget);
let unchecked_radius = widget.motion.radii().top_left;
assert_eq!(unchecked_radius, 20.0, "sm pill = height/2");
widget.checked = true;
layout_with(&mut widget, 200.0, Some(&theme));
paint_at(&mut widget, Size::new(40.0, 40.0), Some(&theme));
assert_eq!(
widget.motion.radii().top_left,
unchecked_radius,
"the leg starts where it was (continuity)"
);
settle_morph(&mut widget);
assert_eq!(widget.motion.radii().top_left, 12.0, "sm square token");
}
#[test]
fn a_runtime_size_change_relayouts_at_the_new_sizes_padding_immediately() {
let mut widget = build(
&toggle_button::<(), _>(false, |_, _| {})
.icon(leaf_any(20.0, 20.0))
.size(ToggleButtonSize::Sm),
);
let sm = layout_with(&mut widget, 500.0, None);
let sm_metrics = ToggleButtonSize::Sm.metrics();
assert_eq!(
sm.width,
sm_metrics.icon_size + sm_metrics.h_padding, "sm icon-only width"
);
widget.size = ToggleButtonSize::Lg;
let lg = layout_with(&mut widget, 500.0, None);
let lg_metrics = ToggleButtonSize::Lg.metrics();
assert_eq!(
lg.width,
lg_metrics.icon_size + lg_metrics.h_padding,
"layout must resolve the NEW size's padding on its very next pass, \
not the stale one only a later paint would have retargeted"
);
}
#[test]
fn standalone_single_member_shape_is_uniform() {
let theme = crate::baseline();
let mut widget = build(&toggle_button::<u32, _>(false, |_, _| {}));
layout_with(&mut widget, 200.0, Some(&theme));
let target = widget.target_radii(Some(&theme), 40.0);
assert_eq!(target, CornerRadii::uniform(20.0));
}
#[test]
fn connected_first_member_is_outer_left_inner_right() {
let theme = crate::baseline();
let mut widget = build(
&toggle_button::<u32, _>(false, |_, _| {})
.group_connected(true)
.first_in_group(true)
.last_in_group(false),
);
layout_with(&mut widget, 200.0, Some(&theme));
let target = widget.target_radii(Some(&theme), 40.0);
assert_eq!(
(target.top_left, target.bottom_left),
(20.0, 20.0),
"outer left"
);
assert_eq!(
(target.top_right, target.bottom_right),
(CONNECTED_INNER_RADIUS, CONNECTED_INNER_RADIUS),
"inner right"
);
}
#[test]
fn connected_last_member_is_inner_left_outer_right() {
let theme = crate::baseline();
let mut widget = build(
&toggle_button::<u32, _>(false, |_, _| {})
.group_connected(true)
.first_in_group(false)
.last_in_group(true),
);
layout_with(&mut widget, 200.0, Some(&theme));
let target = widget.target_radii(Some(&theme), 40.0);
assert_eq!(
(target.top_left, target.bottom_left),
(CONNECTED_INNER_RADIUS, CONNECTED_INNER_RADIUS),
"inner left"
);
assert_eq!(
(target.top_right, target.bottom_right),
(20.0, 20.0),
"outer right"
);
}
#[test]
fn connected_middle_member_is_all_inner_corners() {
let theme = crate::baseline();
let mut widget = build(
&toggle_button::<u32, _>(false, |_, _| {})
.group_connected(true)
.first_in_group(false)
.last_in_group(false),
);
layout_with(&mut widget, 200.0, Some(&theme));
let target = widget.target_radii(Some(&theme), 40.0);
assert_eq!(
target,
CornerRadii::uniform(CONNECTED_INNER_RADIUS),
"a middle member is all inner corners"
);
}
#[test]
fn connected_pressed_member_uses_the_pressed_inner_radius() {
let theme = crate::baseline();
let mut widget = build(
&toggle_button::<u32, _>(false, |_, _| {})
.group_connected(true)
.first_in_group(false)
.last_in_group(false),
);
layout_with(&mut widget, 200.0, Some(&theme));
widget.state.set_pressed(true);
let target = widget.target_radii(Some(&theme), 40.0);
assert_eq!(target, CornerRadii::uniform(CONNECTED_PRESSED_INNER_RADIUS));
}
#[test]
fn connected_checked_idle_member_is_fully_round_on_every_corner() {
let theme = crate::baseline();
let mut widget = build(
&toggle_button::<u32, _>(true, |_, _| {})
.group_connected(true)
.first_in_group(false)
.last_in_group(false),
);
layout_with(&mut widget, 200.0, Some(&theme));
let target = widget.target_radii(Some(&theme), 40.0);
assert_eq!(
target,
CornerRadii::uniform(20.0),
"checked+connected+idle overrides the inner-corner asymmetry"
);
}
#[test]
fn label_falls_back_to_the_unchecked_one_when_checked_label_is_unset() {
let view = toggle_button::<u32, _>(true, |_, _| {}).label("Off");
assert_eq!(view.effective_label_text(), Some("Off"));
}
#[test]
fn checked_label_wins_over_the_unchecked_one_when_both_are_set() {
let view = toggle_button::<u32, _>(true, |_, _| {})
.label("Off")
.checked_label("On");
assert_eq!(view.effective_label_text(), Some("On"));
let unchecked = toggle_button::<u32, _>(false, |_, _| {})
.label("Off")
.checked_label("On");
assert_eq!(unchecked.effective_label_text(), Some("Off"));
}
#[test]
fn build_resolves_the_effective_label_for_the_initial_checked_value() {
let checked_view = toggle_button::<u32, _>(true, |_, _| {})
.label("Off")
.checked_label("On");
let widget = build(&checked_view);
assert_eq!(widget.label.content(), "On");
let unchecked_view = toggle_button::<u32, _>(false, |_, _| {})
.label("Off")
.checked_label("On");
let widget = build(&unchecked_view);
assert_eq!(widget.label.content(), "Off");
}
#[test]
fn rebuild_swaps_the_label_content_on_a_checked_flip() {
let prev = toggle_button::<u32, _>(false, |_, _| {})
.label("Off")
.checked_label("On");
let next = toggle_button::<u32, _>(true, |_, _| {})
.label("Off")
.checked_label("On");
let mut widget = build(&prev);
assert_eq!(widget.label.content(), "Off");
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert_eq!(widget.label.content(), "On");
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn icon_falls_back_and_swaps_the_same_way_as_the_label() {
let unchecked = toggle_button::<(), _>(false, |_, _| {}).icon(leaf_any(20.0, 20.0));
assert!(unchecked.effective_icon_view().is_some());
let checked_no_override =
toggle_button::<(), _>(true, |_, _| {}).icon(leaf_any(20.0, 20.0));
assert!(
checked_no_override.effective_icon_view().is_some(),
"falls back to `icon` while checked"
);
let checked_with_override = toggle_button::<(), _>(true, |_, _| {})
.icon(leaf_any(20.0, 20.0))
.checked_icon(leaf_any(24.0, 24.0));
let widget = build(&checked_with_override);
assert!(widget.icon.is_some());
}
#[test]
fn an_icon_only_toggle_button_halves_its_horizontal_padding() {
let mut icon_only =
build(&toggle_button::<(), _>(false, |_, _| {}).icon(leaf_any(20.0, 20.0)));
layout_with(&mut icon_only, 300.0, None);
assert!(!icon_only.has_label());
assert_eq!(icon_only.h_padding(&ToggleButtonSize::Sm.metrics()), 8.0);
let mut labeled = build(&toggle_button::<(), _>(false, |_, _| {}).label("Bold"));
layout_with(&mut labeled, 300.0, None);
assert!(labeled.has_label());
assert_eq!(labeled.h_padding(&ToggleButtonSize::Sm.metrics()), 16.0);
}
#[test]
fn a_box_narrower_than_the_content_clips_it_instead_of_ellipsizing() {
let mut widget = build(&toggle_button::<(), _>(false, |_, _| {}).label("Days per week"));
let natural = layout_with(&mut widget, f64::INFINITY, None);
let full = paint_at(&mut widget, natural, None).run_glyphs;
assert_eq!(full.len(), 1, "one label run");
assert!(full[0] > 1, "the label shaped some glyphs");
assert!(!widget.content_clipped, "nothing to clip at natural width");
let padding = ToggleButtonSize::Sm.metrics().h_padding;
let shallow = layout_tight_main(&mut widget, natural.width - padding);
let rec = paint_at(&mut widget, shallow, None);
assert_eq!(rec.run_glyphs, full, "the label is untouched");
assert!(
rec.clips.is_empty(),
"and still fits, so nothing is clipped"
);
let squeezed = layout_tight_main(&mut widget, natural.width - padding * 2.0 - 4.0);
let rec = paint_at(&mut widget, squeezed, None);
assert_eq!(
rec.run_glyphs, full,
"the shrinking box covers the label; it is never re-shaped to fit"
);
assert_eq!(
rec.clips,
vec![(Point::ZERO, squeezed)],
"the overflowing content row is clipped to the box (`Clip.hardEdge`)"
);
assert!(
widget.label_origin.x < 0.0,
"the centered row hangs off the leading edge too: {}",
widget.label_origin.x
);
}
#[test]
fn a_squeezed_then_restored_label_keeps_its_full_shape() {
let mut widget = build(&toggle_button::<(), _>(false, |_, _| {}).label("Days per week"));
let natural = layout_with(&mut widget, f64::INFINITY, None);
let full = paint_at(&mut widget, natural, None).run_glyphs;
layout_tight_main(&mut widget, natural.width - 12.0);
for main in [natural.width - 0.0002, natural.width] {
let size = layout_tight_main(&mut widget, main);
assert_eq!(
paint_at(&mut widget, size, None).run_glyphs,
full,
"the label is whole again at main {main}"
);
}
assert!(!widget.content_clipped, "and needs no clip once it fits");
}
#[test]
fn defaults_match_the_reference_constructor_defaults() {
let view = toggle_button::<u32, _>(false, |_, _| {});
assert_eq!(view.variant, ButtonVariant::Filled);
assert_eq!(view.size, ToggleButtonSize::Sm);
assert!(view.enabled);
assert_eq!(view.haptic, HapticSignal::None);
assert!(!view.is_group_connected);
assert!(view.is_first_in_group);
assert!(view.is_last_in_group);
}
#[test]
fn the_named_constructors_map_onto_the_reference_styles() {
assert_eq!(
filled_toggle_button::<u32, _>(false, |_, _| {}).variant,
ButtonVariant::Filled
);
assert_eq!(
tonal_toggle_button::<u32, _>(false, |_, _| {}).variant,
ButtonVariant::Tonal
);
assert_eq!(
elevated_toggle_button::<u32, _>(false, |_, _| {}).variant,
ButtonVariant::Elevated
);
assert_eq!(
outlined_toggle_button::<u32, _>(false, |_, _| {}).variant,
ButtonVariant::Outlined
);
assert_eq!(
text_toggle_button::<u32, _>(false, |_, _| {}).variant,
ButtonVariant::Text
);
}
#[test]
fn a_tap_reports_the_requested_value_and_never_self_mutates() {
let mut widget = build(&toggle_button::<bool, _>(false, |s, v| *s = v));
layout_with(&mut widget, 200.0, None);
let size = Size::new(60.0, 40.0);
let mut state = false;
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
assert!(widget.captured);
assert!(!widget.checked, "the widget itself never flips `checked`");
widget.event(&mut ctx, &ev(PointerPhase::Up, 30.0, 20.0));
assert!(state, "on_checked_change fired with the requested value");
assert!(!widget.checked, "still not self-mutated");
}
#[test]
fn disabling_mid_press_clears_the_press_capture_and_long_press_candidacy() {
let prev = toggle_button::<u32, _>(false, |_, _| {});
let next = toggle_button::<u32, _>(false, |_, _| {}).enabled(false);
let mut widget = build(&prev);
widget.state.set_pressed(true);
widget.captured = true;
widget.long_press.elapsed = true;
let mut counter = 0u64;
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(!widget.state.pressed);
assert!(!widget.captured);
assert!(!widget.long_press.elapsed);
}
#[test]
fn a_long_press_fires_on_move_arrival_after_the_threshold_and_suppresses_the_tap() {
let view =
toggle_button::<(u32, u32), _>(false, |s, _| s.0 += 1).on_long_press(|s| s.1 += 1);
let mut widget = build(&view);
layout_with(&mut widget, 200.0, None);
let size = Size::new(60.0, 40.0);
let mut state = (0u32, 0u32);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 30.0, 20.0),
);
assert!(widget.captured);
widget.long_press.elapsed = true;
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Move, 31.0, 20.0),
);
assert_eq!(state.1, 1, "on_long_press fired");
assert_eq!(state.0, 0, "on_checked_change must not also fire");
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, 31.0, 20.0),
);
assert_eq!(
state.0, 0,
"the up-inside toggle is suppressed after a long-press"
);
}
#[test]
fn a_long_press_still_fires_at_up_if_no_move_arrives_first() {
let view =
toggle_button::<(u32, u32), _>(false, |s, _| s.0 += 1).on_long_press(|s| s.1 += 1);
let mut widget = build(&view);
layout_with(&mut widget, 200.0, None);
let size = Size::new(60.0, 40.0);
let mut state = (0u32, 0u32);
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
widget.long_press.elapsed = true;
widget.event(&mut ctx, &ev(PointerPhase::Up, 30.0, 20.0));
assert_eq!(state.1, 1, "the Up fallback fires the long-press");
assert_eq!(state.0, 0, "the checked toggle is suppressed");
}
#[test]
fn movement_past_touch_slop_cancels_long_press_candidacy() {
let view = toggle_button::<u32, _>(false, |s, _| *s += 1).on_long_press(|s| *s += 100);
let mut widget = build(&view);
layout_with(&mut widget, 200.0, None);
let size = Size::new(200.0, 40.0);
let mut state = 0u32;
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
widget.long_press.elapsed = true; widget.event(
&mut ctx,
&ev(PointerPhase::Move, 30.0 + TOUCH_SLOP + 5.0, 20.0),
);
assert!(!widget.long_press.elapsed, "a drag cancels the candidacy");
}
#[test]
fn cancel_clears_long_press_state_without_touching_app_state() {
let mut widget = build(&toggle_button::<u32, _>(false, |s, _| *s = 1));
layout_with(&mut widget, 200.0, None);
let size = Size::new(60.0, 40.0);
let mut state = 0u32;
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
widget.long_press.elapsed = true;
widget.event(&mut ctx, &ev(PointerPhase::Cancel, 30.0, 20.0));
assert!(!widget.long_press.elapsed);
assert!(!widget.captured);
assert_eq!(state, 0);
}
fn semantics_button_node(
mut view_fn: impl FnMut(&mut ()) -> ToggleButtonView<()> + 'static,
) -> frust_core::accesskit::Node {
let mut root: frust_core::RenderRoot<(), ToggleButtonView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut view_fn, &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();
update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Button)
.expect("a Button node is contributed")
.1
.clone()
}
#[test]
fn semantics_reports_toggled_state_and_label() {
let node = semantics_button_node(|_| toggle_button::<(), _>(true, |_, _| {}).label("Bold"));
assert_eq!(node.label(), Some("Bold"));
assert_eq!(node.toggled(), Some(Toggled::True));
}
#[test]
fn disabled_semantics_report_no_click_action() {
let node =
semantics_button_node(|_| toggle_button::<(), _>(false, |_, _| {}).enabled(false));
assert!(node.is_disabled());
}
}