//! Persisted state-diagram geometry with context-bound, ordered pointer gestures. use super::state_edit::{self, Context, Endpoint, Event, Tool}; use crate::style; use iced::{ Color, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse, widget::{ Action, canvas::{self, Frame, Path, Stroke, Text}, }, }; use oa_core::{ projection::{self, ProjectedNode, ProjectedView}, routing::{self, RoutedView}, *, }; use std::{ cell::RefCell, collections::{BTreeMap, BTreeSet}, sync::Arc, }; /// The scene is clipped below this strip, keeping guidance legible at every pan. pub const GUIDANCE_HEIGHT: f32 = 42.; pub struct StateCanvas<'a> { pub project: &'a Project, pub diagram: Id, pub context: Context, pub selected: Id, /// Presentation-only navigation marker; never an authoring placement. pub navigation_focus: Option, pub tool: Tool, pub zoom: f32, pub pan: Vector, pub editable: bool, } #[derive(Default)] pub struct State { context: Option, tool: Option, pointer: Option, press: Option, source: Option, preview: Option<(Id, Position)>, pan_from: Option<(mouse::Button, Point)>, viewport: Option, scene: RefCell>, geometry: canvas::Cache, paint_key: RefCell>, } #[derive(Clone, Copy)] enum Press { // Starts are window coordinates: contextual controls can move the canvas // between input events without moving the physical pointer. Move { node: Id, start: Point, original: Position, }, Create { owner: Id, kind: Kind, start: Point, }, Connect { source: Id, first: bool, start: Point, dragged: bool, }, Reconnect { relation: Id, endpoint: Endpoint, /// Handles require a drag; an explicitly chosen reconnect tool accepts a click. start: Option, }, } struct Scene { view: ProjectedView, routed: RoutedView, symbol_labels: BTreeMap, } struct CachedScene { context: Context, preview: Option<(Id, Position)>, scene: Arc, } #[derive(PartialEq)] struct PaintKey { context: Context, preview: Option<(Id, Position)>, selected: Id, zoom: f32, pan: Vector, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Hit { Node { placement: Id, element: Id }, Transition(Id), StateAction(Id), } impl State { fn cancel_gesture(&mut self) { self.press = None; self.preview = None; self.pan_from = None; } fn reset(&mut self) { self.cancel_gesture(); self.source = None; self.pointer = None; } } fn point(p: Position) -> Point { Point::new(p.x, p.y) } fn position(p: Point) -> Position { Position { x: p.x, y: p.y } } fn rectangle(n: &ProjectedNode) -> Rectangle { Rectangle::new(point(n.position), Size::new(n.size.width, n.size.height)) } fn expanded(r: Rectangle, amount: f32) -> Rectangle { Rectangle::new( Point::new(r.x - amount, r.y - amount), Size::new(r.width + 2. * amount, r.height + 2. * amount), ) } fn overlap_area(a: Rectangle, b: Rectangle) -> f32 { ((a.x + a.width).min(b.x + b.width) - a.x.max(b.x)).max(0.) * ((a.y + a.height).min(b.y + b.height) - a.y.max(b.y)).max(0.) } fn segment_crosses(rect: Rectangle, a: Position, b: Position) -> bool { let mut enter: f32 = 0.; let mut exit: f32 = 1.; for (start, delta, low, high) in [ (a.x, b.x - a.x, rect.x, rect.x + rect.width), (a.y, b.y - a.y, rect.y, rect.y + rect.height), ] { if delta.abs() < f32::EPSILON { if start < low || start > high { return false; } } else { let first = (low - start) / delta; let last = (high - start) / delta; enter = enter.max(first.min(last)); exit = exit.min(first.max(last)); if enter > exit { return false; } } } true } /// Twelve nearby candidates, settled in stable placement-ID order. This is /// derived scene geometry: names follow previews without changing shared routes. fn symbol_labels( project: &Project, view: &ProjectedView, routed: &RoutedView, ) -> BTreeMap { let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect(); let headers: BTreeMap<_, _> = view .nodes .iter() .map(|n| { ( n.id, Rectangle::new( point(n.position), Size::new( n.size.width, projection::state_header_height(project, n.element).min(n.size.height), ), ), ) }) .collect(); let segments: Vec<_> = routed .edges .values() .flat_map(|edge| edge.points.windows(2).map(|pair| (pair[0], pair[1]))) .collect(); let edge_labels: Vec<_> = routed .edges .values() .filter(|edge| !edge.label.text.is_empty()) .map(|edge| { Rectangle::new( point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height), ) }) .collect(); let mut placed: BTreeMap = BTreeMap::new(); for node in nodes .values() .copied() .filter(|n| projection::state_symbol_size(project.elements[&n.element].kind()).is_some()) { let r = rectangle(node); let name = short(&project.elements[&node.element].name, 220., 11.); let size = Size::new((name.chars().count() as f32 * 6.6).ceil().max(7.), 18.); let mut ancestors = BTreeSet::new(); let mut parent = node.visual_parent; while let Some(id) = parent { if !ancestors.insert(id) { break; } parent = nodes.get(&id).and_then(|node| node.visual_parent); } let obstacles: Vec<_> = nodes .values() .map(|n| { if ancestors.contains(&n.id) { headers[&n.id] } else { rectangle(n) } }) .collect(); let center = r.center(); let sides = |gap| { [ Point::new(r.x + r.width + gap, center.y - size.height / 2.), Point::new(center.x - size.width / 2., r.y + r.height + gap), Point::new(r.x - gap - size.width, center.y - size.height / 2.), Point::new(center.x - size.width / 2., r.y - gap - size.height), ] }; let candidates = sides(7.) .into_iter() .chain([ Point::new(r.x + r.width + 7., r.y - size.height - 7.), Point::new(r.x + r.width + 7., r.y + r.height + 7.), Point::new(r.x - size.width - 7., r.y - size.height - 7.), Point::new(r.x - size.width - 7., r.y + r.height + 7.), ]) .chain(sides(25.)); let score = |label: Rectangle| { let clear = expanded(label, 3.); let mut collisions = segments .iter() .filter(|(a, b)| segment_crosses(clear, *a, *b)) .count(); let mut area = 0.; for obstacle in obstacles .iter() .chain(edge_labels.iter()) .chain(placed.values()) { let overlap = overlap_area(clear, *obstacle); collisions += usize::from(overlap > 0.); area += overlap; } for ancestor in &ancestors { let outside = (label.width * label.height - overlap_area(label, rectangle(nodes[ancestor]))) .max(0.); collisions += usize::from(outside > 0.1); area += outside; } (collisions, area) }; let chosen = candidates .map(|at| Rectangle::new(at, size)) .enumerate() .map(|(index, label)| (score(label), index, label)) .min_by(|a, b| { a.0.0 .cmp(&b.0.0) .then(a.0.1.total_cmp(&b.0.1)) .then(a.1.cmp(&b.1)) }) .expect("the fixed candidate list is nonempty") .2; placed.insert(node.id, chosen); } placed } fn label_text(frame: &mut Frame, value: impl Into, at: Point, size: f32, color: Color) { frame.fill_text(Text { content: value.into(), position: at, size: size.into(), color, ..Default::default() }); } fn short(value: &str, width: f32, font: f32) -> String { let count = (width / (font * 0.6)).max(1.) as usize; if value.chars().count() <= count { value.into() } else { value .chars() .take(count.saturating_sub(1)) .chain(['…']) .collect() } } fn line(a: Point, b: Point) -> Path { Path::new(|p| { p.move_to(a); p.line_to(b); }) } fn polyline(points: &[Position]) -> Path { Path::new(|path| { if let Some(first) = points.first() { path.move_to(point(*first)); for next in &points[1..] { path.line_to(point(*next)); } } }) } fn internal_rows<'a>(project: &'a Project, node: &ProjectedNode) -> Vec<(&'a Relation, Rectangle)> { if node.collapsed || !project .elements .get(&node.element) .is_some_and(|e| e.kind().is_stable_state()) { return vec![]; } let relations: Vec<_> = project .relations .values() .filter(|r| { matches!(&r.body, RelationBody::StateTransition { source, transition_kind: TransitionKind::Internal, .. } if *source == node.element) }) .collect(); let top = node.position.y + projection::state_header_height(project, node.element) - relations.len() as f32 * 56.; relations .into_iter() .enumerate() .map(|(i, relation)| { ( relation, Rectangle::new( Point::new(node.position.x + 8., top + i as f32 * 56.), Size::new((node.size.width - 16.).max(1.), 52.), ), ) }) .collect() } /// These rows represent maintained StateAction relations, not containment of /// the referenced work. Their identity remains selectable independently. fn state_action_rows<'a>( project: &'a Project, node: &ProjectedNode, ) -> Vec<(&'a Relation, Rectangle)> { if node.collapsed || !project .elements .get(&node.element) .is_some_and(|e| e.kind().is_stable_state()) { return vec![]; } let mut actions: Vec<_> = project.relations.values().filter(|r| matches!(r.body, RelationBody::StateAction { source, .. } if source == node.element)).collect(); actions.sort_by_key(|r| match r.body { RelationBody::StateAction { phase, .. } => ( match phase { StateActionKind::Entry => 0, StateActionKind::Do => 1, StateActionKind::Exit => 2, }, r.id, ), _ => unreachable!(), }); let internal_height = internal_rows(project, node).len() as f32 * 56.; let top = node.position.y + projection::state_header_height(project, node.element) - internal_height - actions.len() as f32 * 28.; actions .into_iter() .enumerate() .map(|(index, relation)| { ( relation, Rectangle::new( Point::new(node.position.x + 8., top + index as f32 * 28.), Size::new((node.size.width - 16.).max(1.), 26.), ), ) }) .collect() } /// Action and internal-transition rows are selectable relations, not edges or /// placements. Match the exact rows rendered in this dedicated machine view. pub(crate) fn visible_row_relation( project: &Project, diagram: Id, view: &ProjectedView, id: Id, ) -> bool { let Some(machine) = project .diagrams .get(&diagram) .and_then(|d| d.projection.state_machine()) else { return false; }; let Some(relation) = project.relations.get(&id) else { return false; }; let source = match relation.body { RelationBody::StateAction { source, .. } => source, RelationBody::StateTransition { source, target, transition_kind: TransitionKind::Internal, .. } if projection::state_machine_contains(project, machine, target) => source, _ => return false, }; if !projection::state_machine_contains(project, machine, source) { return false; } view.nodes .iter() .filter(|node| node.element == source) .any(|node| { state_action_rows(project, node) .into_iter() .chain(internal_rows(project, node)) .any(|(row, _)| row.id == id) }) } fn state_action_label(project: &Project, relation: &Relation) -> String { let RelationBody::StateAction { target, phase, .. } = relation.body else { return String::new(); }; let phase = match phase { StateActionKind::Entry => "entry", StateActionKind::Do => "do", StateActionKind::Exit => "exit", }; format!("{phase} / {}", project.named(target)) } /// Match a typed label field by its structural offset, never by a display-name /// substring (names may duplicate or also occur in free-form guards). fn retained_label_field(full: &str, painted: &str, start: usize) -> bool { start < full .chars() .zip(painted.chars()) .take_while(|(a, b)| a == b) .map(|(c, _)| c.len_utf8()) .sum::() } fn transition_label_role( project: &Project, relation: &Relation, subject: Id, width: f32, ) -> Option<&'static str> { let full = routing::state_transition_summary(project, relation); transition_label_role_in_text(project, relation, subject, &short(&full, width, 10.)) } fn transition_label_role_in_text( project: &Project, relation: &Relation, subject: Id, painted: &str, ) -> Option<&'static str> { let RelationBody::StateTransition { transition_kind, triggers, effects, effect, .. } = &relation.body else { return None; }; if project.named(subject).trim().is_empty() { return None; } let full = routing::state_transition_summary(project, relation); let mut offset = format!("{transition_kind:?} · ").len(); for id in triggers { if *id == subject && retained_label_field(&full, painted, offset) { return Some("transition trigger"); } offset += project.named(*id).len() + " OR ".len(); } if !effects.is_empty() { // The canonical routing summary appends typed effects before optional // authored effect prose. Derive its offset from that exact suffix. let mut suffix = format!( " / {}", effects .iter() .map(|id| project.named(*id)) .collect::>() .join(", ") ); if !effect.is_empty() { suffix.push_str(&format!(" / {effect}")); } if let Some(prefix) = full.strip_suffix(&suffix) { offset = prefix.len() + " / ".len(); for id in effects { if *id == subject && retained_label_field(&full, painted, offset) { return Some("transition effect"); } offset += project.named(*id).len() + ", ".len(); } } } None } /// Actual state compartments and painted transition-reference labels. Generic /// projected node/edge identities are handled by the shared saved-view query. pub(crate) fn diagram_usages( project: &Project, diagram: &Diagram, view: &ProjectedView, subject: Id, ) -> Vec { use crate::diagram_usage::{Surface, Target, Usage}; if diagram.projection.state_machine().is_none() { return vec![]; } let mut result = Vec::new(); for node in &view.nodes { for (relation, bounds) in state_action_rows(project, node) { let label = state_action_label(project, relation); let painted = short(&label, bounds.width - 12., 11.); let role = if relation.id == subject { Some("state action") } else if relation.target() == subject && retained_label_field( &label, &painted, label.len() - project.named(subject).len(), ) { Some("state action work") } else { None }; if let Some(role) = role { result.push(Usage { subject, surface: Surface::Saved(diagram.id), target: Target::StateRow { placement: node.id, relation: relation.id, }, role: role.into(), bounds, }); } } for (relation, bounds) in internal_rows(project, node) { let role = if relation.id == subject { Some("internal transition") } else { transition_label_role(project, relation, subject, bounds.width - 12.) }; if let Some(role) = role { result.push(Usage { subject, surface: Surface::Saved(diagram.id), target: Target::StateRow { placement: node.id, relation: relation.id, }, role: role.into(), bounds, }); } } } for edge in routing::route_diagram(project, diagram, view) .edges .values() { let Some(relation) = project.relations.get(&edge.id) else { continue; }; if edge.id == subject || edge.label.text.is_empty() { continue; } // Routing already shortens external summaries before the canvas applies // its width limit. Compare against precisely that doubly shortened text. let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.); if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) { result.push(Usage { subject, surface: Surface::Saved(diagram.id), target: Target::Edge(edge.id), role: role.into(), bounds: crate::diagram_usage::edge_focus_bounds(edge), }); } } result } impl StateCanvas<'_> { fn scale(&self) -> f32 { if self.zoom.is_finite() { self.zoom.clamp(0.0001, 3.) } else { 1. } } fn offset(&self) -> Vector { self.pan } fn world(&self, local: Point) -> Point { let translated = local - self.offset(); Point::new(translated.x / self.scale(), translated.y / self.scale()) } fn content(bounds: Rectangle) -> Rectangle { Rectangle::new( Point::new(0., GUIDANCE_HEIGHT), Size::new(bounds.width, (bounds.height - GUIDANCE_HEIGHT).max(0.)), ) } fn valid(&self) -> bool { self.diagram == self.context.diagram && state_edit::validate_context(self.project, self.context, self.context).is_ok() } fn scene(&self, state: &State) -> Option> { if !self.valid() { return None; } let preview = self .gesture_active(state) .then_some(state.preview) .flatten(); if let Some(cached) = state.scene.borrow().as_ref() && cached.context == self.context && cached.preview == preview { return Some(cached.scene.clone()); } let mut diagram = self.project.diagrams.get(&self.diagram)?.clone(); if let Some((id, at)) = preview && let Some(node) = diagram.nodes.get(&id) { let delta = Position { x: at.x - node.position.x, y: at.y - node.position.y, }; let mut moved = projection::descendant_nodes(self.project, self.diagram, id); moved.push(id); let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect(); let routes = projection::translated_routes(self.project, &diagram, &deltas); for id in moved { if let Some(node) = diagram.nodes.get_mut(&id) { node.position.x += delta.x; node.position.y += delta.y; } } for route in routes { if let Some(stored) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) { *stored = route; } } } let view = projection::project_diagram(self.project, &diagram); let routed = routing::route_diagram(self.project, &diagram, &view); let symbol_labels = symbol_labels(self.project, &view, &routed); let scene = Arc::new(Scene { view, routed, symbol_labels, }); *state.scene.borrow_mut() = Some(CachedScene { context: self.context, preview, scene: scene.clone(), }); Some(scene) } fn symbol_hit(&self, node: &ProjectedNode, at: Point) -> bool { let Some(element) = self.project.elements.get(&node.element) else { return false; }; let rect = rectangle(node); if projection::state_symbol_size(element.kind()).is_none() { return rect.contains(at); } let slop = 5. / self.scale(); let center = rect.center(); let delta = at - center; match element.kind() { Kind::Fork | Kind::Join => expanded(rect, slop).contains(at), Kind::Choice => { delta.x.abs() / (rect.width / 2. + slop) + delta.y.abs() / (rect.height / 2. + slop) <= 1. } Kind::Terminate => { expanded(rect, slop).contains(at) && ((delta.x - delta.y).abs().min((delta.x + delta.y).abs()) <= slop + 2.) } _ => delta.x.hypot(delta.y) <= rect.width / 2. + slop, } } fn node_at<'a>(&self, scene: &'a Scene, world: Point) -> Option<&'a ProjectedNode> { scene .view .nodes .iter() .filter(|n| { self.symbol_hit(n, world) || scene .symbol_labels .get(&n.id) .is_some_and(|label| label.contains(world)) }) .max_by_key(|n| (n.depth, n.id)) } fn vertex_at(&self, scene: &Scene, world: Point) -> Option { self.node_at(scene, world) .filter(|n| { self.project .elements .get(&n.element) .is_some_and(|e| e.kind().is_state()) }) .map(|n| n.element) } fn hit(&self, scene: &Scene, world: Point) -> Option { let node = self.node_at(scene, world); if let Some(node) = node { if let Some((relation, _)) = state_action_rows(self.project, node) .into_iter() .find(|(_, r)| r.contains(world)) { return Some(Hit::StateAction(relation.id)); } if let Some((relation, _)) = internal_rows(self.project, node) .into_iter() .find(|(_, r)| r.contains(world)) { return Some(Hit::Transition(relation.id)); } let container = self .project .elements .get(&node.element) .is_some_and(|e| matches!(e.kind(), Kind::Region | Kind::StateMachine)) || scene .view .nodes .iter() .any(|n| n.visual_parent == Some(node.id)); let rect = rectangle(node); let border = 5. / self.scale(); if !container || world.y <= rect.y + projection::state_header_height(self.project, node.element) || world.x < rect.x + border || world.x > rect.x + rect.width - border || world.y > rect.y + rect.height - border { return Some(Hit::Node { placement: node.id, element: node.element, }); } } let edge = scene .routed .edges .values() .filter_map(|edge| { let label = Rectangle::new( point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height), ); let distance = if label.contains(world) { 0. } else { routing::project_onto_polyline(&edge.points, position(world))?.distance }; (distance <= 6. / self.scale()).then_some((edge.id, distance)) }) .min_by(|a, b| { a.1.total_cmp(&b.1) .then_with(|| (a.0 != self.selected).cmp(&(b.0 != self.selected))) .then(a.0.cmp(&b.0)) }); edge.map(|(id, _)| Hit::Transition(id)).or_else(|| { node.map(|n| Hit::Node { placement: n.id, element: n.element, }) }) } fn endpoint_at(&self, scene: &Scene, world: Point) -> Option<(Id, Endpoint)> { let route = scene.routed.edges.get(&self.selected)?; [ (Endpoint::Source, route.points.first()), (Endpoint::Target, route.points.last()), ] .into_iter() .filter_map(|(endpoint, p)| p.map(|p| (endpoint, world.distance(point(*p))))) .filter(|(_, distance)| *distance <= 8. / self.scale()) .min_by(|a, b| a.1.total_cmp(&b.1)) .map(|(endpoint, _)| (route.id, endpoint)) } fn owner_at(&self, scene: &Scene, world: Point, kind: Kind) -> Option { self.node_at(scene, world) .filter(|n| state_edit::eligible_owner(self.project, self.diagram, n.element, kind)) .map(|n| n.element) } fn move_pointer(&self, state: &mut State, window: Point) { match state.press.as_mut() { Some(Press::Move { node, start, original, }) if window.distance(*start) >= 3. || state.preview.is_some() => { let delta = (window - *start) / self.scale(); state.preview = Some(( *node, Position { x: original.x + delta.x, y: original.y + delta.y, }, )); } Some(Press::Connect { start, dragged, .. }) => { *dragged |= window.distance(*start) >= 3. } _ => {} } } fn gesture_active(&self, state: &State) -> bool { self.editable && state.context == Some(self.context) && state.tool == Some(self.tool) } fn guidance(&self, state: &State, scene: &Scene, world: Option) -> String { if !self.editable { return "Pending edits · apply or cancel them before editing the diagram".into(); } let hovered = world.and_then(|p| self.vertex_at(scene, p)); let press = self.gesture_active(state).then_some(state.press).flatten(); let source = self.gesture_active(state).then_some(state.source).flatten(); let reconnect = match press { Some(Press::Reconnect { relation, endpoint, .. }) => Some((relation, endpoint)), _ => { if let Tool::Reconnect { relation, endpoint } = self.tool { Some((relation, endpoint)) } else { None } } }; if let Some((relation, endpoint)) = reconnect { return match hovered { Some(vertex) if state_edit::can_reconnect( self.project, self.diagram, relation, endpoint, vertex, ) => { format!( "Reconnect {endpoint:?} of {} to {} · release to apply", self.project.named(relation), self.project.named(vertex) ) } _ => format!( "Reconnect {endpoint:?} of {} · choose a compatible vertex · Esc cancels", self.project.named(relation) ), }; } match self.tool { Tool::Vertex(_) | Tool::Region => { let kind = kind_for_tool(self.tool); world.and_then(|p| self.owner_at(scene, p, kind)).map_or_else( || format!("Create {} · click its explicit owning {} · Esc cancels", kind.label(), if kind == Kind::Region { "machine or composite state" } else { "region" }), |owner| format!("Create {} inside {} · click to place", kind.label(), self.project.named(owner))) } Tool::Transition => match source { Some(source) => match hovered { Some(target) if state_edit::can_connect(self.project, self.diagram, source, target) => format!("{} → {} · compatible target · click or release to connect", self.project.named(source), self.project.named(target)), _ => format!("Source: {} · choose a compatible target · Esc cancels", self.project.named(source)), }, None => "Draw transition · click a source, then a compatible target, or drag between vertices".into(), }, _ => match world.and_then(|p| self.hit(scene, p)) { Some(Hit::Transition(id)) => self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default(), Some(Hit::StateAction(id)) => self.project.relations.get(&id).map(|r| format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))).unwrap_or_default(), _ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(), }, } } } fn kind_for_tool(tool: Tool) -> Kind { if let Tool::Vertex(kind) = tool { kind } else { Kind::Region } } impl canvas::Program for StateCanvas<'_> { type State = State; fn update( &self, state: &mut State, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { if state.context != Some(self.context) || state.tool != Some(self.tool) { if state.context != Some(self.context) { state.viewport = None; } state.reset(); state.context = Some(self.context); state.tool = Some(self.tool); } if !self.editable { state.press = None; state.preview = None; state.source = None; } // Winit positions are ordered events. The cursor supplied by Iced is the // final cursor for an entire batch and cannot reinterpret earlier clicks. let cursor = match event { canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { state.pointer = Some(*position); mouse::Cursor::Available(*position) } canvas::Event::Mouse(mouse::Event::CursorLeft) => { state.pointer = None; cursor } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), ) => state.pointer.map_or(cursor, mouse::Cursor::Available), canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { state.pointer = cursor.position(); let size = bounds.size(); if state.viewport != Some(size) { state.viewport = Some(size); return Some(Action::publish(Event::Viewport(self.context, size))); } cursor } canvas::Event::Window(iced::window::Event::Unfocused) => { state.reset(); return None; } _ => cursor, }; let window = cursor.position(); let local = window.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y)); let inside = local.is_some_and(|p| Self::content(bounds).contains(p)); if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event { use keyboard::{Key, key::Named}; if *key == Key::Named(Named::Escape) { state.reset(); return Some(Action::publish(Event::Choose(Tool::Select)).and_capture()); } if *key == Key::Named(Named::Delete) && self.editable && self.tool == Tool::Select && state.press.is_none() && state.pan_from.is_none() && state.source.is_none() && (modifiers.is_empty() || *modifiers == keyboard::Modifiers::SHIFT) { let scene = self.scene(state)?; let placement = scene .view .nodes .iter() .find(|node| node.element == self.selected || node.id == self.selected); let object = placement.map_or(self.selected, |node| node.element); if placement.is_none() && !scene.view.edges.iter().any(|edge| edge.id == object) && !visible_row_relation(self.project, self.diagram, &scene.view, object) { return None; } return Some( Action::publish(Event::Remove(crate::app::diagram_removal::Intent { context: self.context, object, placement: placement.map(|node| node.id), model: modifiers.shift(), })) .and_capture(), ); } if modifiers.is_empty() { let pan = match key.as_ref() { Key::Named(Named::ArrowLeft) => Some(Vector::new(60., 0.)), Key::Named(Named::ArrowRight) => Some(Vector::new(-60., 0.)), Key::Named(Named::ArrowUp) => Some(Vector::new(0., 60.)), Key::Named(Named::ArrowDown) => Some(Vector::new(0., -60.)), _ => None, }; if let Some(delta) = pan { state.cancel_gesture(); return Some(Action::publish(Event::Pan(self.context, delta)).and_capture()); } } } let scene = self.scene(state)?; match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) => { if !inside { return None; } let local = local?; let window = window?; let world = self.world(local); if *button == mouse::Button::Middle { state.cancel_gesture(); state.pan_from = Some((mouse::Button::Middle, window)); return Some(Action::capture()); } if *button == mouse::Button::Right { state.reset(); let (object, placement) = match self.hit(&scene, world) { Some(Hit::Node { element, placement }) => (element, Some(placement)), Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None), None => (self.diagram, None), }; return Some( Action::publish(Event::ContextMenu { context: self.context, object, placement, anchor: window, }) .and_capture(), ); } if *button != mouse::Button::Left || !self.editable { return None; } match self.tool { Tool::Select => { if let Some((relation, endpoint)) = self.endpoint_at(&scene, world) { state.press = Some(Press::Reconnect { relation, endpoint, start: Some(window), }); return Some(Action::capture()); } match self.hit(&scene, world) { Some(Hit::Node { placement, element }) => { let original = self .project .diagrams .get(&self.diagram)? .nodes .get(&placement)? .position; state.press = Some(Press::Move { node: placement, start: window, original, }); Some( Action::publish(Event::Select(self.context, element)) .and_capture(), ) } Some(Hit::Transition(id) | Hit::StateAction(id)) => { Some(Action::publish(Event::Select(self.context, id)).and_capture()) } None => { state.pan_from = Some((mouse::Button::Left, window)); Some(Action::capture()) } } } Tool::Vertex(_) | Tool::Region => { let kind = kind_for_tool(self.tool); let owner = self.owner_at(&scene, world, kind)?; state.press = Some(Press::Create { owner, kind, start: window, }); Some(Action::capture()) } Tool::Transition => { let first = state.source.is_none(); let source = state.source.or_else(|| { self.vertex_at(&scene, world) .filter(|id| state_edit::can_start(self.project, self.diagram, *id)) })?; state.source = Some(source); state.press = Some(Press::Connect { source, first, start: window, dragged: false, }); Some(Action::capture()) } Tool::Reconnect { relation, endpoint } => { state.press = Some(Press::Reconnect { relation, endpoint, start: None, }); Some(Action::capture()) } } } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { let window = window?; if let Some((button, previous)) = state.pan_from { state.pan_from = Some((button, window)); if window != previous { return Some( Action::publish(Event::Pan(self.context, window - previous)) .and_capture(), ); } } self.move_pointer(state, window); Some(Action::request_redraw()) } canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => { if let Some((held, previous)) = state.pan_from && *button == held { state.pan_from = None; return Some(match window { Some(window) if window != previous => { Action::publish(Event::Pan(self.context, window - previous)) .and_capture() } _ => Action::capture(), }); } if *button != mouse::Button::Left { return None; } if let Some(window) = window { self.move_pointer(state, window); } let press = state.press.take()?; let preview = state.preview.take(); let world = local.map(|p| self.world(p)); let event = match press { Press::Move { node, .. } => preview.map(|(_, position)| Event::Move { context: self.context, node, position, }), Press::Create { owner, kind, start } if inside && window?.distance(start) < 3. && self.owner_at(&scene, world?, kind) == Some(owner) => { Some(Event::CreateAt { context: self.context, owner, kind, position: position(world?), }) } Press::Connect { source, first, dragged, .. } if inside && (!first || dragged) => self .vertex_at(&scene, world?) .filter(|target| { state_edit::can_connect(self.project, self.diagram, source, *target) }) .map(|target| { state.source = None; Event::Connect { context: self.context, source, target, } }), Press::Reconnect { relation, endpoint, start, } if inside && start.is_none_or(|start| { window.is_some_and(|p| p.distance(start) >= 3.) }) => { self.vertex_at(&scene, world?) .filter(|vertex| { state_edit::can_reconnect( self.project, self.diagram, relation, endpoint, *vertex, ) }) .map(|vertex| Event::Reconnect { context: self.context, relation, endpoint, vertex, }) } _ => None, }; Some(event.map_or_else(Action::capture, |event| { Action::publish(event).and_capture() })) } canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside => { state.cancel_gesture(); let amount = match delta { mouse::ScrollDelta::Lines { y, .. } => *y, mouse::ScrollDelta::Pixels { y, .. } => *y / 50., }; Some( Action::publish(Event::Zoom( self.context, self.scale() * 1.12_f32.powf(amount.clamp(-8., 8.)), )) .and_capture(), ) } _ => None, } } fn draw( &self, state: &State, renderer: &Renderer, _: &Theme, bounds: Rectangle, cursor: mouse::Cursor, ) -> Vec { let Some(scene) = self.scene(state) else { return vec![]; }; let key = PaintKey { context: self.context, preview: self .gesture_active(state) .then_some(state.preview) .flatten(), selected: self.selected, zoom: self.scale(), pan: self.pan, }; if state.paint_key.borrow().as_ref() != Some(&key) { state.geometry.clear(); *state.paint_key.borrow_mut() = Some(key); } let content = Self::content(bounds); let geometry = state.geometry.draw(renderer, bounds.size(), |frame| { // WGPU appends parent meshes after clipped child meshes. Keep the // parent background outside the content clip so it cannot cover nodes. frame.fill_rectangle( Point::ORIGIN, Size::new(bounds.width, GUIDANCE_HEIGHT), style::BG, ); frame.with_clip(content, |frame| { frame.fill_rectangle(content.position(), content.size(), style::BG); frame.translate(self.offset()); frame.scale(self.scale()); for node in &scene.view.nodes { paint_node( frame, self.project, node, self.selected, scene.symbol_labels.get(&node.id), ); } // Containers are already painted. Route bodies have shared // clearance from state headers and internal-transition rows. for edge in scene.routed.edges.values() { let active = edge.id == self.selected; let color = if active { style::TEAL } else { style::INK }; frame.stroke( &polyline(&edge.points), Stroke::default().with_color(color).with_width(if active { 2.4 } else { 1.4 }), ); if let [.., previous, end] = edge.points.as_slice() { let direction = point(*end) - point(*previous); let length = direction.x.hypot(direction.y).max(0.001); let direction = direction / length; let side = Vector::new(-direction.y, direction.x) * 4.; frame.stroke( &Path::new(|p| { p.move_to(point(*end) - direction * 10. + side); p.line_to(point(*end)); p.line_to(point(*end) - direction * 10. - side); }), Stroke::default().with_color(color).with_width(1.5), ); } let label = &edge.label; frame.fill_rectangle( point(label.bounds.position), Size::new(label.bounds.size.width, label.bounds.size.height), Color::WHITE, ); let width = label.bounds.size.width - 10.; label_text( frame, short(self.project.named(edge.id), width, 10.), point(label.bounds.position) + Vector::new(5., 0.), 10., color, ); label_text( frame, short(&label.text, width, 10.), point(label.bounds.position) + Vector::new(5., 12.), 10., style::MUTED, ); if active { for end in [edge.points.first(), edge.points.last()] .into_iter() .flatten() { frame.fill(&Path::circle(point(*end), 5. / self.scale()), Color::WHITE); frame.stroke( &Path::circle(point(*end), 5. / self.scale()), Stroke::default() .with_color(style::TEAL) .with_width(1.6 / self.scale()), ); } } } }); }); let mut overlay = Frame::new(renderer, bounds.size()); let local = cursor.position_in(bounds).filter(|p| content.contains(*p)); let world = local.map(|p| self.world(p)); overlay.with_clip(content, |frame| { frame.translate(self.offset()); frame.scale(self.scale()); if let Some(bounds) = self.navigation_focus { frame.stroke( &Path::rectangle(bounds.position(), bounds.size()), Stroke::default() .with_color(style::TEAL) .with_width(2.5 / self.scale()), ); } if let Some(at) = world && self.editable { let owner = match self.tool { Tool::Vertex(_) | Tool::Region => { self.owner_at(&scene, at, kind_for_tool(self.tool)) } _ => None, }; if let Some(owner) = owner && let Some(node) = scene.view.nodes.iter().find(|n| n.element == owner) { let r = rectangle(node); frame.stroke( &Path::rectangle(r.position(), r.size()), Stroke::default() .with_color(style::TEAL) .with_width(2. / self.scale()), ); } let press = self.gesture_active(state).then_some(state.press).flatten(); let source = self.gesture_active(state).then_some(state.source).flatten(); let from = if let Some(Press::Reconnect { relation, endpoint, .. }) = press { scene .routed .edges .get(&relation) .and_then(|r| { if endpoint == Endpoint::Source { r.points.last() } else { r.points.first() } }) .copied() } else if let Tool::Reconnect { relation, endpoint } = self.tool { scene .routed .edges .get(&relation) .and_then(|r| { if endpoint == Endpoint::Source { r.points.last() } else { r.points.first() } }) .copied() } else { source .and_then(|id| scene.view.nodes.iter().find(|n| n.element == id)) .map(|n| position(rectangle(n).center())) }; if let Some(from) = from { let mut stroke = Stroke::default() .with_color(style::TEAL) .with_width(1.8 / self.scale()); stroke.line_dash = canvas::LineDash { segments: &[6., 4.], offset: 0, }; frame.stroke(&line(point(from), at), stroke); frame.stroke( &Path::circle(at, 5. / self.scale()), Stroke::default().with_color(style::TEAL), ); } } }); label_text( &mut overlay, short( &self.guidance(state, &scene, world), bounds.width - 24., 12., ), Point::new(12., 12.), 12., style::INK, ); overlay.stroke( &line( Point::new(0., GUIDANCE_HEIGHT - 1.), Point::new(bounds.width, GUIDANCE_HEIGHT - 1.), ), Stroke::default().with_color(style::LINE), ); vec![geometry, overlay.into_geometry()] } fn mouse_interaction( &self, state: &State, bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { if state.pan_from.is_some() { return mouse::Interaction::Grabbing; } if state.press.is_some() { return mouse::Interaction::Grabbing; } if !cursor .position_in(bounds) .is_some_and(|p| Self::content(bounds).contains(p)) { return mouse::Interaction::default(); } if self.editable && self.tool != Tool::Select { mouse::Interaction::Crosshair } else { mouse::Interaction::Pointer } } } fn paint_node( frame: &mut Frame, project: &Project, node: &ProjectedNode, selected: Id, symbol_label: Option<&Rectangle>, ) { let Some(element) = project.elements.get(&node.element) else { return; }; let r = rectangle(node); let color = if element.id == selected { style::TEAL } else { style::INK }; let stroke = Stroke::default() .with_color(color) .with_width(if element.id == selected { 2.4 } else { 1.3 }); if projection::state_symbol_size(element.kind()).is_some() { let center = r.center(); let radius = r.width / 2.; let circle = Path::circle(center, radius); match element.kind() { Kind::Initial | Kind::Junction => frame.fill(&circle, color), Kind::Final => { frame.fill(&circle, Color::WHITE); frame.stroke(&circle, stroke); frame.fill(&Path::circle(center, radius - 4.), color); } Kind::Fork | Kind::Join => frame.fill_rectangle(r.position(), r.size(), color), Kind::Choice => { let path = Path::new(|p| { p.move_to(Point::new(center.x, r.y)); p.line_to(Point::new(r.x + r.width, center.y)); p.line_to(Point::new(center.x, r.y + r.height)); p.line_to(Point::new(r.x, center.y)); p.close(); }); frame.fill(&path, Color::WHITE); frame.stroke(&path, stroke); } Kind::Terminate => { frame.stroke( &line(r.position(), Point::new(r.x + r.width, r.y + r.height)), stroke, ); frame.stroke( &line( Point::new(r.x + r.width, r.y), Point::new(r.x, r.y + r.height), ), stroke, ); } _ => { frame.fill(&circle, Color::WHITE); frame.stroke(&circle, stroke); match element.kind() { Kind::ShallowHistory | Kind::DeepHistory => label_text( frame, if element.kind() == Kind::DeepHistory { "H*" } else { "H" }, center - Vector::new( if element.kind() == Kind::DeepHistory { 9. } else { 5. }, 9., ), 14., color, ), Kind::ExitPoint => { let d = radius * 0.6; frame.stroke( &line(center - Vector::new(d, d), center + Vector::new(d, d)), stroke, ); frame.stroke( &line(center + Vector::new(d, -d), center + Vector::new(-d, d)), stroke, ); } _ => {} } } } if let Some(label) = symbol_label { label_text( frame, short(&element.name, 220., 11.), label.position(), 11., color, ); } return; } let path = if element.kind() == Kind::Region { Path::rectangle(r.position(), r.size()) } else { Path::rounded_rectangle(r.position(), r.size(), 10.into()) }; frame.fill( &path, if element.kind() == Kind::Region { Color::from_rgb8(247, 250, 253) } else if element.kind() == Kind::StateMachine { Color::from_rgb8(234, 241, 248) } else { Color::WHITE }, ); let mut border = stroke; if element.kind() == Kind::Region { border.line_dash = canvas::LineDash { segments: &[7., 4.], offset: 0, }; } frame.stroke(&path, border); label_text( frame, short(&element.name, r.width - 24., 14.), r.position() + Vector::new(12., 10.), 14., color, ); label_text( frame, if node.collapsed { format!("{} · collapsed", element.kind().label()) } else { element.kind().label().into() }, r.position() + Vector::new(12., 33.), 10., style::MUTED, ); frame.stroke( &line( Point::new(r.x + 1., r.y + 55.), Point::new(r.x + r.width - 1., r.y + 55.), ), Stroke::default().with_color(style::LINE), ); if node.collapsed { return; } if let Some(SharedBody::State { entry, exit, do_activity, .. }) = element.shared() { let actions = [("entry", entry), ("do", do_activity), ("exit", exit)] .into_iter() .filter(|(_, text)| !text.is_empty()) .map(|(kind, text)| format!("{kind} / {text}")) .collect::>() .join(" · "); if !actions.is_empty() { label_text( frame, short(&actions, r.width - 24., 10.), r.position() + Vector::new(12., 65.), 10., style::MUTED, ); } } for (relation, row) in state_action_rows(project, node) { if relation.id == selected { paint_selected_row(frame, row); } label_text( frame, short(&state_action_label(project, relation), row.width - 12., 11.), row.position() + Vector::new(6., 5.), 11., if relation.id == selected { style::TEAL } else { style::MUTED }, ); } for (relation, row) in internal_rows(project, node) { if relation.id == selected { paint_selected_row(frame, row); } frame.stroke( &line(row.position(), Point::new(row.x + row.width, row.y)), Stroke::default().with_color(style::LINE), ); label_text( frame, short( &format!("INTERNAL · {}", relation.name), row.width - 12., 11., ), row.position() + Vector::new(6., 6.), 11., if relation.id == selected { style::TEAL } else { color }, ); label_text( frame, short( &routing::state_transition_summary(project, relation), row.width - 12., 10., ), row.position() + Vector::new(6., 27.), 10., style::MUTED, ); } } fn paint_selected_row(frame: &mut Frame, row: Rectangle) { frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242)); if row.width <= 2. || row.height <= 2. { return; } // Selection remains visible without hue. Keep the outline inside the // existing row so it cannot cover its separator or change its hit bounds. frame.stroke( &Path::rectangle( row.position() + Vector::new(1., 1.), Size::new(row.width - 2., row.height - 2.), ), Stroke::default().with_color(style::TEAL).with_width(1.5), ); } #[cfg(test)] mod tests { use super::*; use crate::document::Document; struct Fixture { project: Project, diagram: Id, machine: Id, region: Id, initial: Id, a: Id, b: Id, c: Id, leaf: Id, } fn fixture() -> Fixture { let mut project = Project::empty(); let mut add = |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap(); let machine = add(Kind::StateMachine, "Operating modes", None); let region = add(Kind::Region, "Main region", Some(machine)); let initial = add(Kind::Initial, "Start", Some(region)); let a = add(Kind::State, "Operating", Some(region)); let b = add(Kind::State, "Standby", Some(region)); let c = add(Kind::State, "Stopped", Some(region)); let inner = add(Kind::Region, "Nested region", Some(a)); let leaf = add(Kind::State, "Working", Some(inner)); let diagram = Id::new_v4(); project.diagrams.insert( diagram, Diagram { id: diagram, alias: "operating_view".into(), name: "Operating view".into(), description: String::new(), layer: Layer::Sa, projection: ProjectionKind::StateMachine { machine }, nodes: BTreeMap::new(), routes: vec![], view_options: Default::default(), }, ); for (element, x, y, width, height) in [ (machine, 0., 0., 1100., 900.), (region, 24., 64., 1000., 800.), (initial, 60., 160., 224., 94.), (a, 180., 180., 440., 500.), (b, 760., 180., 224., 94.), (c, 760., 430., 224., 94.), (inner, 204., 350., 380., 300.), (leaf, 310., 450., 224., 94.), ] { let node = project.place(diagram, element, Position { x, y }); project .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&node) .unwrap() .size = oa_core::Size { width, height }; } validate_candidate(&project).unwrap(); Fixture { project, diagram, machine, region, initial, a, b, c, leaf, } } #[test] fn diagram_usage_state_rows_and_label_references_follow_actual_projection() { use crate::diagram_usage::Target; let mut f = fixture(); let work = f .project .add_element(Kind::Function, Layer::Sa, "Act", None) .unwrap(); let event = f .project .add_element(Kind::Event, Layer::Sa, "E", None) .unwrap(); let unused = f .project .add_element(Kind::Event, Layer::Sa, "E", None) .unwrap(); let action = f .project .relate(RelationKind::StateAction, "Entry", f.a, work); let internal = f .project .relate(RelationKind::StateTransition, "Internal", f.a, f.a); let external = f .project .relate(RelationKind::StateTransition, "External", f.a, f.b); for (id, kind) in [ (internal, TransitionKind::Internal), (external, TransitionKind::External), ] { if let RelationBody::StateTransition { transition_kind, triggers, effects, .. } = &mut f.project.relations.get_mut(&id).unwrap().body { *transition_kind = kind; *triggers = vec![event]; *effects = vec![work]; } } let before = f.project.clone(); let diagram = &f.project.diagrams[&f.diagram]; let view = projection::project_diagram(&f.project, diagram); let placement = view.nodes.iter().find(|n| n.element == f.a).unwrap().id; let action_target = Target::StateRow { placement, relation: action, }; let internal_target = Target::StateRow { placement, relation: internal, }; assert_eq!( diagram_usages(&f.project, diagram, &view, action)[0].target, action_target ); assert_eq!( diagram_usages(&f.project, diagram, &view, internal)[0].target, internal_target ); let events = diagram_usages(&f.project, diagram, &view, event); assert_eq!(events.len(), 2); assert!(events.iter().any(|u| u.target == internal_target)); assert!(events.iter().any(|u| u.target == Target::Edge(external))); assert!(diagram_usages(&f.project, diagram, &view, unused).is_empty()); // External edge identity belongs to the shared saved-view adapter. assert!(diagram_usages(&f.project, diagram, &view, external).is_empty()); let work_usages = diagram_usages(&f.project, diagram, &view, work); assert!(work_usages.iter().any(|u| u.target == action_target)); assert!(work_usages.iter().any(|u| u.target == internal_target)); let row = work_usages .iter() .find(|u| u.target == action_target) .unwrap(); let canvas = canvas(&f, Tool::Select, 1.); let scene = canvas.scene(&State::default()).unwrap(); assert_eq!( canvas.hit(&scene, row.bounds.center()), Some(Hit::StateAction(action)) ); assert_eq!(f.project, before); f.project .diagrams .get_mut(&f.diagram) .unwrap() .nodes .get_mut(&placement) .unwrap() .collapsed = true; let diagram = &f.project.diagrams[&f.diagram]; let folded = projection::project_diagram(&f.project, diagram); assert!(diagram_usages(&f.project, diagram, &folded, action).is_empty()); assert!(diagram_usages(&f.project, diagram, &folded, internal).is_empty()); } #[test] fn diagram_usage_transition_labels_do_not_match_prose_or_fields_beyond_cutoff() { let mut f = fixture(); let early = f .project .add_element(Kind::Event, Layer::Sa, "A", None) .unwrap(); let late = f .project .add_element(Kind::Event, Layer::Sa, "B", None) .unwrap(); let relation = f .project .relate(RelationKind::StateTransition, "Transition", f.a, f.b); if let RelationBody::StateTransition { triggers, guard, .. } = &mut f.project.relations.get_mut(&relation).unwrap().body { *triggers = vec![early, late]; *guard = "B".into(); } let relation = &f.project.relations[&relation]; assert_eq!( transition_label_role(&f.project, relation, early, 84.), Some("transition trigger") ); assert_eq!(transition_label_role(&f.project, relation, late, 84.), None); assert_eq!( transition_label_role(&f.project, relation, late, 800.), Some("transition trigger") ); assert!(!retained_label_field("1234A678", "1234…", 4)); } #[test] fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() { use crate::diagram_usage::Target; let mut f = fixture(); let first = f .project .add_element(Kind::Event, Layer::Sa, &"A".repeat(35), None) .unwrap(); let second = f .project .add_element(Kind::Event, Layer::Sa, "Second", None) .unwrap(); let external = f .project .relate(RelationKind::StateTransition, "External", f.a, f.b); let internal = f .project .relate(RelationKind::StateTransition, "Internal", f.a, f.a); for (id, kind) in [ (external, TransitionKind::External), (internal, TransitionKind::Internal), ] { if let RelationBody::StateTransition { transition_kind, triggers, .. } = &mut f.project.relations.get_mut(&id).unwrap().body { *transition_kind = kind; *triggers = vec![first, second]; } } let diagram = &f.project.diagrams[&f.diagram]; let view = projection::project_diagram(&f.project, diagram); let routed = routing::route_diagram(&f.project, diagram, &view); let edge = &routed.edges[&external]; let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.); // Independent painted-text oracle: 11 prefix characters + 35 As, then // only the space before OR survives the routing limit. assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35))); assert!(!painted.contains("Second")); let usages = diagram_usages(&f.project, diagram, &view, second); assert!(!usages.iter().any(|u| u.target == Target::Edge(external))); assert!(usages.iter().any( |u| matches!(u.target, Target::StateRow { relation, .. } if relation == internal) )); assert!( diagram_usages(&f.project, diagram, &view, first) .iter() .any(|u| u.target == Target::Edge(external)) ); } fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> { StateCanvas { project: &f.project, diagram: f.diagram, context: Context { document: Id::new_v4(), diagram: f.diagram, revision: 7, epoch: 3, }, selected: f.machine, navigation_focus: None, tool, zoom, pan: Vector::new(20., 30.), editable: true, } } fn bounds() -> Rectangle { Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.)) } fn screen(canvas: &StateCanvas<'_>, world: Point) -> Point { Point::new(world.x * canvas.scale(), world.y * canvas.scale()) + canvas.offset() + Vector::new(bounds().x, bounds().y) } fn header(canvas: &StateCanvas<'_>, element: Id) -> Point { let scene = canvas.scene(&State::default()).unwrap(); let node = scene .view .nodes .iter() .find(|n| n.element == element) .unwrap(); point(node.position) + Vector::new(20., 20.) } fn placement(f: &Fixture, element: Id) -> Id { f.project.diagrams[&f.diagram] .nodes .values() .find(|n| n.element == element) .unwrap() .id } fn input( canvas: &StateCanvas<'_>, state: &mut State, event: mouse::Event, batch_cursor: Point, ) -> Option { input_at_bounds(canvas, state, event, batch_cursor, bounds()) } fn input_at_bounds( canvas: &StateCanvas<'_>, state: &mut State, event: mouse::Event, batch_cursor: Point, bounds: Rectangle, ) -> Option { canvas::Program::update( canvas, state, &canvas::Event::Mouse(event), bounds, mouse::Cursor::Available(batch_cursor), ) .and_then(|action| action.into_inner().0) } #[test] fn selection_relayout_does_not_move_nodes_or_change_drag_distance() { for zoom in [0.5, 1., 2.] { for delta in [Vector::ZERO, Vector::new(36., 24.)] { let mut f = fixture(); let transition = f.project.relate( RelationKind::StateTransition, "Selected transition", f.a, f.c, ); let mut canvas = canvas(&f, Tool::Select, zoom); canvas.selected = transition; let mut state = State::default(); let start = screen(&canvas, header(&canvas, f.b)); motion(&canvas, &mut state, start, start); assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start), Some(Event::Select(_, id)) if id == f.b)); // App selection removes contextual controls above the canvas. // Context stays valid, but subsequent events have new bounds. canvas.selected = f.b; let shifted = Rectangle::new(bounds().position() - Vector::new(22., 38.), bounds().size()); let release = start + delta * zoom; input_at_bounds( &canvas, &mut state, mouse::Event::CursorMoved { position: release }, release, shifted, ); let event = input_at_bounds( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), release, shifted, ); if delta == Vector::ZERO { assert!( event.is_none(), "A stationary selection must not edit the model" ); } else { let node = placement(&f, f.b); let original = f.project.diagrams[&f.diagram].nodes[&node].position; assert!( matches!(event, Some(Event::Move { node: moved, position, .. }) if moved == node && position == Position { x: original.x + delta.x, y: original.y + delta.y, }) ); } assert!(state.press.is_none() && state.preview.is_none()); } } } #[test] fn relayout_does_not_turn_a_transition_click_into_a_drag_or_pan() { let f = fixture(); let mut canvas = canvas(&f, Tool::Transition, 1.); let mut state = State::default(); let start = screen(&canvas, header(&canvas, f.b)); let shifted = Rectangle::new(bounds().position() - Vector::new(0., 24.), bounds().size()); motion(&canvas, &mut state, start, start); input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start, ); assert!( input_at_bounds( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start, shifted ) .is_none() ); assert_eq!(state.source, Some(f.b)); canvas.tool = Tool::Select; input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), start, ); assert!( input_at_bounds( &canvas, &mut state, mouse::Event::CursorMoved { position: start }, start, shifted ) .is_none() ); assert!( input_at_bounds( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Middle), start, shifted ) .is_none() ); assert!(state.pan_from.is_none()); } #[test] fn context_menu_keeps_window_anchor_and_explicit_placement_identity() { let f = fixture(); let canvas = canvas(&f, Tool::Select, 1.); let mut state = State::default(); let at = screen(&canvas, header(&canvas, f.b)); motion(&canvas, &mut state, at, at); assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at), Some(Event::ContextMenu { context, object, placement: Some(node), anchor }) if context == canvas.context && object == f.b && node == placement(&f, f.b) && anchor == at)); let background = Point::new( bounds().x + bounds().width - 10., bounds().y + bounds().height - 10., ); motion(&canvas, &mut state, background, background); assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), background), Some(Event::ContextMenu { context, object, placement: None, anchor }) if context == canvas.context && object == f.diagram && anchor == background)); } fn motion( canvas: &StateCanvas<'_>, state: &mut State, actual: Point, batch_cursor: Point, ) -> Option { input( canvas, state, mouse::Event::CursorMoved { position: actual }, batch_cursor, ) } fn click(canvas: &StateCanvas<'_>, state: &mut State, at: Point) -> Vec { let at = screen(canvas, at); motion(canvas, state, at, at) .into_iter() .chain(input( canvas, state, mouse::Event::ButtonPressed(mouse::Button::Left), at, )) .chain(input( canvas, state, mouse::Event::ButtonReleased(mouse::Button::Left), at, )) .collect() } #[test] fn ordered_stationary_click_does_not_move_on_later_motion_in_the_same_batch() { let f = fixture(); let canvas = canvas(&f, Tool::Select, 1.); let mut state = State::default(); let press = screen(&canvas, header(&canvas, f.a)); let later = screen(&canvas, header(&canvas, f.b)); assert!(motion(&canvas, &mut state, press, later).is_none()); assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), later), Some(Event::Select(context, id)) if context == canvas.context && id == f.a)); assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), later ) .is_none() ); assert!(motion(&canvas, &mut state, later, later).is_none()); assert!(state.press.is_none() && state.preview.is_none() && state.pan_from.is_none()); } #[test] fn drag_uses_ordered_release_at_each_zoom_and_moves_descendants_in_one_undo() { for zoom in [0.5, 1., 2.] { let f = fixture(); let mut canvas = canvas(&f, Tool::Select, zoom); let mut state = State::default(); let press = screen(&canvas, header(&canvas, f.a)); let release = press + Vector::new(36., 24.) * zoom; let later = release + Vector::new(150., 90.); motion(&canvas, &mut state, press, later); assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), later), Some(Event::Select(_, id)) if id == f.a)); // App selection changes are not a document/diagram change. canvas.selected = f.a; assert!(motion(&canvas, &mut state, release, later).is_none()); let event = input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), later, ) .unwrap(); let node = placement(&f, f.a); let original = f.project.diagrams[&f.diagram].nodes[&node].position; assert!( matches!(&event, Event::Move { context, node: moved, position } if *context == canvas.context && *moved == node && *position == Position { x: original.x + 36., y: original.y + 24. }) ); assert!(motion(&canvas, &mut state, later, later).is_none()); assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), later ) .is_none() ); let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); assert_eq!( plan.selected, f.a, "Selection remains semantic, not the placement UUID" ); let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap(); let source = document.bundle().clone(); document.execute(plan.command).unwrap(); let leaf = placement(&f, f.leaf); let before = f.project.diagrams[&f.diagram].nodes[&leaf].position; assert_eq!( document.project().diagrams[&f.diagram].nodes[&leaf].position, Position { x: before.x + 36., y: before.y + 24. } ); assert!(document.undo()); assert_eq!(document.project(), &f.project); assert_eq!(document.bundle(), &source); assert!( !document.undo(), "A drag produces exactly one committed history entry" ); } } #[test] fn context_tool_and_pending_form_changes_cancel_mutating_gestures() { let f = fixture(); for reset in 0..5 { let mut canvas = canvas(&f, Tool::Select, 1.); let mut state = State::default(); let press = screen(&canvas, header(&canvas, f.a)); let release = press + Vector::new(40., 30.); motion(&canvas, &mut state, press, release); input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), release, ); motion(&canvas, &mut state, release, release); assert!(state.preview.is_some()); match reset { 0 => canvas.context.document = Id::new_v4(), 1 => canvas.context.revision += 1, 2 => canvas.context.epoch += 1, 3 => canvas.tool = Tool::Transition, _ => canvas.editable = false, } // A draw before the next input must not expose the old preview. assert!(!canvas.gesture_active(&state)); let scene = canvas.scene(&state).unwrap(); let original = projection::project_view(&f.project, f.diagram); assert_eq!(scene.view.nodes, original.nodes); assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), release ) .is_none() ); assert!(state.press.is_none() && state.preview.is_none() && state.source.is_none()); } } #[test] fn transition_supports_click_click_and_drag_without_coalesced_accidental_edges() { let f = fixture(); for drag in [false, true] { let canvas = canvas(&f, Tool::Transition, 1.); let mut state = State::default(); let source = screen(&canvas, header(&canvas, f.a)); let target = screen(&canvas, header(&canvas, f.c)); motion(&canvas, &mut state, source, target); assert!( input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), target ) .is_none() ); if !drag { assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), target ) .is_none() ); assert_eq!(state.source, Some(f.a)); } assert!(motion(&canvas, &mut state, target, target).is_none()); if !drag { input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), target, ); } let event = input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), target, ) .unwrap(); assert!(matches!(event, Event::Connect { context, source, target } if context == canvas.context && source == f.a && target == f.c)); assert!(state.source.is_none() && state.press.is_none()); } let canvas = canvas(&f, Tool::Transition, 1.); let mut state = State::default(); assert!(click(&canvas, &mut state, header(&canvas, f.b)).is_empty()); let events = click(&canvas, &mut state, header(&canvas, f.b)); assert!( matches!(events.as_slice(), [Event::Connect { source, target, .. }] if *source == f.b && *target == f.b), "A deliberate second click can create a self-loop" ); } #[test] fn creation_uses_the_explicit_deepest_owner_and_never_a_fallback_ancestor() { let f = fixture(); let mut canvas = canvas(&f, Tool::Vertex(Kind::State), 1.); let mut state = State::default(); let owner = header(&canvas, f.region); assert!(matches!(click(&canvas, &mut state, owner).as_slice(), [Event::CreateAt { context, owner: id, kind: Kind::State, position: at }] if *context == canvas.context && *id == f.region && *at == position(owner))); assert!( click(&canvas, &mut state, header(&canvas, f.a)).is_empty(), "Clicking a state must not silently create a sibling in its ancestor region" ); assert!(click(&canvas, &mut state, Point::new(1400., 1000.)).is_empty()); canvas.tool = Tool::Region; assert!( matches!(click(&canvas, &mut state, header(&canvas, f.a)).as_slice(), [Event::CreateAt { owner, kind: Kind::Region, .. }] if *owner == f.a) ); assert!( matches!(click(&canvas, &mut state, header(&canvas, f.machine)).as_slice(), [Event::CreateAt { owner, kind: Kind::Region, .. }] if *owner == f.machine) ); } #[test] fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() { let mut f = fixture(); let relation = f .project .relate(RelationKind::StateTransition, "Go standby", f.a, f.b); let mut canvas = canvas(&f, Tool::Select, 1.); canvas.selected = relation; let mut state = State::default(); let scene = canvas.scene(&state).unwrap(); let end = point(*scene.routed.edges[&relation].points.last().unwrap()); let handle = screen(&canvas, end); motion(&canvas, &mut state, handle, handle); input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), handle, ); assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), handle ) .is_none() ); let target = screen(&canvas, header(&canvas, f.c)); motion(&canvas, &mut state, handle, target); input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), target, ); motion(&canvas, &mut state, target, target); let event = input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), target, ) .unwrap(); assert!( matches!(&event, Event::Reconnect { context, relation: id, endpoint: Endpoint::Target, vertex } if *context == canvas.context && *id == relation && *vertex == f.c) ); let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); let accepted = prepare_command(&f.project, &plan.command).unwrap().project; assert_eq!(plan.selected, relation); assert_eq!(accepted.relations[&relation].source(), f.a); assert_eq!(accepted.relations[&relation].target(), f.c); } #[test] fn typed_state_actions_are_named_selectable_rows_before_internal_transitions_and_regions() { let mut f = fixture(); let mut action_ids = vec![]; for (phase, name) in [ (StateActionKind::Exit, "Release resources"), (StateActionKind::Entry, "Restore settings"), (StateActionKind::Do, "Monitor pressure"), ] { let work = f .project .add_element(Kind::Function, Layer::Sa, name, None) .unwrap(); let id = f .project .relate(RelationKind::StateAction, "Action link", f.a, work); if let RelationBody::StateAction { phase: value, .. } = &mut f.project.relations.get_mut(&id).unwrap().body { *value = phase; } action_ids.push(id); } if let Some(SharedBody::State { entry, .. }) = f.project.elements.get_mut(&f.a).unwrap().shared_mut() { *entry = "Authored entry notes".into(); } let internal = f .project .relate(RelationKind::StateTransition, "Update status", f.a, f.a); if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { *transition_kind = TransitionKind::Internal; } let before = f.project.clone(); let canvas = canvas(&f, Tool::Select, 0.75); let scene = canvas.scene(&State::default()).unwrap(); let a = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap(); let rows = state_action_rows(&f.project, a); assert_eq!( rows.iter() .map(|(r, _)| state_action_label(&f.project, r)) .collect::>(), [ "entry / Restore settings", "do / Monitor pressure", "exit / Release resources" ] ); assert_eq!( projection::state_header_height(&f.project, f.a), 64. + 28. + 3. * 28. + 56. ); let internal_top = internal_rows(&f.project, a)[0].1.y; assert!(rows[0].1.y >= a.position.y + 92.); assert!(rows.last().unwrap().1.y + 28. <= internal_top); let child_region = scene .view .nodes .iter() .find(|n| n.visual_parent == Some(a.id)) .unwrap(); assert!( child_region.position.y >= a.position.y + projection::state_header_height(&f.project, f.a) ); for (relation, row) in rows { assert_eq!( canvas.hit(&scene, row.center()), Some(Hit::StateAction(relation.id)) ); let events = click(&canvas, &mut State::default(), row.center()); assert!( matches!(events.as_slice(), [Event::Select(context, id)] if *context == canvas.context && *id == relation.id), "Clicking a typed action row must select its relation in this view: {events:?}" ); assert!( !scene .view .nodes .iter() .any(|n| n.element == relation.target()) ); assert!(!scene.routed.edges.contains_key(&relation.id)); } let mut collapsed = a.clone(); collapsed.collapsed = true; assert!(state_action_rows(&f.project, &collapsed).is_empty()); assert_eq!( f.project, before, "Reading and selecting action rows cannot mutate work or ownership" ); let target = f.project.relations[&action_ids[1]].target(); f.project.elements.get_mut(&target).unwrap().name = "Reload settings".into(); assert_eq!( state_action_label(&f.project, &f.project.relations[&action_ids[1]]), "entry / Reload settings" ); } #[test] fn internal_rows_and_pseudostate_hits_use_shared_persisted_geometry() { let mut f = fixture(); for name in ["Check pressure", "Update status"] { let id = f .project .relate(RelationKind::StateTransition, name, f.a, f.a); if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&id).unwrap().body { *transition_kind = TransitionKind::Internal; } } let edge = f .project .relate(RelationKind::StateTransition, "Remain standby", f.b, f.b); let canvas = canvas(&f, Tool::Select, 3.); let scene = canvas.scene(&State::default()).unwrap(); let a = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap(); let rows = internal_rows(&f.project, a); assert_eq!(rows.len(), 2); assert_eq!(rows[1].1.y - rows[0].1.y, 56.); assert_eq!( rows[1].1.y + 56., a.position.y + projection::state_header_height(&f.project, f.a) ); for (relation, rect) in rows { assert_eq!( canvas.hit(&scene, rect.center()), Some(Hit::Transition(relation.id)) ); } let initial = scene .view .nodes .iter() .find(|n| n.element == f.initial) .unwrap(); assert_eq!( initial.size, projection::state_symbol_size(Kind::Initial).unwrap() ); assert!(canvas.symbol_hit(initial, rectangle(initial).center())); assert!( !canvas.symbol_hit(initial, point(initial.position)), "The invisible square corner is not part of the circular initial symbol" ); let loop_route = &scene.routed.edges[&edge]; assert!(loop_route.points.len() >= 4); assert!( loop_route .points .windows(2) .all(|pair| pair[0].x == pair[1].x || pair[0].y == pair[1].y) ); assert!(!scene.routed.edges.values().any(|r| matches!( f.project.relations[&r.id].body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, .. } ))); } #[test] fn pseudostate_names_avoid_rightward_and_downward_routes_and_share_hit_bounds() { for rightward in [true, false] { let mut f = fixture(); let initial = placement(&f, f.initial); let target = placement(&f, f.b); let diagram = f.project.diagrams.get_mut(&f.diagram).unwrap(); diagram .nodes .retain(|_, n| [f.machine, f.region, f.initial, f.b].contains(&n.element)); diagram.nodes.get_mut(&initial).unwrap().position = Position { x: 250., y: 220. }; diagram.nodes.get_mut(&target).unwrap().position = if rightward { Position { x: 500., y: 181. } } else { Position { x: 146., y: 450. } }; let transition = f.project .relate(RelationKind::StateTransition, "Begin", f.initial, f.b); let canvas = canvas(&f, Tool::Select, 1.); let scene = canvas.scene(&State::default()).unwrap(); let symbol = scene.view.nodes.iter().find(|n| n.id == initial).unwrap(); let legacy = Rectangle::new( Point::new( symbol.position.x + symbol.size.width + 7., symbol.position.y, ), Size::new(33., 18.), ); let route = &scene.routed.edges[&transition]; if rightward { assert!( route .points .windows(2) .any(|p| segment_crosses(legacy, p[0], p[1])), "The regression fixture must cross the former fixed right-hand name" ); } else { assert!( route.points[1].y > route.points[0].y, "The second case leaves the Initial downward" ); } let label = scene.symbol_labels[&initial]; assert!(scene.routed.edges.values().all(|edge| { edge.points .windows(2) .all(|p| !segment_crosses(expanded(label, 3.), p[0], p[1])) })); assert_eq!(canvas.vertex_at(&scene, label.center()), Some(f.initial)); assert_eq!( canvas.hit(&scene, label.center()), Some(Hit::Node { placement: initial, element: f.initial }) ); assert_eq!( symbol.size, projection::state_symbol_size(Kind::Initial).unwrap() ); assert_eq!( f.project.diagrams[&f.diagram].nodes[&initial].position, Position { x: 250., y: 220. } ); } } #[test] fn stacked_symbol_names_avoid_each_other_and_are_independent_of_input_order() { let mut f = fixture(); let initial = placement(&f, f.initial); f.project .diagrams .get_mut(&f.diagram) .unwrap() .nodes .retain(|_, n| [f.machine, f.region, f.initial].contains(&n.element)); f.project .diagrams .get_mut(&f.diagram) .unwrap() .nodes .get_mut(&initial) .unwrap() .position = Position { x: 250., y: 220. }; let junction = f .project .add_element(Kind::Junction, Layer::Sa, "Second marker", Some(f.region)) .unwrap(); f.project .place(f.diagram, junction, Position { x: 250., y: 240. }); let canvas = canvas(&f, Tool::Select, 1.); let scene = canvas.scene(&State::default()).unwrap(); let labels: Vec<_> = scene.symbol_labels.values().copied().collect(); assert_eq!(labels.len(), 2); assert_eq!(overlap_area(expanded(labels[0], 3.), labels[1]), 0.); for node in scene .view .nodes .iter() .filter(|n| scene.symbol_labels.contains_key(&n.id)) { let label = scene.symbol_labels[&node.id]; assert_eq!(canvas.vertex_at(&scene, label.center()), Some(node.element)); for other in scene.view.nodes.iter().filter(|other| { projection::state_symbol_size(f.project.elements[&other.element].kind()).is_some() }) { assert_eq!(overlap_area(expanded(label, 3.), rectangle(other)), 0.); } } let mut reversed = scene.view.clone(); reversed.nodes.reverse(); assert_eq!( symbol_labels(&f.project, &reversed, &scene.routed), scene.symbol_labels ); } #[test] fn pan_is_button_scoped_read_only_and_hover_does_not_reproject_the_scene() { let f = fixture(); let mut canvas = canvas(&f, Tool::Select, 1.); canvas.editable = false; let mut state = State::default(); let start = screen(&canvas, header(&canvas, f.a)); let end = start + Vector::new(32., 21.); assert!(motion(&canvas, &mut state, start, start).is_none()); let scene = canvas.scene(&state).unwrap(); assert!(Arc::ptr_eq(&scene, &canvas.scene(&state).unwrap())); input( &canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), end, ); assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end ) .is_none() ); assert!(matches!(motion(&canvas, &mut state, end, end), Some(Event::Pan(context, delta)) if context == canvas.context && delta == Vector::new(32., 21.))); assert!( input( &canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Middle), end ) .is_none() ); assert!(state.pan_from.is_none()); canvas.pan += Vector::new(32., 21.); canvas.zoom = 0.5; assert!( Arc::ptr_eq(&scene, &canvas.scene(&state).unwrap()), "Pan, zoom, selection, and hover reuse the persisted projection and routing" ); assert!(motion(&canvas, &mut state, start, start).is_none()); assert!(state.pan_from.is_none()); } #[test] fn escape_and_view_switch_discard_the_armed_transition_source() { let f = fixture(); let mut canvas = canvas(&f, Tool::Transition, 1.); let mut state = State::default(); click(&canvas, &mut state, header(&canvas, f.a)); assert_eq!(state.source, Some(f.a)); let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Escape), modified_key: keyboard::Key::Named(keyboard::key::Named::Escape), physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::empty(), text: None, repeat: false, }); let result = canvas::Program::update( &canvas, &mut state, &escape, bounds(), mouse::Cursor::Unavailable, ) .and_then(|action| action.into_inner().0); assert!(matches!(result, Some(Event::Choose(Tool::Select)))); assert!(state.source.is_none() && state.press.is_none()); click(&canvas, &mut state, header(&canvas, f.a)); assert_eq!(state.source, Some(f.a)); let at = screen(&canvas, header(&canvas, f.b)); canvas.diagram = Id::new_v4(); canvas.context.diagram = canvas.diagram; assert!(motion(&canvas, &mut state, at, at).is_none()); assert!(state.source.is_none()); assert!(canvas.scene(&state).is_none()); } #[test] fn viewport_uses_full_canvas_coordinates_and_republishes_for_a_new_context() { let f = fixture(); let mut canvas = canvas(&f, Tool::Select, 1.); let mut state = State::default(); let redraw = canvas::Event::Window(iced::window::Event::RedrawRequested( std::time::Instant::now(), )); let mut redraw_message = |canvas: &StateCanvas<'_>| { canvas::Program::update( canvas, &mut state, &redraw, bounds(), mouse::Cursor::Unavailable, ) .and_then(|action| action.into_inner().0) }; let expected = bounds().size(); assert!( matches!(redraw_message(&canvas), Some(Event::Viewport(context, size)) if context == canvas.context && size == expected) ); assert!(redraw_message(&canvas).is_none()); canvas.context.document = Id::new_v4(); assert!( matches!(redraw_message(&canvas), Some(Event::Viewport(context, size)) if context == canvas.context && size == expected) ); } }