//! Allocation work-body input admission. Gesture authority lives here, while //! the canvas retains ordered native pointer positions and frozen hit geometry. use super::{App, Message, Page}; use crate::{ allocation_drag as plan, diagram_selection::Snapshot, diagram_usage::{Surface, Target as SelectionTarget}, route_edit::Context, }; use iced::{Point, Rectangle, Task, Vector}; use oa_core::{projection::ProjectedView, routing::RoutedView, *}; use std::{ collections::{BTreeMap, BTreeSet}, sync::Arc, }; pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving elements."; const ARRANGE_ARMED: &str = "Arrange next drag armed · presentation only · Esc cancels"; #[derive(Debug, Clone, Copy, PartialEq)] pub struct Stamp { pub context: Context, pub epoch: u64, pub snapshot: usize, pub bounds: Rectangle, pub pan: Vector, pub zoom: f32, } impl Stamp { pub fn world(self, screen: Point) -> Point { Point::new( (screen.x - self.bounds.x - self.pan.x) / self.zoom, (screen.y - self.bounds.y - self.pan.y) / self.zoom, ) } pub fn viewport(self) -> plan::Rect { let p = self.world(Point::new(self.bounds.x, self.bounds.y)); plan::Rect { position: Position { x: p.x, y: p.y }, size: Size { width: self.bounds.width / self.zoom, height: self.bounds.height / self.zoom, }, } } } #[derive(Debug, Clone)] pub struct Target { pub element: Id, pub placement: Id, pub region: Rectangle, pub name: String, pub ancestors: Vec, } #[derive(Debug, Clone)] pub struct Frozen { pub targets: Vec, cards: Vec, controls: Vec, edges: Vec<(Point, Point)>, radius: f32, } #[derive(Debug, Clone, PartialEq)] pub enum Hit { Destination(plan::Destination, Option), OutsideViewport, Occluded, Ambiguous(Vec), } pub fn contains(r: Rectangle, p: Point) -> bool { p.x >= r.x && p.y >= r.y && p.x < r.x + r.width && p.y < r.y + r.height } impl Frozen { pub fn capture( project: &Project, diagram: &Diagram, affected: &BTreeMap, view: &ProjectedView, routed: &RoutedView, zoom: f32, ) -> Self { let mut targets = Vec::new(); let mut cards = Vec::new(); for n in &view.nodes { if affected.contains_key(&n.id) { continue; } let region = Rectangle::new( Point::new(n.position.x, n.position.y), iced::Size::new(n.size.width, n.size.height), ); let Some(e) = project.elements.get(&n.element) else { cards.push(region); continue; }; if e.kind().is_performer() && e.layer() == diagram.layer { let mut ancestors = Vec::new(); let mut parent = e.parent; while let Some(id) = parent { if ancestors.contains(&id) { break; } ancestors.push(id); parent = project.elements.get(&id).and_then(|e| e.parent); } let name = project.named(e.id).to_owned(); let collapsed = diagram.nodes[&n.id].collapsed; targets.push(Target { element: e.id, placement: n.id, region: if collapsed { Rectangle { height: region.height.min(62.), ..region } } else { region }, name, ancestors, }); } else { cards.push(region); } } let radius = (10. / zoom).max(10.); let mut controls: Vec<_> = view .ports .iter() .filter(|port| !affected.contains_key(&port.owner)) .map(|port| { Rectangle::new( Point::new(port.position.x - radius, port.position.y - radius), iced::Size::new(radius * 2., radius * 2.), ) }) .collect(); let mut edges = Vec::new(); for edge in routed.edges.values() { if let Some(relation) = project.relations.get(&edge.id) { for end in routing::association_end_geometry(relation, edge) { if !end.label.text.is_empty() { controls.push(Rectangle::new( Point::new(end.label.bounds.position.x, end.label.bounds.position.y), iced::Size::new( end.label.bounds.size.width, end.label.bounds.size.height, ), )); } } } if !edge.label.text.is_empty() { controls.push(Rectangle::new( Point::new(edge.label.bounds.position.x, edge.label.bounds.position.y), iced::Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height), )); } for pin in &edge.pins { controls.push(Rectangle::new( Point::new(pin.x - 8. / zoom, pin.y - 8. / zoom), iced::Size::new(16. / zoom, 16. / zoom), )); } for points in edge.points.windows(2) { edges.push(( Point::new(points[0].x, points[0].y), Point::new(points[1].x, points[1].y), )); } } Self { targets, cards, controls, edges, radius: 6. / zoom, } } pub fn resolve(&self, stamp: Stamp, screen: Option, arrange: bool) -> Hit { let Some(screen) = screen.filter(|p| contains(stamp.bounds, *p)) else { return Hit::OutsideViewport; }; let p = stamp.world(screen); // Drag status lives in the toolbar, outside this actual canvas viewport. if self .controls .iter() .chain(&self.cards) .any(|r| contains(*r, p)) || self.edges.iter().any(|(a, b)| { let d = *b - *a; let length = d.x * d.x + d.y * d.y; let t = if length == 0. { 0. } else { (((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length).clamp(0., 1.) }; p.distance(*a + d * t) <= self.radius }) { return Hit::Occluded; } if arrange { return Hit::Destination(plan::Destination::LayoutOnly, None); } let hits: Vec<_> = self .targets .iter() .enumerate() .filter(|(_, t)| contains(t.region, p)) .map(|(i, _)| i) .collect(); for (i, a) in hits.iter().enumerate() { for b in &hits[i + 1..] { let a = &self.targets[*a]; let b = &self.targets[*b]; if !a.ancestors.contains(&b.element) && !b.ancestors.contains(&a.element) { return Hit::Ambiguous(hits); } } } match hits .into_iter() .max_by_key(|i| self.targets[*i].ancestors.len()) { Some(i) => Hit::Destination( plan::Destination::Performer(self.targets[i].element), Some(i), ), None => Hit::Destination(plan::Destination::OutsidePerformers, None), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Assign, Arrange, } #[derive(Debug, Clone)] pub(crate) struct Press { pub stamp: Stamp, pub token: Id, pub start: Point, pub selection: Snapshot, pub target: SelectionTarget, pub mode: Mode, } #[derive(Debug, Clone)] pub(crate) enum Event { Press(Arc), Preview(Stamp, Id, Option), Release(Stamp, Id, Option), Refused(u64, String), Cancel(Id), ArrangeNext, Unfocused, KeyboardBoundary, } pub struct PreviewPlacement { pub diagram: Diagram, pub view: ProjectedView, pub delta: Position, } pub struct Preview { pub token: Id, pub candidate: Candidate, pub placement: PreviewPlacement, pub routed: Arc, } impl Preview { pub fn project(&self) -> &Project { self.candidate.project() } #[cfg(test)] pub fn index(&self) -> &view_options::ViewIndex { self.candidate.index() } } enum Prepared { Singleton(Box), Assignment(plan::assignment::Scope), Arrange(Arc), Performers(Arc), Refused(String), } #[derive(Clone)] pub enum Candidate { Singleton(Arc), Assignment(Arc), Arrange(Arc), Performers(Arc), } enum Placed { Planned(plan::PlacementPlan), Performers { preview: PreviewPlacement, command: Box, requested: Position, }, } impl Placed { fn diagram(&self) -> &Diagram { match self { Self::Planned(p) => &p.diagram, Self::Performers { preview, .. } => &preview.diagram, } } fn view(&self) -> &ProjectedView { match self { Self::Planned(p) => &p.view, Self::Performers { preview, .. } => &preview.view, } } fn into_preview(self) -> PreviewPlacement { match self { Self::Planned(p) => PreviewPlacement { diagram: p.diagram, view: p.view, delta: p.delta, }, Self::Performers { preview, .. } => preview, } } } /// Ordinary performer movement preserves the established core translation policy. /// It shares input provenance, not the explicit Arrange geometry restrictions. pub struct PerformerPlan { accepted: Project, index: view_options::ViewIndex, diagram: Id, selected: BTreeSet, anchor: Id, affected: BTreeMap, } impl PerformerPlan { fn place(&self, requested: Position) -> Result { let original = &self.accepted.diagrams[&self.diagram]; let anchor = original.nodes[&self.anchor].position; let delta = if requested == anchor { Position { x: 0., y: 0. } } else { Position { x: (requested.x / 10.).round() * 10. - anchor.x, y: (requested.y / 10.).round() * 10. - anchor.y, } }; let command = diagram_edit::translate_placements(&self.accepted, self.diagram, &self.selected, delta) .map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?; let mut diagram = original.clone(); let Command::Batch(edits) = &command else { unreachable!("translation is an atomic batch") }; for edit in edits { match edit { Command::EditNode { diagram: id, original, node, } if *id == self.diagram => { diagram.nodes.insert(*original, node.clone()); } Command::SetEdgeRoute { diagram: id, route } if *id == self.diagram => { let stored = diagram .routes .iter_mut() .find(|r| r.edge == route.edge) .ok_or(plan::Refusal::StaleScope)?; *stored = route.clone(); } _ => { return Err(plan::Refusal::InvalidCandidate( "Unexpected performer movement edit.".into(), )); } } } let view = projection::project_diagram_with_index(&self.accepted, &diagram, &self.index); Ok(Placed::Performers { preview: PreviewPlacement { diagram, view, delta, }, command: Box::new(command), requested, }) } } impl Candidate { pub fn project(&self) -> &Project { match self { Self::Singleton(p) => p.project(), Self::Assignment(p) => p.project(), Self::Arrange(p) => p.project(), Self::Performers(p) => &p.accepted, } } #[cfg(test)] pub fn index(&self) -> &view_options::ViewIndex { match self { Self::Singleton(p) => p.index(), Self::Assignment(p) => p.index(), Self::Arrange(p) => p.index(), Self::Performers(p) => &p.index, } } fn place( &self, requested: Position, presentation: plan::PresentationContext, ) -> Result { match self { Self::Singleton(p) => plan::place(p, requested, presentation).map(Placed::Planned), Self::Assignment(p) => { plan::assignment::place(p, requested, presentation).map(Placed::Planned) } Self::Arrange(p) => { plan::arrange::place(p, requested, presentation).map(Placed::Planned) } Self::Performers(p) => p.place(requested), } } fn finish(&self, live: &Project, placed: &Placed) -> Result, plan::Refusal> { match (self, placed) { (Self::Singleton(p), Placed::Planned(result)) => plan::finish(p, live, result), (Self::Assignment(p), Placed::Planned(result)) => { plan::assignment::finish(p, live, result) } (Self::Arrange(p), Placed::Planned(result)) => plan::arrange::finish(p, live, result), ( Self::Performers(p), Placed::Performers { preview, command, requested, }, ) => { if live != &p.accepted { return Err(plan::Refusal::StaleScope); } let checked = p.place(*requested)?; let Placed::Performers { preview: checked, command: checked_command, .. } = checked else { unreachable!() }; if checked.diagram != preview.diagram || checked.view != preview.view || checked.delta != preview.delta || &checked_command != command { return Err(plan::Refusal::StaleScope); } let mut expected = p.accepted.clone(); expected.diagrams.insert(p.diagram, checked.diagram); let actual = prepare_command(live, &checked_command) .map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))? .project; if actual != expected { return Err(plan::Refusal::InvalidCandidate( "Performer movement would change unrelated records.".into(), )); } Ok((actual != *live).then_some(*checked_command)) } _ => Err(plan::Refusal::StaleScope), } } fn label(&self) -> String { let (label, warnings) = match self { Self::Singleton(p) => (p.label.clone(), p.warnings.len()), Self::Assignment(p) => (p.label().to_owned(), p.warnings().len()), Self::Arrange(p) => ( format!( "Arrange {} selected elements · {} affected placements", p.selected().len(), p.affected().len() ), 0, ), Self::Performers(p) => ( format!("Move {} selected performer(s)", p.selected.len()), 0, ), }; if warnings == 0 { label } else { format!("{label} · {warnings} model warning(s) retained") } } } struct Active { press: Arc, selection: Snapshot, crossed: bool, prepared: Prepared, frozen: Option>, inspection_targets: Vec, ids: BTreeMap, cache: Vec<(plan::Destination, Result)>, } pub(crate) struct Feedback { pub press: Arc, pub frozen: Option>, ordinary_performers: bool, pub targets: Vec, /// Last captured target names for inspection only; never resolver input. pub inspection_targets: Vec, pub text: String, pub refused: bool, } impl Feedback { pub fn inspection_identity(&self) -> String { if self.refused && self.targets.is_empty() && !self.inspection_targets.is_empty() { let names = self .inspection_targets .iter() .filter_map(|index| { self.frozen .as_ref()? .targets .get(*index) .map(|t| t.name.as_str()) }) .collect::>() .join(" / "); format!( "{}\nLast target{}: {}\nReturn to the canvas to choose a destination; releasing here cancels the drag.", self.destination_identity(), if self.inspection_targets.len() > 1 { "s (ambiguous)" } else { "" }, names ) } else { self.destination_identity() } } /// Captured accepted names, independent of clipped status/refusal prose. pub fn destination_identity(&self) -> String { match self.targets.as_slice() { [] => if self.ordinary_performers { "Move performers" } else if self.press.mode == Mode::Arrange { "Arrange only" } else if self.refused { "No eligible destination" } else { "Outside performers" } .into(), [index] => self .frozen .as_ref() .and_then(|f| f.targets.get(*index)) .map(|t| t.name.clone()) .unwrap_or_else(|| "No eligible destination".into()), indices => format!( "Ambiguous: {}", indices .iter() .filter_map(|index| self .frozen .as_ref()? .targets .get(*index) .map(|t| t.name.as_str())) .collect::>() .join(" / ") ), } } } #[derive(Default)] pub struct State { pub epoch: u64, pub arrange_next: bool, active: Option, pub preview: Option>, pub feedback: Option, } #[derive(Clone, Copy)] pub struct Capability<'a> { pub state: &'a State, pub clean: bool, pub snapshot: usize, } impl State { fn retire(&mut self) { self.epoch = self.epoch.wrapping_add(1); self.active = None; self.preview = None; self.feedback = None; self.arrange_next = false; } } pub fn refusal(r: &plan::Refusal) -> String { match r { plan::Refusal::Ineligible(s) | plan::Refusal::InvalidCandidate(s) | plan::Refusal::LayoutRequired(s) => s.clone(), plan::Refusal::StaleScope => "The view changed. Start this drag again.".into(), plan::Refusal::MixedLayoutRequired => "This work has a distributed or mixed assignment. Drag a specific leaf; arranging this group needs a layout step.".into(), plan::Refusal::RoleExitRequired { message, .. } => message.clone(), plan::Refusal::MissingAllocationId(_) => "Assignment identity expired. Start this drag again.".into(), plan::Refusal::CollateralChangeRequired(records) => format!( "Assignment needs dependent edits to {}. Resolve these before dragging again.", records.iter().map(|r| r.name.as_str()).collect::>().join(", ") ), } } impl App { pub fn allocation_drag_clean(&self) -> bool { self.allocation_input_owner() && !self.draft_dirty() && !self.source_dirty() && !self.behavior.has_pending() } pub(crate) fn allocation_input_owner(&self) -> bool { self.page == Page::Diagram && !self.context_help && self .active_diagram .and_then(|id| self.project().diagrams.get(&id)) .is_some_and(|d| d.projection == ProjectionKind::Allocation) && self.architecture.is_none() && self.connection.is_none() && self.model_menu.is_none() && !self.reference_input_active() && !self.busy && !self.hosted.visible && self.workflow.is_none() && self.pending.is_none() && self.file_prompt.is_none() && !self.delete_pending && self.diagram_usages.is_none() } pub fn allocation_capability(&self) -> Capability<'_> { Capability { state: &self.allocation_drag, clean: self.allocation_drag_clean(), snapshot: Arc::as_ptr(&self.document.snapshot()) as usize, } } fn allocation_stamp_valid(&self, stamp: Stamp) -> bool { self.allocation_drag_clean() && self.route_context() == Some(stamp.context) && self.allocation_drag.epoch == stamp.epoch && Arc::as_ptr(&self.document.snapshot()) as usize == stamp.snapshot && self.pan == stamp.pan && self.zoom == stamp.zoom && self.canvas_size.is_none_or(|s| s == stamp.bounds.size()) && stamp.bounds.width > 0. && stamp.bounds.height > 0. && stamp.zoom > 0. && [ stamp.bounds.x, stamp.bounds.y, stamp.bounds.width, stamp.bounds.height, stamp.pan.x, stamp.pan.y, stamp.zoom, ] .iter() .all(|value| value.is_finite()) } /// Called before general input dispatch. Even equal-valued actions (Fit, /// Undo/Redo, tool/modal round trips) retire old render-time capabilities. pub(super) fn allocation_before(&mut self, message: &Message) { if !matches!( message, Message::WorkDrag(_) | Message::CheckpointTick | Message::CheckpointWritten(..) | Message::Lint(_) | Message::Linted(..) | Message::CanvasResized(_) | Message::WindowResized(_) | Message::SourceViewport(_) | Message::SourceSymbolsReady(_) | Message::SourceRenameReady(_) | Message::DiagramUsagesReady(_) | Message::DiagramRevealViewport(..) ) { let clear_armed_status = self.allocation_drag.arrange_next && self.status == ARRANGE_ARMED; self.allocation_drag.retire(); if clear_armed_status { self.status = "Arrange cancelled".into(); } } } pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> { ( self.route_context(), self.pan, self.zoom, self.canvas_size, self.window_size, self.allocation_drag_clean(), Arc::as_ptr(&self.document.snapshot()) as usize, ) } pub(super) fn allocation_retire(&mut self) { self.allocation_drag.retire(); } fn allocation_selection_valid(&self, snapshot: &Snapshot) -> bool { self.multiselect_context().is_some_and(|current| { self.diagram_selection .as_ref() .is_some_and(|selection| selection.matches(snapshot, current)) }) } pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool { if self.allocation_drag.active.is_some() || !self.allocation_stamp_valid(press.stamp) || (press.mode == Mode::Arrange) != self.allocation_drag.arrange_next || !self.allocation_selection_valid(&press.selection) || press.selection.context().scope.surface != Surface::Saved(press.stamp.context.diagram) || !press.start.x.is_finite() || !press.start.y.is_finite() || !contains(press.stamp.bounds, press.start) { return false; } let view = projection::project_view_with_index( self.project(), press.stamp.context.diagram, self.document.view_index(), ); super::multiselect::visible( self.project(), press.stamp.context.diagram, &view, &press.target, ) } /// Bare Allocation movement has no lifetime provenance. All actual Allocation /// placement gestures now enter Press/Active, including ordinary performers. pub(super) fn allocation_legacy_move(&self, message: &Message) -> bool { let allocation = |diagram| { self.project() .diagrams .get(&diagram) .is_some_and(|d| d.projection == ProjectionKind::Allocation) }; match message { Message::MoveNode(..) => self.active_diagram.is_some_and(allocation), Message::DiagramSelection(super::multiselect::Event::Move(snapshot, _)) => { matches!(snapshot.context().scope.surface, Surface::Saved(diagram) if allocation(diagram)) } _ => false, } } pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool { match event { Event::Cancel(token) | Event::Preview(_, token, _) | Event::Release(_, token, _) => { self.allocation_drag .active .as_ref() .is_some_and(|active| active.press.token == *token) } Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch, _ => true, } } fn allocation_prepare(&self, press: &Press, selection: &Snapshot) -> Prepared { let SelectionTarget::Placement(anchor) = &press.target else { return Prepared::Refused("Move requires only placed work or performers; ports and connections cannot be moved with this selection.".into()); }; let anchor = *anchor; let Some(ids) = super::multiselect::placements(selection) else { return Prepared::Refused("Move requires only placed work or performers; remove ports, rows and connections from the selection.".into()); }; let project = self.project(); let diagram = press.stamp.context.diagram; let d = &project.diagrams[&diagram]; let selected: BTreeMap<_, _> = ids.iter().map(|id| (*id, d.nodes[id].element)).collect(); let result = if press.mode == Mode::Arrange { plan::arrange::capture(project, diagram, &selected, anchor) .map(|p| Prepared::Arrange(Arc::new(p))) } else if selected .values() .all(|id| project.elements[id].kind().is_work()) { if selected.len() == 1 { plan::capture(project, diagram, selected[&anchor], anchor) .map(|scope| Prepared::Singleton(Box::new(scope))) } else { plan::assignment::capture(project, diagram, &selected, anchor) .map(Prepared::Assignment) } } else if selected .values() .all(|id| project.elements[id].kind().is_performer()) { validate_candidate(project) .map_err(|e| plan::Refusal::InvalidCandidate(e.to_string())) .map(|_| { let mut affected = BTreeMap::new(); for id in &ids { for member in std::iter::once(*id) .chain(projection::descendant_nodes(project, diagram, *id)) { affected.insert(member, d.nodes[&member].clone()); } } Prepared::Performers(Arc::new(PerformerPlan { accepted: project.clone(), index: view_options::ViewIndex::new(project), diagram, selected: ids, anchor, affected, })) }) } else { Err(plan::Refusal::Ineligible("Assignment requires only work. Arm Arrange next drag to arrange a supported work/performer selection.".into())) }; result.unwrap_or_else(|reason| Prepared::Refused(refusal(&reason))) } pub(super) fn allocation_event(&mut self, event: Event) -> Task { if !self.allocation_event_envelope(&event) { return Task::none(); } match event { Event::ArrangeNext => { self.allocation_drag.retire(); if self.allocation_drag_clean() { self.allocation_drag.arrange_next = true; self.status = ARRANGE_ARMED.into(); } else { self.status = FINISH_EDIT.into(); } } Event::Unfocused => self.allocation_drag.retire(), Event::KeyboardBoundary => { if self.allocation_drag.active.is_some() { self.allocation_drag.retire(); } } Event::Cancel(_) => { self.allocation_drag.retire(); self.status = "Drag canceled".into(); } Event::Refused(_, text) => { self.allocation_drag.retire(); self.status = text; } Event::Press(press) => { if self.allocation_drag.active.is_some() { return Task::none(); } if !self.allocation_press_owns_current_view(&press) { self.status = if self.allocation_drag_clean() { "The view changed. Start this drag again.".into() } else { FINISH_EDIT.into() }; return Task::none(); } // A selected-member press has no selection or inspector effect. // An unselected press replaces selection atomically in this handler. if !press.selection.targets().contains(&press.target) { self.multiselect_event(super::multiselect::Event::Choose( press.selection.context(), press.target.clone(), super::multiselect::Choice::Replace, )); } let Some(selection) = self.multiselect_snapshot() else { return Task::none(); }; if !selection.targets().contains(&press.target) || (!press.selection.targets().contains(&press.target) && selection.targets() != [press.target.clone()]) { return Task::none(); } let diagram = press.stamp.context.diagram; let view = projection::project_view_with_index( self.project(), diagram, self.document.view_index(), ); if !selection.targets().iter().all(|target| { super::multiselect::visible(self.project(), diagram, &view, target) }) { self.allocation_drag.retire(); self.status = "The view changed. Start this drag again.".into(); return Task::none(); } let prepared = self.allocation_prepare(&press, &selection); let (affected, leaf_ids) = match &prepared { Prepared::Singleton(scope) => ( Some(&scope.placements), scope.leaves.keys().copied().collect::>(), ), Prepared::Assignment(scope) => ( Some(scope.affected()), scope.leaves().keys().copied().collect(), ), Prepared::Arrange(plan) => (Some(plan.affected()), Vec::new()), Prepared::Performers(plan) => (Some(&plan.affected), Vec::new()), Prepared::Refused(_) => (None, Vec::new()), }; let frozen = affected.map(|affected| { let d = &self.project().diagrams[&diagram]; let routed = routing::route_diagram(self.project(), d, &view); Arc::new(Frozen::capture( self.project(), d, affected, &view, &routed, press.stamp.zoom, )) }); let ids = leaf_ids.into_iter().map(|id| (id, Id::new_v4())).collect(); self.allocation_drag.arrange_next = false; self.allocation_drag.preview = None; self.allocation_drag.feedback = None; self.allocation_drag.active = Some(Active { press, selection, crossed: false, prepared, frozen, inspection_targets: Vec::new(), ids, cache: Vec::new(), }); } Event::Preview(stamp, token, pointer) => { self.allocation_motion(stamp, token, pointer, false) } Event::Release(stamp, token, pointer) => { self.allocation_motion(stamp, token, pointer, true) } } Task::none() } fn allocation_motion( &mut self, stamp: Stamp, token: Id, pointer: Option, release: bool, ) { let Some(active) = self.allocation_drag.active.as_ref() else { return; }; if active.press.token != token { return; } if !self.allocation_stamp_valid(stamp) || !self.allocation_selection_valid(&active.selection) || active.press.stamp != stamp { self.allocation_drag.retire(); self.status = if self.allocation_drag_clean() { "The view changed. Start this drag again.".into() } else { FINISH_EDIT.into() }; return; } let mut active = self.allocation_drag.active.take().unwrap(); let press = active.press.clone(); active.crossed |= pointer.is_some_and(|p| p.distance(press.start) > 3.); let in_bounds = pointer .is_some_and(|p| p.x.is_finite() && p.y.is_finite() && contains(stamp.bounds, p)); if !active.crossed && in_bounds { if release { self.multiselect_event(super::multiselect::Event::Collapse( active.selection, press.target.clone(), )); self.allocation_drag.retire(); self.status = "Element selected".into(); } else { self.allocation_drag.active = Some(active); } return; } let ordinary_performers = matches!(&active.prepared, Prepared::Performers(_)); let hit = if !in_bounds { Hit::OutsideViewport } else if ordinary_performers { Hit::Destination(plan::Destination::LayoutOnly, None) } else { active.frozen.as_ref().map_or(Hit::Occluded, |f| { f.resolve(stamp, pointer, press.mode == Mode::Arrange) }) }; if active.crossed { match &hit { Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target], Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(), _ => {} } } let mut targets = Vec::new(); let mut preview = None; let result: Result = if matches!(hit, Hit::OutsideViewport) { Err(if release { "Drag canceled outside the canvas." } else { "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." }.into()) } else if let Prepared::Refused(reason) = &active.prepared { Err(reason.clone()) } else { match hit { Hit::OutsideViewport => unreachable!(), Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()), Hit::Ambiguous(indices) => { targets = indices; Err("Two unrelated performers overlap here. Use an exposed region.".into()) } Hit::Destination(destination, target) => { targets.extend(target); let candidate = if let Some((_, cached)) = active.cache.iter().find(|(d, _)| *d == destination) { cached.clone() } else { let candidate = match &active.prepared { Prepared::Singleton(scope) => plan::plan_semantics(self.project(), scope, destination, &active.ids) .map(|p| Candidate::Singleton(Arc::new(p))), Prepared::Assignment(scope) => plan::assignment::plan_semantics(scope, destination, &active.ids) .map(|p| Candidate::Assignment(Arc::new(p))), Prepared::Arrange(plan) => Ok(Candidate::Arrange(plan.clone())), Prepared::Performers(plan) => Ok(Candidate::Performers(plan.clone())), Prepared::Refused(_) => unreachable!(), }; active.cache.push((destination, candidate.clone())); candidate }; candidate.map_err(|r| refusal(&r)).and_then(|candidate| { if !ordinary_performers && stamp.zoom * 16. < 9. { return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into()); } let SelectionTarget::Placement(anchor) = &press.target else { unreachable!("supported placement") }; let original = self.project().diagrams[&stamp.context.diagram].nodes[anchor].position; let delta = pointer.unwrap() - press.start; let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom }; let presentation = plan::PresentationContext { viewport: stamp.viewport(), zoom: stamp.zoom, target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }), }; let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?; let label = candidate.label(); if release { if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? { self.document.execute(command) .map_err(|e| format!("{e} · retry this drag after correcting the source issue"))?; // sync refreshes the retained primary once; no anchor promotion. self.sync(); self.reconcile_multiselect(); } } else { let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view())); preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed })); } Ok(label) }) } } }; let refused = result.is_err(); let text = result.unwrap_or_else(|text| text); self.status = text.clone(); if release { self.allocation_drag.retire(); } else { let inspection_targets = active.inspection_targets.clone(); let frozen = active.frozen.clone(); self.allocation_drag.active = Some(active); self.allocation_drag.preview = preview; self.allocation_drag.feedback = Some(Feedback { press, frozen, ordinary_performers, targets, inspection_targets, text, refused, }); } } } #[cfg(test)] #[path = "allocation_drag_tests.rs"] mod tests;