use super::behavior::graph::{Card, CardKind, Chart};
use super::behavior::pulse;
use super::behavior_panel::{CHAR_W, LIST_THUMB, LIST_TRACK};
use super::registry::{self, PanelKey};
use super::theme;
use super::widget::{self, place_rounded, point_in};
use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
const BASE: u32 = registry::base(PanelKey::Behavior);
pub(crate) const HINT_LABEL: AssetId = AssetId(BASE + 0x1F0);
pub(crate) const CARD_POOL: usize = 32;
const SEG_POOL: usize = 96;
const WIRE_LABEL_POOL: usize = 32;
pub(crate) fn card_bg(i: usize) -> AssetId {
AssetId(BASE + 0x200 + i as u32)
}
pub(crate) fn card_title(i: usize) -> AssetId {
AssetId(BASE + 0x240 + i as u32)
}
pub(crate) fn card_detail(i: usize) -> AssetId {
AssetId(BASE + 0x280 + i as u32)
}
pub(crate) fn wire_label(i: usize) -> AssetId {
AssetId(BASE + 0x2C0 + i as u32)
}
pub(crate) fn segment(i: usize) -> AssetId {
AssetId(BASE + 0x300 + i as u32)
}
pub(crate) fn card_break(i: usize) -> AssetId {
AssetId(BASE + 0x360 + i as u32)
}
const CARD_W: f32 = 184.0;
const CARD_H: f32 = 46.0;
const COL_GAP: f32 = 72.0;
const ROW_GAP: f32 = 14.0;
const PITCH_X: f32 = CARD_W + COL_GAP;
const PITCH_Y: f32 = CARD_H + ROW_GAP;
const MARGIN: f32 = 12.0;
const CARD_PAD: f32 = 8.0;
const WIRE_W: f32 = 2.0;
const ELBOW_IN: f32 = 12.0;
const LABEL_IN: f32 = ELBOW_IN + 6.0;
const LABEL_RUN: f32 = COL_GAP - LABEL_IN;
const LABEL_CHAR_W: f32 = 10.0;
pub(crate) const LABEL_CHARS: usize = (LABEL_RUN / LABEL_CHAR_W) as usize;
const INDICATOR_H: f32 = 4.0;
const NODE_TINT: [f32; 4] = [0.17, 0.18, 0.23, 1.0];
const TRIGGER_TINT: [f32; 4] = [0.16, 0.30, 0.34, 1.0];
const ADD_TINT: [f32; 4] = [0.13, 0.13, 0.16, 0.85];
const VARIABLE_TINT: [f32; 4] = [0.28, 0.24, 0.16, 1.0];
const ASSET_TINT: [f32; 4] = [0.22, 0.19, 0.36, 1.0];
const MISSING_TINT: [f32; 4] = [0.32, 0.14, 0.17, 1.0];
const BORDER_TINT: [f32; 4] = [0.30, 0.32, 0.40, 1.0];
const SELECTED_BORDER: [f32; 4] = [0.55, 0.70, 0.98, 1.0];
const FAULT_BORDER: [f32; 4] = [0.85, 0.42, 0.45, 1.0];
const HOVER_BORDER: [f32; 4] = [0.44, 0.48, 0.60, 1.0];
const WIRE_TINT: [f32; 4] = [0.38, 0.41, 0.52, 1.0];
const TRACK_TINT: [f32; 4] = [0.12, 0.12, 0.15, 0.9];
const THUMB_TINT: [f32; 4] = [0.40, 0.44, 0.56, 0.95];
const BREAK_TINT: [f32; 4] = [0.85, 0.30, 0.32, 1.0];
const BREAK_R: f32 = 3.5;
pub(crate) struct ChartView<'a> {
pub chart: &'a Chart,
pub selected: Option<usize>,
pub faulted: Option<usize>,
pub pulses: &'a [(usize, f32)],
pub breakpoints: &'a [usize],
pub pan: [f32; 2],
pub mouse: [f32; 2],
pub overflow: &'static str,
}
pub(crate) fn extent(chart: &Chart) -> [f32; 2] {
[
chart.columns as f32 * PITCH_X - COL_GAP + MARGIN * 2.0,
chart.rows as f32 * PITCH_Y - ROW_GAP + MARGIN * 2.0,
]
}
pub(crate) fn max_pan(chart: &Chart, canvas: [f32; 2]) -> [f32; 2] {
let e = extent(chart);
[(e[0] - canvas[0]).max(0.0), (e[1] - canvas[1]).max(0.0)]
}
pub(crate) fn clamp_pan(pan: [f32; 2], chart: &Chart, canvas: [f32; 2]) -> [f32; 2] {
let m = max_pan(chart, canvas);
[pan[0].clamp(0.0, m[0]), pan[1].clamp(0.0, m[1])]
}
pub(crate) fn card_rect(card: &Card, band: [f32; 4], pan: [f32; 2]) -> [f32; 4] {
[
band[0] + MARGIN + card.column as f32 * PITCH_X - pan[0],
band[1] + MARGIN + card.row as f32 * PITCH_Y - pan[1],
CARD_W,
CARD_H,
]
}
pub(crate) fn pan_to(card: &Card, canvas: [f32; 2], pan: [f32; 2], chart: &Chart) -> [f32; 2] {
let x = MARGIN + card.column as f32 * PITCH_X;
let y = MARGIN + card.row as f32 * PITCH_Y;
let want = [
pan[0].clamp(x + CARD_W + MARGIN - canvas[0], x - MARGIN),
pan[1].clamp(y + CARD_H + MARGIN - canvas[1], y - MARGIN),
];
clamp_pan(want, chart, canvas)
}
pub(crate) fn width_for(columns: usize) -> f32 {
columns as f32 * PITCH_X - COL_GAP + MARGIN * 2.0 + 2.0
}
pub(crate) fn band(o: [f32; 2], s: [f32; 2], body_top: f32) -> [f32; 4] {
[
o[0] + 1.0,
body_top,
(s[0] - 2.0).max(0.0),
(o[1] + s[1] - body_top - 2.0).max(0.0),
]
}
fn visible_part(rect: [f32; 4], band: [f32; 4]) -> Option<[f32; 4]> {
let x0 = rect[0].max(band[0]);
let y0 = rect[1].max(band[1]);
let x1 = (rect[0] + rect[2]).min(band[0] + band[2]);
let y1 = (rect[1] + rect[3]).min(band[1] + band[3]);
(x1 > x0 && y1 > y0).then_some([x0, y0, x1 - x0, y1 - y0])
}
fn overlaps(a: [f32; 4], b: [f32; 4]) -> bool {
a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]
}
pub(crate) fn hit_card(view: &ChartView, mx: f32, my: f32, band: [f32; 4]) -> Option<usize> {
if !point_in(mx, my, band) {
return None;
}
view.chart
.cards
.iter()
.take(CARD_POOL)
.position(|c| point_in(mx, my, card_rect(c, band, view.pan)))
}
pub(crate) fn apply(world: &mut World, view: &ChartView, band: [f32; 4]) {
layout_wires(world, view, band);
layout_cards(world, view, band);
layout_hint(world, view, band);
layout_indicator(world, view, band);
}
fn layout_cards(world: &mut World, view: &ChartView, band: [f32; 4]) {
for slot in 0..CARD_POOL {
let Some(card) = view.chart.cards.get(slot) else {
hide_card(world, slot);
continue;
};
let rect = card_rect(card, band, view.pan);
let Some(part) = visible_part(rect, band) else {
hide_card(world, slot);
continue;
};
let selected = view.selected == Some(slot);
let faulted = view.faulted == Some(slot);
let hovered = point_in(view.mouse[0], view.mouse[1], rect);
let border = if faulted {
FAULT_BORDER
} else if selected {
SELECTED_BORDER
} else if hovered {
HOVER_BORDER
} else {
BORDER_TINT
};
let pulse_alpha = view
.pulses
.iter()
.find(|(s, _)| *s == slot)
.map(|(_, a)| *a)
.unwrap_or(0.0);
widget::place_bordered(
world,
card_bg(slot),
part,
pulse::blend(fill(card.kind), pulse_alpha),
border,
if selected || faulted { 2.0 } else { 1.0 },
);
place_rounded(
world,
card_break(slot),
[
part[0] + part[2] - BREAK_R * 2.0 - 5.0,
part[1] + 5.0,
BREAK_R * 2.0,
BREAK_R * 2.0,
],
BREAK_TINT,
BREAK_R,
view.breakpoints.contains(&slot),
);
let readable = part[0] <= rect[0] + 0.5 && part[3] >= CARD_H - 0.5;
let budget = ((part[2] - CARD_PAD * 2.0) / CHAR_W).max(0.0) as usize;
if !readable || budget < 4 {
widget::set_label_visible(world, card_title(slot), false);
widget::set_label_visible(world, card_detail(slot), false);
continue;
}
widget::place_left_label(
world,
card_title(slot),
[part[0] + CARD_PAD, part[1] + 9.0],
&widget::clip_text(&card.title, budget),
title_color(card.kind),
true,
);
widget::place_left_label(
world,
card_detail(slot),
[part[0] + CARD_PAD, part[1] + 27.0],
&widget::clip_text(&card.detail, budget),
theme::LABEL_DIM,
!card.detail.is_empty(),
);
}
}
fn fill(kind: CardKind) -> [f32; 4] {
match kind {
CardKind::Trigger => TRIGGER_TINT,
CardKind::Node | CardKind::Behavior => NODE_TINT,
CardKind::Add => ADD_TINT,
CardKind::Variable => VARIABLE_TINT,
CardKind::Asset => ASSET_TINT,
CardKind::Missing => MISSING_TINT,
}
}
fn title_color(kind: CardKind) -> [f32; 3] {
match kind {
CardKind::Add => theme::LABEL_DIM,
CardKind::Missing => theme::LOG_ERROR,
_ => theme::HEADING,
}
}
fn hide_card(world: &mut World, slot: usize) {
widget::set_sprite_visible(world, card_bg(slot), false);
widget::set_sprite_visible(world, card_break(slot), false);
widget::set_label_visible(world, card_title(slot), false);
widget::set_label_visible(world, card_detail(slot), false);
}
fn layout_wires(world: &mut World, view: &ChartView, band: [f32; 4]) {
let mut seg = 0;
let mut label = 0;
let mut taken: Vec<[f32; 4]> = Vec::new();
for wire in &view.chart.wires {
let (Some(from), Some(to)) = (
view.chart
.cards
.get(wire.from)
.filter(|_| wire.from < CARD_POOL),
view.chart
.cards
.get(wire.to)
.filter(|_| wire.to < CARD_POOL),
) else {
continue;
};
let a = card_rect(from, band, view.pan);
let b = card_rect(to, band, view.pan);
let (ax, ay) = (a[0] + a[2], a[1] + a[3] * 0.5);
let (bx, by) = (b[0], b[1] + b[3] * 0.5);
let mid = ax + ELBOW_IN;
for rect in elbow(ax, ay, bx, by, mid) {
if seg >= SEG_POOL {
break;
}
if let Some(part) = visible_part(rect, band) {
place_rounded(world, segment(seg), part, WIRE_TINT, 0.0, true);
seg += 1;
}
}
if let Some(text) = wire.label.as_deref().filter(|_| label < WIRE_LABEL_POOL) {
let text = widget::clip_text(text, LABEL_CHARS);
let x = (ax + LABEL_IN).max(bx - LABEL_RUN);
let w = text.chars().count() as f32 * LABEL_CHAR_W;
let free = [by - theme::TEXT_HALF - 9.0, by + 9.0]
.into_iter()
.map(|y| [x, y, w, theme::TEXT_HALF * 2.0])
.find(|over| !taken.iter().any(|r| overlaps(*r, *over)));
if let Some(over) = free.filter(|over| point_in(over[0], over[1], band)) {
widget::place_left_label(
world,
wire_label(label),
[over[0], over[1]],
&text,
theme::LABEL_DIM,
true,
);
taken.push(over);
label += 1;
}
}
}
for i in seg..SEG_POOL {
widget::set_sprite_visible(world, segment(i), false);
}
for i in label..WIRE_LABEL_POOL {
widget::set_label_visible(world, wire_label(i), false);
}
}
fn elbow(ax: f32, ay: f32, bx: f32, by: f32, mid: f32) -> Vec<[f32; 4]> {
let half = WIRE_W * 0.5;
if (by - ay).abs() <= 1.0 {
return vec![[ax, ay - half, (bx - ax).max(0.0), WIRE_W]];
}
vec![
[ax, ay - half, (mid - ax).max(0.0), WIRE_W],
[
mid - half,
ay.min(by) - half,
WIRE_W,
(by - ay).abs() + WIRE_W,
],
[mid - half, by - half, (bx - mid).max(0.0) + half, WIRE_W],
]
}
fn layout_hint(world: &mut World, view: &ChartView, band: [f32; 4]) {
let total = view.chart.cards.len();
let hidden = total.saturating_sub(CARD_POOL);
widget::place_message(
world,
HINT_LABEL,
[
band[0] + MARGIN,
band[1] + band[3] - 18.0,
(band[2] - MARGIN * 2.0).max(0.0),
widget::LINE_H,
],
&format!("{hidden} more {}", view.overflow),
theme::LOG_WARN,
hidden > 0,
);
}
fn layout_indicator(world: &mut World, view: &ChartView, band: [f32; 4]) {
let e = extent(view.chart);
if e[0] <= band[2] {
widget::set_sprite_visible(world, LIST_TRACK, false);
widget::set_sprite_visible(world, LIST_THUMB, false);
return;
}
let y = band[1] + band[3] - INDICATOR_H - 2.0;
place_rounded(
world,
LIST_TRACK,
[band[0] + MARGIN, y, band[2] - MARGIN * 2.0, INDICATOR_H],
TRACK_TINT,
INDICATOR_H * 0.5,
true,
);
let track_w = band[2] - MARGIN * 2.0;
let thumb_w = (track_w * band[2] / e[0]).max(20.0);
let max = max_pan(view.chart, [band[2], band[3]])[0].max(1.0);
let off = (track_w - thumb_w) * (view.pan[0] / max).clamp(0.0, 1.0);
place_rounded(
world,
LIST_THUMB,
[band[0] + MARGIN + off, y, thumb_w, INDICATOR_H],
THUMB_TINT,
INDICATOR_H * 0.5,
true,
);
}
pub(crate) fn hide_all(world: &mut World) {
widget::hide_all(world, &all_sprite_ids(), &all_label_ids(), &[]);
}
pub(crate) fn all_sprite_ids() -> Vec<AssetId> {
let mut ids: Vec<AssetId> = (0..SEG_POOL).map(segment).collect();
ids.extend((0..CARD_POOL).map(card_bg));
ids.extend((0..CARD_POOL).map(card_break));
ids
}
pub(crate) fn all_label_ids() -> Vec<AssetId> {
let mut ids = vec![HINT_LABEL];
ids.extend((0..WIRE_LABEL_POOL).map(wire_label));
ids.extend((0..CARD_POOL).map(card_title));
ids.extend((0..CARD_POOL).map(card_detail));
ids
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{Sprite, TextLabel};
use crate::editor::behavior::graph;
use serde_json::json;
fn injected_world() -> World {
let mut world = World::new();
for id in all_sprite_ids() {
world.add_component(Sprite {
asset_id: id,
..Default::default()
});
}
for id in all_label_ids() {
world.add_component(TextLabel {
asset_id: id,
..Default::default()
});
}
world
}
fn branching() -> Chart {
graph::chart(&json!({
"on": "tick",
"do": [
{"if": {
"cond": {"bool": true},
"then": [{"show": {"target": "self"}}],
"else": [{"hide": {"target": "self"}}],
}},
{"save": {}},
],
}))
}
fn view<'a>(chart: &'a Chart, pan: [f32; 2]) -> ChartView<'a> {
ChartView {
overflow: "nodes -- open the outline to reach them",
chart,
selected: None,
faulted: None,
pulses: &[],
breakpoints: &[],
pan,
mouse: [-1.0, -1.0],
}
}
const BAND: [f32; 4] = [100.0, 200.0, 600.0, 300.0];
fn label(world: &World, id: AssetId) -> TextLabel {
world
.query::<TextLabel>()
.find(|l| l.asset_id == id)
.cloned()
.unwrap()
}
fn sprite(world: &World, id: AssetId) -> Sprite {
world
.query::<Sprite>()
.find(|s| s.asset_id == id)
.cloned()
.unwrap()
}
#[test]
fn cards_lay_out_left_to_right_at_the_pitch() {
let chart = branching();
let trigger = card_rect(&chart.cards[0], BAND, [0.0, 0.0]);
let branch = card_rect(&chart.cards[1], BAND, [0.0, 0.0]);
assert_eq!(branch[0] - trigger[0], PITCH_X);
assert_eq!(branch[1], trigger[1]);
let otherwise = chart.cards.iter().find(|c| c.title == "hide").unwrap();
assert_eq!(
card_rect(otherwise, BAND, [0.0, 0.0])[1] - branch[1],
PITCH_Y
);
}
#[test]
fn panning_moves_the_cards_and_stops_at_the_chart_edge() {
let chart = branching();
let at_zero = card_rect(&chart.cards[0], BAND, [0.0, 0.0]);
let panned = card_rect(&chart.cards[0], BAND, [40.0, 0.0]);
assert_eq!(at_zero[0] - panned[0], 40.0);
let e = extent(&chart);
assert!(e[0] > BAND[2], "{e:?}");
let canvas = [BAND[2], BAND[3]];
assert_eq!(clamp_pan([-30.0, -30.0], &chart, canvas), [0.0, 0.0]);
assert_eq!(
clamp_pan([9_000.0, 9_000.0], &chart, canvas),
max_pan(&chart, canvas),
);
}
#[test]
fn a_card_off_the_band_keeps_its_visible_part_and_drops_its_text() {
let chart = branching();
let mut world = injected_world();
apply(&mut world, &view(&chart, [MARGIN + 60.0, 0.0]), BAND);
let bg = sprite(&world, card_bg(0));
assert!(bg.visible);
assert_eq!(bg.x, BAND[0]);
assert_eq!(bg.width, CARD_W - 60.0);
assert!(!label(&world, card_title(0)).visible, "text left the card");
apply(
&mut world,
&view(&chart, [MARGIN + CARD_W + 40.0, 0.0]),
BAND,
);
assert!(!sprite(&world, card_bg(0)).visible);
}
#[test]
fn a_pin_label_clears_both_the_cards_and_its_own_wire() {
check_wire_labels(branching());
}
#[test]
fn a_long_wire_label_is_kept_inside_the_gap() {
let mut chart = branching();
for wire in &mut chart.wires {
wire.label = Some("reads".to_string());
}
check_wire_labels(chart);
}
fn check_wire_labels(chart: Chart) {
let mut world = injected_world();
let band = [100.0, 200.0, 2_000.0, 600.0];
apply(&mut world, &view(&chart, [0.0, 0.0]), band);
let cards: Vec<[f32; 4]> = chart
.cards
.iter()
.map(|c| card_rect(c, band, [0.0, 0.0]))
.collect();
let verticals: Vec<[f32; 4]> = (0..SEG_POOL)
.map(|i| sprite(&world, segment(i)))
.filter(|s| s.visible && s.width <= WIRE_W + 0.01)
.map(|s| [s.x, s.y, s.width, s.height])
.collect();
assert!(!verticals.is_empty(), "the else branch drops a run");
let mut checked = 0;
for i in 0..WIRE_LABEL_POOL {
let l = label(&world, wire_label(i));
if !l.visible {
continue;
}
checked += 1;
let rect = [
l.x,
l.y,
l.content.chars().count() as f32 * CHAR_W,
theme::TEXT_HALF * 2.0,
];
for card in &cards {
assert!(
!overlaps(rect, *card),
"'{}' over a card {card:?}",
l.content
);
}
for run in &verticals {
assert!(!overlaps(rect, *run), "'{}' over a wire {run:?}", l.content);
}
}
assert!(checked >= 2, "both branches are labelled");
}
fn wired(places: &[(usize, usize)], wires: &[(usize, usize, &str)]) -> Chart {
Chart {
cards: places
.iter()
.map(|&(column, row)| Card {
column,
row,
title: "card".to_string(),
detail: String::new(),
kind: CardKind::Behavior,
path: Vec::new(),
settles: Vec::new(),
behavior: None,
})
.collect(),
wires: wires
.iter()
.map(|&(from, to, label)| graph::Wire {
from,
to,
label: Some(label.to_string()),
})
.collect(),
columns: places.iter().map(|p| p.0 + 1).max().unwrap_or(0),
rows: places.iter().map(|p| p.1 + 1).max().unwrap_or(0),
}
}
const WIDE: [f32; 4] = [100.0, 200.0, 2_000.0, 600.0];
fn label_rects(world: &World) -> Vec<[f32; 4]> {
(0..WIRE_LABEL_POOL)
.map(|i| label(world, wire_label(i)))
.filter(|l| l.visible)
.map(|l| {
[
l.x,
l.y,
l.content.chars().count() as f32 * LABEL_CHAR_W,
theme::TEXT_HALF * 2.0,
]
})
.collect()
}
#[test]
fn two_labels_leaving_one_card_land_apart() {
let chart = wired(
&[(0, 0), (1, 0), (3, 0)],
&[(0, 1, "sets"), (0, 2, "hides")],
);
let mut world = injected_world();
apply(&mut world, &view(&chart, [0.0, 0.0]), WIDE);
let drawn = label_rects(&world);
assert_eq!(drawn.len(), 2, "both wires are labelled");
assert!(!overlaps(drawn[0], drawn[1]), "{drawn:?} share a place");
for (r, card) in drawn.iter().zip([&chart.cards[1], &chart.cards[2]]) {
let card = card_rect(card, WIDE, [0.0, 0.0]);
assert!(r[0] + r[2] <= card[0], "{r:?} reaches {card:?}");
}
}
#[test]
fn two_labels_reaching_one_card_take_both_sides_of_its_run() {
let chart = wired(
&[(0, 0), (0, 1), (1, 0)],
&[(0, 2, "hides"), (1, 2, "shows")],
);
let mut world = injected_world();
apply(&mut world, &view(&chart, [0.0, 0.0]), WIDE);
let drawn = label_rects(&world);
assert_eq!(drawn.len(), 2, "neither wire loses its word");
assert!(!overlaps(drawn[0], drawn[1]), "{drawn:?} share a place");
let run = card_rect(&chart.cards[2], WIDE, [0.0, 0.0])[1] + CARD_H * 0.5;
assert!(drawn[0][1] + drawn[0][3] <= run, "{drawn:?}");
assert!(drawn[1][1] >= run, "{drawn:?}");
}
#[test]
fn a_branch_wire_elbows_and_carries_its_pin_label() {
let chart = branching();
let mut world = injected_world();
apply(&mut world, &view(&chart, [0.0, 0.0]), BAND);
let labels: Vec<String> = (0..WIRE_LABEL_POOL)
.map(|i| label(&world, wire_label(i)))
.filter(|l| l.visible)
.map(|l| l.content)
.collect();
assert!(labels.contains(&"then".to_string()), "{labels:?}");
assert!(labels.contains(&"else".to_string()), "{labels:?}");
let drawn: Vec<Sprite> = (0..SEG_POOL)
.map(|i| sprite(&world, segment(i)))
.filter(|s| s.visible)
.collect();
assert!(
drawn.iter().any(|s| (s.width - WIRE_W).abs() < 0.01),
"an elbow is more than one run"
);
for s in &drawn {
assert!(
(s.height - WIRE_W).abs() < 0.01 || (s.width - WIRE_W).abs() < 0.01,
"{s:?}",
);
}
}
#[test]
fn clicking_lands_on_the_card_under_the_cursor() {
let chart = branching();
let v = view(&chart, [0.0, 0.0]);
let target = card_rect(&chart.cards[1], BAND, [0.0, 0.0]);
let hit = hit_card(&v, target[0] + 4.0, target[1] + 4.0, BAND);
assert_eq!(hit, Some(1));
assert_eq!(
hit_card(&v, target[0] - COL_GAP * 0.5, target[1] + 4.0, BAND),
None
);
assert_eq!(hit_card(&v, BAND[0] - 5.0, BAND[1] + 5.0, BAND), None);
}
#[test]
fn selecting_a_card_pans_only_as_far_as_it_must() {
let chart = branching();
let narrow = [100.0, 200.0, 300.0, 300.0];
let canvas = [narrow[2], narrow[3]];
assert_eq!(
pan_to(&chart.cards[0], canvas, [0.0, 0.0], &chart),
[0.0, 0.0]
);
let far = chart.cards.iter().find(|c| c.title == "save").unwrap();
let pan = pan_to(far, canvas, [0.0, 0.0], &chart);
let rect = card_rect(far, narrow, pan);
assert!(rect[0] >= narrow[0], "{rect:?}");
assert!(
rect[0] + rect[2] <= narrow[0] + narrow[2] + 0.01,
"{rect:?}"
);
}
#[test]
fn a_body_past_the_pool_says_how_much_is_missing() {
let body: Vec<serde_json::Value> =
(0..CARD_POOL + 5).map(|_| json!({"save": {}})).collect();
let chart = graph::chart(&json!({"on": "start", "do": body}));
let mut world = injected_world();
apply(&mut world, &view(&chart, [0.0, 0.0]), BAND);
let hint = label(&world, HINT_LABEL);
assert!(hint.visible);
assert!(hint.content.starts_with("7 more nodes"), "{}", hint.content);
}
}