use std::rc::Rc;
use frust::authoring::{
Affine, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
EventResult, InputEvent, Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, Point, PointerPhase,
Rect, SemanticsCtx, Size, View, Widget, any, build_child, erase_callback, rebuild_child,
route_event_single, teardown_child, visit_children,
};
use frust::{AnimationController, Theme};
use super::{finite_or_zero, reduce_motion};
use crate::tokens::{MaterialMotion, MaterialSpacing};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OverlaySide {
Top,
Right,
#[default]
Bottom,
Left,
}
impl OverlaySide {
pub fn opposite(self) -> Self {
match self {
OverlaySide::Top => OverlaySide::Bottom,
OverlaySide::Bottom => OverlaySide::Top,
OverlaySide::Left => OverlaySide::Right,
OverlaySide::Right => OverlaySide::Left,
}
}
pub fn is_vertical(self) -> bool {
matches!(self, OverlaySide::Top | OverlaySide::Bottom)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OverlayAlign {
Start,
#[default]
Center,
End,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OverlayPlacement {
pub side: OverlaySide,
pub align: OverlayAlign,
pub offset: f64,
pub flip: bool,
pub clamp: bool,
}
pub const OVERLAY_ANCHOR_GAP: f64 = MaterialSpacing::XS;
pub const ANCHORED_ENTER_SCALE: f64 = 0.95;
const PROGRESS_EPSILON: f64 = 1e-4;
const SHADOW_SPILL: f64 = 48.0;
impl Default for OverlayPlacement {
fn default() -> Self {
OverlayPlacement {
side: OverlaySide::default(),
align: OverlayAlign::default(),
offset: OVERLAY_ANCHOR_GAP,
flip: true,
clamp: true,
}
}
}
impl OverlayPlacement {
pub fn on(side: OverlaySide) -> Self {
OverlayPlacement {
side,
..Self::default()
}
}
pub fn align(mut self, align: OverlayAlign) -> Self {
self.align = align;
self
}
pub fn offset(mut self, offset: f64) -> Self {
self.offset = offset;
self
}
pub fn flip(mut self, flip: bool) -> Self {
self.flip = flip;
self
}
pub fn clamp(mut self, clamp: bool) -> Self {
self.clamp = clamp;
self
}
}
fn fits(side: OverlaySide, anchor: Rect, content: Size, area: Rect, offset: f64) -> bool {
match side {
OverlaySide::Top => anchor.y0 - offset - content.height >= area.y0,
OverlaySide::Bottom => anchor.y1 + offset + content.height <= area.y1,
OverlaySide::Left => anchor.x0 - offset - content.width >= area.x0,
OverlaySide::Right => anchor.x1 + offset + content.width <= area.x1,
}
}
fn align_start(align: OverlayAlign, a0: f64, a1: f64, extent: f64) -> f64 {
match align {
OverlayAlign::Start => a0,
OverlayAlign::Center => (a0 + a1) / 2.0 - extent / 2.0,
OverlayAlign::End => a1 - extent,
}
}
fn clamp_into(rect: Rect, area: Rect) -> Rect {
let x = rect.x0.min(area.x1 - rect.width()).max(area.x0);
let y = rect.y0.min(area.y1 - rect.height()).max(area.y0);
Rect::from_origin_size(Point::new(x, y), rect.size())
}
pub fn place_anchored(
anchor: Rect,
content: Size,
area: Rect,
placement: OverlayPlacement,
) -> Rect {
let mut side = placement.side;
if placement.flip
&& !fits(side, anchor, content, area, placement.offset)
&& fits(side.opposite(), anchor, content, area, placement.offset)
{
side = side.opposite();
}
let origin = match side {
OverlaySide::Top => Point::new(
align_start(placement.align, anchor.x0, anchor.x1, content.width),
anchor.y0 - placement.offset - content.height,
),
OverlaySide::Bottom => Point::new(
align_start(placement.align, anchor.x0, anchor.x1, content.width),
anchor.y1 + placement.offset,
),
OverlaySide::Left => Point::new(
anchor.x0 - placement.offset - content.width,
align_start(placement.align, anchor.y0, anchor.y1, content.height),
),
OverlaySide::Right => Point::new(
anchor.x1 + placement.offset,
align_start(placement.align, anchor.y0, anchor.y1, content.height),
),
};
let rect = Rect::from_origin_size(origin, content);
if placement.clamp {
clamp_into(rect, area)
} else {
rect
}
}
#[derive(Clone, Debug, Default)]
pub struct OverlayAnchor(Rc<std::cell::Cell<Rect>>);
impl OverlayAnchor {
pub fn new() -> Self {
Self::default()
}
pub fn rect(&self) -> Rect {
self.0.get()
}
pub fn set(&self, rect: Rect) {
self.0.set(rect);
}
}
pub fn overlay_anchor<State: 'static, V: View<State>>(
anchor: &OverlayAnchor,
child: V,
) -> OverlayAnchorView<State> {
OverlayAnchorView {
child: any(child),
anchor: anchor.clone(),
}
}
pub struct OverlayAnchorView<State: 'static> {
child: AnyView<State>,
anchor: OverlayAnchor,
}
pub struct OverlayAnchorWidget {
child: ChildPod,
anchor: OverlayAnchor,
}
impl<State: 'static> View<State> for OverlayAnchorView<State> {
type Element = OverlayAnchorWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayAnchorWidget {
OverlayAnchorWidget {
child: build_child(&self.child, ctx),
anchor: self.anchor.clone(),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut OverlayAnchorWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.anchor = self.anchor.clone();
rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
}
fn teardown(&self, element: &mut OverlayAnchorWidget, ctx: &mut BuildCtx<'_>) {
teardown_child(&self.child, &mut element.child, ctx);
}
}
impl Widget for OverlayAnchorWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.child.layout_child(ctx, bc);
self.child.set_origin(Point::ORIGIN);
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.anchor
.set(Rect::from_origin_size(ctx.origin(), ctx.size()));
self.child.paint_child(ctx, scene);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
route_event_single(&mut self.child, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.child.semantics_child(ctx);
}
visit_children!(child);
}
type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
pub fn anchored_overlay<State: 'static, V: View<State>>(content: V) -> AnchoredOverlayView<State> {
AnchoredOverlayView {
content: any(content),
anchor: OverlayAnchor::new(),
placement: OverlayPlacement::default(),
open: true,
on_dismiss: None,
}
}
pub struct AnchoredOverlayView<State: 'static> {
content: AnyView<State>,
anchor: OverlayAnchor,
placement: OverlayPlacement,
open: bool,
on_dismiss: Option<OnDismiss<State>>,
}
impl<State: 'static> AnchoredOverlayView<State> {
pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
self.anchor = anchor.clone();
self
}
pub fn placement(mut self, placement: OverlayPlacement) -> Self {
self.placement = placement;
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
self.on_dismiss = Some(Rc::new(on_dismiss));
self
}
}
pub struct AnchoredOverlayWidget {
content: ChildPod,
anchor: OverlayAnchor,
placement: OverlayPlacement,
open: bool,
on_dismiss: Option<ErasedCallback>,
rect: Rect,
anchor_used: Rect,
host_origin: Point,
anim: AnimationController,
ramp: (f64, f64),
progress: f64,
started: bool,
}
impl AnchoredOverlayWidget {
pub fn content_rect(&self) -> Rect {
self.rect
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn progress(&self) -> f64 {
self.progress
}
fn visible(&self) -> bool {
self.open || self.progress > PROGRESS_EPSILON
}
fn begin_ramp(&mut self, to: f64) {
let from = self.progress;
self.ramp = (from, to);
let entering = to > from;
let fraction = (to - from).abs().clamp(0.0, 1.0);
let base = if entering {
MaterialMotion::SHORT_4
} else {
MaterialMotion::SHORT_3
};
let curve = if entering {
MaterialMotion::EMPHASIZED_DECELERATE
} else {
MaterialMotion::STANDARD_ACCELERATE
};
self.anim = AnimationController::new(base.mul_f64(fraction)).with_curve(curve);
self.anim.forward();
self.started = true;
}
fn ramp_value(&self) -> f64 {
let (from, to) = self.ramp;
from + (to - from) * self.anim.value_clamped()
}
fn advance(&mut self, ctx: &mut PaintCtx, reduce: bool) {
if !self.started {
self.started = true;
if self.open && !reduce {
self.anim.forward();
}
}
let next = if reduce {
if self.anim.is_animating() {
self.anim.stop();
}
self.ramp.1
} else if self.anim.is_animating() {
if self.anim.advance(ctx.frame_time()) {
ctx.request_frame();
}
self.ramp_value()
} else {
self.progress
};
if (next - self.progress).abs() > PROGRESS_EPSILON {
self.progress = next;
ctx.request_frame();
}
}
fn pivot(&self) -> Point {
let anchor_local = self.anchor_used - self.host_origin.to_vec2();
let center = anchor_local.center();
Point::new(
center.x.clamp(self.rect.x0, self.rect.x1),
center.y.clamp(self.rect.y0, self.rect.y1),
)
}
}
impl<State: 'static> View<State> for AnchoredOverlayView<State> {
type Element = AnchoredOverlayWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> AnchoredOverlayWidget {
AnchoredOverlayWidget {
content: build_child(&self.content, ctx),
anchor: self.anchor.clone(),
placement: self.placement,
open: self.open,
on_dismiss: self.on_dismiss.as_ref().map(erase_callback),
rect: Rect::ZERO,
anchor_used: Rect::ZERO,
host_origin: Point::ORIGIN,
anim: AnimationController::new(MaterialMotion::SHORT_4)
.with_curve(MaterialMotion::EMPHASIZED_DECELERATE),
ramp: (0.0, if self.open { 1.0 } else { 0.0 }),
progress: 0.0,
started: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut AnchoredOverlayWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = rebuild_child(&prev.content, &self.content, &mut element.content, ctx);
if element.placement != self.placement {
element.placement = self.placement;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.open != self.open {
element.open = self.open;
element.begin_ramp(if self.open { 1.0 } else { 0.0 });
flags |= ChangeFlags::PAINT;
}
element.anchor = self.anchor.clone();
element.on_dismiss = self.on_dismiss.as_ref().map(erase_callback);
flags
}
fn teardown(&self, element: &mut AnchoredOverlayWidget, ctx: &mut BuildCtx<'_>) {
teardown_child(&self.content, &mut element.content, ctx);
}
}
impl Widget for AnchoredOverlayWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let area = Size::new(
finite_or_zero(bc.max().width),
finite_or_zero(bc.max().height),
);
let content = self.content.layout_child(ctx, &BoxConstraints::loose(area));
self.anchor_used = self.anchor.rect();
let anchor_local = self.anchor_used - self.host_origin.to_vec2();
self.rect = place_anchored(
anchor_local,
content,
Rect::from_origin_size(Point::ORIGIN, area),
self.placement,
);
self.content.set_origin(self.rect.origin());
bc.constrain(area)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
if ctx.origin() != self.host_origin || self.anchor.rect() != self.anchor_used {
self.host_origin = ctx.origin();
ctx.request_layout();
}
let reduce = reduce_motion(Theme::from_paint_ctx(ctx));
self.advance(ctx, reduce);
if !self.visible() {
self.progress = 0.0;
return;
}
let ramping = self.progress < 1.0 - PROGRESS_EPSILON;
if ramping {
let origin = ctx.origin();
let placed = self.rect + origin.to_vec2();
let layer = placed.inflate(SHADOW_SPILL, SHADOW_SPILL);
scene.push_layer(layer.origin(), layer.size(), self.progress as f32);
let pivot = self.pivot() + origin.to_vec2();
let scale = ANCHORED_ENTER_SCALE + (1.0 - ANCHORED_ENTER_SCALE) * self.progress;
scene.push_transform(
Affine::translate(pivot.to_vec2())
* Affine::scale(scale)
* Affine::translate(-pivot.to_vec2()),
);
}
self.content.paint_child(ctx, scene);
if ramping {
scene.pop_transform();
scene.pop_layer();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if !self.open {
if event.is_broadcast() {
self.content.event_child(ctx, event);
return EventResult::Ignored;
}
if let InputEvent::Pointer(p) = event
&& p.phase == PointerPhase::Down
&& self.progress > PROGRESS_EPSILON
&& self.rect.contains(p.position)
{
return EventResult::Handled;
}
return EventResult::Ignored;
}
if matches!(event, InputEvent::Pointer(p) if p.phase == PointerPhase::Down) {
ctx.request_focus();
}
if route_event_single(&mut self.content, ctx, event) == EventResult::Handled {
return EventResult::Handled;
}
if let InputEvent::Key(key) = event {
if key.key == Key::Named(NamedKey::Escape)
&& let Some(on_dismiss) = self.on_dismiss.as_mut()
{
on_dismiss(ctx);
return EventResult::Handled;
}
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !self.rect.contains(p.position)
&& let Some(on_dismiss) = self.on_dismiss.as_mut()
{
on_dismiss(ctx);
}
EventResult::Handled
}
_ => EventResult::Ignored,
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
if self.open {
self.content.semantics_child(ctx);
}
}
visit_children!(content);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{
Color, KeyEvent, Modifiers, PointerButton, PointerEvent, text::TextContext,
};
use frust::{Brightness, FrameTime};
use frust_core::RenderRoot;
use std::any::Any;
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 600.0);
const WINDOW: Size = Size::new(400.0, 600.0);
const CONTENT: Size = Size::new(120.0, 60.0);
const ANCHOR: Rect = Rect::new(100.0, 200.0, 180.0, 240.0);
fn anchor_rect(x: f64, y: f64, w: f64, h: f64) -> Rect {
Rect::from_origin_size(Point::new(x, y), Size::new(w, h))
}
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
#[test]
fn the_default_placement_is_bottom_center_at_the_material_gap() {
let p = OverlayPlacement::default();
assert_eq!(p.side, OverlaySide::Bottom);
assert_eq!(p.align, OverlayAlign::Center);
assert_eq!(p.offset, OVERLAY_ANCHOR_GAP);
assert_eq!(OVERLAY_ANCHOR_GAP, MaterialSpacing::XS);
assert!(p.flip && p.clamp);
}
#[test]
fn each_side_offsets_off_its_own_edge() {
let a = anchor_rect(100.0, 200.0, 80.0, 40.0);
let content = Size::new(120.0, 60.0);
let raw = |side| OverlayPlacement::on(side).flip(false).clamp(false);
let bottom = place_anchored(a, content, AREA, raw(OverlaySide::Bottom));
assert_eq!(bottom.y0, a.y1 + OVERLAY_ANCHOR_GAP);
let top = place_anchored(a, content, AREA, raw(OverlaySide::Top));
assert_eq!(top.y1, a.y0 - OVERLAY_ANCHOR_GAP);
let right = place_anchored(a, content, AREA, raw(OverlaySide::Right));
assert_eq!(right.x0, a.x1 + OVERLAY_ANCHOR_GAP);
let left = place_anchored(a, content, AREA, raw(OverlaySide::Left));
assert_eq!(left.x1, a.x0 - OVERLAY_ANCHOR_GAP);
for r in [bottom, top, right, left] {
assert_eq!(r.size(), content);
}
}
#[test]
fn align_lines_up_leading_center_or_trailing_edges() {
let a = anchor_rect(100.0, 200.0, 80.0, 40.0);
let content = Size::new(120.0, 60.0);
let at = |align| {
place_anchored(
a,
content,
AREA,
OverlayPlacement::on(OverlaySide::Bottom).align(align),
)
};
assert_eq!(at(OverlayAlign::Start).x0, a.x0);
assert_eq!(at(OverlayAlign::End).x1, a.x1);
assert_eq!(at(OverlayAlign::Center).center().x, a.center().x);
let at = |align| {
place_anchored(
a,
content,
AREA,
OverlayPlacement::on(OverlaySide::Right).align(align),
)
};
assert_eq!(at(OverlayAlign::Start).y0, a.y0);
assert_eq!(at(OverlayAlign::End).y1, a.y1);
assert_eq!(at(OverlayAlign::Center).center().y, a.center().y);
}
#[test]
fn a_side_that_does_not_fit_flips_to_the_opposite_one() {
let a = anchor_rect(100.0, 560.0, 80.0, 20.0);
let content = Size::new(120.0, 100.0);
let flipped = place_anchored(a, content, AREA, OverlayPlacement::default());
assert_eq!(
flipped.y1,
a.y0 - OVERLAY_ANCHOR_GAP,
"flipped above the anchor"
);
let pinned = place_anchored(a, content, AREA, OverlayPlacement::default().flip(false));
assert_eq!(pinned.y1, AREA.y1, "clamped, not flipped");
let tall = Size::new(120.0, 590.0);
let kept = place_anchored(a, tall, AREA, OverlayPlacement::default());
assert_eq!(kept.y1, AREA.y1);
assert_eq!(OverlaySide::Left.opposite(), OverlaySide::Right);
assert!(OverlaySide::Top.is_vertical() && !OverlaySide::Right.is_vertical());
}
#[test]
fn clamping_shifts_the_rect_back_inside_and_can_be_turned_off() {
let a = anchor_rect(380.0, 100.0, 20.0, 20.0);
let content = Size::new(200.0, 50.0);
let clamped = place_anchored(a, content, AREA, OverlayPlacement::default());
assert_eq!(clamped.x1, AREA.x1);
assert_eq!(
clamped.y0,
a.y1 + OVERLAY_ANCHOR_GAP,
"only the cross axis moved"
);
let free = place_anchored(a, content, AREA, OverlayPlacement::default().clamp(false));
assert!(free.x1 > AREA.x1, "unclamped placement may overflow");
let huge = Size::new(600.0, 50.0);
let pinned = place_anchored(a, huge, AREA, OverlayPlacement::default());
assert_eq!(pinned.x0, AREA.x0);
}
#[test]
fn an_uncaptured_anchor_places_at_the_area_origin() {
let content = Size::new(80.0, 40.0);
let rect = place_anchored(Rect::ZERO, content, AREA, OverlayPlacement::default());
assert_eq!(rect.x0, AREA.x0, "clamped in from the centred overhang");
assert_eq!(rect.y0, OVERLAY_ANCHOR_GAP);
}
#[derive(Default)]
struct AppState {
dismissed: u32,
pressed: u32,
}
struct Panel(Size);
struct PanelWidget(Size);
impl View<AppState> for Panel {
type Element = PanelWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> PanelWidget {
PanelWidget(self.0)
}
fn rebuild(
&self,
_prev: &Self,
element: &mut PanelWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.0 = self.0;
ChangeFlags::NONE
}
}
impl Widget for PanelWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.0)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
if p.phase == PointerPhase::Down {
ctx.state_mut::<AppState>().pressed += 1;
return EventResult::Handled;
}
EventResult::Ignored
}
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size)>,
layers: Vec<f32>,
transforms: Vec<Affine>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
self.rects.push((origin, size));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn push_layer(&mut self, _origin: Point, _size: Size, alpha: f32) {
self.layers.push(alpha);
}
fn pop_layer(&mut self) {}
fn push_transform(&mut self, transform: Affine) {
self.transforms.push(transform);
}
fn pop_transform(&mut self) {}
}
struct Harness {
root: RenderRoot<AppState, frust::StackView<AppState>>,
state: AppState,
tcx: TextContext,
anchor: OverlayAnchor,
clock_ms: f64,
}
impl Harness {
fn new() -> Self {
let anchor = OverlayAnchor::new();
anchor.set(ANCHOR);
let mut h = Harness {
root: RenderRoot::new(),
state: AppState::default(),
tcx: TextContext::new(),
anchor,
clock_ms: 0.0,
};
h.pass();
h
}
fn pass(&mut self) {
let anchor = self.anchor.clone();
let mut logic = move |_s: &mut AppState| {
frust::stack().child(
anchored_overlay(Panel(CONTENT))
.anchor(&anchor)
.on_dismiss(|s: &mut AppState| s.dismissed += 1),
)
};
self.root.rebuild(&mut logic, &mut self.state);
self.root
.layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
}
fn frame(&mut self, step_ms: f64) -> Recorder {
self.clock_ms += step_ms;
let mut rec = Recorder::default();
self.root.paint(&mut rec, ft_ms(self.clock_ms));
rec
}
fn settle(&mut self) {
self.frame(0.0);
self.frame(1000.0);
}
fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) -> EventResult {
let outcome = self.root.event(
&mut self.state,
&InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
}),
);
if outcome.handled {
EventResult::Handled
} else {
EventResult::Ignored
}
}
fn escape(&mut self) {
self.root.event(
&mut self.state,
&InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
}),
);
}
}
fn host(open: bool) -> (AnchoredOverlayWidget, OverlayAnchor) {
let cell = OverlayAnchor::new();
cell.set(ANCHOR);
let view = anchored_overlay(Panel(CONTENT)).anchor(&cell).open(open);
let mut counter = 0u64;
let mut w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(WINDOW));
(w, cell)
}
fn paint_at(w: &mut AnchoredOverlayWidget, ms: f64) -> Recorder {
let mut rec = Recorder::default();
let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(ms));
w.paint(&mut ctx, &mut rec);
rec
}
fn close(w: &mut AnchoredOverlayWidget) {
w.open = false;
w.begin_ramp(0.0);
}
fn dispatch(
w: &mut AnchoredOverlayWidget,
state: &mut AppState,
event: &InputEvent,
) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
w.event(&mut ctx, event)
}
#[test]
fn the_host_fills_its_area_and_places_the_content_against_the_anchor() {
let mut h = Harness::new();
let rect = place_anchored(ANCHOR, CONTENT, AREA, OverlayPlacement::default());
assert_eq!(rect.y0, ANCHOR.y1 + OVERLAY_ANCHOR_GAP);
assert_eq!(rect.center().x, ANCHOR.center().x);
h.settle();
let rec = h.frame(16.0);
assert_eq!(rec.rects, vec![(rect.origin(), CONTENT)]);
assert!(rec.layers.is_empty(), "a settled host composites plainly");
}
#[test]
fn an_unbounded_height_collapses_the_host_area_and_pins_the_content_to_its_top() {
let cell = OverlayAnchor::new();
cell.set(ANCHOR);
let view = anchored_overlay(Panel(CONTENT)).anchor(&cell);
let mut counter = 0u64;
let mut w = View::<AppState>::build(&view, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let scrolled = BoxConstraints::new(
Size::new(WINDOW.width, 0.0),
Size::new(WINDOW.width, f64::INFINITY),
);
let size = w.layout(&mut lctx, &scrolled);
assert_eq!(size.height, 0.0, "no vertical area to fill");
assert_eq!(
w.content_rect().y0,
0.0,
"the panel clamps to the top of a zero-height area, wherever its \
anchor sits"
);
let bounded = w.layout(&mut lctx, &BoxConstraints::tight(WINDOW));
assert_eq!(bounded, WINDOW);
assert_eq!(w.content_rect().y0, ANCHOR.y1 + OVERLAY_ANCHOR_GAP);
}
#[test]
fn a_press_outside_the_content_dismisses_and_is_swallowed() {
let mut h = Harness::new();
h.settle();
assert_eq!(
h.pointer(PointerPhase::Down, 5.0, 5.0),
EventResult::Handled,
"the light dismiss consumes the press"
);
assert_eq!(h.state.dismissed, 1);
assert_eq!(h.state.pressed, 0, "the content never saw it");
}
#[test]
fn a_press_inside_the_content_routes_to_it_and_never_dismisses() {
let mut h = Harness::new();
h.settle();
let rect = place_anchored(ANCHOR, CONTENT, AREA, OverlayPlacement::default());
let c = rect.center();
assert_eq!(
h.pointer(PointerPhase::Down, c.x, c.y),
EventResult::Handled
);
assert_eq!(h.state.pressed, 1);
assert_eq!(h.state.dismissed, 0);
}
#[test]
fn moves_outside_the_content_pass_through_untouched() {
let mut h = Harness::new();
h.settle();
assert_eq!(
h.pointer(PointerPhase::Move, 5.0, 5.0),
EventResult::Ignored,
"a hover-driven trigger under the host keeps its own moves"
);
assert_eq!(h.state.dismissed, 0);
}
#[test]
fn escape_dismisses_once_a_press_has_focused_the_host() {
let mut h = Harness::new();
h.settle();
h.escape();
assert_eq!(
h.state.dismissed, 0,
"no focus yet, so Escape has no chain to travel"
);
let rect = place_anchored(ANCHOR, CONTENT, AREA, OverlayPlacement::default());
let c = rect.center();
h.pointer(PointerPhase::Down, c.x, c.y);
h.pointer(PointerPhase::Up, c.x, c.y);
h.escape();
assert_eq!(h.state.dismissed, 1);
}
#[test]
fn a_moved_anchor_is_re_placed_on_the_next_layout() {
let mut h = Harness::new();
h.settle();
h.anchor.set(anchor_rect(20.0, 20.0, 40.0, 20.0));
h.pass();
let rec = h.frame(16.0);
let expected = place_anchored(h.anchor.rect(), CONTENT, AREA, OverlayPlacement::default());
assert_eq!(rec.rects[0].0, expected.origin());
}
#[test]
fn the_entrance_ramps_alpha_and_scale_then_settles() {
let (mut w, _cell) = host(true);
let first = paint_at(&mut w, 0.0);
assert_eq!(first.layers, vec![0.0], "starts transparent");
assert_eq!(first.transforms.len(), 1, "and scaled down");
let mid = paint_at(&mut w, 100.0);
let alpha = mid.layers[0];
assert!(alpha > 0.0 && alpha < 1.0, "mid-ramp: {alpha}");
let done = paint_at(&mut w, 1000.0);
assert!(
done.layers.is_empty() && done.transforms.is_empty(),
"a settled host composites plainly"
);
assert!((w.progress() - 1.0).abs() < 1e-9);
assert_eq!(done.rects.len(), 1, "and still paints its content");
}
#[test]
fn the_entrance_scale_pivots_on_the_edge_facing_the_trigger() {
let (mut w, _cell) = host(true);
paint_at(&mut w, 0.0);
let pivot = w.pivot();
assert_eq!(pivot.y, w.content_rect().y0);
assert!((pivot.x - ANCHOR.center().x).abs() < 1e-9);
}
#[test]
fn a_closed_kept_mounted_host_paints_nothing_once_the_exit_settles() {
let (mut w, _cell) = host(true);
paint_at(&mut w, 0.0);
paint_at(&mut w, 1000.0);
assert!((w.progress() - 1.0).abs() < 1e-9, "entered");
close(&mut w);
let mid = paint_at(&mut w, 1000.0);
assert_eq!(mid.rects.len(), 1, "still painted mid-exit");
let closing = paint_at(&mut w, 1075.0);
assert!(w.progress() > 0.0 && w.progress() < 1.0, "{}", w.progress());
assert_eq!(closing.rects.len(), 1, "and still visible");
let settled = paint_at(&mut w, 2000.0);
assert_eq!(w.progress(), 0.0);
assert!(
settled.rects.is_empty() && settled.layers.is_empty(),
"a settled closed host paints nothing at all"
);
assert!(paint_at(&mut w, 3000.0).rects.is_empty());
}
#[test]
fn a_press_is_swallowed_mid_exit_and_passes_through_once_settled() {
let (mut w, _cell) = host(true);
paint_at(&mut w, 0.0);
paint_at(&mut w, 1000.0);
close(&mut w);
paint_at(&mut w, 1000.0);
paint_at(&mut w, 1075.0);
assert!(w.progress() > PROGRESS_EPSILON, "mid-exit");
let mut state = AppState::default();
let inside = w.content_rect().center();
let outside = Point::new(5.0, 5.0);
assert_eq!(
dispatch(&mut w, &mut state, &down(inside)),
EventResult::Handled,
"the fading panel absorbs the press"
);
assert_eq!(state.pressed, 0, "and never forwards it to the content");
assert_eq!(state.dismissed, 0);
assert_eq!(
dispatch(&mut w, &mut state, &down(outside)),
EventResult::Ignored,
"a press outside it falls through to the page"
);
paint_at(&mut w, 3000.0);
assert_eq!(
dispatch(&mut w, &mut state, &down(inside)),
EventResult::Ignored
);
}
fn down(p: Point) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: p,
button: PointerButton::Primary,
})
}
#[test]
fn a_host_mounted_closed_never_paints_and_publishes_no_semantics() {
let (mut w, _cell) = host(false);
let rec = paint_at(&mut w, 0.0);
assert!(rec.rects.is_empty() && rec.layers.is_empty());
assert!(!w.is_open());
assert_eq!(w.progress(), 0.0);
assert_eq!(w.content_rect().size(), CONTENT);
}
#[test]
fn reduce_motion_collapses_the_ramp_to_a_jump() {
let mut theme = crate::baseline().with_brightness(Brightness::Light);
theme.motion.reduce_motion = true;
let paint_reduced = |w: &mut AnchoredOverlayWidget| {
let mut rec = Recorder::default();
let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, FrameTime::ZERO)
.with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
rec
};
let (mut w, _cell) = host(true);
let rec = paint_reduced(&mut w);
assert!((w.progress() - 1.0).abs() < 1e-9, "settled on the spot");
assert!(rec.layers.is_empty(), "no fade layer");
assert_eq!(rec.rects.len(), 1, "the content is there whole");
let (mut closed_host, _cell) = host(false);
let rec = paint_reduced(&mut closed_host);
assert_eq!(closed_host.progress(), 0.0);
assert!(
rec.rects.is_empty() && rec.layers.is_empty(),
"a closed host paints nothing under reduced motion either"
);
}
#[test]
fn semantics_are_published_only_while_open() {
fn logic(_s: &mut AppState) -> AnchoredOverlayView<AppState> {
anchored_overlay(frust::text("menu item")).open(true)
}
fn closed(_s: &mut AppState) -> AnchoredOverlayView<AppState> {
anchored_overlay(frust::text("menu item")).open(false)
}
let mut root: RenderRoot<AppState, AnchoredOverlayView<AppState>> = RenderRoot::new();
let mut state = AppState::default();
let mut tcx = TextContext::new();
root.rebuild(&mut logic, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let open_nodes = root.semantics().nodes.len();
root.rebuild(&mut closed, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
assert!(
root.semantics().nodes.len() < open_nodes,
"a closed host contributes no subtree"
);
}
#[test]
fn the_anchor_wrapper_captures_the_triggers_window_rect_at_paint() {
let cell = OverlayAnchor::new();
assert_eq!(cell.rect(), Rect::ZERO, "nothing captured before a paint");
let mut root: RenderRoot<AppState, frust::PaddingView<AppState>> = RenderRoot::new();
let mut state = AppState::default();
let captured = cell.clone();
let mut logic = move |_s: &mut AppState| {
frust::Padding(
frust::EdgeInsets::symmetric(24.0, 12.0),
overlay_anchor(&captured, Panel(CONTENT)),
)
};
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), FrameTime::ZERO);
assert_eq!(
cell.rect(),
Rect::from_origin_size(Point::new(24.0, 12.0), CONTENT)
);
cell.set(Rect::ZERO);
assert_eq!(cell.rect(), Rect::ZERO, "the cell is writable by hand too");
}
}