use std::collections::BTreeSet;
use std::rc::Rc;
use std::time::Duration;
use frust::authoring::{Action, Role};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, ErasedArgCallback,
EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
View, Widget, any,
};
use frust::{AnimationController, FrameTime, IconData, SpringDesc, Theme, Tween};
use kurbo::{Affine, BezPath, Point, Size, Vec2};
use peniko::{Brush, Color};
use crate::card_list::{card_position, card_radii};
use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
use crate::press::presses;
pub const EXPANDABLE_OUTER_RADIUS: f64 = 24.0;
pub const EXPANDABLE_INNER_RADIUS: f64 = 4.0;
pub const EXPANDABLE_HOVER_RADIUS: f64 = 10.0;
pub const EXPANDABLE_PRESSED_RADIUS: f64 = 4.0;
pub const EXPANDABLE_GAP: f64 = 4.0;
pub const EXPANDABLE_ICON_ROTATION: f64 = std::f64::consts::PI;
const HEADER_PAD_X: f64 = 16.0;
const HEADER_PAD_TOP: f64 = 14.0;
const HEADER_PAD_BOTTOM: f64 = 2.0;
const BODY_PAD_X: f64 = 16.0;
const BODY_PAD_TOP: f64 = 0.0;
const BODY_PAD_BOTTOM: f64 = 20.0;
const CHEVRON_SIZE: f64 = 24.0;
const CHEVRON_PAD: f64 = 8.0;
const CHEVRON_SLOT: f64 = CHEVRON_SIZE + CHEVRON_PAD * 2.0;
const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
pub const REVEAL_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 380.0,
damping_ratio: 0.8,
};
const REVEAL_PERIOD: Duration = Duration::from_millis(300);
const REVEAL_LAUNCH_VELOCITY: f64 = 1e-3;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ExpandMode {
#[default]
SingleOpen,
Independent,
}
pub fn is_expanded(expanded: &BTreeSet<usize>, index: usize) -> bool {
expanded.contains(&index)
}
pub fn toggle_expanded(
expanded: &BTreeSet<usize>,
index: usize,
mode: ExpandMode,
) -> BTreeSet<usize> {
if expanded.contains(&index) {
let mut next = expanded.clone();
next.remove(&index);
return next;
}
let mut next = match mode {
ExpandMode::SingleOpen => BTreeSet::new(),
ExpandMode::Independent => expanded.clone(),
};
next.insert(index);
next
}
struct Reveal {
value: f64,
target: f64,
tween: Tween<f64>,
driver: AnimationController,
}
impl Reveal {
fn new(initial: f64) -> Self {
Self {
value: initial,
target: initial,
tween: Tween::new(initial, initial),
driver: AnimationController::new(REVEAL_PERIOD),
}
}
fn set_target(&mut self, target: f64) {
if self.target == target {
return;
}
self.target = target;
self.tween = Tween::new(self.value, target);
self.driver = AnimationController::new(REVEAL_PERIOD);
self.driver.fling(REVEAL_LAUNCH_VELOCITY, REVEAL_SPRING);
}
fn snap(&mut self) {
self.value = self.target;
self.tween = Tween::new(self.target, self.target);
self.driver.stop();
}
fn advance(&mut self, now: FrameTime) -> bool {
let animating = self.driver.advance(now);
self.value = self.tween.lerp(self.driver.value_clamped()).clamp(0.0, 1.0);
animating
}
}
pub struct ExpandableItem<State: 'static> {
header: AnyView<State>,
body: AnyView<State>,
}
pub fn expandable_item<State: 'static, H: View<State>, B: View<State>>(
header: H,
body: B,
) -> ExpandableItem<State> {
ExpandableItem {
header: any(header),
body: any(body),
}
}
type OnToggleIndex<State> = Rc<dyn Fn(&mut State, usize)>;
pub struct ExpandableListView<State: 'static> {
items: Vec<ExpandableItem<State>>,
expanded: BTreeSet<usize>,
outer_radius: f64,
inner_radius: f64,
hover_radius: f64,
pressed_radius: f64,
expanded_radius: Option<f64>,
gap: f64,
color: Option<Color>,
haptic: HapticSignal,
on_toggle: Option<OnToggleIndex<State>>,
}
pub fn expandable_list<State: 'static>(
items: impl IntoIterator<Item = ExpandableItem<State>>,
) -> ExpandableListView<State> {
ExpandableListView {
items: items.into_iter().collect(),
expanded: BTreeSet::new(),
outer_radius: EXPANDABLE_OUTER_RADIUS,
inner_radius: EXPANDABLE_INNER_RADIUS,
hover_radius: EXPANDABLE_HOVER_RADIUS,
pressed_radius: EXPANDABLE_PRESSED_RADIUS,
expanded_radius: None,
gap: EXPANDABLE_GAP,
color: None,
haptic: HapticSignal::None,
on_toggle: None,
}
}
impl<State: 'static> ExpandableListView<State> {
pub fn expanded(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
self.expanded = indices.into_iter().collect();
self
}
pub fn outer_radius(mut self, radius: f64) -> Self {
self.outer_radius = radius;
self
}
pub fn inner_radius(mut self, radius: f64) -> Self {
self.inner_radius = radius;
self
}
pub fn hover_radius(mut self, radius: f64) -> Self {
self.hover_radius = radius;
self
}
pub fn pressed_radius(mut self, radius: f64) -> Self {
self.pressed_radius = radius;
self
}
pub fn expanded_radius(mut self, radius: f64) -> Self {
self.expanded_radius = Some(radius);
self
}
pub fn gap(mut self, gap: f64) -> Self {
self.gap = gap;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
pub fn on_toggle<F: Fn(&mut State, usize) + 'static>(mut self, on_toggle: F) -> Self {
self.on_toggle = Some(Rc::new(on_toggle));
self
}
fn flat_views(&self) -> Vec<&AnyView<State>> {
self.items
.iter()
.flat_map(|item| [&item.header, &item.body])
.collect()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct ItemGeometry {
y: f64,
header: f64,
body_full: f64,
height: f64,
}
pub struct ExpandableListWidget {
pods: Vec<ChildPod>,
expanded: BTreeSet<usize>,
reveals: Vec<Reveal>,
geometry: Vec<ItemGeometry>,
laid_out_reveals: Vec<f64>,
outer_radius: f64,
inner_radius: f64,
hover_radius: f64,
pressed_radius: f64,
expanded_radius: Option<f64>,
gap: f64,
color: Option<Color>,
haptic: HapticSignal,
interactive: bool,
width: f64,
armed: Option<usize>,
active: Option<usize>,
state: InteractionState,
chevron: Option<(BezPath, f64)>,
on_toggle: Option<ErasedArgCallback<usize>>,
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_container(theme: Option<&Theme>, override_color: Option<Color>) -> Color {
if let Some(color) = override_color {
return color;
}
match theme {
Some(theme) => theme.scheme().surface_container_highest,
None => FILLED_CONTAINER,
}
}
fn resolve_content(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_surface,
None => ON_SURFACE,
}
}
fn resolve_chevron(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_surface_variant,
None => ON_SURFACE_VARIANT,
}
}
impl<State: 'static> View<State> for ExpandableListView<State> {
type Element = ExpandableListWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ExpandableListWidget {
let pods = self
.flat_views()
.into_iter()
.map(|view| frust::authoring::build_child(view, ctx))
.collect();
let reveals = (0..self.items.len())
.map(|index| {
Reveal::new(if self.expanded.contains(&index) {
1.0
} else {
0.0
})
})
.collect();
ExpandableListWidget {
pods,
expanded: self.expanded.clone(),
reveals,
geometry: vec![ItemGeometry::default(); self.items.len()],
laid_out_reveals: vec![0.0; self.items.len()],
outer_radius: self.outer_radius,
inner_radius: self.inner_radius,
hover_radius: self.hover_radius,
pressed_radius: self.pressed_radius,
expanded_radius: self.expanded_radius,
gap: self.gap,
color: self.color,
haptic: self.haptic,
interactive: self.on_toggle.is_some(),
width: 0.0,
armed: None,
active: None,
state: InteractionState::new(),
chevron: Some(IconData::from(crate::icons::EXPAND_MORE_ROUNDED).resolve()),
on_toggle: self
.on_toggle
.as_ref()
.map(frust::authoring::erase_callback_arg),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ExpandableListWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let prev_flat = prev.flat_views();
let next_flat = self.flat_views();
let mut flags = frust::authoring::rebuild_children(
&prev_flat,
&next_flat,
&mut element.pods,
ctx,
|view| *view,
|_| None,
);
if prev.items.len() != self.items.len() {
element.armed = None;
element.active = None;
element.state.set_pressed(false);
element.state.set_hovered(false);
element.reveals = (0..self.items.len())
.map(|index| {
Reveal::new(if self.expanded.contains(&index) {
1.0
} else {
0.0
})
})
.collect();
element.geometry = vec![ItemGeometry::default(); self.items.len()];
element.laid_out_reveals = vec![0.0; self.items.len()];
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else if element.expanded != self.expanded {
for (index, reveal) in element.reveals.iter_mut().enumerate() {
reveal.set_target(if self.expanded.contains(&index) {
1.0
} else {
0.0
});
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
element.expanded = self.expanded.clone();
if element.outer_radius != self.outer_radius
|| element.inner_radius != self.inner_radius
|| element.hover_radius != self.hover_radius
|| element.pressed_radius != self.pressed_radius
|| element.expanded_radius != self.expanded_radius
|| element.color != self.color
{
element.outer_radius = self.outer_radius;
element.inner_radius = self.inner_radius;
element.hover_radius = self.hover_radius;
element.pressed_radius = self.pressed_radius;
element.expanded_radius = self.expanded_radius;
element.color = self.color;
flags |= ChangeFlags::PAINT;
}
if element.gap != self.gap {
element.gap = self.gap;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let now_interactive = self.on_toggle.is_some();
if element.interactive != now_interactive {
element.interactive = now_interactive;
if !now_interactive {
element.armed = None;
element.active = None;
element.state.set_pressed(false);
element.state.set_hovered(false);
}
flags |= ChangeFlags::PAINT;
}
element.haptic = self.haptic;
element.on_toggle = self
.on_toggle
.as_ref()
.map(frust::authoring::erase_callback_arg);
flags
}
fn teardown(&self, element: &mut ExpandableListWidget, ctx: &mut BuildCtx<'_>) {
for (view, pod) in self.flat_views().into_iter().zip(element.pods.iter_mut()) {
frust::authoring::teardown_child(view, pod, ctx);
}
}
}
impl ExpandableListWidget {
fn item_count(&self) -> usize {
self.pods.len() / 2
}
fn header_at(&self, pos: Point) -> Option<usize> {
if pos.x < 0.0 || pos.x >= self.width {
return None;
}
self.geometry
.iter()
.position(|g| pos.y >= g.y && pos.y < g.y + g.header)
}
fn radii_at(&self, index: usize) -> CornerRadii {
if let Some(radius) = self.expanded_radius
&& self.expanded.contains(&index)
{
return CornerRadii::uniform(radius);
}
let inner = if self.armed == Some(index) && self.state.pressed {
self.pressed_radius
} else if self.active == Some(index) && self.state.hovered {
self.hover_radius
} else {
self.inner_radius
};
card_radii(
card_position(index, self.item_count()),
self.outer_radius,
inner,
)
}
fn clear_press(&mut self) {
self.armed = None;
self.state.set_pressed(false);
if !self.state.is_active() {
self.active = None;
}
}
fn chevron_path(&self, index: usize) -> Option<BezPath> {
let (path, design) = self.chevron.as_ref()?;
if *design <= 0.0 {
return None;
}
let geometry = self.geometry.get(index)?;
let center = Point::new(
self.width - HEADER_PAD_X - CHEVRON_SLOT / 2.0,
geometry.y + HEADER_PAD_TOP + CHEVRON_SLOT / 2.0,
);
let angle = self.reveals.get(index).map_or(0.0, |r| r.value) * EXPANDABLE_ICON_ROTATION;
let scale = CHEVRON_SIZE / design;
let transform = Affine::translate(Vec2::new(center.x, center.y))
* Affine::rotate(angle)
* Affine::scale(scale)
* Affine::translate(Vec2::new(-design / 2.0, -design / 2.0));
Some(transform * path.clone())
}
}
impl Widget for ExpandableListWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
self.width = width;
let header_width = (width - HEADER_PAD_X * 2.0 - CHEVRON_SLOT).max(0.0);
let body_width = (width - BODY_PAD_X * 2.0).max(0.0);
let count = self.item_count();
self.geometry.resize(count, ItemGeometry::default());
self.laid_out_reveals.resize(count, 0.0);
let mut y = 0.0;
for index in 0..count {
let header_bc = BoxConstraints::new(
Size::new(header_width, 0.0),
Size::new(header_width, f64::INFINITY),
);
let header_size = self.pods[index * 2].layout_child(ctx, &header_bc);
let header = header_size.height.max(CHEVRON_SLOT) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM;
self.pods[index * 2].set_origin(Point::new(HEADER_PAD_X, y + HEADER_PAD_TOP));
let body_bc = BoxConstraints::new(
Size::new(body_width, 0.0),
Size::new(body_width, f64::INFINITY),
);
let body_size = self.pods[index * 2 + 1].layout_child(ctx, &body_bc);
let body_full = body_size.height + BODY_PAD_TOP + BODY_PAD_BOTTOM;
self.pods[index * 2 + 1].set_origin(Point::new(BODY_PAD_X, y + header + BODY_PAD_TOP));
let reveal = self.reveals.get(index).map_or(0.0, |r| r.value);
self.laid_out_reveals[index] = reveal;
let height = header + (reveal * body_full).max(0.0);
self.geometry[index] = ItemGeometry {
y,
header,
body_full,
height,
};
y += height;
if index + 1 < count {
y += self.gap;
}
}
bc.constrain(Size::new(width, y))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let container = resolve_container(theme, self.color);
let content = resolve_content(theme);
let chevron_color = resolve_chevron(theme);
let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
let now = ctx.frame_time();
for reveal in &mut self.reveals {
if reduce_motion {
reveal.snap();
} else if reveal.advance(now) {
ctx.request_layout();
}
}
if self
.reveals
.iter()
.map(|r| r.value)
.ne(self.laid_out_reveals.iter().copied())
{
ctx.request_layout();
}
let hovered = self.interactive && ctx.is_hovered();
self.state.set_hovered(hovered);
if !hovered && !self.state.pressed {
self.active = None;
}
let origin = ctx.origin();
let overlay_opacity = if self.interactive {
self.state.resolve_opacity()
} else {
0.0
};
let count = self.item_count();
let radii: Vec<CornerRadii> = (0..count).map(|index| self.radii_at(index)).collect();
let chevrons: Vec<Option<BezPath>> =
(0..count).map(|index| self.chevron_path(index)).collect();
for index in 0..count {
let Some(geometry) = self.geometry.get(index).copied() else {
continue;
};
let absolute = Point::new(origin.x, origin.y + geometry.y);
let size = Size::new(self.width, geometry.height);
scene.fill_rounded_rect_radii(absolute, size, radii[index], container);
if overlay_opacity > 0.0 && self.active == Some(index) {
scene.fill_rounded_rect_radii(
absolute,
size,
radii[index],
with_alpha(content, overlay_opacity),
);
}
self.pods[index * 2].paint_child(ctx, scene);
let revealed = geometry.height - geometry.header;
if revealed > 0.0 {
scene.push_clip(
Point::new(absolute.x, absolute.y + geometry.header),
Size::new(self.width, revealed),
);
self.pods[index * 2 + 1].paint_child(ctx, scene);
scene.pop_clip();
}
if let Some(path) = &chevrons[index] {
scene.fill_path(origin, path, &Brush::Solid(chevron_color));
}
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() || !self.interactive {
return frust::authoring::route_event(&mut self.pods, ctx, event);
}
let InputEvent::Pointer(p) = event else {
return frust::authoring::route_event(&mut self.pods, ctx, event);
};
match p.phase {
PointerPhase::Down => {
let Some(index) = self.header_at(p.position).filter(|_| presses(p)) else {
return frust::authoring::route_event(&mut self.pods, ctx, event);
};
self.armed = Some(index);
self.active = Some(index);
self.state.set_pressed(true);
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
let Some(armed) = self.armed else {
let result = frust::authoring::route_event(&mut self.pods, ctx, event);
let over = self.header_at(p.position);
if over.is_some() {
ctx.claim_hover();
}
let changed = self.state.set_hovered(over.is_some()) || self.active != over;
self.active = over;
if changed {
ctx.request_redraw();
}
return result;
};
let still_on_header = self.header_at(p.position) == Some(armed);
if self.state.set_pressed(still_on_header) {
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
let Some(armed) = self.armed else {
return frust::authoring::route_event(&mut self.pods, ctx, event);
};
if self.header_at(p.position) == Some(armed) {
if self.haptic != HapticSignal::None {
MaterialHaptics::fire(self.haptic);
}
if let Some(on_toggle) = self.on_toggle.as_mut() {
(on_toggle)(ctx, armed);
}
}
self.clear_press();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if self.armed.is_none() {
return frust::authoring::route_event(&mut self.pods, ctx, event);
}
self.clear_press();
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::List,
|node| {
if self.interactive {
node.add_action(Action::Click);
}
},
|ctx| {
for pod in &self.pods {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(pods);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::TextContext;
use frust_widgets::test_support::leaf_any;
use kurbo::Shape;
use std::any::Any;
const WIDTH: f64 = 320.0;
const HEADER_CHILD: f64 = 20.0;
const BODY_CHILD: f64 = 60.0;
fn header_height() -> f64 {
HEADER_CHILD.max(CHEVRON_SLOT) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM
}
fn body_height() -> f64 {
BODY_CHILD + BODY_PAD_TOP + BODY_PAD_BOTTOM
}
fn items(count: usize) -> Vec<ExpandableItem<()>> {
(0..count)
.map(|_| ExpandableItem {
header: leaf_any(40.0, HEADER_CHILD),
body: leaf_any(40.0, BODY_CHILD),
})
.collect()
}
fn build<S: 'static>(view: &ExpandableListView<S>) -> ExpandableListWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut ExpandableListWidget) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(
&mut lctx,
&BoxConstraints::new(Size::new(WIDTH, 0.0), Size::new(WIDTH, f64::INFINITY)),
)
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, CornerRadii, Color)>,
clips: Vec<(Point, Size)>,
paths: Vec<BezPath>,
}
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 push_clip(&mut self, origin: Point, size: Size) {
self.clips.push((origin, size));
}
fn pop_clip(&mut self) {}
fn fill_path(&mut self, origin: Point, path: &BezPath, _brush: &Brush) {
self.paths
.push(Affine::translate(Vec2::new(origin.x, origin.y)) * path.clone());
}
}
fn paint_at(
w: &mut ExpandableListWidget,
t_ms: u64,
theme: Option<&Theme>,
) -> (Recorder, bool) {
let mut rec = Recorder::default();
let size = Size::new(WIDTH, 4000.0);
let mut ctx =
PaintCtx::for_test(Point::ZERO, size, FrameTime::from_nanos(t_ms * 1_000_000));
if let Some(theme) = theme {
ctx = ctx.with_theme(theme);
}
w.paint(&mut ctx, &mut rec);
let needs_layout = ctx.needs_layout();
(rec, needs_layout)
}
fn dispatch(
w: &mut ExpandableListWidget,
state: &mut dyn Any,
phase: PointerPhase,
pos: Point,
) -> EventResult {
let event = InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: pos,
button: frust::authoring::PointerButton::Primary,
});
let mut ctx = EventCtx::new(state, Point::ZERO, Size::new(WIDTH, 4000.0));
w.event(&mut ctx, &event)
}
#[test]
fn single_open_closes_every_other_item() {
let open: BTreeSet<usize> = [0, 1].into_iter().collect();
let next = toggle_expanded(&open, 2, ExpandMode::SingleOpen);
assert_eq!(next, [2].into_iter().collect::<BTreeSet<usize>>());
}
#[test]
fn independent_mode_keeps_the_others_open() {
let open: BTreeSet<usize> = [0, 1].into_iter().collect();
let next = toggle_expanded(&open, 2, ExpandMode::Independent);
assert_eq!(next, [0, 1, 2].into_iter().collect::<BTreeSet<usize>>());
}
#[test]
fn collapsing_is_mode_independent() {
let open: BTreeSet<usize> = [0, 1].into_iter().collect();
assert_eq!(
toggle_expanded(&open, 1, ExpandMode::SingleOpen),
[0].into_iter().collect::<BTreeSet<usize>>()
);
assert_eq!(
toggle_expanded(&open, 1, ExpandMode::Independent),
[0].into_iter().collect::<BTreeSet<usize>>()
);
}
#[test]
fn is_expanded_reads_the_app_owned_set() {
let open: BTreeSet<usize> = [3].into_iter().collect();
assert!(is_expanded(&open, 3));
assert!(!is_expanded(&open, 0));
}
#[test]
fn single_open_is_the_default_mode() {
assert_eq!(ExpandMode::default(), ExpandMode::SingleOpen);
}
#[test]
fn a_collapsed_item_reports_only_its_header_height() {
let view: ExpandableListView<()> = expandable_list(items(1));
let mut w = build(&view);
let size = layout(&mut w);
assert_eq!(size.height, header_height());
assert_eq!(w.geometry[0].header, header_height());
assert_eq!(
w.geometry[0].body_full,
body_height(),
"the body is measured in full even while collapsed"
);
}
#[test]
fn an_item_built_expanded_reports_header_plus_full_body_on_the_first_layout() {
let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
let mut w = build(&view);
let size = layout(&mut w);
assert!((size.height - (header_height() + body_height())).abs() < 1e-9);
}
#[test]
fn a_mid_reveal_height_sits_between_collapsed_and_expanded() {
let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
let mut w = build(&view);
layout(&mut w);
w.reveals[0].value = 0.5;
let size = layout(&mut w);
assert!((size.height - (header_height() + 0.5 * body_height())).abs() < 1e-9);
}
#[test]
fn items_stack_with_the_gap_between_them() {
let view: ExpandableListView<()> = expandable_list(items(3));
let mut w = build(&view);
let size = layout(&mut w);
assert_eq!(size.height, header_height() * 3.0 + EXPANDABLE_GAP * 2.0);
assert_eq!(w.geometry[0].y, 0.0);
assert_eq!(w.geometry[1].y, header_height() + EXPANDABLE_GAP);
}
#[test]
fn paint_requests_layout_through_the_settle_frame() {
let collapsed: ExpandableListView<()> = expandable_list(items(1));
let mut w = build(&collapsed);
layout(&mut w);
let (_, settled) = paint_at(&mut w, 0, None);
assert!(!settled, "an already-settled stack requests no layout");
let expanded: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
let mut counter = 0u64;
let flags = <ExpandableListView<()> as View<()>>::rebuild(
&expanded,
&collapsed,
&mut w,
&mut BuildCtx::new(&mut counter),
);
assert!(flags.needs_layout());
let mut frames = 0u32;
let mut heights = Vec::new();
let mut t_ms = 0u64;
let mut needs_layout = true;
loop {
if !needs_layout {
break;
}
heights.push(layout(&mut w).height);
let (_, next_needs_layout) = paint_at(&mut w, t_ms, None);
frames += 1;
assert!(
frames < 600,
"the reveal should settle well inside 600 frames"
);
needs_layout = next_needs_layout;
t_ms += 16;
}
assert!(frames > 1, "the reveal spans more than one frame");
let collapsed_h = header_height();
let expanded_h = header_height() + body_height();
assert!(
heights
.iter()
.all(|h| *h >= collapsed_h - 1e-9 && *h <= expanded_h + 1e-9),
"every laid-out frame stays inside [{collapsed_h}, {expanded_h}]: {heights:?}"
);
let peak = heights
.iter()
.position(|h| *h >= expanded_h - 1e-9)
.expect("the reveal reaches its target");
assert!(peak > 0, "the first frame is not already fully revealed");
assert!(
heights[..=peak]
.windows(2)
.all(|pair| pair[1] >= pair[0] - 1e-9),
"the reveal rises to the target without backsliding: {heights:?}"
);
let last = heights.last().copied().expect("at least one frame");
assert_eq!(
last, expanded_h,
"the last layout a shell-honest driver runs lands exactly on the target height"
);
let (_, still) = paint_at(&mut w, t_ms + 16, None);
assert!(!still, "settled: no more layout requests");
}
#[test]
fn reduce_motion_snaps_the_reveal_and_requests_one_relayout_no_animation() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let collapsed: ExpandableListView<()> = expandable_list(items(1));
let mut w = build(&collapsed);
let collapsed_h = layout(&mut w).height;
assert_eq!(collapsed_h, header_height(), "starts fully collapsed");
let expanded: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
let mut counter = 0u64;
<ExpandableListView<()> as View<()>>::rebuild(
&expanded,
&collapsed,
&mut w,
&mut BuildCtx::new(&mut counter),
);
let (_, needs_layout) = paint_at(&mut w, 0, Some(&theme));
assert_eq!(w.reveals[0].value, 1.0, "snapped straight to the target");
assert!(
needs_layout,
"the snap changed a layout-relevant value, so it must request one relayout"
);
let expanded_h = header_height() + body_height();
let snapped_h = layout(&mut w).height;
assert_eq!(
snapped_h, expanded_h,
"the item's height reaches the target on the snap frame, not some later one"
);
let (_, still) = paint_at(&mut w, 16, Some(&theme));
assert!(
!still,
"a snap requests exactly one relayout, never an ongoing frame request"
);
}
#[test]
fn only_the_toggled_item_animates() {
let collapsed: ExpandableListView<()> = expandable_list(items(3));
let mut w = build(&collapsed);
layout(&mut w);
let expanded: ExpandableListView<()> = expandable_list(items(3)).expanded([1]);
let mut counter = 0u64;
<ExpandableListView<()> as View<()>>::rebuild(
&expanded,
&collapsed,
&mut w,
&mut BuildCtx::new(&mut counter),
);
let mut t_ms = 0u64;
loop {
let (rec, needs_layout) = paint_at(&mut w, t_ms, None);
assert!(
rec.clips.len() <= 1,
"only the toggled item ever reveals a body clip, got {}",
rec.clips.len()
);
if !needs_layout {
break;
}
layout(&mut w);
t_ms += 16;
assert!(t_ms < 10_000, "the reveal should settle well inside 10s");
}
assert_eq!(w.reveals[0].value, 0.0);
assert_eq!(w.reveals[2].value, 0.0);
assert_eq!(w.reveals[1].value, 1.0);
}
#[test]
fn the_body_clip_covers_exactly_the_revealed_band() {
let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
let mut w = build(&view);
layout(&mut w);
let (rec, _) = paint_at(&mut w, 0, None);
assert_eq!(rec.clips.len(), 1);
let (clip_origin, clip_size) = rec.clips[0];
assert_eq!(clip_origin.y, header_height());
assert!((clip_size.height - body_height()).abs() < 1e-9);
assert_eq!(clip_size.width, WIDTH);
}
#[test]
fn a_collapsed_item_paints_no_body_clip() {
let view: ExpandableListView<()> = expandable_list(items(2));
let mut w = build(&view);
layout(&mut w);
let (rec, _) = paint_at(&mut w, 0, None);
assert!(rec.clips.is_empty());
}
#[test]
fn the_chevron_rotates_with_the_reveal() {
let closed: ExpandableListView<()> = expandable_list(items(1));
let mut w = build(&closed);
layout(&mut w);
let (rec_closed, _) = paint_at(&mut w, 0, None);
let closed_box = rec_closed.paths[0].bounding_box();
let open: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
let mut w2 = build(&open);
layout(&mut w2);
let (rec_open, _) = paint_at(&mut w2, 0, None);
let open_box = rec_open.paths[0].bounding_box();
let slot_x = WIDTH - HEADER_PAD_X - CHEVRON_SLOT / 2.0;
let slot_y = HEADER_PAD_TOP + CHEVRON_SLOT / 2.0;
assert!(
(closed_box.center().x + open_box.center().x - 2.0 * slot_x).abs() < 1e-6,
"closed {closed_box:?} / open {open_box:?} about x={slot_x}"
);
assert!(
(closed_box.center().y + open_box.center().y - 2.0 * slot_y).abs() < 1e-6,
"closed {closed_box:?} / open {open_box:?} about y={slot_y}"
);
assert_ne!(
rec_closed.paths[0].to_svg(),
rec_open.paths[0].to_svg(),
"the chevron path must actually rotate"
);
}
#[test]
fn the_reveal_spring_matches_the_reference_preset() {
let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
assert_eq!(REVEAL_SPRING.mass, 1.0);
assert_eq!(REVEAL_SPRING.stiffness, reference.stiffness);
assert_eq!(REVEAL_SPRING.damping_ratio, reference.damping_ratio);
}
#[test]
fn item_corners_follow_the_card_list_position_matrix() {
let view: ExpandableListView<()> = expandable_list(items(3));
let mut w = build(&view);
layout(&mut w);
let (rec, _) = paint_at(&mut w, 0, None);
let radii: Vec<CornerRadii> = rec.rrects.iter().map(|(_, _, r, _)| *r).collect();
let (outer, inner) = (EXPANDABLE_OUTER_RADIUS, EXPANDABLE_INNER_RADIUS);
assert_eq!(
radii,
vec![
card_radii(crate::CardPosition::First, outer, inner),
card_radii(crate::CardPosition::Middle, outer, inner),
card_radii(crate::CardPosition::Last, outer, inner),
]
);
}
#[test]
fn an_expanded_radius_override_rounds_the_open_item_uniformly() {
let view: ExpandableListView<()> = expandable_list(items(2))
.expanded([0])
.expanded_radius(28.0);
let mut w = build(&view);
layout(&mut w);
let (rec, _) = paint_at(&mut w, 0, None);
assert_eq!(rec.rrects[0].2, CornerRadii::uniform(28.0));
assert_ne!(rec.rrects[1].2, CornerRadii::uniform(28.0));
}
#[test]
fn a_pressed_item_swaps_in_the_pressed_inner_radius() {
let view: ExpandableListView<()> = expandable_list(items(2)).on_toggle(|_: &mut (), _| {});
let mut w = build(&view);
layout(&mut w);
let mut state = ();
dispatch(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(10.0, header_height() / 2.0),
);
let expected = card_radii(
crate::CardPosition::First,
EXPANDABLE_OUTER_RADIUS,
EXPANDABLE_PRESSED_RADIUS,
);
assert_eq!(w.radii_at(0), expected);
}
#[test]
fn a_hovered_item_swaps_in_the_hover_inner_radius() {
let view: ExpandableListView<()> = expandable_list(items(2)).on_toggle(|_: &mut (), _| {});
let mut w = build(&view);
layout(&mut w);
let mut state = ();
let second_y = w.geometry[1].y + 1.0;
dispatch(
&mut w,
&mut state,
PointerPhase::Move,
Point::new(10.0, second_y),
);
assert_eq!(w.active, Some(1));
assert!(w.state.hovered);
assert_eq!(
w.radii_at(1),
card_radii(
crate::CardPosition::Last,
EXPANDABLE_OUTER_RADIUS,
EXPANDABLE_HOVER_RADIUS,
)
);
assert_eq!(
w.radii_at(0),
card_radii(
crate::CardPosition::First,
EXPANDABLE_OUTER_RADIUS,
EXPANDABLE_INNER_RADIUS,
)
);
}
#[test]
fn a_header_press_released_inside_reports_its_index() {
#[derive(Default)]
struct S {
toggled: Vec<usize>,
}
let view: ExpandableListView<S> = expandable_list((0..3).map(|_| {
expandable_item(
frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
)
}))
.on_toggle(|s: &mut S, index| s.toggled.push(index));
let mut w = build(&view);
layout(&mut w);
let mut state = S::default();
let y = w.geometry[1].y + header_height() / 2.0;
dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y));
dispatch(&mut w, &mut state, PointerPhase::Up, Point::new(10.0, y));
assert_eq!(state.toggled, vec![1]);
assert!(w.expanded.is_empty());
}
#[test]
fn a_release_outside_the_armed_header_fires_nothing() {
#[derive(Default)]
struct S {
toggled: Vec<usize>,
}
let view: ExpandableListView<S> = expandable_list((0..2).map(|_| {
expandable_item(
frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
)
}))
.on_toggle(|s: &mut S, index| s.toggled.push(index));
let mut w = build(&view);
layout(&mut w);
let mut state = S::default();
let y0 = header_height() / 2.0;
let y1 = w.geometry[1].y + header_height() / 2.0;
dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y0));
dispatch(&mut w, &mut state, PointerPhase::Up, Point::new(10.0, y1));
assert!(state.toggled.is_empty());
}
#[test]
fn a_cancel_clears_the_press_without_firing() {
#[derive(Default)]
struct S {
toggled: Vec<usize>,
}
let view: ExpandableListView<S> = expandable_list((0..1).map(|_| {
expandable_item(
frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
)
}))
.on_toggle(|s: &mut S, index| s.toggled.push(index));
let mut w = build(&view);
layout(&mut w);
let mut state = S::default();
let y = header_height() / 2.0;
dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y));
dispatch(
&mut w,
&mut state,
PointerPhase::Cancel,
Point::new(10.0, y),
);
assert!(state.toggled.is_empty());
assert!(w.armed.is_none());
assert!(!w.state.pressed);
}
#[test]
fn a_secondary_press_never_arms_the_header() {
let view: ExpandableListView<()> = expandable_list(items(1)).on_toggle(|_: &mut (), _| {});
let mut w = build(&view);
layout(&mut w);
let event = InputEvent::Pointer(frust::authoring::PointerEvent {
phase: PointerPhase::Down,
position: Point::new(10.0, header_height() / 2.0),
button: frust::authoring::PointerButton::Secondary,
});
let mut state = ();
let sa: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(WIDTH, 4000.0));
w.event(&mut ctx, &event);
assert!(w.armed.is_none());
}
#[test]
fn a_press_in_a_revealed_body_never_arms_the_header() {
let view: ExpandableListView<()> = expandable_list(items(1))
.expanded([0])
.on_toggle(|_: &mut (), _| {});
let mut w = build(&view);
layout(&mut w);
let mut state = ();
let in_body = header_height() + 10.0;
dispatch(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(10.0, in_body),
);
assert!(w.armed.is_none());
}
#[test]
fn a_stack_without_on_toggle_routes_pointers_to_its_children() {
let view: ExpandableListView<()> = expandable_list(items(1));
let mut w = build(&view);
layout(&mut w);
let mut state = ();
dispatch(
&mut w,
&mut state,
PointerPhase::Down,
Point::new(10.0, header_height() / 2.0),
);
assert!(w.armed.is_none());
}
#[test]
fn header_hit_testing_ignores_the_gap_between_items() {
let view: ExpandableListView<()> = expandable_list(items(2));
let mut w = build(&view);
layout(&mut w);
let gap_y = header_height() + EXPANDABLE_GAP / 2.0;
assert_eq!(w.header_at(Point::new(10.0, gap_y)), None);
assert_eq!(w.header_at(Point::new(10.0, 1.0)), Some(0));
assert_eq!(
w.header_at(Point::new(10.0, w.geometry[1].y + 1.0)),
Some(1)
);
}
#[test]
fn a_structural_change_reseeds_the_reveal_drivers() {
let two: ExpandableListView<()> = expandable_list(items(2)).expanded([1]);
let mut w = build(&two);
layout(&mut w);
assert_eq!(w.reveals.len(), 2);
let three: ExpandableListView<()> = expandable_list(items(3)).expanded([2]);
let mut counter = 0u64;
<ExpandableListView<()> as View<()>>::rebuild(
&three,
&two,
&mut w,
&mut BuildCtx::new(&mut counter),
);
assert_eq!(w.reveals.len(), 3);
assert_eq!(w.reveals[2].value, 1.0, "the new set seeds at rest");
assert_eq!(w.reveals[1].value, 0.0);
}
#[test]
fn semantics_is_a_clickable_list_forwarding_every_pod() {
fn logic(_s: &mut ()) -> ExpandableListView<()> {
expandable_list([expandable_item(frust::text("header"), frust::text("body"))])
.on_toggle(|_: &mut (), _| {})
}
let mut root: frust_core::RenderRoot<(), ExpandableListView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(WIDTH, 800.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::List)
.expect("the stack contributes a Role::List node");
assert!(
node.supports_action(Action::Click),
"an interactive stack carries Action::Click"
);
assert!(
!node.children().is_empty(),
"every header and body pod is a semantics child"
);
}
}