#![cfg(not(target_arch = "wasm32"))]
mod common;
use iced::advanced::widget::{Tree, Widget};
use iced::advanced::{Layout, layout, mouse, renderer};
use iced::widget::text;
use iced::{Color, Element, Length, Point, Rectangle, Size, Theme};
use iced_wgpu::core::clipboard;
use common::shared;
use iced_nodegraph::{ColorQuad, NodeGraph, NodeStyle, Pattern, default_node_style, node};
use iced_wgpu::Renderer;
use iced_wgpu::graphics::Viewport;
const W: u32 = 320;
const H: u32 = 240;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct RouteIds;
impl iced_nodegraph::Ids for RouteIds {
type NodeId = usize;
type PinId = usize;
type EdgeId = usize;
type AnchorId = usize;
type Payload = ();
}
fn render_one_node() -> Option<Vec<[u8; 4]>> {
let mut guard = shared()?;
let renderer = &mut *guard;
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(
Point::new(W as f32 * 0.5 - 40.0, H as f32 * 0.5 - 20.0),
1.0,
);
graph = graph.push_node(node(
0_usize,
Point::new(0.0, 0.0),
Element::from(text("Hi")),
));
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Unavailable,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
Some(bytes.as_chunks::<4>().0.to_vec())
}
#[test]
fn full_widget_renders_localized_node() {
use std::collections::HashMap;
let Some(px) = render_one_node() else {
eprintln!("no GPU adapter - skipping full_widget_renders_localized_node");
return;
};
assert_eq!(px.len(), (W * H) as usize);
let mut counts: HashMap<[u8; 4], usize> = HashMap::new();
for p in &px {
*counts.entry(*p).or_default() += 1;
}
let distinct = counts.len();
let (&bg, &bg_count) = counts.iter().max_by_key(|(_, c)| **c).unwrap();
let bg_frac = bg_count as f32 / px.len() as f32;
assert!(
distinct > 3,
"near-uniform frame ({distinct} distinct colours): background or node \
failed to render",
);
assert!(
bg_frac < 0.97,
"node did not render: background colour {bg:?} covers {:.1}% of the frame",
bg_frac * 100.0,
);
assert!(
bg_frac > 0.30,
"full-screen garbage: no dominant background (top colour only {:.1}%)",
bg_frac * 100.0,
);
}
fn zoomout_grid_missing_nodes(scale: f32, frames: u32) -> Option<usize> {
zoomout_grid_missing_nodes_at(scale, frames, Point::new(-327.7, -132.0), 0.24131)
}
fn zoomout_grid_missing_nodes_at(scale: f32, frames: u32, cam: Point, zoom: f32) -> Option<usize> {
use iced::widget::container;
use iced::widget::text;
const GW: u32 = 640;
const GH: u32 = 480;
let pw = (GW as f32 * scale) as u32;
let ph = (GH as f32 * scale) as u32;
let mut guard = shared()?;
let renderer = &mut *guard;
let nw = 60.0_f32;
let nh = 40.0_f32;
let lattice: Vec<(f32, f32)> = {
let mut v = Vec::new();
let mut tly = 30.0;
while tly < GH as f32 - 30.0 {
let mut tlx = 30.0;
while tlx < GW as f32 - 30.0 {
v.push((tlx, tly));
tlx += 42.0;
}
tly += 38.0;
}
v
};
let world_of = |tlx: f32, tly: f32| (tlx / zoom - cam.x, tly / zoom - cam.y);
let centers: Vec<(f32, f32)> = lattice
.iter()
.map(|&(tlx, tly)| (tlx + nw * zoom * 0.5, tly + nh * zoom * 0.5))
.collect();
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(GW as f32, GH as f32));
let mut px: Vec<[u8; 4]> = Vec::new();
for _ in 0..frames.max(1) {
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(GW as f32))
.height(Length::Fixed(GH as f32))
.camera(cam, zoom);
for (id, &(tlx, tly)) in lattice.iter().enumerate() {
let (wx, wy) = world_of(tlx, tly);
graph = graph.push_node(
node(
id,
Point::new(wx, wy),
Element::from(
container(text(""))
.width(Length::Fixed(nw))
.height(Length::Fixed(nh)),
),
)
.style(|theme, status| NodeStyle {
fill_color: ColorQuad::solid(Color::from_rgb(1.0, 0.0, 0.0)),
..default_node_style(theme, status)
}),
);
}
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(GW as f32, GH as f32)),
);
let layout = Layout::new(&layout_node);
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Unavailable,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(pw, ph), scale),
Color::TRANSPARENT,
);
px = bytes.as_chunks::<4>().0.to_vec();
}
let is_red = |p: &[u8; 4]| p[0] > 120 && p[1] < 90 && p[2] < 90;
let exp_w = nw * zoom * scale;
let exp_h = nh * zoom * scale;
let mut missing = 0usize;
for (scx, scy) in ¢ers {
if *scx < 16.0 || *scy < 16.0 || *scx > (GW as f32 - 16.0) || *scy > (GH as f32 - 16.0) {
continue;
}
let cx = (*scx * scale) as i32;
let cy = (*scy * scale) as i32;
let win = (24.0 * scale) as i32;
let (mut rminx, mut rminy, mut rmaxx, mut rmaxy) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
let mut red = 0;
for dy in -win..=win {
for dx in -win..=win {
let x = cx + dx;
let y = cy + dy;
if x < 0 || y < 0 || x >= pw as i32 || y >= ph as i32 {
continue;
}
if is_red(&px[(y as u32 * pw + x as u32) as usize]) {
red += 1;
rminx = rminx.min(x);
rminy = rminy.min(y);
rmaxx = rmaxx.max(x);
rmaxy = rmaxy.max(y);
}
}
}
if red < 4 {
missing += 1;
continue;
}
let bw = (rmaxx - rminx + 1) as f32;
let bh = (rmaxy - rminy + 1) as f32;
let wrong_size = bw < exp_w * 0.5
|| bh < exp_h * 0.5
|| bw > exp_w * 2.2 + 6.0
|| bh > exp_h * 2.2 + 6.0;
if wrong_size {
missing += 1;
}
}
Some(missing)
}
#[test]
fn zoomout_grid_all_nodes_render() {
for scale in [1.0_f32, 1.5, 2.0] {
let Some(missing) = zoomout_grid_missing_nodes(scale, 1) else {
eprintln!("no GPU adapter - skipping zoomout_grid_all_nodes_render");
return;
};
assert_eq!(
missing, 0,
"{missing} node fills did not render at zoom 0.24, scale {scale} \
(zoom-out float collapse)",
);
}
}
#[test]
fn offscreen_node_does_not_desync_later_nodes() {
use iced::widget::{container, text};
const GW: u32 = 800;
const GH: u32 = 320;
const VW: u32 = 640; let zoom = 1.0_f32;
let cam = Point::new(0.0, 0.0);
let nw = 80.0_f32;
let nh = 44.0_f32;
let Some(mut guard) = shared() else {
eprintln!("no GPU adapter - skipping offscreen_node_does_not_desync_later_nodes");
return;
};
let renderer = &mut *guard;
let visible = [(60.0_f32, 120.0_f32), (240.0, 120.0), (420.0, 120.0)];
let mut worlds = vec![(700.0_f32, 120.0_f32)];
worlds.extend_from_slice(&visible);
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(GW as f32))
.height(Length::Fixed(GH as f32))
.camera(cam, zoom);
for (id, &(wx, wy)) in worlds.iter().enumerate() {
graph = graph.push_node(
node(
id,
Point::new(wx, wy),
Element::from(
container(text("Xy"))
.width(Length::Fixed(nw))
.height(Length::Fixed(nh)),
),
)
.style(|theme, status| NodeStyle {
fill_color: ColorQuad::solid(Color::from_rgb(0.10, 0.15, 0.55)),
shadow_distance: 0.0,
shadow_color: Color::TRANSPARENT,
..default_node_style(theme, status)
}),
);
}
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(GW as f32, GH as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(GW as f32, GH as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Unavailable,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(VW, GH), 1.0),
Color::from_rgb(0.0, 0.0, 0.0),
);
let px: Vec<[u8; 4]> = bytes.as_chunks::<4>().0.to_vec();
let is_fill = |p: &[u8; 4]| p[2] > 90 && p[2] as i32 > p[0] as i32 + 30;
let is_text = |p: &[u8; 4]| p[0] > 170 && p[1] > 170 && p[2] > 170;
let mut broken = Vec::new();
for (i, &(wx, wy)) in visible.iter().enumerate() {
let cx = ((wx + nw * 0.5 + cam.x) * zoom) as i32;
let cy = ((wy + nh * 0.5 + cam.y) * zoom) as i32;
let (mut fill_px, mut text_px) = (0, 0);
for dy in -20..=20i32 {
for dx in -36..=36i32 {
let x = cx + dx;
let y = cy + dy;
if x < 0 || y < 0 || x >= VW as i32 || y >= GH as i32 {
continue;
}
let p = &px[(y as u32 * VW + x as u32) as usize];
if is_fill(p) {
fill_px += 1;
}
if is_text(p) {
text_px += 1;
}
}
}
if fill_px < 100 || text_px < 3 {
broken.push((i + 1, fill_px, text_px));
}
}
assert!(
broken.is_empty(),
"off-viewport node 0 desynced later nodes' DrawData: broken (node, fill_px, text_px) = {broken:?}",
);
}
#[test]
fn full_widget_render_is_deterministic() {
let Some(a) = render_one_node() else {
eprintln!("no GPU adapter - skipping full_widget_render_is_deterministic");
return;
};
let b = render_one_node().expect("GPU was available a moment ago");
let differ = a.iter().zip(b.iter()).filter(|(x, y)| x != y).count();
assert_eq!(differ, 0, "full-widget render flickered on {differ} pixels");
}
#[test]
#[ignore = "visual probe: writes selected_node_idle.png / selected_node_sel.png"]
fn probe_selected_node_appearance() {
for (selected, name) in [(false, "idle"), (true, "sel")] {
let Some(px) = render_node_selection(selected) else {
eprintln!("no GPU adapter; skipping");
return;
};
let path = format!("selected_node_{name}.png");
let file = std::fs::File::create(&path).unwrap();
let mut enc = png::Encoder::new(std::io::BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
let flat: Vec<u8> = px.iter().flat_map(|p| p.iter().copied()).collect();
w.write_image_data(&flat).unwrap();
eprintln!("wrote {path}");
}
}
fn render_node_selection(selected: bool) -> Option<Vec<[u8; 4]>> {
let mut guard = shared()?;
let renderer = &mut *guard;
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(
Point::new(W as f32 * 0.5 - 40.0, H as f32 * 0.5 - 20.0),
1.0,
);
graph = graph.push_node(
node(
0_usize,
Point::new(0.0, 0.0),
Element::from(
iced::widget::container(text("Node"))
.width(Length::Fixed(160.0))
.height(Length::Fixed(90.0)),
),
)
.selected(selected),
);
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Unavailable,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
Some(bytes.as_chunks::<4>().0.to_vec())
}
fn render_nested_graph() -> Option<Vec<[u8; 4]>> {
let mut guard = shared()?;
let renderer = &mut *guard;
let inner: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(150.0))
.height(Length::Fixed(100.0))
.camera(Point::ORIGIN, 1.0)
.push_node(
node(
0_usize,
Point::new(10.0, 10.0),
Element::from(
iced::widget::container(iced::widget::Space::new())
.width(Length::Fixed(40.0))
.height(Length::Fixed(20.0)),
),
)
.style(|theme, status| NodeStyle {
fill_color: ColorQuad::solid(Color::from_rgb(1.0, 0.0, 0.0)),
corner_radius: 0.0,
border_pattern: Pattern::solid(0.0),
border_outline_width: 0.0,
shadow_distance: 0.0,
shadow_color: Color::TRANSPARENT,
..default_node_style(theme, status)
}),
);
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(Point::ORIGIN, 2.0);
graph = graph.push_node(node(0_usize, Point::ORIGIN, Element::from(inner)));
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Unavailable,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
Some(bytes.as_chunks::<4>().0.to_vec())
}
#[test]
fn nested_graph_sdf_scales_with_outer_zoom() {
let Some(px) = render_nested_graph() else {
eprintln!("no GPU adapter; skipping");
return;
};
let at = |x: u32, y: u32| px[(y * W + x) as usize];
let is_red = |p: [u8; 4]| p[0] > 120 && p[1] < 90 && p[2] < 90;
assert!(
is_red(at(30, 30)),
"inner node fill missing near its top-left corner: {:?}",
at(30, 30),
);
assert!(
is_red(at(90, 50)),
"inner node fill must reach the far side of its doubled rect: {:?}",
at(90, 50),
);
assert!(
!is_red(at(110, 50)),
"inner node fill must stop at its doubled rect: {:?}",
at(110, 50),
);
}
#[test]
#[ignore = "visual probe: writes nested_graph.png"]
fn probe_nested_graph_appearance() {
let Some(px) = render_nested_graph() else {
eprintln!("no GPU adapter; skipping");
return;
};
let path = "nested_graph.png";
let file = std::fs::File::create(path).unwrap();
let mut enc = png::Encoder::new(std::io::BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
let flat: Vec<u8> = px.iter().flat_map(|p| p.iter().copied()).collect();
w.write_image_data(&flat).unwrap();
eprintln!("wrote {path}");
}
fn render_pin_shape(shape: iced_nodegraph::PinShape) -> Option<Vec<[u8; 4]>> {
use iced_nodegraph::{PinDirection, PinSide, PinStyle, default_pin_style, node_pin};
let mut guard = shared()?;
let renderer = &mut *guard;
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(Point::ORIGIN, 1.0);
graph = graph.push_node(
node(
0_usize,
Point::new(90.0, 70.0),
Element::from(
iced::widget::container(
node_pin(PinSide::Left, 0_usize, text("")).direction(PinDirection::Output),
)
.width(Length::Fixed(120.0))
.height(Length::Fixed(80.0)),
),
)
.pin_style(move |theme, _pin, _peer, status| PinStyle {
color: Color::from_rgb(1.0, 0.0, 1.0).into(),
radius: 20.0,
shape,
cutout_radius: 0.0,
border_width: 0.0,
..default_pin_style(theme, status)
}),
);
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Unavailable,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
Some(bytes.as_chunks::<4>().0.to_vec())
}
fn magenta_box_fill(px: &[[u8; 4]]) -> f32 {
let hit = |p: &[u8; 4]| p[0] > 200 && p[2] > 200 && p[1] < 60;
let (mut x0, mut y0, mut x1, mut y1) = (W, H, 0u32, 0u32);
let mut count = 0u32;
for (i, p) in px.iter().enumerate() {
if !hit(p) {
continue;
}
let (x, y) = (i as u32 % W, i as u32 / W);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
count += 1;
}
assert!(count > 300, "pin indicator did not render ({count} px)");
count as f32 / ((x1 - x0 + 1) * (y1 - y0 + 1)) as f32
}
#[test]
fn every_pin_shape_draws_its_own_silhouette() {
use iced_nodegraph::PinShape;
let Some(circle) = render_pin_shape(PinShape::Circle) else {
eprintln!("no GPU adapter - skipping every_pin_shape_draws_its_own_silhouette");
return;
};
let square = render_pin_shape(PinShape::Square).expect("GPU was available a moment ago");
let circle_fill = magenta_box_fill(&circle);
let square_fill = magenta_box_fill(&square);
assert!(
circle_fill < 0.85,
"Circle filled {circle_fill:.3} of its bounding box; a disc covers about 0.785",
);
assert!(
square_fill > 0.93,
"Square filled {square_fill:.3} of its bounding box; a square covers about 1.0 - \
it is being drawn as a disc",
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RouteScene {
Bare,
Anchored,
Wrapped,
Dragging,
HoveringEnd,
}
fn render_routed_edge(scene: RouteScene) -> Option<Vec<[u8; 4]>> {
use iced::widget::container;
use iced_nodegraph::{PinDirection, PinRef, PinSide, anchor, edge, node_pin};
let mut guard = shared()?;
let renderer = &mut *guard;
const ANCHOR: usize = 9;
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(Point::new(20.0, 20.0), 1.0)
.on_connect(|_, _| ())
.on_anchor_move(|_id, _position| ());
let pin_body = || {
container(text("p"))
.width(Length::Fixed(60.0))
.height(Length::Fixed(50.0))
};
graph = graph.push_node(node(
0usize,
Point::new(0.0, 0.0),
Element::from(node_pin(PinSide::Right, 0usize, pin_body()).direction(PinDirection::Output)),
));
graph = graph.push_node(node(
1usize,
Point::new(200.0, 0.0),
Element::from(node_pin(PinSide::Left, 0usize, pin_body()).direction(PinDirection::Input)),
));
let routed = matches!(
scene,
RouteScene::Wrapped | RouteScene::Dragging | RouteScene::HoveringEnd
);
let route: Vec<usize> = if routed { vec![ANCHOR] } else { Vec::new() };
graph = graph
.push_edge(edge((), PinRef::new(0usize, 0usize), PinRef::new(1usize, 0usize)).route(route));
if scene != RouteScene::Bare {
graph = graph.push_anchor(anchor(ANCHOR, Point::new(115.0, 160.0)));
}
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
let mut feed =
|graph: &mut NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer>,
tree: &mut Tree,
event: iced::Event,
cursor: mouse::Cursor| {
graph.update(
tree,
&event,
layout,
cursor,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
};
feed(
&mut graph,
&mut tree,
iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
mouse::Cursor::Unavailable,
);
let mut cursor = mouse::Cursor::Unavailable;
if scene == RouteScene::HoveringEnd {
let on_end = GLOW_HOVER;
feed(
&mut graph,
&mut tree,
iced::Event::Mouse(mouse::Event::CursorMoved { position: on_end }),
mouse::Cursor::Available(on_end),
);
cursor = mouse::Cursor::Available(on_end);
}
if scene == RouteScene::Dragging {
let grab = Point::new(135.0, 180.0);
let held = Point::new(95.0, 130.0);
for (event, at) in [
(
iced::Event::Mouse(mouse::Event::CursorMoved { position: grab }),
grab,
),
(
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
grab,
),
(
iced::Event::Mouse(mouse::Event::CursorMoved { position: held }),
held,
),
] {
feed(&mut graph, &mut tree, event, mouse::Cursor::Available(at));
}
cursor = mouse::Cursor::Available(held);
}
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
cursor,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
Some(bytes.as_chunks::<4>().0.to_vec())
}
fn render_route_drag(route: &[usize], drag: bool) -> Option<Vec<[u8; 4]>> {
use iced::widget::container;
use iced_nodegraph::{PinDirection, PinRef, PinSide, anchor, edge, node_pin};
let mut guard = shared()?;
let renderer = &mut *guard;
const ANCHOR: usize = 9;
let mut graph: NodeGraph<'static, RouteIds, (), Theme, Renderer> = NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(Point::new(20.0, 20.0), 1.0)
.on_anchor_create(|_, _| ())
.on_route_attach(|_, _| ())
.on_route_detach(|_, _| ());
let pin_body = || {
container(text("p"))
.width(Length::Fixed(60.0))
.height(Length::Fixed(50.0))
};
graph = graph.push_node(node(
0usize,
Point::new(0.0, 0.0),
Element::from(node_pin(PinSide::Right, 0usize, pin_body()).direction(PinDirection::Output)),
));
graph = graph.push_node(node(
1usize,
Point::new(200.0, 0.0),
Element::from(node_pin(PinSide::Left, 0usize, pin_body()).direction(PinDirection::Input)),
));
graph = graph.push_anchor(anchor(ANCHOR, Point::new(115.0, 160.0)));
graph = graph.push_edge(
edge(
0usize,
PinRef::new(0usize, 0usize),
PinRef::new(1usize, 0usize),
)
.route(route.to_vec()),
);
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
let mut cursor = mouse::Cursor::Unavailable;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
if drag {
let mid = Point::new(150.0, 45.0);
let core = Point::new(135.0, 180.0);
for (event, at) in [
(
iced::Event::Mouse(mouse::Event::CursorMoved { position: mid }),
mid,
),
(
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
mid,
),
(
iced::Event::Mouse(mouse::Event::CursorMoved { position: core }),
core,
),
] {
graph.update(
&mut tree,
&event,
layout,
mouse::Cursor::Available(at),
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
}
cursor = mouse::Cursor::Available(core);
}
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
cursor,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
Some(bytes.as_chunks::<4>().0.to_vec())
}
fn render_regrab(after_detach: &[usize], at_reattach: &[usize]) -> Option<Vec<[u8; 4]>> {
use iced::widget::container;
use iced_nodegraph::{PinDirection, PinRef, PinSide, anchor, edge, node_pin};
let mut guard = shared()?;
let renderer = &mut *guard;
const ANCHOR: usize = 9;
let scene = |route: &[usize]| {
let mut graph: NodeGraph<'static, RouteIds, (), Theme, Renderer> = NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(Point::new(20.0, 20.0), 1.0)
.on_anchor_create(|_, _| ())
.on_route_attach(|_, _| ())
.on_route_detach(|_, _| ());
let pin_body = || {
container(text("p"))
.width(Length::Fixed(60.0))
.height(Length::Fixed(50.0))
};
graph = graph.push_node(node(
0usize,
Point::new(0.0, 0.0),
Element::from(
node_pin(PinSide::Right, 0usize, pin_body()).direction(PinDirection::Output),
),
));
graph = graph.push_node(node(
1usize,
Point::new(200.0, 0.0),
Element::from(
node_pin(PinSide::Left, 0usize, pin_body()).direction(PinDirection::Input),
),
));
graph = graph.push_anchor(anchor(ANCHOR, Point::new(115.0, 160.0)));
graph = graph.push_edge(
edge(
0usize,
PinRef::new(0usize, 0usize),
PinRef::new(1usize, 0usize),
)
.route(route.to_vec()),
);
graph
};
let wrap = Point::new(135.0, 191.0);
let away = Point::new(135.0, 240.0);
let core = Point::new(135.0, 180.0);
let steps: [(&[usize], iced::Event, Point); 4] = [
(
&[ANCHOR],
iced::Event::Mouse(mouse::Event::CursorMoved { position: wrap }),
wrap,
),
(
&[ANCHOR],
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
wrap,
),
(
&[ANCHOR],
iced::Event::Mouse(mouse::Event::CursorMoved { position: away }),
away,
),
(
after_detach,
iced::Event::Mouse(mouse::Event::CursorMoved { position: core }),
core,
),
];
let mut graph = scene(&[ANCHOR]);
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
for (route, event, at) in steps {
let mut g = scene(route);
tree.diff(&g as &dyn Widget<(), Theme, Renderer>);
g.update(
&mut tree,
&event,
layout,
mouse::Cursor::Available(at),
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
}
let final_graph = scene(at_reattach);
tree.diff(&final_graph as &dyn Widget<(), Theme, Renderer>);
final_graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Available(core),
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
let _ = &mut graph;
Some(bytes.as_chunks::<4>().0.to_vec())
}
#[test]
fn a_snapped_route_preview_draws_the_cable_it_will_commit() {
let Some(previewed) = render_route_drag(&[], true) else {
eprintln!("no GPU adapter; skipping");
return;
};
let committed = render_route_drag(&[9], false).expect("adapter present");
let bare = render_route_drag(&[], false).expect("adapter present");
let regrabbed_pending = render_regrab(&[], &[]).expect("adapter present");
let regrabbed_applied = render_regrab(&[], &[9]).expect("adapter present");
const CX: u32 = 135;
const CY: u32 = 180;
const R: u32 = 40;
let outside_the_anchor = |a: &[[u8; 4]], b: &[[u8; 4]]| {
let mut count = 0;
for y in 0..H {
for x in 0..W {
let near = x.abs_diff(CX) <= R && y.abs_diff(CY) <= R;
if !near && a[(y * W + x) as usize] != b[(y * W + x) as usize] {
count += 1;
}
}
}
count
};
let pc = outside_the_anchor(&previewed, &committed);
let pb = outside_the_anchor(&previewed, &bare);
let cb = outside_the_anchor(&committed, &bare);
eprintln!("previewed vs committed: {pc}, previewed vs bare: {pb}, committed vs bare: {cb}");
assert_eq!(
pc, 0,
"the previewed cable runs somewhere the committed one does not",
);
assert!(
pb > 200,
"the preview still draws the straight bare cable: the snapped wrap is missing",
);
for (label, frame) in [
("host pending", ®rabbed_pending),
("host applied", ®rabbed_applied),
] {
let vs_committed = outside_the_anchor(frame, &committed);
let vs_bare = outside_the_anchor(frame, &bare);
eprintln!("re-grabbed ({label}): vs committed {vs_committed}, vs bare {vs_bare}");
assert_eq!(
vs_committed, 0,
"re-attaching to the anchor it was pulled off ({label}) draws a \
different cable than committing it does",
);
assert!(
vs_bare > 200,
"re-attaching to the anchor it was pulled off ({label}) draws the \
straight bare cable: the wrap is missing",
);
}
}
const GLOW_HOVER: Point = Point::new(95.7, 48.1);
fn diff_in(a: &[[u8; 4]], b: &[[u8; 4]], x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
let mut count = 0;
for y in y0..=y1 {
for x in x0..=x1 {
let i = (y * W + x) as usize;
if a[i] != b[i] {
count += 1;
}
}
}
count
}
#[test]
fn full_widget_renders_routed_edge_and_anchor() {
let Some(bare) = render_routed_edge(RouteScene::Bare) else {
eprintln!("no GPU adapter - skipping full_widget_renders_routed_edge_and_anchor");
return;
};
let anchored = render_routed_edge(RouteScene::Anchored).expect("GPU was available");
let routed = render_routed_edge(RouteScene::Wrapped).expect("GPU was available");
for (label, px) in [
("bare", &bare),
("anchored", &anchored),
("routed", &routed),
] {
let distinct: std::collections::HashSet<[u8; 4]> = px.iter().copied().collect();
assert!(
distinct.len() > 3,
"{label} frame is near-uniform ({} distinct colours): nothing rendered",
distinct.len(),
);
}
let diff = |a: &[[u8; 4]], b: &[[u8; 4]]| a.iter().zip(b).filter(|(x, y)| x != y).count();
let anchor_px = diff(&bare, &anchored);
assert!(
anchor_px > 20,
"pushing an anchor changed only {anchor_px} pixels: the core did not draw",
);
let cable_px = diff(&anchored, &routed);
assert!(
cable_px > 400,
"routing the edge changed only {cable_px} pixels: the anchor did not bend the cable",
);
let dragging = render_routed_edge(RouteScene::Dragging).expect("GPU was available");
let preview_px = diff(&routed, &dragging);
assert!(
preview_px > 400,
"holding the anchor mid-drag changed only {preview_px} pixels: the preview is missing",
);
}
#[test]
fn a_hovered_cable_end_glows() {
let Some(idle) = render_routed_edge(RouteScene::Wrapped) else {
eprintln!("no GPU adapter - skipping a_hovered_cable_end_glows");
return;
};
let hovered = render_routed_edge(RouteScene::HoveringEnd).expect("GPU was available");
let near = diff_in(&idle, &hovered, 82, 38, 130, 58);
let far = diff_in(&idle, &hovered, 195, 38, 218, 58);
assert!(
near > 40,
"hovering the cable's output end changed only {near} pixels there: \
the glow is missing",
);
assert_eq!(
far, 0,
"the far end changed too: the glow marks the whole cable instead of the \
stretch the press would take",
);
}
fn render_particles(particles: &[(f32, f32)]) -> Option<(Vec<[u8; 4]>, bool)> {
use iced::time::{Duration, Instant};
use iced::widget::container;
use iced_nodegraph::{ParticleStyle, PinDirection, PinRef, PinSide, edge, node_pin, particle};
let mut guard = shared()?;
let renderer = &mut *guard;
let mut graph: NodeGraph<'static, iced_nodegraph::Indexed, (), Theme, Renderer> =
NodeGraph::default()
.width(Length::Fixed(W as f32))
.height(Length::Fixed(H as f32))
.camera(Point::new(20.0, 20.0), 1.0)
.on_connect(|_, _| ());
let pin_body = || {
container(text("p"))
.width(Length::Fixed(60.0))
.height(Length::Fixed(50.0))
};
graph = graph.push_node(node(
0usize,
Point::new(0.0, 0.0),
Element::from(node_pin(PinSide::Right, 0usize, pin_body()).direction(PinDirection::Output)),
));
graph = graph.push_node(node(
1usize,
Point::new(200.0, 0.0),
Element::from(node_pin(PinSide::Left, 0usize, pin_body()).direction(PinDirection::Input)),
));
let now = Instant::now();
let born = |age: f32| {
if age >= 0.0 {
now - Duration::from_secs_f32(age)
} else {
now + Duration::from_secs_f32(-age)
}
};
graph = graph.push_edge(
edge((), PinRef::new(0usize, 0usize), PinRef::new(1usize, 0usize)).particles(
particles.iter().map(|&(age, speed)| {
particle(born(age), speed).style(|_| ParticleStyle {
color: Color::from_rgb(1.0, 0.0, 1.0),
radius: 5.0,
})
}),
),
);
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Renderer>);
let layout_node = graph.layout(
&mut tree,
&*renderer,
&layout::Limits::new(Size::ZERO, Size::new(W as f32, H as f32)),
);
let layout = Layout::new(&layout_node);
let viewport_rect = Rectangle::new(Point::ORIGIN, Size::new(W as f32, H as f32));
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
let mut clipboard = clipboard::Null;
graph.update(
&mut tree,
&iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(-1.0, -1.0),
}),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
graph.draw(
&tree,
renderer,
&Theme::Dark,
&renderer::Style {
text_color: Color::WHITE,
},
layout,
mouse::Cursor::Unavailable,
&viewport_rect,
);
let bytes = renderer.screenshot(
&Viewport::with_physical_size(Size::new(W, H), 1.0),
Color::TRANSPARENT,
);
let mut msgs: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut msgs);
graph.update(
&mut tree,
&iced::Event::Window(iced::window::Event::RedrawRequested(Instant::now())),
layout,
mouse::Cursor::Unavailable,
&*renderer,
&mut clipboard,
&mut shell,
&viewport_rect,
);
let redraws = shell.redraw_request() == iced::window::RedrawRequest::NextFrame;
Some((bytes.as_chunks::<4>().0.to_vec(), redraws))
}
#[test]
fn a_particle_rides_the_cable_by_speed_and_age() {
let Some((px, redraws)) = render_particles(&[(1.0, 70.0)]) else {
eprintln!("no GPU adapter; skipping");
return;
};
assert!(redraws, "a moving particle did not request the next frame");
let magenta = |p: [u8; 4]| p[0] > 150 && p[1] < 100 && p[2] > 150;
let at = |x: u32, y: u32| px[(y * W + x) as usize];
assert!(
magenta(at(150, 45)),
"no particle at the cable's midpoint: {:?}",
at(150, 45)
);
assert!(
!magenta(at(120, 45)),
"particle drawn behind its position: {:?}",
at(120, 45)
);
assert!(
!magenta(at(180, 45)),
"particle drawn ahead of its position: {:?}",
at(180, 45)
);
}
#[test]
fn an_arrived_particle_is_not_drawn() {
let Some((px, redraws)) = render_particles(&[(3.0, 70.0)]) else {
eprintln!("no GPU adapter; skipping");
return;
};
assert!(!redraws, "an arrived particle kept the frame loop running");
let magenta = |p: [u8; 4]| p[0] > 150 && p[1] < 100 && p[2] > 150;
assert!(
!px.iter().copied().any(magenta),
"an arrived particle is still drawn somewhere in the frame"
);
}
#[test]
fn an_unborn_particle_is_not_drawn() {
let Some((px, redraws)) = render_particles(&[(-1.0, 70.0)]) else {
eprintln!("no GPU adapter; skipping");
return;
};
assert!(redraws, "a pending particle did not request the next frame");
let magenta = |p: [u8; 4]| p[0] > 150 && p[1] < 100 && p[2] > 150;
assert!(
!px.iter().copied().any(magenta),
"an unborn particle is drawn somewhere in the frame"
);
}