use super::support::*;
#[test]
fn settled_mode_snaps_hover_envelope_to_one() {
let (mut tree, mut state) = lay_out_counter();
state.set_animation_mode(AnimationMode::Settled);
state.hovered = Some(target(&tree, &state, "inc"));
state.apply_to_state();
let needs_redraw = state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
assert!(!needs_redraw, "Settled mode should never report in flight");
assert_eq!(
envelope_for(&tree, &state, "inc", EnvelopeKind::Hover),
Some(1.0)
);
assert_eq!(
envelope_for(&tree, &state, "inc", EnvelopeKind::Press),
Some(0.0)
);
}
#[test]
fn live_mode_eases_hover_envelope_over_multiple_ticks() {
let (mut tree, mut state) = lay_out_counter();
let t0 = Instant::now();
state.tick_visual_animations(&mut tree, t0, &Palette::default());
state.hovered = Some(target(&tree, &state, "inc"));
state.apply_to_state();
let needs_redraw = state.tick_visual_animations(
&mut tree,
t0 + std::time::Duration::from_millis(8),
&Palette::default(),
);
let mid = envelope_for(&tree, &state, "inc", EnvelopeKind::Hover).expect("hover envelope");
assert!(
needs_redraw,
"spring should still be in flight after one 8 ms tick"
);
assert!(
mid > 0.0 && mid < 1.0,
"expected envelope mid-flight, got {mid}",
);
}
#[test]
fn build_value_change_survives_hover_envelope() {
let mut tree_a =
column([row([button("X").key("x").fill(Color::rgb(255, 0, 0))])]).padding(20.0);
let mut state = UiState::new();
layout(&mut tree_a, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.set_animation_mode(AnimationMode::Settled);
state.hovered = Some(target(&tree_a, &state, "x"));
state.apply_to_state();
state.tick_visual_animations(&mut tree_a, Instant::now(), &Palette::default());
assert_eq!(
envelope_for(&tree_a, &state, "x", EnvelopeKind::Hover),
Some(1.0)
);
let mut tree_b =
column([row([button("X").key("x").fill(Color::rgb(0, 0, 255))])]).padding(20.0);
layout(&mut tree_b, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.apply_to_state();
state.tick_visual_animations(&mut tree_b, Instant::now(), &Palette::default());
let observed = find_fill(&tree_b, "x").expect("x fill");
assert_eq!(
(observed.r, observed.g, observed.b),
(0, 0, 255),
"build fill should pass through unchanged — envelope handles state delta separately",
);
assert_eq!(
envelope_for(&tree_b, &state, "x", EnvelopeKind::Hover),
Some(1.0)
);
}
#[test]
fn focus_ring_alpha_eases_in_and_out() {
let (mut tree, mut state) = lay_out_counter();
state.set_animation_mode(AnimationMode::Settled);
state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
assert_eq!(
envelope_for(&tree, &state, "inc", EnvelopeKind::FocusRing),
Some(0.0)
);
let (mut tree, _) = lay_out_counter();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.focused = Some(target(&tree, &state, "inc"));
state.set_focus_visible(true);
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
assert_eq!(
envelope_for(&tree, &state, "inc", EnvelopeKind::FocusRing),
Some(1.0)
);
let (mut tree, _) = lay_out_counter();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.focused = None;
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
assert_eq!(
envelope_for(&tree, &state, "inc", EnvelopeKind::FocusRing),
Some(0.0)
);
}
#[test]
fn focus_ring_stays_dim_when_focus_is_not_visible() {
let (mut tree, mut state) = lay_out_counter();
state.set_animation_mode(AnimationMode::Settled);
state.focused = Some(target(&tree, &state, "inc"));
assert!(!state.focus_visible);
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
assert_eq!(
envelope_for(&tree, &state, "inc", EnvelopeKind::FocusRing),
Some(0.0),
"ring must stay off until focus_visible is raised",
);
}
#[test]
fn focus_ring_lights_up_on_always_show_focus_ring_widgets_even_without_focus_visible() {
let selection = crate::selection::Selection::default();
let mut tree = column([row([crate::widgets::text_input::text_input(
"", &selection, "field",
)])])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.set_animation_mode(AnimationMode::Settled);
state.focused = Some(target(&tree, &state, "field"));
assert!(!state.focus_visible);
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
assert_eq!(
envelope_for(&tree, &state, "field", EnvelopeKind::FocusRing),
Some(1.0),
"always_show_focus_ring widgets ring on click too",
);
}
#[test]
fn focus_ring_stays_on_when_focused_node_is_also_hovered() {
let selection = crate::selection::Selection::default();
let mut tree = column([row([crate::widgets::text_input::text_input(
"", &selection, "field",
)])])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.set_animation_mode(AnimationMode::Settled);
let field = target(&tree, &state, "field");
state.focused = Some(field.clone());
state.hovered = Some(field);
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
assert_eq!(
envelope_for(&tree, &state, "field", EnvelopeKind::FocusRing),
Some(1.0),
"focus ring must stay on while the focused node is also the hover target",
);
}
#[test]
fn app_fill_settles_to_new_value_in_settled_mode() {
use crate::anim::Timing;
let mut tree_a = column([
crate::text("0"),
row([button("X")
.key("x")
.fill(Color::rgb(255, 0, 0))
.animate(Timing::SPRING_STANDARD)]),
])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree_a, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.set_animation_mode(AnimationMode::Settled);
state.tick_visual_animations(&mut tree_a, Instant::now(), &Palette::default());
assert_eq!(
find_fill(&tree_a, "x").map(|c| (c.r, c.g, c.b)),
Some((255, 0, 0))
);
let mut tree_b = column([
crate::text("0"),
row([button("X")
.key("x")
.fill(Color::rgb(0, 0, 255))
.animate(Timing::SPRING_STANDARD)]),
])
.padding(20.0);
layout(&mut tree_b, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.tick_visual_animations(&mut tree_b, Instant::now(), &Palette::default());
assert_eq!(
find_fill(&tree_b, "x").map(|c| (c.r, c.g, c.b)),
Some((0, 0, 255)),
"Settled mode should snap to the new build value",
);
}
#[test]
fn app_fill_eases_in_live_mode() {
use crate::anim::Timing;
let mut tree_a = column([row([button("X")
.key("x")
.fill(Color::rgb(255, 0, 0))
.animate(Timing::SPRING_STANDARD)])])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree_a, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
let t0 = Instant::now();
state.tick_visual_animations(&mut tree_a, t0, &Palette::default());
let mut tree_b = column([row([button("X")
.key("x")
.fill(Color::rgb(0, 0, 255))
.animate(Timing::SPRING_STANDARD)])])
.padding(20.0);
layout(&mut tree_b, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
let needs_redraw = state.tick_visual_animations(
&mut tree_b,
t0 + std::time::Duration::from_millis(8),
&Palette::default(),
);
let mid = find_fill(&tree_b, "x").expect("mid fill");
assert!(
needs_redraw,
"spring should still be in flight after one tick"
);
assert!(
mid.r < 255 && mid.b < 255,
"expected mid-flight, got {mid:?}",
);
assert!(mid.r > 0 || mid.b > 0, "should have moved off the start",);
}
#[test]
fn token_tagged_fill_eases_through_draw_ops_without_snapping() {
use crate::anim::Timing;
use crate::draw_ops::draw_ops_with_theme;
use crate::ir::DrawOp;
use crate::shader::UniformValue;
use crate::theme::Theme;
use crate::tokens;
let mut tree_a = column([row([button("X")
.key("x")
.fill(tokens::PRIMARY)
.animate(Timing::SPRING_STANDARD)])])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree_a, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
let t0 = Instant::now();
state.tick_visual_animations(&mut tree_a, t0, &Palette::default());
let mut tree_b = column([row([button("X")
.key("x")
.fill(tokens::CARD)
.animate(Timing::SPRING_STANDARD)])])
.padding(20.0);
layout(&mut tree_b, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.tick_visual_animations(
&mut tree_b,
t0 + std::time::Duration::from_millis(8),
&Palette::default(),
);
let theme = Theme::default();
let ops = draw_ops_with_theme(&tree_b, &state, &theme);
let quad_fill = ops
.iter()
.find_map(|op| match op {
DrawOp::Quad { id, uniforms, .. } if id.contains("x") => {
uniforms.get("fill").and_then(|v| match v {
UniformValue::Color(c) => Some(*c),
_ => None,
})
}
_ => None,
})
.expect("button quad fill");
let p = tokens::PRIMARY;
let c = tokens::CARD;
assert_ne!(
(quad_fill.r, quad_fill.g, quad_fill.b),
(p.r, p.g, p.b),
"rendered fill must not snap back to the source token's rgb",
);
assert_ne!(
(quad_fill.r, quad_fill.g, quad_fill.b),
(c.r, c.g, c.b),
"rendered fill must not snap forward to the target token's rgb",
);
}
#[test]
fn spring_color_converges_to_target_token_near_equilibrium() {
use crate::tokens;
use crate::widgets::checkbox::checkbox;
use std::time::Duration;
fn tree(v: bool) -> El {
column([checkbox(v).key("agree")]).padding(8.0)
}
fn find_cb(n: &El) -> Option<&El> {
if matches!(n.kind, Kind::Custom("checkbox")) {
return Some(n);
}
n.children.iter().find_map(find_cb)
}
let palette = Palette::default();
let mut state = UiState::new();
let vp = Rect::new(0.0, 0.0, 200.0, 60.0);
let mut t = tree(false);
layout(&mut t, &mut state, vp);
let t0 = Instant::now();
state.tick_visual_animations(&mut t, t0, &palette);
let mut settled_to_token = false;
for i in 1..=96 {
let mut t = tree(true);
layout(&mut t, &mut state, vp);
let now_i = t0 + Duration::from_millis(16 * i);
state.tick_visual_animations(&mut t, now_i, &palette);
let cb = find_cb(&t).unwrap();
let f = cb.fill.expect("fill present");
if f.token == Some("primary") {
settled_to_token = true;
assert_eq!(
(f.r, f.g, f.b),
(tokens::PRIMARY.r, tokens::PRIMARY.g, tokens::PRIMARY.b)
);
break;
}
}
assert!(
settled_to_token,
"spring fill must converge to `target` and restore the token; \
pre-fix this froze at ~(253,253,253) with token=None forever",
);
}
#[test]
fn anim_target_uses_active_palette_rgb_not_compile_time_constant() {
use crate::theme::Theme;
use crate::widgets::checkbox::checkbox;
use std::time::Duration;
fn tree(v: bool) -> El {
column([checkbox(v).key("agree")]).padding(8.0)
}
fn find_cb(n: &El) -> Option<&El> {
if matches!(n.kind, Kind::Custom("checkbox")) {
return Some(n);
}
n.children.iter().find_map(find_cb)
}
let theme = Theme::radix_slate_blue_dark();
let palette = theme.palette();
let mut state = UiState::new();
let vp = Rect::new(0.0, 0.0, 200.0, 60.0);
let mut t = tree(false);
layout(&mut t, &mut state, vp);
let t0 = Instant::now();
state.tick_visual_animations(&mut t, t0, palette);
let mut t = tree(true);
layout(&mut t, &mut state, vp);
state.tick_visual_animations(&mut t, t0 + Duration::from_millis(64), palette);
let cb = find_cb(&t).unwrap();
let f = cb.fill.expect("fill present");
assert!(
f.r < f.g && f.g < f.b,
"mid-flight rgb must lie on slate blue's ramp (R<G<B), not on the \
default-dark gray ramp (R≈G≈B). got fill={f:?}",
);
assert!(
f.b as i32 - f.r as i32 > 30,
"mid-flight rgb must have meaningfully diverged on the slate blue \
line, not just one rounding step apart. got fill={f:?}",
);
}
#[test]
fn app_translate_eases_on_rebuild() {
use crate::anim::Timing;
let mut tree_a = column([row([button("slide")
.key("s")
.translate(0.0, 0.0)
.animate(Timing::SPRING_STANDARD)])])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree_a, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.set_animation_mode(AnimationMode::Settled);
state.tick_visual_animations(&mut tree_a, Instant::now(), &Palette::default());
let mut tree_b = column([row([button("slide")
.key("s")
.translate(100.0, 50.0)
.animate(Timing::SPRING_STANDARD)])])
.padding(20.0);
layout(&mut tree_b, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.tick_visual_animations(&mut tree_b, Instant::now(), &Palette::default());
let n = find_node(&tree_b, "s").expect("s node");
assert!((n.translate.0 - 100.0).abs() < 0.5);
assert!((n.translate.1 - 50.0).abs() < 0.5);
}
#[test]
fn state_envelope_composes_on_app_eased_fill() {
use crate::anim::Timing;
let mut tree = column([row([button("X")
.key("x")
.fill(Color::rgb(100, 100, 100))
.animate(Timing::SPRING_STANDARD)])])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.set_animation_mode(AnimationMode::Settled);
state.hovered = Some(target(&tree, &state, "x"));
state.apply_to_state();
state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
let n_fill = find_fill(&tree, "x").expect("x fill");
assert_eq!((n_fill.r, n_fill.g, n_fill.b), (100, 100, 100));
assert_eq!(
envelope_for(&tree, &state, "x", EnvelopeKind::Hover),
Some(1.0)
);
}
#[test]
fn app_animation_skipped_when_animate_not_set() {
fn swatch(fill: Color) -> El {
El::new(Kind::Custom("swatch"))
.key("x")
.fill(fill)
.width(Size::Fixed(40.0))
.height(Size::Fixed(20.0))
}
let mut tree_a = column([row([swatch(Color::rgb(255, 0, 0))])]).padding(20.0);
let mut state = UiState::new();
layout(&mut tree_a, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.tick_visual_animations(&mut tree_a, Instant::now(), &Palette::default());
let mut tree_b = column([row([swatch(Color::rgb(0, 0, 255))])]).padding(20.0);
layout(&mut tree_b, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.tick_visual_animations(&mut tree_b, Instant::now(), &Palette::default());
let observed = find_fill(&tree_b, "x").expect("x fill");
assert_eq!(
(observed.r, observed.g, observed.b),
(0, 0, 255),
"no .animate() — value should snap",
);
}
#[test]
fn animation_entries_gc_when_node_leaves_tree() {
let (mut tree_a, mut state) = lay_out_counter();
state.hovered = Some(target(&tree_a, &state, "inc"));
state.apply_to_state();
state.tick_visual_animations(&mut tree_a, Instant::now(), &Palette::default());
let inc_id_a = find_id(&tree_a, "inc").expect("inc id");
assert!(
state
.animation
.animations
.keys()
.any(|(id, _)| id == &inc_id_a),
"expected at least one entry for inc"
);
let mut tree_b = column([crate::text("0"), row([button("-").key("dec")])]).padding(20.0);
layout(&mut tree_b, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
state.hovered = None;
state.apply_to_state();
state.tick_visual_animations(&mut tree_b, Instant::now(), &Palette::default());
assert!(
!state
.animation
.animations
.keys()
.any(|(id, _)| id == &inc_id_a),
"stale entries for inc were not GC'd"
);
}