use core::fmt::{self, Debug};
use waterui::accessibility::{AccessibilityRole, AccessibilityState};
use waterui::color::Color;
use waterui::gesture::{DragEvent, DragGesture, GesturePhase};
use waterui::layout::padding::EdgeInsets;
use waterui::layout::{
Layout, LayoutInvalidationCallback, Point, ProposalSize, Rect, Size, SubView, SubviewPlacement,
container::FixedContainer,
};
use waterui::prelude::dynamic::watch;
use waterui::reactive::watcher::BoxWatcherGuard;
use waterui::reactive::{Signal as _, SignalExt as _, binding, zip};
use waterui::shape::{ShapeExt as _, UnevenRoundedRectangle};
use waterui::{AnyView, Binding, Environment, View, ViewExt as _};
use waterui_controls::button::Button;
use waterui_controls::label::{IntoLabel, Label};
use waterui_core::handler::{BoxedAction, Handler, boxed_action};
use crate::color::{OnSecondaryContainer, OnSurface, SecondaryContainer, SurfaceContainer};
use crate::semantics::{conditional_color, interaction_style};
const CONTAINER_HEIGHT: f32 = 40.0;
const BETWEEN_SPACE: f32 = 2.0;
const INNER_CORNER_RADIUS: f32 = 8.0;
const PRESSED_INNER_CORNER_RADIUS: f32 = 4.0;
const OUTER_CORNER_RADIUS: f32 = CONTAINER_HEIGHT / 2.0;
const SEGMENT_HORIZONTAL_SPACE: f32 = 16.0;
const fn normalized(radius: f32) -> f32 {
radius / CONTAINER_HEIGHT
}
const fn inner_radius(selected: bool, pressed: bool) -> f32 {
if selected {
OUTER_CORNER_RADIUS
} else if pressed {
PRESSED_INNER_CORNER_RADIUS
} else {
INNER_CORNER_RADIUS
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SegmentPosition {
Only,
Leading,
Middle,
Trailing,
}
impl SegmentPosition {
const fn of(index: usize, count: usize) -> Self {
match (index, count) {
(_, 0 | 1) => Self::Only,
(0, _) => Self::Leading,
(index, count) if index + 1 == count => Self::Trailing,
_ => Self::Middle,
}
}
const fn radii(self, inner: f32) -> (f32, f32) {
match self {
Self::Only => (OUTER_CORNER_RADIUS, OUTER_CORNER_RADIUS),
Self::Leading => (OUTER_CORNER_RADIUS, inner),
Self::Middle => (inner, inner),
Self::Trailing => (inner, OUTER_CORNER_RADIUS),
}
}
}
pub struct ConnectedButton {
label: Label,
selected: Binding<bool>,
action: BoxedAction<()>,
}
impl Debug for ConnectedButton {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ConnectedButton")
.field("label", &self.label)
.finish_non_exhaustive()
}
}
impl ConnectedButton {
#[must_use]
pub fn new<F, Args>(label: impl IntoLabel, selected: &Binding<bool>, action: F) -> Self
where
F: Handler<Args, ()> + 'static,
{
Self {
label: label.into_label(),
selected: selected.clone(),
action: Box::new(boxed_action(action)),
}
}
}
#[must_use]
pub fn connected_button<F, Args>(
label: impl IntoLabel,
selected: &Binding<bool>,
action: F,
) -> ConnectedButton
where
F: Handler<Args, ()> + 'static,
{
ConnectedButton::new(label, selected, action)
}
#[derive(Debug)]
pub struct ConnectedButtonGroup {
segments: Vec<ConnectedButton>,
}
impl ConnectedButtonGroup {
#[must_use]
pub const fn new() -> Self {
Self {
segments: Vec::new(),
}
}
#[must_use]
pub fn segment(mut self, segment: ConnectedButton) -> Self {
self.segments.push(segment);
self
}
#[must_use]
pub fn segments(mut self, segments: impl IntoIterator<Item = ConnectedButton>) -> Self {
self.segments.extend(segments);
self
}
}
impl Default for ConnectedButtonGroup {
fn default() -> Self {
Self::new()
}
}
impl View for ConnectedButtonGroup {
fn body(self, _env: &Environment) -> impl View {
let count = self.segments.len();
let segments = self
.segments
.into_iter()
.enumerate()
.map(|(index, segment)| {
let position = SegmentPosition::of(index, count);
let mut action = segment.action;
let selected = segment.selected;
let pressed = binding(false);
let pressed_for_gesture = pressed.clone();
let shape = zip::zip(selected.clone(), pressed).map(move |(selected, pressed)| {
let (leading, trailing) = position.radii(inner_radius(selected, pressed));
UnevenRoundedRectangle::new(
normalized(leading),
normalized(trailing),
normalized(leading),
normalized(trailing),
)
});
let container =
conditional_color(selected.clone(), SecondaryContainer, SurfaceContainer);
let content = conditional_color(selected.clone(), OnSecondaryContainer, OnSurface);
let accessibility_state =
selected.map(|selected| AccessibilityState::new().selected(selected));
segment
.label
.foreground(content.clone())
.padding_with(EdgeInsets::new(
0.0,
0.0,
SEGMENT_HORIZONTAL_SPACE,
SEGMENT_HORIZONTAL_SPACE,
))
.height(CONTAINER_HEIGHT)
.background(ReactiveSegmentShape {
shape,
color: container.into(),
})
.gesture(DragGesture::new(0.0), move |env: Environment| {
let phase = env
.get::<DragEvent>()
.expect("connected button gesture is missing its DragEvent")
.phase;
pressed_for_gesture.set(matches!(
phase,
GesturePhase::Started | GesturePhase::Updated
));
})
.on_tap(move |env: Environment| action(&env))
.a11y_role(AccessibilityRole::Button)
.a11y_state_signal(accessibility_state)
.install(interaction_style(content, f64::from(OUTER_CORNER_RADIUS)))
})
.collect::<Vec<_>>();
waterui::component::HStack::new(
waterui::layout::stack::VerticalAlignment::Center,
BETWEEN_SPACE,
segments,
)
}
}
#[derive(Debug)]
struct ReactiveSegmentShape<S> {
shape: S,
color: Color,
}
impl<S> View for ReactiveSegmentShape<S>
where
S: waterui::Signal<Output = UnevenRoundedRectangle> + 'static,
{
fn body(self, _env: &Environment) -> impl View {
let color = self.color;
watch(self.shape, move |shape| shape.fill(color.clone()))
}
}
#[must_use]
pub const fn connected_button_group() -> ConnectedButtonGroup {
ConnectedButtonGroup::new()
}
const GROUP_BETWEEN_SPACE: f32 = 12.0;
const GROUP_EXPANDED_RATIO: f32 = 0.15;
pub struct GroupButton {
label: Label,
action: BoxedAction<()>,
compression_limit: f32,
}
impl Debug for GroupButton {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GroupButton")
.field("label", &self.label)
.field("compression_limit", &self.compression_limit)
.finish_non_exhaustive()
}
}
impl GroupButton {
#[must_use]
pub fn new<F, Args>(label: impl IntoLabel, action: F) -> Self
where
F: Handler<Args, ()> + 'static,
{
Self {
label: label.into_label(),
action: Box::new(boxed_action(action)),
compression_limit: SEGMENT_HORIZONTAL_SPACE,
}
}
#[must_use]
pub const fn compression_limit(mut self, limit: f32) -> Self {
self.compression_limit = limit;
self
}
}
#[must_use]
pub fn group_button<F, Args>(label: impl IntoLabel, action: F) -> GroupButton
where
F: Handler<Args, ()> + 'static,
{
GroupButton::new(label, action)
}
#[derive(Debug)]
pub struct ButtonGroup {
buttons: Vec<GroupButton>,
}
impl ButtonGroup {
#[must_use]
pub const fn new() -> Self {
Self {
buttons: Vec::new(),
}
}
#[must_use]
pub fn button(mut self, button: GroupButton) -> Self {
self.buttons.push(button);
self
}
#[must_use]
pub fn buttons(mut self, buttons: impl IntoIterator<Item = GroupButton>) -> Self {
self.buttons.extend(buttons);
self
}
}
impl Default for ButtonGroup {
fn default() -> Self {
Self::new()
}
}
impl View for ButtonGroup {
fn body(self, _env: &Environment) -> impl View {
let pressed: Vec<Binding<bool>> = self.buttons.iter().map(|_| binding(false)).collect();
let limits: Vec<f32> = self
.buttons
.iter()
.map(|button| button.compression_limit)
.collect();
let layout = ButtonGroupLayout {
pressed: pressed.clone(),
limits,
};
let children = self
.buttons
.into_iter()
.zip(pressed)
.map(|(button, pressed)| {
let mut action = button.action;
AnyView::new(
Button::new(button.label)
.gesture(DragGesture::new(0.0), move |env: Environment| {
let phase = env
.get::<DragEvent>()
.expect("group button gesture is missing its DragEvent")
.phase;
pressed.set(matches!(
phase,
GesturePhase::Started | GesturePhase::Updated
));
})
.on_tap(move |env: Environment| action(&env)),
)
})
.collect::<Vec<_>>();
FixedContainer::from_parts(Box::new(layout), children).a11y_role(AccessibilityRole::Group)
}
}
#[must_use]
pub const fn button_group() -> ButtonGroup {
ButtonGroup::new()
}
struct ButtonGroupLayout {
pressed: Vec<Binding<bool>>,
limits: Vec<f32>,
}
impl Debug for ButtonGroupLayout {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ButtonGroupLayout")
.field("buttons", &self.pressed.len())
.finish_non_exhaustive()
}
}
impl ButtonGroupLayout {
fn resting_widths(children: &[&dyn SubView]) -> Vec<f32> {
children
.iter()
.map(|child| child.measure(ProposalSize::UNSPECIFIED).size.width)
.collect()
}
fn expand(&self, widths: &mut [f32]) {
let count = widths.len();
for index in 0..count {
if !self.pressed[index].get() {
continue;
}
let reach = GROUP_EXPANDED_RATIO * widths[index] / 2.0;
let mut growth = 0.0;
for neighbour in [
index.checked_sub(1),
(index + 1 < count).then_some(index + 1),
]
.into_iter()
.flatten()
{
let limit = self.limits[neighbour];
let taken = reach.min(limit).min(widths[neighbour]).max(0.0);
widths[neighbour] -= taken;
growth += taken;
}
widths[index] += growth;
}
}
fn row_height(children: &[&dyn SubView]) -> f32 {
children
.iter()
.map(|child| child.measure(ProposalSize::UNSPECIFIED).size.height)
.fold(CONTAINER_HEIGHT, f32::max)
}
}
impl Layout for ButtonGroupLayout {
fn size_that_fits(&self, _proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
let widths = Self::resting_widths(children);
let gap_count = u16::try_from(children.len().saturating_sub(1))
.expect("a button group holds a sane number of buttons");
let gaps = GROUP_BETWEEN_SPACE * f32::from(gap_count);
Size::new(
widths.iter().sum::<f32>() + gaps,
Self::row_height(children),
)
}
fn place(
&self,
bounds: Rect,
_proposal: ProposalSize,
children: &[&dyn SubView],
) -> Vec<SubviewPlacement> {
let mut widths = Self::resting_widths(children);
self.expand(&mut widths);
let height = Self::row_height(children).min(bounds.height());
let top = (bounds.height() - height).mul_add(0.5, bounds.y());
let mut x = bounds.x();
widths
.into_iter()
.map(|width| {
let frame = Rect::new(Point::new(x, top), Size::new(width, height));
x += width + GROUP_BETWEEN_SPACE;
SubviewPlacement::new(frame, ProposalSize::new(Some(width), Some(height)))
})
.collect()
}
fn watch_invalidation(&self, invalidate: LayoutInvalidationCallback) -> Vec<BoxWatcherGuard> {
self.pressed
.iter()
.map(|pressed| {
let invalidate = invalidate.clone();
pressed.watch(move |_| invalidate())
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::{
BETWEEN_SPACE, ButtonGroupLayout, CONTAINER_HEIGHT, GROUP_BETWEEN_SPACE,
GROUP_EXPANDED_RATIO, INNER_CORNER_RADIUS, OUTER_CORNER_RADIUS,
PRESSED_INNER_CORNER_RADIUS, SegmentPosition, inner_radius, normalized,
};
use waterui::reactive::binding;
fn layout(limits: &[f32], pressed: Option<usize>) -> ButtonGroupLayout {
ButtonGroupLayout {
pressed: limits
.iter()
.enumerate()
.map(|(index, _)| binding(Some(index) == pressed))
.collect(),
limits: limits.to_vec(),
}
}
#[test]
fn button_group_tokens_match_compose_button_group_defaults() {
assert_eq!(GROUP_BETWEEN_SPACE, 12.0);
assert_eq!(GROUP_EXPANDED_RATIO, 0.15);
}
#[test]
fn a_pressed_middle_button_grows_by_what_both_neighbours_give_up() {
let mut widths = [100.0, 100.0, 100.0];
let before: f32 = widths.iter().sum();
layout(&[16.0, 16.0, 16.0], Some(1)).expand(&mut widths);
assert_eq!(widths[0], 92.5);
assert_eq!(widths[1], 115.0);
assert_eq!(widths[2], 92.5);
assert_eq!(widths.iter().sum::<f32>(), before);
}
#[test]
fn a_pressed_end_button_only_leans_on_the_neighbour_it_has() {
let mut widths = [100.0, 100.0, 100.0];
layout(&[16.0, 16.0, 16.0], Some(0)).expand(&mut widths);
assert_eq!(widths[0], 107.5);
assert_eq!(widths[1], 92.5);
assert_eq!(widths[2], 100.0);
}
#[test]
fn a_neighbours_compression_limit_caps_what_it_gives_up() {
let mut widths = [100.0, 400.0, 100.0];
layout(&[4.0, 4.0, 4.0], Some(1)).expand(&mut widths);
assert_eq!(widths[0], 96.0);
assert_eq!(widths[1], 408.0);
assert_eq!(widths[2], 96.0);
}
#[test]
fn an_untouched_group_leaves_every_width_alone() {
let mut widths = [80.0, 120.0];
layout(&[16.0, 16.0], None).expand(&mut widths);
assert_eq!(widths, [80.0, 120.0]);
}
#[test]
fn connected_group_tokens_match_compose_button_group_tokens() {
assert_eq!(CONTAINER_HEIGHT, 40.0);
assert_eq!(BETWEEN_SPACE, 2.0);
assert_eq!(INNER_CORNER_RADIUS, 8.0);
assert_eq!(PRESSED_INNER_CORNER_RADIUS, 4.0);
assert_eq!(OUTER_CORNER_RADIUS, 20.0);
}
#[test]
fn only_the_groups_outer_edges_are_fully_round() {
let inner = INNER_CORNER_RADIUS;
assert_eq!(
SegmentPosition::of(0, 1).radii(inner),
(OUTER_CORNER_RADIUS, OUTER_CORNER_RADIUS)
);
assert_eq!(
SegmentPosition::of(0, 3).radii(inner),
(OUTER_CORNER_RADIUS, inner)
);
assert_eq!(SegmentPosition::of(1, 3).radii(inner), (inner, inner));
assert_eq!(
SegmentPosition::of(2, 3).radii(inner),
(inner, OUTER_CORNER_RADIUS)
);
}
#[test]
fn selection_outranks_a_press_on_the_inner_corners() {
assert_eq!(inner_radius(false, false), INNER_CORNER_RADIUS);
assert_eq!(inner_radius(false, true), PRESSED_INNER_CORNER_RADIUS);
assert_eq!(inner_radius(true, false), OUTER_CORNER_RADIUS);
assert_eq!(inner_radius(true, true), OUTER_CORNER_RADIUS);
}
#[test]
fn every_corner_radius_normalizes_within_range() {
for radius in [
INNER_CORNER_RADIUS,
PRESSED_INNER_CORNER_RADIUS,
OUTER_CORNER_RADIUS,
] {
let normalized = normalized(radius);
assert!(
normalized > 0.0 && normalized <= 0.5,
"{radius} -> {normalized}"
);
}
}
#[test]
fn layout_contract_button_press_updates_child_offers() {
use crate::layout_test_support::FixedLeaf;
use waterui::layout::{Layout, ProposalSize, Rect, Size};
let child = FixedLeaf(Size::new(100.0, 40.0));
let layout = layout(&[20.0, 20.0], None);
let bounds = Rect::from_size(Size::new(212.0, 40.0));
for pressed in [false, true, false] {
layout.pressed[0].set(pressed);
let placements = layout.place(bounds, ProposalSize::UNSPECIFIED, &[&child, &child]);
let widths = if pressed {
[107.5, 92.5]
} else {
[100.0, 100.0]
};
for (placement, width) in placements.iter().zip(widths) {
assert_eq!(
placement.proposal,
ProposalSize::new(Some(width), Some(40.0))
);
assert_eq!(placement.frame.size(), &Size::new(width, 40.0));
}
}
}
}