//! 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, route_edit::Context}; use iced::{Point, Rectangle, Task, Vector}; use oa_core::{projection::ProjectedView, routing::RoutedView, *}; use std::{collections::BTreeMap, sync::Arc}; pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work."; #[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, scope: &plan::Scope, view: &ProjectedView, routed: &RoutedView, zoom: f32, ) -> Self { let mut targets = Vec::new(); let mut cards = Vec::new(); for n in &view.nodes { if scope.placements.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() == scope.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 = scope.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| !scope.placements.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)] pub struct Press { pub stamp: Stamp, pub token: Id, pub start: Point, pub scope: plan::Scope, pub frozen: Frozen, pub arrange: bool, } #[derive(Debug, Clone)] pub enum Event { Press(Arc), Preview(Stamp, Id, Option), Release(Stamp, Id, Option), Refused(u64, String), Cancel(Id), ArrangeNext, Unfocused, KeyboardBoundary, } pub struct Preview { pub token: Id, pub semantic: Arc, pub placement: plan::PlacementPlan, pub routed: Arc, } struct Active { press: Arc, inspection_targets: Vec, ids: BTreeMap, cache: Vec<( plan::Destination, Result, plan::Refusal>, )>, } pub struct Feedback { pub press: Arc, 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() .map(|index| self.press.frozen.targets[*index].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.press.arrange { "Arrange only" } else if self.refused { "No eligible destination" } else { "Outside performers" } .into(), [index] => self.press.frozen.targets[*index].name.clone(), indices => format!( "Ambiguous: {}", indices .iter() .map(|index| self.press.frozen.targets[*index].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 .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. } /// 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(..) ) { self.allocation_drag.retire(); } } 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 select_work_clean(&mut self, press: &Press) -> bool { if !self.allocation_stamp_valid(press.stamp) { return false; } self.selected = press.scope.work; self.selected_node = Some(press.scope.placement); self.selected_bend = None; self.refresh_draft(); true } pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool { self.allocation_drag.active.is_none() && self.allocation_stamp_valid(press.stamp) && press.arrange == self.allocation_drag.arrange_next } 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, // Invalid Press packets deliberately report why the view or draft // changed. Leave their existing validation and feedback intact. _ => true, } } 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 next drag armed · moves work only · Esc cancels".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(token) => { if self .allocation_drag .active .as_ref() .is_some_and(|a| a.press.token == token) { self.allocation_drag.retire(); self.status = "Work drag canceled".into(); } } Event::Refused(epoch, text) => { if epoch == self.allocation_drag.epoch { 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(); } let checked = plan::capture( self.project(), press.scope.diagram.id, press.scope.work, press.scope.placement, ); if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) { self.status = checked .err() .as_ref() .map(refusal) .unwrap_or_else(|| "The view changed. Start this drag again.".into()); self.allocation_drag.retire(); return Task::none(); } let ids = press .scope .leaves .keys() .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, 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, ) { if self .allocation_drag .active .as_ref() .is_none_or(|a| a.press.token != token) { return; // An old packet cannot cancel a newer gesture. } if !self.allocation_stamp_valid(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 Some(mut active) = self.allocation_drag.active.take() else { return; }; if active.press.token != token || active.press.stamp != stamp { self.allocation_drag.active = Some(active); return; } let press = active.press.clone(); let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.); let hit = press.frozen.resolve(stamp, pointer, press.arrange); if moved { 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 = match hit { Hit::OutsideViewport => Err(if release { "Work drag canceled outside the canvas." } else { "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." }.into()), _ if !moved => Ok("Work selected".into()), 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 semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) { result.clone() } else { let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new); active.cache.push((destination, result.clone())); result }; semantic.map_err(|r| refusal(&r)).and_then(|semantic| { if 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 pointer = pointer.unwrap(); let delta = pointer - press.start; let original = press.scope.placements[&press.scope.placement].position; 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| { let t = &press.frozen.targets[i]; plan::TargetPresentation { placement: t.placement } }), }; let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?; let label = if semantic.warnings.is_empty() { semantic.label.clone() } else { format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len()) }; if release { let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?; if let Some(command) = command { // Bypass applying selectors and unrelated repair helpers. The // planner's exact Batch enters the authoritative source pipeline. self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?; self.sync(); self.selected = press.scope.work; self.selected_node = Some(press.scope.placement); self.refresh_draft(); } } else { let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view)); preview = Some(Arc::new(Preview { token, semantic, placement, 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(); self.allocation_drag.active = Some(active); self.allocation_drag.preview = preview; self.allocation_drag.feedback = Some(Feedback { press, targets, inspection_targets, text, refused, }); } } } #[cfg(test)] #[path = "allocation_drag_tests.rs"] mod tests;