use std::cell::{Cell, RefCell};
use std::rc::Rc;
use iced::advanced::widget::{Tree, Widget};
use iced::advanced::{Layout, layout, mouse, renderer};
use iced::{
Background, Color, Element, Length, Pixels, Point, Rectangle, Size, Theme, Transformation,
Vector,
};
use iced_wgpu::core::clipboard;
use iced_wgpu::core::image;
use iced_wgpu::core::text;
use crate::{NodeGraph, node};
struct Stub;
thread_local! {
static LAYER_STACK: RefCell<Vec<Rectangle>> = const { RefCell::new(Vec::new()) };
static CHILD_CLIP: Cell<Option<Rectangle>> = const { Cell::new(None) };
}
impl renderer::Renderer for Stub {
fn start_layer(&mut self, bounds: Rectangle) {
LAYER_STACK.with(|s| s.borrow_mut().push(bounds));
}
fn end_layer(&mut self) {
LAYER_STACK.with(|s| {
s.borrow_mut().pop();
});
}
fn start_transformation(&mut self, _t: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _new_bounds: Rectangle) {}
fn fill_quad(&mut self, _quad: renderer::Quad, _background: impl Into<Background>) {}
fn allocate_image(
&mut self,
_handle: &image::Handle,
_callback: impl FnOnce(Result<image::Allocation, image::Error>) + Send + 'static,
) {
}
}
impl text::Renderer for Stub {
type Font = iced::Font;
type Paragraph = iced_wgpu::graphics::text::Paragraph;
type Editor = iced_wgpu::graphics::text::Editor;
const ICON_FONT: iced::Font = iced::Font::DEFAULT;
const CHECKMARK_ICON: char = '0';
const ARROW_DOWN_ICON: char = '0';
const SCROLL_UP_ICON: char = '0';
const SCROLL_DOWN_ICON: char = '0';
const SCROLL_LEFT_ICON: char = '0';
const SCROLL_RIGHT_ICON: char = '0';
const ICED_LOGO: char = '0';
fn default_font(&self) -> Self::Font {
iced::Font::default()
}
fn default_size(&self) -> Pixels {
Pixels(16.0)
}
fn fill_paragraph(
&mut self,
_paragraph: &Self::Paragraph,
_position: Point,
_color: Color,
_clip_bounds: Rectangle,
) {
}
fn fill_editor(
&mut self,
_editor: &Self::Editor,
_position: Point,
_color: Color,
_clip_bounds: Rectangle,
) {
}
fn fill_text(
&mut self,
_text: text::Text,
_position: Point,
_color: Color,
_clip_bounds: Rectangle,
) {
}
}
impl iced_wgpu::primitive::Renderer for Stub {
fn draw_primitive(&mut self, _bounds: Rectangle, _primitive: impl iced_wgpu::Primitive) {}
}
#[derive(Clone)]
struct Capture(Rc<Cell<Option<Rectangle>>>);
struct ViewportRecorder {
on_draw: Capture,
on_update: Capture,
}
impl<Message> Widget<Message, Theme, Stub> for ViewportRecorder {
fn size(&self) -> Size<Length> {
Size::new(Length::Fixed(40.0), Length::Fixed(20.0))
}
fn layout(
&mut self,
_tree: &mut Tree,
_renderer: &Stub,
limits: &layout::Limits,
) -> layout::Node {
layout::Node::new(limits.resolve(Length::Fixed(40.0), Length::Fixed(20.0), Size::ZERO))
}
fn draw(
&self,
_tree: &Tree,
_renderer: &mut Stub,
_theme: &Theme,
_style: &renderer::Style,
_layout: Layout<'_>,
_cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.on_draw.0.set(Some(*viewport));
CHILD_CLIP.with(|c| c.set(LAYER_STACK.with(|s| s.borrow().last().copied())));
}
fn update(
&mut self,
_tree: &mut Tree,
_event: &iced::Event,
_layout: Layout<'_>,
_cursor: mouse::Cursor,
_renderer: &Stub,
_clipboard: &mut dyn clipboard::Clipboard,
_shell: &mut iced_wgpu::core::Shell<'_, Message>,
viewport: &Rectangle,
) {
self.on_update.0.set(Some(*viewport));
}
}
impl<'a, Message: 'a> From<ViewportRecorder> for Element<'a, Message, Theme, Stub> {
fn from(value: ViewportRecorder) -> Self {
Element::new(value)
}
}
fn build_graph_with_recorder(
graph_w: f32,
graph_h: f32,
node_world_pos: Point,
) -> (
NodeGraph<'static, usize, usize, (), (), Theme, Stub>,
Capture, // on_draw
Capture, // on_update
) {
let on_draw = Capture(Rc::new(Cell::new(None)));
let on_update = Capture(Rc::new(Cell::new(None)));
let recorder = ViewportRecorder {
on_draw: on_draw.clone(),
on_update: on_update.clone(),
};
let mut graph: NodeGraph<'static, usize, usize, (), (), Theme, Stub> = NodeGraph::default()
.width(Length::Fixed(graph_w))
.height(Length::Fixed(graph_h));
graph.push_node(node(0_usize, node_world_pos, Element::from(recorder)));
(graph, on_draw, on_update)
}
#[test]
fn draw_clips_child_viewport_to_graph_bounds() {
let (mut graph, on_draw, _on_update) =
build_graph_with_recorder(200.0, 200.0, Point::new(500.0, 500.0));
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Stub>);
let mut renderer = Stub;
let layout_node = graph.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1024.0, 768.0)),
);
let layout = Layout::new(&layout_node);
let outer_viewport = Rectangle::new(Point::ORIGIN, Size::new(1024.0, 768.0));
graph.draw(
&tree,
&mut renderer,
&Theme::Light,
&renderer::Style {
text_color: Color::BLACK,
},
layout,
mouse::Cursor::Unavailable,
&outer_viewport,
);
let recorded = on_draw
.0
.get()
.expect("recorder was never drawn — NodeGraph::draw did not reach child");
assert!(
recorded.width <= 200.0 && recorded.height <= 200.0,
"child draw viewport {recorded:?} should be clipped to NodeGraph bounds (200x200)",
);
}
#[test]
fn draw_clips_node_content_layer_to_graph_bounds() {
CHILD_CLIP.with(|c| c.set(None));
let (mut graph, _on_draw, _on_update) =
build_graph_with_recorder(200.0, 200.0, Point::new(180.0, 50.0));
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Stub>);
let mut renderer = Stub;
let layout_node = graph.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1024.0, 768.0)),
);
let layout = Layout::new(&layout_node);
let outer_viewport = Rectangle::new(Point::ORIGIN, Size::new(1024.0, 768.0));
graph.draw(
&tree,
&mut renderer,
&Theme::Light,
&renderer::Style {
text_color: Color::BLACK,
},
layout,
mouse::Cursor::Unavailable,
&outer_viewport,
);
let clip = CHILD_CLIP
.with(|c| c.get())
.expect("node content was never drawn under a clip layer");
assert!(
clip.x + clip.width <= 200.5,
"node content clip {clip:?} must not extend past graph right edge (200)",
);
}
fn wheel_event() -> iced::Event {
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0.0, y: 5.0 },
})
}
fn run_update_with_cursor(graph_w: f32, graph_h: f32, cursor: mouse::Cursor) -> Rc<Cell<bool>> {
let mut base_graph: NodeGraph<'static, usize, usize, (), (), Theme, Stub> =
NodeGraph::default()
.width(Length::Fixed(graph_w))
.height(Length::Fixed(graph_h));
base_graph.push_node(node(
0_usize,
Point::new(0.0, 0.0),
Element::<(), _, _>::from(EmptyLeaf),
));
let camera_changed = Rc::new(Cell::new(false));
let cc = camera_changed.clone();
let mut graph = base_graph.on_pan(move |_pos, _zoom| cc.set(true));
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Stub>);
let renderer = Stub;
let layout_node = graph.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1024.0, 768.0)),
);
let layout = Layout::new(&layout_node);
let mut messages: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut messages);
let mut clipboard = clipboard::Null;
let viewport = Rectangle::new(Point::ORIGIN, Size::new(1024.0, 768.0));
graph.update(
&mut tree,
&wheel_event(),
layout,
cursor,
&renderer,
&mut clipboard,
&mut shell,
&viewport,
);
camera_changed
}
struct EmptyLeaf;
impl<Message> Widget<Message, Theme, Stub> for EmptyLeaf {
fn size(&self) -> Size<Length> {
Size::new(Length::Fixed(10.0), Length::Fixed(10.0))
}
fn layout(&mut self, _: &mut Tree, _: &Stub, limits: &layout::Limits) -> layout::Node {
layout::Node::new(limits.resolve(Length::Fixed(10.0), Length::Fixed(10.0), Size::ZERO))
}
fn draw(
&self,
_: &Tree,
_: &mut Stub,
_: &Theme,
_: &renderer::Style,
_: Layout<'_>,
_: mouse::Cursor,
_: &Rectangle,
) {
}
}
impl<'a, Message: 'a> From<EmptyLeaf> for Element<'a, Message, Theme, Stub> {
fn from(w: EmptyLeaf) -> Self {
Element::new(w)
}
}
#[test]
fn wheel_scroll_with_levitating_cursor_does_not_zoom() {
let changed = run_update_with_cursor(
200.0,
200.0,
mouse::Cursor::Levitating(Point::new(100.0, 100.0)),
);
assert!(
!changed.get(),
"wheel scroll under a levitating cursor must not change the camera",
);
}
#[test]
fn wheel_scroll_outside_graph_bounds_does_not_zoom() {
let changed = run_update_with_cursor(
200.0,
200.0,
mouse::Cursor::Available(Point::new(500.0, 500.0)),
);
assert!(
!changed.get(),
"wheel scroll outside the graph bounds must not change the camera",
);
}
#[test]
fn wheel_scroll_inside_graph_bounds_zooms() {
let changed = run_update_with_cursor(
200.0,
200.0,
mouse::Cursor::Available(Point::new(100.0, 100.0)),
);
assert!(
changed.get(),
"wheel scroll over the graph must still change the camera",
);
}
#[test]
fn update_clips_child_viewport_to_graph_bounds() {
let (mut graph, _on_draw, on_update) =
build_graph_with_recorder(200.0, 200.0, Point::new(500.0, 500.0));
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Stub>);
let renderer = Stub;
let layout_node = graph.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1024.0, 768.0)),
);
let layout = Layout::new(&layout_node);
let outer_viewport = Rectangle::new(Point::ORIGIN, Size::new(1024.0, 768.0));
let mut shell_messages: Vec<()> = Vec::new();
let mut shell = iced_wgpu::core::Shell::new(&mut shell_messages);
let mut clipboard = clipboard::Null;
let event = iced::Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(50.0, 50.0),
});
graph.update(
&mut tree,
&event,
layout,
mouse::Cursor::Available(Point::new(50.0, 50.0)),
&renderer,
&mut clipboard,
&mut shell,
&outer_viewport,
);
let recorded = on_update
.0
.get()
.expect("recorder did not see an update event from NodeGraph");
assert!(
recorded.width <= 200.0 && recorded.height <= 200.0,
"child update viewport {recorded:?} should be clipped to NodeGraph bounds (200x200)",
);
}
fn content_clip_at(widget_origin: Vector, node_world: Point) -> Option<Rectangle> {
CHILD_CLIP.with(|c| c.set(None));
let (mut graph, _on_draw, _on_update) = build_graph_with_recorder(200.0, 200.0, node_world);
let mut tree = Tree::new(&graph as &dyn Widget<(), Theme, Stub>);
let mut renderer = Stub;
let layout_node = graph.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1024.0, 768.0)),
);
let layout = Layout::with_offset(widget_origin, &layout_node);
let outer = Rectangle::new(Point::ORIGIN, Size::new(1024.0, 768.0));
graph.draw(
&tree,
&mut renderer,
&Theme::Light,
&renderer::Style {
text_color: Color::BLACK,
},
layout,
mouse::Cursor::Unavailable,
&outer,
);
CHILD_CLIP.with(|c| c.get())
}
#[test]
fn content_clip_at_left_edge_does_not_go_negative() {
let clip = content_clip_at(Vector::ZERO, Point::new(-20.0, 50.0))
.expect("node content was never drawn under a clip layer");
assert!(
clip.x >= -0.5,
"left-straddling content clip {clip:?} must not extend past the graph left edge (0)",
);
}
#[test]
fn content_clip_at_top_edge_does_not_go_negative() {
let clip = content_clip_at(Vector::ZERO, Point::new(50.0, -10.0))
.expect("node content was never drawn under a clip layer");
assert!(
clip.y >= -0.5,
"top-straddling content clip {clip:?} must not extend past the graph top edge (0)",
);
}
#[test]
fn content_clip_fully_inside_is_not_overclipped() {
let clip = content_clip_at(Vector::ZERO, Point::new(50.0, 50.0))
.expect("node content was never drawn under a clip layer");
assert!(
(clip.width - 40.0).abs() < 1.0 && (clip.height - 20.0).abs() < 1.0,
"fully-inside content clip {clip:?} should match the node bounds (40x20)",
);
}
#[test]
fn content_clip_fully_outside_is_empty() {
let clip = content_clip_at(Vector::ZERO, Point::new(500.0, 500.0))
.expect("node content was never drawn under a clip layer");
assert!(
clip.width == 0.0 && clip.height == 0.0,
"fully-outside content clip {clip:?} must have zero area",
);
}
#[test]
fn content_clip_respects_nonzero_widget_origin() {
let clip = content_clip_at(Vector::new(0.0, 100.0), Point::new(50.0, 50.0))
.expect("node content was never drawn under a clip layer");
assert!(
clip.y >= 99.5 && clip.y + clip.height <= 300.5,
"content clip {clip:?} must stay within the origin-offset graph bounds (y 100..300)",
);
}