use kui_core::{Color, Core, Easing, Enter, InputEvent, Key, NodeSpec, Size, TextStyle, Vec2};
const VIEW: Size = Size { w: 200.0, h: 120.0 };
fn frame(core: &mut Core, now: f64, show: bool) {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
if show {
ui.text_in_keyed(
"panel",
NodeSpec::column()
.size(80.0, 40.0)
.bg(Color::WHITE)
.on_click("panel")
.focusable()
.transition(100.0)
.easing(Easing::Linear)
.exit(Enter::from(40.0, 0.0).opacity(0.0)),
"bye",
TextStyle::new(12.0).color(Color::WHITE),
);
}
ui.finish();
}
fn solids(core: &mut Core) -> Vec<(f32, f32)> {
let (dl, _) = core.output();
dl.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid)
.map(|q| (q.rect.x, q.color.a))
.collect()
}
#[test]
fn a_departing_subtree_is_replayed_and_then_dropped() {
let mut core = Core::new();
frame(&mut core, 0.0, true);
let live = solids(&mut core);
assert_eq!(live.len(), 1, "the panel");
assert_eq!(live[0], (0.0, 1.0));
assert!(
!core.animating(),
"nothing is in flight while it is declared"
);
frame(&mut core, 0.0, false);
assert_eq!(solids(&mut core), vec![(0.0, 1.0)], "frozen where it left");
assert!(core.animating());
assert_eq!(core.depart.node_count(), 2, "the box and its text");
frame(&mut core, 0.05, false);
let mid = solids(&mut core);
assert_eq!(mid.len(), 1);
assert!((mid[0].0 - 20.0).abs() < 1e-3, "halfway out: {:?}", mid[0]);
assert!((mid[0].1 - 0.5).abs() < 1e-3, "halfway faded: {:?}", mid[0]);
frame(&mut core, 0.1, false);
assert!(solids(&mut core).is_empty());
assert!(!core.animating());
assert!(core.depart.is_empty());
assert_eq!(core.depart.node_count(), 0);
}
#[test]
fn a_ghost_keeps_its_own_text() {
let mut core = Core::new();
let build = |core: &mut Core, now: f64, show: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
ui.text("live", TextStyle::new(12.0).color(Color::WHITE));
if show {
ui.text_in_keyed(
"panel",
NodeSpec::column()
.size(80.0, 40.0)
.transition(100.0)
.easing(Easing::Linear)
.exit(Enter::from(0.0, 40.0)),
"bye",
TextStyle::new(12.0).color(Color::WHITE),
);
}
ui.finish();
};
let glyphs = |core: &mut Core| {
let (dl, _) = core.output();
dl.quads
.iter()
.filter(|q| q.kind != kui_core::QuadKind::Solid)
.count()
};
build(&mut core, 0.0, true);
let both = glyphs(&mut core);
assert!(both > 4, "'live' and 'bye': {both}");
build(&mut core, 0.0, false);
assert_eq!(
glyphs(&mut core),
both,
"the ghost still draws 'bye' beside the live 'live'"
);
build(&mut core, 0.2, false);
let alone = glyphs(&mut core);
assert!(alone > 0 && alone < both, "just 'live' now: {alone}");
}
#[test]
fn a_ghost_is_a_picture_not_a_node() {
let mut core = Core::new();
frame(&mut core, 0.0, true);
frame(&mut core, 0.02, false);
assert!(!solids(&mut core).is_empty(), "it is on screen");
let roles: Vec<&str> = core
.access_tree()
.nodes
.iter()
.map(|n| n.role.name())
.collect();
assert_eq!(roles, vec!["window"], "no access row for a departing node");
core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 10.0)));
let mut events = core.handle_input(InputEvent::mouse_down(1));
events.extend(core.handle_input(InputEvent::mouse_up()));
assert!(events.is_empty(), "a ghost takes no clicks: {events:?}");
assert!(!core.is_hovered(Key::ROOT.str("panel")));
core.focus_next(true);
assert_eq!(core.focus(), None, "a ghost is not a Tab stop");
}
#[test]
fn a_key_that_comes_back_wins_immediately() {
let mut core = Core::new();
frame(&mut core, 0.0, true);
frame(&mut core, 0.02, false);
assert_eq!(core.depart.node_count(), 2);
frame(&mut core, 0.04, true);
assert!(core.depart.is_empty());
assert_eq!(solids(&mut core), vec![(0.0, 1.0)]);
}
#[test]
fn without_both_halves_a_removed_node_still_vanishes_at_once() {
for spec in [
NodeSpec::column().transition(100.0),
NodeSpec::column()
.exit(Enter::from(40.0, 0.0))
.transition(0.0),
] {
let mut core = Core::new();
let build = |core: &mut Core, now: f64, show: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
if show {
ui.leaf_keyed("panel", spec.clone().size(80.0, 40.0).bg(Color::WHITE));
}
ui.finish();
};
build(&mut core, 0.0, true);
assert_eq!(solids(&mut core).len(), 1);
build(&mut core, 0.01, false);
assert!(solids(&mut core).is_empty(), "gone at once");
assert!(core.depart.is_empty());
assert!(!core.animating());
}
}
#[test]
fn a_driver_without_a_clock_gets_the_disappearance_it_always_had() {
let mut core = Core::new();
let build = |core: &mut Core, show: bool| {
let mut ui = core.frame(VIEW, 1.0);
if show {
ui.leaf_keyed(
"panel",
NodeSpec::column()
.size(80.0, 40.0)
.bg(Color::WHITE)
.transition(100.0)
.exit(Enter::from(40.0, 0.0)),
);
}
ui.finish();
};
build(&mut core, true);
build(&mut core, false);
assert!(solids(&mut core).is_empty());
assert!(core.depart.is_empty());
assert!(!core.animating());
}
#[test]
fn a_ghost_escapes_its_ancestors_clip_and_keeps_its_place() {
let mut core = Core::new();
let build = |core: &mut Core, now: f64, show: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
ui.with_keyed(
"clipper",
NodeSpec::column()
.size(50.0, 20.0)
.clip()
.bg(Color::rgb8(1, 2, 3)),
|ui| {
if show {
ui.leaf_keyed(
"inner",
NodeSpec::column()
.size(40.0, 10.0)
.bg(Color::WHITE)
.transition(100.0)
.easing(Easing::Linear)
.exit(Enter::from(200.0, 0.0)),
);
}
},
);
ui.finish();
};
build(&mut core, 0.0, true);
build(&mut core, 0.0, false);
build(&mut core, 0.05, false);
let (dl, _) = core.output();
let quads: Vec<_> = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid)
.collect();
assert_eq!(quads.len(), 2, "the clipper, and the ghost after it");
let ghost = quads[1];
let ghost_clip = dl.clip_of(ghost);
assert!(
(ghost.rect.x - 100.0).abs() < 1e-3,
"halfway to +200: {:?}",
ghost.rect
);
assert!(
ghost_clip.rect.w > 1e8,
"unclipped, though its parent clips: {:?}",
ghost_clip.rect
);
}
#[test]
fn a_ghost_keeps_the_clips_its_own_subtree_established() {
let mut core = Core::new();
let build = |core: &mut Core, now: f64, show: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
if show {
ui.with_keyed(
"box",
NodeSpec::column()
.size(50.0, 20.0)
.clip()
.bg(Color::rgb8(1, 2, 3))
.transition(100.0)
.easing(Easing::Linear)
.exit(Enter::from(200.0, 0.0)),
|ui| {
ui.leaf(NodeSpec::column().size(40.0, 30.0).bg(Color::WHITE));
ui.leaf(NodeSpec::column().size(40.0, 10.0).bg(Color::WHITE));
},
);
}
ui.finish();
};
build(&mut core, 0.0, true);
build(&mut core, 0.0, false);
build(&mut core, 0.05, false);
let (dl, _) = core.output();
let quads: Vec<_> = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid)
.collect();
assert_eq!(
quads.len(),
2,
"the box and the child it shows; the one past its edge is culled: {:?}",
quads.iter().map(|q| q.rect).collect::<Vec<_>>()
);
let (bx, child) = (quads[0], quads[1]);
let (bx_clip, child_clip) = (dl.clip_of(bx), dl.clip_of(child));
assert!(
bx_clip.rect.w > 1e8,
"the box itself is unclipped: {:?}",
bx_clip.rect
);
assert!((bx.rect.x - 100.0).abs() < 1e-3, "halfway: {:?}", bx.rect);
assert!(
(child_clip.rect.x - 100.0).abs() < 1e-3
&& (child_clip.rect.w - 50.0).abs() < 1e-3
&& (child_clip.rect.h - 20.0).abs() < 1e-3,
"the child is clipped to the box where it now is: {:?}",
child_clip.rect
);
}
fn list(core: &mut Core, now: f64, from: usize, rows: usize) {
core.set_time(now);
let mut ui = core.frame(Size::new(400.0, 400.0), 1.0);
for i in from..from + rows {
ui.leaf_indexed(
i as u64,
NodeSpec::column()
.size(10.0, 1.0)
.bg(Color::WHITE)
.transition(100.0)
.exit(Enter::default().opacity(0.0)),
);
}
ui.finish();
}
#[test]
fn a_removal_over_the_budget_animates_nothing_and_says_so() {
let mut core = Core::new();
let rows = kui_core::depart::MAX_NODES + 100;
list(&mut core, 0.0, 0, rows);
core.take_warnings();
list(&mut core, 0.01, 0, 0);
assert!(core.depart.is_empty(), "whole or not at all");
assert_eq!(core.depart.node_count(), 0);
let warnings = core.take_warnings();
assert_eq!(
warnings.len(),
1,
"one warning for the frame, not one per row"
);
assert_eq!(warnings[0].code, "exit-budget");
assert!(
warnings[0]
.message
.contains(&format!("removed {rows} nodes")),
"the sentence names the frame's removal: {}",
warnings[0].message
);
assert!(!core.animating(), "and nothing is in flight");
}
#[test]
fn a_new_removal_outranks_the_ghosts_already_in_flight() {
use kui_core::depart::MAX_NODES;
let rows = MAX_NODES / 2 + 44;
const SECOND: usize = 100_000;
let mut core = Core::new();
let both = |core: &mut Core, now: f64, first: usize, second: usize| {
core.set_time(now);
let mut ui = core.frame(Size::new(400.0, 400.0), 1.0);
for (from, n) in [(0, first), (SECOND, second)] {
for i in from..from + n {
ui.leaf_indexed(
i as u64,
NodeSpec::column()
.size(10.0, 1.0)
.bg(Color::WHITE)
.transition(100.0)
.exit(Enter::default().opacity(0.0)),
);
}
}
ui.finish();
};
both(&mut core, 0.0, rows, rows);
core.take_warnings();
both(&mut core, 0.01, 0, rows);
assert_eq!(core.depart.node_count(), rows);
both(&mut core, 0.03, 0, 0);
assert_eq!(
core.depart.node_count(),
MAX_NODES,
"full, with the new removal whole"
);
let keys: Vec<Key> = core.depart.keys().collect();
assert_eq!(keys.len(), MAX_NODES);
assert_eq!(keys[0], Key::ROOT.index(88), "the oldest 88 went, in order");
assert_eq!(
keys[rows - 88],
Key::ROOT.index(SECOND as u64),
"then the whole second list"
);
assert_eq!(
keys[MAX_NODES - 1],
Key::ROOT.index((SECOND + rows - 1) as u64)
);
assert!(
core.take_warnings().is_empty(),
"eviction is the policy, not a warning"
);
assert!(core.animating());
}
#[test]
fn a_whole_toast_stack_departs_at_once_and_plays_out() {
use kui_core::{Align, FloatConfig};
const CARDS: usize = 6;
let mut core = Core::new();
let build = |core: &mut Core, now: f64, count: usize| {
core.set_time(now);
let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
ui.with(
NodeSpec::column()
.float(FloatConfig::viewport().inside(Align::End, Align::End))
.gap(10.0),
|ui| {
for i in 0..count {
ui.text_in_keyed(
&format!("toast-{i}"),
NodeSpec::column()
.size(120.0, 30.0)
.bg(Color::WHITE)
.transition(100.0)
.easing(Easing::Linear)
.enter(Enter::from(200.0, 0.0))
.exit(Enter::from(200.0, 0.0).opacity(0.0))
.slide(),
"saved",
TextStyle::new(12.0).color(Color::WHITE),
);
}
},
);
ui.finish();
};
build(&mut core, 0.0, CARDS);
build(&mut core, 1.0, CARDS);
assert_eq!(solids(&mut core).len(), CARDS);
assert!(!core.animating());
build(&mut core, 1.0, 0);
assert_eq!(core.depart.node_count(), CARDS * 2);
assert_eq!(
solids(&mut core).len(),
CARDS,
"all of them, frozen in place"
);
assert!(core.animating(), "and the driver owes frames for them");
build(&mut core, 1.05, 0);
let mid = solids(&mut core);
assert_eq!(mid.len(), CARDS);
for (x, a) in &mid {
assert!((x - 380.0).abs() < 1e-3, "halfway to +200 from 280: {x}");
assert!((a - 0.5).abs() < 1e-3, "halfway faded: {a}");
}
build(&mut core, 1.1, 0);
assert!(solids(&mut core).is_empty());
assert!(!core.animating());
assert!(core.depart.is_empty());
}
fn painted(core: &mut Core, names: &[(Color, &'static str)]) -> Vec<&'static str> {
let (dl, _) = core.output();
dl.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid)
.map(|q| {
names
.iter()
.find(|(c, _)| {
(c.r - q.color.r).abs() < 1e-3
&& (c.g - q.color.g).abs() < 1e-3
&& (c.b - q.color.b).abs() < 1e-3
})
.map(|(_, n)| *n)
.unwrap_or("?")
})
.collect()
}
const PANEL: Color = Color {
r: 10.0 / 255.0,
g: 0.0 / 255.0,
b: 0.0 / 255.0,
a: 1.0,
};
const HUD: Color = Color {
r: 0.0 / 255.0,
g: 10.0 / 255.0,
b: 0.0 / 255.0,
a: 1.0,
};
const A: Color = Color {
r: 0.0 / 255.0,
g: 0.0 / 255.0,
b: 10.0 / 255.0,
a: 1.0,
};
const B: Color = Color {
r: 20.0 / 255.0,
g: 0.0 / 255.0,
b: 0.0 / 255.0,
a: 1.0,
};
const C: Color = Color {
r: 0.0 / 255.0,
g: 20.0 / 255.0,
b: 0.0 / 255.0,
a: 1.0,
};
const NAMES: &[(Color, &str)] = &[(PANEL, "panel"), (HUD, "hud"), (A, "a"), (B, "b"), (C, "c")];
fn leaving(spec: NodeSpec) -> NodeSpec {
spec.transition(100.0)
.easing(Easing::Linear)
.exit(Enter::from(40.0, 0.0))
}
#[test]
fn a_float_leaves_under_the_floats_that_were_above_it() {
use kui_core::{Align, FloatConfig};
let mut core = Core::new();
let build = |core: &mut Core, now: f64, panel: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
if panel {
ui.leaf_keyed(
"panel",
leaving(
NodeSpec::column()
.float(FloatConfig::viewport())
.size(60.0, 120.0)
.bg(PANEL),
),
);
}
ui.leaf(
NodeSpec::column()
.float(FloatConfig::viewport().at(Align::Start, Align::End))
.size(80.0, 20.0)
.bg(HUD),
);
ui.finish();
};
build(&mut core, 0.0, true);
assert_eq!(painted(&mut core, NAMES), ["panel", "hud"]);
build(&mut core, 0.0, false);
assert_eq!(
painted(&mut core, NAMES),
["panel", "hud"],
"the ghost keeps the panel's place under the HUD"
);
build(&mut core, 0.05, false);
assert_eq!(
painted(&mut core, NAMES),
["panel", "hud"],
"and stays there"
);
let (dl, _) = core.output();
let ghost = dl.quads.iter().find(|q| q.color == PANEL).unwrap();
assert!(
(ghost.rect.x - 20.0).abs() < 1e-3,
"and is mid-exit: {:?}",
ghost.rect
);
}
#[test]
fn an_in_flow_ghost_keeps_its_place_between_its_siblings() {
use kui_core::FloatConfig;
let mut core = Core::new();
let build = |core: &mut Core, now: f64, b: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
let cell = |bg| NodeSpec::column().size(30.0, 10.0).bg(bg);
ui.leaf_keyed("a", cell(A));
if b {
ui.leaf_keyed("b", leaving(cell(B)));
}
ui.leaf_keyed("c", cell(C));
ui.leaf_keyed("hud", cell(HUD).float(FloatConfig::viewport()));
ui.finish();
};
build(&mut core, 0.0, true);
assert_eq!(painted(&mut core, NAMES), ["a", "b", "c", "hud"]);
build(&mut core, 0.05, false);
assert_eq!(
painted(&mut core, NAMES),
["a", "b", "c", "hud"],
"b's ghost is painted under c, and under the float"
);
}
#[test]
fn a_ghost_whose_neighbour_leaves_too_keeps_the_order_they_had() {
let mut core = Core::new();
let build = |core: &mut Core, now: f64, a: bool, b: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
let cell = |bg| leaving(NodeSpec::column().size(30.0, 10.0).bg(bg));
if a {
ui.leaf_keyed("a", cell(A));
}
if b {
ui.leaf_keyed("b", cell(B));
}
ui.leaf_keyed("c", cell(C));
ui.finish();
};
build(&mut core, 0.0, true, true);
build(&mut core, 0.02, false, true);
assert_eq!(
painted(&mut core, NAMES),
["a", "b", "c"],
"a's ghost under b"
);
build(&mut core, 0.04, false, false);
assert_eq!(
painted(&mut core, NAMES),
["a", "b", "c"],
"b left too: a's ghost is under b's, and both under c"
);
build(&mut core, 0.06, false, false);
assert_eq!(painted(&mut core, NAMES), ["a", "b", "c"]);
}
#[test]
fn a_ghost_that_lost_its_place_ends_its_pass_under_the_floats() {
use kui_core::FloatConfig;
let mut core = Core::new();
let build = |core: &mut Core, now: f64, phase: u32| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
let cell = |bg| NodeSpec::column().size(30.0, 10.0).bg(bg);
ui.leaf_keyed("a", cell(A));
if phase < 1 {
ui.leaf_keyed("b", leaving(cell(B)));
}
if phase < 2 {
ui.leaf_keyed("c", cell(C));
}
ui.leaf_keyed("hud", cell(HUD).float(FloatConfig::viewport()));
ui.finish();
};
build(&mut core, 0.0, 0);
build(&mut core, 0.02, 1);
assert_eq!(painted(&mut core, NAMES), ["a", "b", "c", "hud"]);
build(&mut core, 0.04, 2);
assert_eq!(
painted(&mut core, NAMES),
["a", "b", "hud"],
"after the in-flow content, under the float"
);
}
#[test]
fn a_float_that_opened_over_another_leaves_over_it() {
use kui_core::{Align, FloatConfig};
let mut core = Core::new();
let build = |core: &mut Core, now: f64, panel: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
if panel {
ui.leaf_keyed(
"panel",
leaving(
NodeSpec::column()
.float(FloatConfig::viewport())
.size(60.0, 120.0)
.bg(PANEL),
),
);
}
ui.leaf(
NodeSpec::column()
.float(FloatConfig::viewport().at(Align::Start, Align::End))
.size(80.0, 20.0)
.bg(HUD),
);
ui.finish();
};
build(&mut core, 0.0, false);
build(&mut core, 0.0, true);
assert_eq!(painted(&mut core, NAMES), ["hud", "panel"], "opened later");
build(&mut core, 0.0, false);
assert_eq!(
painted(&mut core, NAMES),
["hud", "panel"],
"the ghost keeps the panel's place over the HUD"
);
build(&mut core, 0.05, false);
assert_eq!(painted(&mut core, NAMES), ["hud", "panel"]);
build(&mut core, 0.1, false);
assert_eq!(painted(&mut core, NAMES), ["hud"]);
}
#[test]
fn a_ghost_inside_a_float_stays_in_that_layer() {
use kui_core::FloatConfig;
let mut core = Core::new();
let build = |core: &mut Core, now: f64, b: bool| {
core.set_time(now);
let mut ui = core.frame(VIEW, 1.0);
let cell = |bg| NodeSpec::column().size(30.0, 10.0).bg(bg);
ui.with_keyed(
"panel",
NodeSpec::column()
.float(FloatConfig::viewport())
.size(60.0, 60.0)
.bg(PANEL),
|ui| {
ui.leaf_keyed("a", cell(A));
if b {
ui.leaf_keyed("b", leaving(cell(B)));
}
ui.leaf_keyed("c", cell(C));
},
);
ui.leaf_keyed("hud", cell(HUD).float(FloatConfig::viewport()));
ui.finish();
};
build(&mut core, 0.0, true);
assert_eq!(painted(&mut core, NAMES), ["panel", "a", "b", "c", "hud"]);
build(&mut core, 0.02, false);
assert_eq!(
painted(&mut core, NAMES),
["panel", "a", "b", "c", "hud"],
"between its siblings, inside the panel's layer"
);
}