diff --git a/crates/oa-desktop/src/allocation_drag.rs b/crates/oa-desktop/src/allocation_drag.rs index 5f6b266d..98a27d0b 100644 --- a/crates/oa-desktop/src/allocation_drag.rs +++ b/crates/oa-desktop/src/allocation_drag.rs @@ -210,7 +210,6 @@ impl SemanticPlan { pub fn project(&self) -> &Project { &self.candidate } - #[cfg(test)] pub fn index(&self) -> &ViewIndex { &self.index } @@ -485,6 +484,57 @@ pub struct PresentationContext { pub target: Option, } +impl PresentationContext { + fn header_visible(self, header: Rect, outgoing: bool) -> bool { + if !outgoing { + return self.viewport.contains(header); + } + // Leaving a performer must not require fitting the entire work card + // inside the camera. Retain a 24px grab region so the result stays + // directly reachable; geometry/model admission is independent. + let width = (header.position.x + header.size.width) + .min(self.viewport.position.x + self.viewport.size.width) + - header.position.x.max(self.viewport.position.x); + let height = (header.position.y + header.size.height) + .min(self.viewport.position.y + self.viewport.size.height) + - header.position.y.max(self.viewport.position.y); + width * self.zoom >= 24. && height * self.zoom >= 24. + } +} + +#[test] +fn outgoing_header_retains_a_grab_region_without_requiring_full_camera_containment() { + let context = PresentationContext { + viewport: Rect { + position: Position { x: 0., y: 0. }, + size: Size { + width: 800., + height: 400., + }, + }, + zoom: 0.75, + target: None, + }; + let mut header = Rect { + position: Position { x: -100., y: 30. }, + size: Size { + width: 224., + height: 62., + }, + }; + assert!(context.header_visible(header, true)); + assert!(!context.header_visible(header, false)); + header.position.x = -192.; // exactly 32 world pixels = 24 logical pixels + assert!(context.header_visible(header, true)); + header.position.x -= 1.; + assert!(!context.header_visible(header, true)); + header.position.x = 0.; + header.position.y = 368.; + assert!(context.header_visible(header, true)); + header.position.y += 1.; + assert!(!context.header_visible(header, true)); +} + #[derive(Debug)] pub struct PlacementPlan { pub diagram: Diagram, @@ -496,8 +546,8 @@ pub struct PlacementPlan { commands: Vec, } -/// `requested` is the original selected placement position plus pointer motion -/// in the frozen transform (therefore preserving the press offset). Snap only +/// `requested` preserves the frozen pointer offset unless the caller proposes +/// bounded outward clearance for an outgoing drop. Snap only /// this root; apply the resulting identical delta to captured Q and its routes. /// No whole Project clone, validator or semantic ViewIndex rebuild occurs here. pub fn place( @@ -577,7 +627,7 @@ pub fn place( height: selected.size.height.min(62.), }, }; - if !presentation.viewport.contains(header) { + if !presentation.header_visible(header, plan.destination == Destination::OutsidePerformers) { return Err(Refusal::LayoutRequired( "Make room or adjust the view, then drag again.".into(), )); diff --git a/crates/oa-desktop/src/allocation_drag/arrange.rs b/crates/oa-desktop/src/allocation_drag/arrange.rs index 8a8a7311..dd4aafac 100644 --- a/crates/oa-desktop/src/allocation_drag/arrange.rs +++ b/crates/oa-desktop/src/allocation_drag/arrange.rs @@ -188,7 +188,7 @@ pub fn place( }; geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?; scenes?; - geometry::check_headers(&view, &plan.selected, presentation.viewport)?; + geometry::check_headers(&view, &plan.selected, presentation, false)?; Ok(PlacementPlan { diagram, view, diff --git a/crates/oa-desktop/src/allocation_drag/assignment.rs b/crates/oa-desktop/src/allocation_drag/assignment.rs index 64bd47e8..033be6d3 100644 --- a/crates/oa-desktop/src/allocation_drag/assignment.rs +++ b/crates/oa-desktop/src/allocation_drag/assignment.rs @@ -398,7 +398,12 @@ pub fn place( &expanded, )?; geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?; - geometry::check_headers(&view, scope.selected(), presentation.viewport)?; + geometry::check_headers( + &view, + scope.selected(), + presentation, + plan.destination == Destination::OutsidePerformers, + )?; Ok(PlacementPlan { diagram, view, diff --git a/crates/oa-desktop/src/allocation_drag/geometry.rs b/crates/oa-desktop/src/allocation_drag/geometry.rs index bd53fecc..2ea1db91 100644 --- a/crates/oa-desktop/src/allocation_drag/geometry.rs +++ b/crates/oa-desktop/src/allocation_drag/geometry.rs @@ -170,7 +170,8 @@ pub(super) fn check_rigid( pub(super) fn check_headers( view: &ProjectedView, selected: &BTreeMap, - viewport: Rect, + presentation: PresentationContext, + outgoing: bool, ) -> Result<(), Refusal> { for (id, element) in selected { let node = view @@ -185,7 +186,7 @@ pub(super) fn check_headers( height: node.size.height.min(62.), }, }; - if !viewport.contains(header) { + if !presentation.header_visible(header, outgoing) { return Err(Refusal::LayoutRequired( "Make room or adjust the view, then drag again.".into(), )); diff --git a/crates/oa-desktop/src/app/allocation_drag.rs b/crates/oa-desktop/src/app/allocation_drag.rs index b6c32949..e645b801 100644 --- a/crates/oa-desktop/src/app/allocation_drag.rs +++ b/crates/oa-desktop/src/app/allocation_drag.rs @@ -14,6 +14,8 @@ use std::{ sync::Arc, }; +mod clearance; + pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving elements."; @@ -56,6 +58,7 @@ pub struct Target { #[derive(Debug, Clone)] pub struct Frozen { pub targets: Vec, + pub moving: Vec, cards: Vec, controls: Vec, edges: Vec<(Point, Point)>, @@ -82,14 +85,16 @@ impl Frozen { ) -> Self { let mut targets = Vec::new(); let mut cards = Vec::new(); + let mut moving = 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), ); + if affected.contains_key(&n.id) { + moving.push(region); + continue; + } let Some(e) = project.elements.get(&n.element) else { cards.push(region); continue; @@ -172,6 +177,7 @@ impl Frozen { } Self { targets, + moving, cards, controls, edges, @@ -407,7 +413,6 @@ impl Candidate { Self::Performers(p) => &p.accepted, } } - #[cfg(test)] pub fn index(&self) -> &view_options::ViewIndex { match self { Self::Singleton(p) => p.index(), @@ -525,6 +530,8 @@ pub(crate) struct Feedback { pub inspection_targets: Vec, pub text: String, pub refused: bool, + /// Pointer-following outline only; it never authorizes a model change. + pub ghost_delta: Option, } impl Feedback { pub fn inspection_identity(&self) -> String { @@ -1055,7 +1062,26 @@ impl App { 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 exact = candidate.place(requested, presentation); + let placed = if destination == plan::Destination::OutsidePerformers + && matches!(&exact, Err(plan::Refusal::LayoutRequired(_))) + { + let affected = match &active.prepared { + Prepared::Singleton(scope) => Some(&scope.placements), + Prepared::Assignment(scope) => Some(scope.affected()), + _ => None, + }; + affected.and_then(|affected| { + clearance::requests( + (self.project(), self.document.view_index()), + (candidate.project(), candidate.index()), stamp.context.diagram, + affected, *anchor, requested, + (stamp.world(press.start), stamp.world(pointer.unwrap())), + ).into_iter().find_map(|position| candidate.place(position, presentation).ok()) + }).map_or(exact, Ok) + } else { + exact + }.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))? { @@ -1082,6 +1108,14 @@ impl App { } else { let inspection_targets = active.inspection_targets.clone(); let frozen = active.frozen.clone(); + // A rejected intermediate position is not a return gesture. Keep the + // last admitted scene and show current motion separately; release + // always recomputes admission and never commits this retained view. + if refused && in_bounds && preview.is_none() { + preview = self.allocation_drag.preview.take(); + } + let ghost_delta = (refused && in_bounds) + .then(|| (pointer.unwrap() - press.start) * (1. / stamp.zoom)); self.allocation_drag.active = Some(active); self.allocation_drag.preview = preview; self.allocation_drag.feedback = Some(Feedback { @@ -1092,6 +1126,7 @@ impl App { inspection_targets, text, refused, + ghost_delta, }); } } diff --git a/crates/oa-desktop/src/app/allocation_drag/clearance.rs b/crates/oa-desktop/src/app/allocation_drag/clearance.rs new file mode 100644 index 00000000..c8f5798d --- /dev/null +++ b/crates/oa-desktop/src/app/allocation_drag/clearance.rs @@ -0,0 +1,595 @@ +//! Bounded outgoing clearance proposals. The ordinary placement and semantic +//! validators still decide whether any proposal may be previewed or committed. +use iced::Point; +use oa_core::{Id, NodePlacement, Position, Project, projection, view_options::ViewIndex}; +use std::collections::{BTreeMap, BTreeSet}; + +const GRID: f32 = 10.; +const GAP: f32 = 10.; + +#[derive(Clone, Copy)] +struct Bounds { + left: f32, + top: f32, + right: f32, + bottom: f32, +} + +impl Bounds { + fn node(node: &projection::ProjectedNode) -> Option { + let result = Self { + left: node.position.x, + top: node.position.y, + right: node.position.x + node.size.width, + bottom: node.position.y + node.size.height, + }; + ([result.left, result.top, result.right, result.bottom] + .iter() + .all(|value| value.is_finite()) + && result.right > result.left + && result.bottom > result.top) + .then_some(result) + } + + fn union(self, other: Self) -> Self { + Self { + left: self.left.min(other.left), + top: self.top.min(other.top), + right: self.right.max(other.right), + bottom: self.bottom.max(other.bottom), + } + } + + fn overlaps(self, other: Self) -> bool { + self.left < other.right + && other.left < self.right + && self.top < other.bottom + && other.top < self.bottom + } +} + +/// Called only after an OutsidePerformers candidate fails ordinary placement. +/// Coordinates use the frozen world transform. The entire affected footprint +/// receives one anchor translation; this never moves or packs other cards. +pub(super) fn requests( + accepted: (&Project, &ViewIndex), + candidate: (&Project, &ViewIndex), + diagram: Id, + affected: &BTreeMap, + anchor: Id, + requested: Position, + motion: (Point, Point), +) -> Vec { + let (accepted, accepted_index) = accepted; + let (candidate, candidate_index) = candidate; + let (start, pointer) = motion; + if ![ + requested.x, + requested.y, + start.x, + start.y, + pointer.x, + pointer.y, + ] + .iter() + .all(|value| value.is_finite()) + { + return Vec::new(); + } + let (Some(original), Some(base), Some(anchor)) = ( + accepted.diagrams.get(&diagram), + candidate.diagrams.get(&diagram), + affected.get(&anchor), + ) else { + return Vec::new(); + }; + let snapped = Position { + x: (requested.x / GRID).round() * GRID, + y: (requested.y / GRID).round() * GRID, + }; + let delta = Position { + x: snapped.x - anchor.position.x, + y: snapped.y - anchor.position.y, + }; + let before = projection::project_diagram_with_index(accepted, original, accepted_index); + let before_nodes: BTreeMap<_, _> = before.nodes.iter().map(|node| (node.id, node)).collect(); + let mut sources = BTreeSet::new(); + for node in before + .nodes + .iter() + .filter(|node| affected.contains_key(&node.id)) + { + let mut parent = node.visual_parent; + let mut seen = BTreeSet::new(); + while let Some(id) = parent { + if !seen.insert(id) { + return Vec::new(); + } + let Some(node) = before_nodes.get(&id) else { + break; + }; + if !affected.contains_key(&id) + && accepted + .elements + .get(&node.element) + .is_some_and(|element| element.kind().is_performer()) + { + sources.insert(id); + } + parent = node.visual_parent; + } + } + if sources.is_empty() { + return Vec::new(); + } + // Projection after responsibility removal supplies the former performer's + // remaining enclosure, including other allocated work. Projecting the moved + // block also includes the full visible descendant enclosure of every root. + let mut moved = base.clone(); + for (id, captured) in affected { + let Some(node) = moved.nodes.get_mut(id) else { + return Vec::new(); + }; + node.position = Position { + x: captured.position.x + delta.x, + y: captured.position.y + delta.y, + }; + if ![ + node.position.x, + node.position.y, + node.size.width, + node.size.height, + ] + .iter() + .all(|value| value.is_finite()) + { + return Vec::new(); + } + } + let after = projection::project_diagram_with_index(candidate, &moved, candidate_index); + let mut footprint: Option = None; + for node in after + .nodes + .iter() + .filter(|node| affected.contains_key(&node.id)) + { + let Some(bounds) = Bounds::node(node) else { + return Vec::new(); + }; + footprint = Some(footprint.map_or(bounds, |previous| previous.union(bounds))); + } + let Some(footprint) = footprint else { + return Vec::new(); + }; + let mut overlaps = Vec::new(); + for node in after.nodes.iter().filter(|node| sources.contains(&node.id)) { + let (Some(old), Some(current)) = (Bounds::node(before_nodes[&node.id]), Bounds::node(node)) + else { + return Vec::new(); + }; + if footprint.overlaps(current) { + overlaps.push((old, current)); + } + } + if overlaps.is_empty() { + return Vec::new(); + } + + // Each entry is a half-plane actually crossed by the pointer. At a corner, + // prefer the boundary reached first on the press-to-pointer segment. The + // direction index is a stable tie-break, independent of placement UUIDs. + let mut crossings = [None::; 4]; + for (old, _) in &overlaps { + for (direction, initial, final_, boundary, outward) in [ + ( + 0, + start.x, + pointer.x, + old.left, + pointer.x < old.left && start.x >= old.left, + ), + ( + 1, + start.x, + pointer.x, + old.right, + pointer.x >= old.right && start.x < old.right, + ), + ( + 2, + start.y, + pointer.y, + old.top, + pointer.y < old.top && start.y >= old.top, + ), + ( + 3, + start.y, + pointer.y, + old.bottom, + pointer.y >= old.bottom && start.y < old.bottom, + ), + ] { + if outward { + let fraction = (boundary - initial) / (final_ - initial); + if fraction.is_finite() && (0. ..=1.).contains(&fraction) { + crossings[direction] = + Some(crossings[direction].map_or(fraction, |old| old.min(fraction))); + } + } + } + } + let mut directions: Vec<_> = crossings + .into_iter() + .enumerate() + .filter_map(|(direction, fraction)| fraction.map(|fraction| (direction, fraction))) + .collect(); + directions.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0))); + let mut result = Vec::new(); + for (direction, _) in directions { + let mut corrected = snapped; + match direction { + 0 => { + let edge = overlaps + .iter() + .map(|(_, current)| current.left) + .fold(f32::INFINITY, f32::min); + corrected.x = ((snapped.x + edge - GAP - footprint.right) / GRID).floor() * GRID; + } + 1 => { + let edge = overlaps + .iter() + .map(|(_, current)| current.right) + .fold(f32::NEG_INFINITY, f32::max); + corrected.x = ((snapped.x + edge + GAP - footprint.left) / GRID).ceil() * GRID; + } + 2 => { + let edge = overlaps + .iter() + .map(|(_, current)| current.top) + .fold(f32::INFINITY, f32::min); + corrected.y = ((snapped.y + edge - GAP - footprint.bottom) / GRID).floor() * GRID; + } + 3 => { + let edge = overlaps + .iter() + .map(|(_, current)| current.bottom) + .fold(f32::NEG_INFINITY, f32::max); + corrected.y = ((snapped.y + edge + GAP - footprint.top) / GRID).ceil() * GRID; + } + _ => unreachable!(), + } + if corrected.x.is_finite() && corrected.y.is_finite() && !result.contains(&corrected) { + result.push(corrected); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::allocation_drag as plan; + use oa_core::{Kind, Layer, ProjectionKind, RelationKind, Size}; + + struct Fixture { + project: Project, + diagram: Id, + performer: Id, + work: Id, + node: Id, + } + + fn xy(x: f32, y: f32) -> Position { + Position { x, y } + } + + fn fixture(layer: Layer) -> Fixture { + let mut project = Project::empty(); + let (performer, work) = match layer { + Layer::Oa => (Kind::Entity, Kind::Activity), + Layer::Sa => (Kind::System, Kind::Function), + }; + let performer = project + .add_element(performer, layer, "Performer", None) + .unwrap(); + let work = project.add_element(work, layer, "Work", None).unwrap(); + let diagram = project.ensure_diagram(layer); + project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation; + let performer = project.place(diagram, performer, xy(100., 100.)); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&performer) + .unwrap() + .size = Size { + width: 400., + height: 250., + }; + let node = project.place(diagram, work, xy(160., 180.)); + let owner = project.diagrams[&diagram].nodes[&performer].element; + project.relate(RelationKind::Allocation, "Responsibility", owner, work); + Fixture { + project, + diagram, + performer, + work, + node, + } + } + + fn semantic(f: &Fixture) -> (plan::Scope, plan::SemanticPlan) { + let scope = plan::capture(&f.project, f.diagram, f.work, f.node).unwrap(); + let semantic = plan::plan_semantics( + &f.project, + &scope, + plan::Destination::OutsidePerformers, + &BTreeMap::new(), + ) + .unwrap(); + (scope, semantic) + } + + fn presentation() -> plan::PresentationContext { + plan::PresentationContext { + viewport: plan::Rect { + position: xy(-2000., -2000.), + size: Size { + width: 5000., + height: 5000., + }, + }, + zoom: 1., + target: None, + } + } + + #[test] + fn each_outgoing_edge_clears_the_source_and_passes_ordinary_placement() { + for layer in [Layer::Oa, Layer::Sa] { + let f = fixture(layer); + let (scope, semantic) = semantic(&f); + for (start, pointer, requested, expected) in [ + ( + Point::new(170., 210.), + Point::new(99., 210.), + xy(89., 180.), + xy(-140., 180.), + ), + ( + Point::new(340., 210.), + Point::new(501., 210.), + xy(321., 180.), + xy(510., 180.), + ), + ( + Point::new(200., 190.), + Point::new(200., 99.), + xy(160., 89.), + xy(160., -10.), + ), + ( + Point::new(200., 260.), + Point::new(200., 351.), + xy(160., 271.), + xy(160., 360.), + ), + ] { + assert!(matches!( + plan::place(&semantic, requested, presentation()), + Err(plan::Refusal::LayoutRequired(_)) + )); + let proposed = requests( + (&f.project, &ViewIndex::new(&f.project)), + (semantic.project(), semantic.index()), + f.diagram, + &scope.placements, + f.node, + requested, + (start, pointer), + ); + assert_eq!(proposed, vec![expected]); + let placed = plan::place(&semantic, proposed[0], presentation()).unwrap(); + assert!( + plan::finish(&semantic, &f.project, &placed) + .unwrap() + .is_some() + ); + } + } + } + + #[test] + fn source_shrink_uses_candidate_enclosure_not_frozen_target() { + let mut f = fixture(Layer::Oa); + f.project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&f.performer) + .unwrap() + .size = Size { + width: 224., + height: 94., + }; + let (scope, semantic) = semantic(&f); + let proposed = requests( + (&f.project, &ViewIndex::new(&f.project)), + (semantic.project(), semantic.index()), + f.diagram, + &scope.placements, + f.node, + xy(229., 180.), + (Point::new(340., 210.), Point::new(409., 210.)), + ); + assert_eq!(proposed, vec![xy(340., 180.)]); + plan::place(&semantic, proposed[0], presentation()).unwrap(); + } + + #[test] + fn corner_order_follows_boundary_crossing_and_is_deterministic() { + let f = fixture(Layer::Sa); + let (scope, semantic) = semantic(&f); + // Right crossed at 160/161 of the segment; bottom at 90/91. + let args = ( + xy(321., 271.), + Point::new(340., 260.), + Point::new(501., 351.), + ); + let proposed = requests( + (&f.project, &ViewIndex::new(&f.project)), + (semantic.project(), semantic.index()), + f.diagram, + &scope.placements, + f.node, + args.0, + (args.1, args.2), + ); + assert_eq!(proposed, vec![xy(320., 360.), xy(510., 270.)]); + assert_eq!( + proposed, + requests( + (&f.project, &ViewIndex::new(&f.project)), + (semantic.project(), semantic.index()), + f.diagram, + &scope.placements, + f.node, + args.0, + (args.1, args.2) + ) + ); + } + + #[test] + fn no_crossing_no_overlap_no_source_and_nonfinite_requests_are_empty() { + let f = fixture(Layer::Oa); + let (scope, semantic) = semantic(&f); + for (requested, pointer) in [ + (xy(170., 180.), Point::new(210., 210.)), + (xy(700., 180.), Point::new(740., 210.)), + (xy(f32::NAN, 180.), Point::new(99., 210.)), + (xy(89., 180.), Point::new(f32::INFINITY, 210.)), + ] { + assert!( + requests( + (&f.project, &ViewIndex::new(&f.project)), + (semantic.project(), semantic.index()), + f.diagram, + &scope.placements, + f.node, + requested, + (Point::new(200., 210.), pointer) + ) + .is_empty() + ); + } + assert!( + requests( + (semantic.project(), semantic.index()), + (semantic.project(), semantic.index()), + f.diagram, + &scope.placements, + f.node, + xy(89., 180.), + (Point::new(200., 210.), Point::new(99., 210.)) + ) + .is_empty() + ); + } + + #[test] + fn descendant_footprint_controls_clearance_without_mutating_projects() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let kind = f.project.elements[&f.work].kind(); + let child = f + .project + .add_element(kind, layer, "Child", Some(f.work)) + .unwrap(); + let child_node = f.project.place(f.diagram, child, xy(240., 270.)); + // First-child allocation transfer is normally performed by commands; + // this fixture expresses the resulting leaf allocation directly. + f.project.relations.clear(); + let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element; + f.project.relate( + RelationKind::Allocation, + "Leaf responsibility", + owner, + child, + ); + let (scope, semantic) = semantic(&f); + assert!(scope.placements.contains_key(&child_node)); + let before = f.project.clone(); + let candidate = semantic.project().clone(); + let proposed = requests( + (&f.project, &ViewIndex::new(&f.project)), + (&candidate, semantic.index()), + f.diagram, + &scope.placements, + f.node, + xy(89., 180.), + (Point::new(170., 210.), Point::new(99., 210.)), + ); + assert_eq!(proposed, vec![xy(-240., 180.)]); + let placed = plan::place(&semantic, proposed[0], presentation()).unwrap(); + let root = &placed.diagram.nodes[&f.node]; + let child = &placed.diagram.nodes[&child_node]; + assert_eq!(child.position.x - root.position.x, 80.); + assert_eq!(child.position.y - root.position.y, 90.); + assert_eq!(f.project, before); + assert_eq!(semantic.project(), &candidate); + } + } + + #[test] + fn independent_selected_roots_keep_one_rigid_group_translation() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let kind = f.project.elements[&f.work].kind(); + let second = f + .project + .add_element(kind, layer, "Second work", None) + .unwrap(); + let second_node = f.project.place(f.diagram, second, xy(240., 300.)); + let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element; + f.project.relate( + RelationKind::Allocation, + "Second responsibility", + owner, + second, + ); + let selected = BTreeMap::from([(f.node, f.work), (second_node, second)]); + let scope = + plan::assignment::capture(&f.project, f.diagram, &selected, f.node).unwrap(); + let semantic = plan::assignment::plan_semantics( + &scope, + plan::Destination::OutsidePerformers, + &BTreeMap::new(), + ) + .unwrap(); + let proposed = requests( + (&f.project, &ViewIndex::new(&f.project)), + (semantic.project(), semantic.index()), + f.diagram, + scope.affected(), + f.node, + xy(89., 180.), + (Point::new(170., 210.), Point::new(99., 210.)), + ); + assert_eq!(proposed, vec![xy(-220., 180.)]); + let placed = plan::assignment::place(&semantic, proposed[0], presentation()).unwrap(); + let root = &placed.diagram.nodes[&f.node]; + let second = &placed.diagram.nodes[&second_node]; + assert_eq!(second.position.x - root.position.x, 80.); + assert_eq!(second.position.y - root.position.y, 120.); + assert!( + plan::assignment::finish(&semantic, &f.project, &placed) + .unwrap() + .is_some() + ); + } + } +} diff --git a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs index e445ac69..8417adb0 100644 --- a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs @@ -30,6 +30,117 @@ async fn mount() -> (Fixture, Mounted) { mounted.settle(&mut f.app).await; (f, mounted) } + +#[tokio::test] +async fn actual_tree_outgoing_boundary_motion_preview_release_and_cancel() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = outgoing_fixture(layer); + f.app.layer = layer; + f.app.window_size = iced::Size::new(1440., 900.); + let _ = f.app.update(Message::Select(f.work)); + let mut mounted = Mounted::new(&mut f.app).await; + f.app.zoom = 0.75; + f.app.pan = Vector::new(20., 15.); + f.app.fit_to_view = false; + mounted.settle(&mut f.app).await; + let original = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let from = at(&mut mounted, &f, xy(660., 225.)); + mounted + .batch( + &mut f.app, + &[ + Input::Mouse(mouse::Event::CursorMoved { position: from }), + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + ], + mouse::Cursor::Available(from), + ) + .await; + // Separate input/update/layout turns expose the held boundary transition + // that a one-batch distant drop cannot exercise. + for x in [620., 560., 510., 499., 490.] { + let to = at(&mut mounted, &f, xy(x, 225.)); + mounted + .batch( + &mut f.app, + &[Input::Mouse(mouse::Event::CursorMoved { position: to })], + mouse::Cursor::Available(to), + ) + .await; + assert_eq!(f.app.project(), &original); + assert_eq!(f.app.document.bundle(), &source); + assert!( + !f.app.allocation_drag.feedback.as_ref().unwrap().refused, + "{layer:?} x={x}: {}", + f.app.status + ); + assert!(f.app.allocation_drag.preview.is_some()); + let _ = mounted.pixels(&f.app); + } + let expected = f + .app + .allocation_drag + .preview + .as_ref() + .unwrap() + .placement + .diagram + .clone(); + let to = at(&mut mounted, &f, xy(490., 225.)); + mounted + .batch( + &mut f.app, + &[Input::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + ))], + mouse::Cursor::Available(to), + ) + .await; + assert_eq!(&f.app.project().diagrams[&f.diagram], &expected); + assert!( + !f.app + .project() + .relations + .values() + .any(|r| r.kind() == RelationKind::Allocation && r.target() == f.work) + ); + let _ = f.app.update(Message::Undo); + mounted.settle(&mut f.app).await; + assert_eq!(f.app.document.bundle(), &source); + let from = at(&mut mounted, &f, xy(660., 225.)); + let to = at(&mut mounted, &f, xy(490., 225.)); + mounted + .batch( + &mut f.app, + &[ + Input::Mouse(mouse::Event::CursorMoved { position: from }), + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Input::Mouse(mouse::Event::CursorMoved { position: to }), + ], + mouse::Cursor::Available(to), + ) + .await; + mounted + .batch( + &mut f.app, + &[key(keyboard::key::Named::Escape)], + mouse::Cursor::Available(to), + ) + .await; + mounted + .batch( + &mut f.app, + &[Input::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + ))], + mouse::Cursor::Available(to), + ) + .await; + assert_eq!(f.app.document.bundle(), &source); + assert!(f.app.allocation_drag.preview.is_none()); + assert!(!f.app.document.can_undo()); + } +} fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle { mounted .geometry(app) @@ -481,6 +592,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva if pan.y == -100. { // A genuine clipping refusal in the same held gesture, then // recovery at the original exact point: no camera rebasing. + let retained = f.app.allocation_drag.preview.clone().unwrap(); let clipped = at(&mut mounted, &f, xy(160., 110.)); mounted .batch( @@ -492,7 +604,19 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva ) .await; assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused); - assert!(f.app.allocation_drag.preview.is_none()); + assert!(Arc::ptr_eq( + f.app.allocation_drag.preview.as_ref().unwrap(), + &retained + )); + assert!( + f.app + .allocation_drag + .feedback + .as_ref() + .unwrap() + .ghost_delta + .is_some() + ); assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas); assert_eq!(destination_bounds(&mut mounted, &f.app), slot); assert_eq!(f.app.project(), &before); diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index 46be13a0..58b5a391 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -1239,6 +1239,7 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc inspection_targets: vec![], text: "A card blocks this drop".into(), refused: true, + ghost_delta: None, }; assert_eq!(feedback.destination_identity(), "No eligible destination"); feedback.text = "Release inside the captured canvas".into(); @@ -1246,3 +1247,117 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc feedback.refused = false; assert_eq!(feedback.destination_identity(), "Outside performers"); } + +fn outgoing_fixture(layer: Layer) -> Fixture { + let mut f = fixture(layer); + drag(&mut f, screen(xy(160., 190.))); + let mut project = f.app.project().clone(); + let d = project.diagrams.get_mut(&f.diagram).unwrap(); + let performer = d.nodes.values_mut().find(|n| n.element == f.a).unwrap(); + performer.position = xy(500., 100.); + performer.size = Size { + width: 300., + height: 300., + }; + d.nodes.get_mut(&f.node).unwrap().position = xy(540., 190.); + replace(&mut f, project); + f +} + +#[test] +fn outgoing_boundary_drop_clears_source_without_changing_other_placements() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = outgoing_fixture(layer); + let before = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let from = screen(xy(660., 225.)); + let to = screen(xy(490., 225.)); + let mut events = batch(&f.app, from, to); + let release = events.pop().unwrap(); + send(&mut f.app, events); + let feedback = f.app.allocation_drag.feedback.as_ref().unwrap(); + assert!(!feedback.refused, "{}", feedback.text); + let preview = f.app.allocation_drag.preview.as_ref().unwrap(); + let expected_diagram = preview.placement.diagram.clone(); + let work = &expected_diagram.nodes[&f.node]; + assert!(work.position.x + work.size.width <= 490.); + assert_eq!(work.position.y, 190.); + for (id, node) in &before.diagrams[&f.diagram].nodes { + if *id != f.node { + assert_eq!(&expected_diagram.nodes[id], node); + } + } + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + let _ = f.app.update(release); + assert_eq!(&f.app.project().diagrams[&f.diagram], &expected_diagram); + assert!( + !f.app + .project() + .relations + .values() + .any(|r| r.kind() == RelationKind::Allocation && r.target() == f.work) + ); + let after = f.app.project().clone(); + let saved = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &after); + assert_eq!(f.app.document.bundle(), &saved); + } +} + +#[test] +fn refused_motion_retains_last_scene_but_release_never_commits_it() { + let mut f = outgoing_fixture(Layer::Sa); + let mut project = f.app.project().clone(); + let obstacle = project + .add_element(Kind::Function, Layer::Sa, "Unrelated work", None) + .unwrap(); + project.place(f.diagram, obstacle, xy(1250., 120.)); + replace(&mut f, project); + let original = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let p = admit_press(&mut f); + let clear = screen(xy(450., 225.)); + let _ = f.app.update(Message::WorkDrag(Event::Preview( + p.stamp, + p.token, + Some(clear), + ))); + let last = f.app.allocation_drag.preview.clone().expect(&f.app.status); + // Another work card blocks this location: the prior legal outgoing scene is display + // state only, and must not become a fallback command on release. + let blocked = screen(xy(1260., 130.)); + let _ = f.app.update(Message::WorkDrag(Event::Preview( + p.stamp, + p.token, + Some(blocked), + ))); + assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused); + assert!( + f.app + .allocation_drag + .feedback + .as_ref() + .unwrap() + .ghost_delta + .is_some() + ); + assert!(Arc::ptr_eq( + f.app.allocation_drag.preview.as_ref().unwrap(), + &last + )); + let _ = f.app.update(Message::WorkDrag(Event::Release( + p.stamp, + p.token, + Some(blocked), + ))); + assert_eq!(f.app.project(), &original); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo()); + assert!(f.app.allocation_drag.preview.is_none()); +} diff --git a/crates/oa-desktop/src/diagram/work_drag.rs b/crates/oa-desktop/src/diagram/work_drag.rs index 0cc4957d..28506d3a 100644 --- a/crates/oa-desktop/src/diagram/work_drag.rs +++ b/crates/oa-desktop/src/diagram/work_drag.rs @@ -243,6 +243,21 @@ impl canvas::Program for WorkFeedback<'_> { let Some(frozen) = &feedback.frozen else { return vec![]; }; + if let Some(delta) = feedback.ghost_delta { + for region in &frozen.moving { + let top = Point::new( + (region.x + delta.x) * self.0.zoom + self.0.pan.x, + (region.y + delta.y) * self.0.zoom + self.0.pan.y, + ); + frame.stroke( + &Path::rectangle( + top, + iced::Size::new(region.width * self.0.zoom, region.height * self.0.zoom), + ), + Stroke::default().with_color(color).with_width(2.), + ); + } + } for index in &feedback.targets { let target = &frozen.targets[*index]; let region = target.region; diff --git a/docs/language.html b/docs/language.html index ddae8876..22c01211 100644 --- a/docs/language.html +++ b/docs/language.html @@ -2702,7 +2702,19 @@ performer change with diagram placement. The preview MUST identify the destination and preserve readable work and destination identity. Destination identity is shown outside the canvas, independently of explanatory or refusal text. The outlined target's hit region remains fixed throughout the gesture; -feedback MUST NOT obscure the work or change its pointer-relative placement. +feedback MUST NOT obscure the work. Preserve pointer-relative placement when +the requested position is valid. When an outgoing drop would leave work +overlapping its former performer, the editor MAY propose a minimal grid-aligned +translation through a crossed performer boundary, with one grid unit of +clearance. It MUST preview that exact position, translate the complete affected +work group rigidly, and recheck all semantic and collision constraints on release. +It MUST NOT move unrelated placements or search arbitrary free space. If no +proposal is valid, a pointer-following outline MAY accompany the last valid +preview; that retained preview MUST NOT authorize a refused release. +Outgoing work headers MAY be partly outside the viewport provided each retains +at least a 24-by-24 logical-pixel grab region. Releasing the pointer outside the +viewport still cancels. Other assignment and Arrange gestures retain their +complete-header visibility requirement. A clean click selects without applying unrelated drafts. A refused work press with an unapplied property draft MUST preserve its text, caret and focused editor. An accepted drag is one atomic model/source/history transaction: diff --git a/docs/language.rst b/docs/language.rst index 028db93b..7ff15dbb 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -2128,7 +2128,19 @@ performer change with diagram placement. The preview MUST identify the destination and preserve readable work and destination identity. Destination identity is shown outside the canvas, independently of explanatory or refusal text. The outlined target's hit region remains fixed throughout the gesture; -feedback MUST NOT obscure the work or change its pointer-relative placement. +feedback MUST NOT obscure the work. Preserve pointer-relative placement when +the requested position is valid. When an outgoing drop would leave work +overlapping its former performer, the editor MAY propose a minimal grid-aligned +translation through a crossed performer boundary, with one grid unit of +clearance. It MUST preview that exact position, translate the complete affected +work group rigidly, and recheck all semantic and collision constraints on release. +It MUST NOT move unrelated placements or search arbitrary free space. If no +proposal is valid, a pointer-following outline MAY accompany the last valid +preview; that retained preview MUST NOT authorize a refused release. +Outgoing work headers MAY be partly outside the viewport provided each retains +at least a 24-by-24 logical-pixel grab region. Releasing the pointer outside the +viewport still cancels. Other assignment and Arrange gestures retain their +complete-header visibility requirement. A clean click selects without applying unrelated drafts. A refused work press with an unapplied property draft MUST preserve its text, caret and focused editor. An accepted drag is one atomic model/source/history transaction: diff --git a/harness/drag-out-20260925.md b/harness/drag-out-20260925.md new file mode 100644 index 00000000..6c06fc83 --- /dev/null +++ b/harness/drag-out-20260925.md @@ -0,0 +1,67 @@ +# Outgoing allocation dragging + +Scope: [issue 40](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/40), +based on published Windows source `715036e132677dd2bc2fb37ed316d93dc9987aec`. +This contribution is not integrated release acceptance. User requirements remain +in `PROJECT_GOAL.md`; this note records a revision to derived interaction policy. + +## Reproduction and cause + +The released executable SHA256 +`2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399` +opened a copied Allocation/Process companion fixture on Windows. At 1440x900, +72% Fit, the OA assign view's X-receive was dragged from window (486,546) to +(365,546), just beyond A's left edge. It remained assigned at its original +position, reporting enlarged card overlap. A control drag to (625,572) removed +assignment; Undo restored the original scene. These are actual native end-state +observations; intermediate jitter was diagnosed from source, not frame capture. + +Frozen targets resolve from the pointer, while placement retains its grab +offset. The pointer therefore leaves its performer before the full work card +does. Former parent/child intersection becomes unrelated overlap after removing +allocation. Refused motion previously cleared the preview and redrew the saved +scene. Additionally, whole-header viewport containment rejects a useful outgoing +position in the fitted reproduction even when its model geometry is valid. + +## Selected policy + +Try exact snapped pointer placement first. Only an outgoing assignment candidate +with a placement refusal may propose bounded source clearance: move the entire +captured work footprint through a crossed frozen source boundary, clearing the +post-removal source enclosure by one 10-world-unit grid cell. Consider at most +four directions, ordered by press-to-pointer boundary-crossing fraction and a +fixed direction tie-break. Existing placement, collision, semantic, exact-command +and source/history admission remain authoritative. No neighbor is moved, no +general packing/search occurs, and no semantic refusal is overridden. + +The accepted/candidate semantic indexes are reused during fallback projections. +Outgoing headers may be partly clipped but each must retain a 24x24 logical-pixel +grab region. Other assignment and Arrange visibility rules remain unchanged. +The exact corrected candidate is previewed; release recomputes it. Blocked +in-bounds motion retains its last admitted scene and adds an orange pointer +outline. This display state confers no permission to commit on refused release. +Cancel, outside release and gesture invalidation retire it normally. + +Rejected alternatives: retaining a moving outline alone leaves the boundary +dead band; allowing unrelated overlap makes unassigned work look allocated; +general auto-layout changes unrelated placement and makes drag intent harder to +predict. The previous unconditional pointer-offset rule is narrowed in +`docs/language.rst` and its HTML copy. Real neighboring-card collisions and +insufficient visible grab area can still refuse a drop. + +## Verification status + +Independent source diagnosis and repair critique found no semantic/stale/cancel +bypass. Review exposed the remaining whole-header camera restriction and an +obsolete retained-preview assertion; both were revised. Added checks cover +OA/SA boundary clearance, corners, source shrinkage, rigid descendants/groups, +real App frame-by-frame input, exact source/history, cancellation, blocked release +and outgoing grab-area boundaries. Execution and changed-binary native results +are pending; test definitions are not passing evidence. + +The first build attempt failed before tests because the sparse checkout omitted +`build/windows_manifest.rs`; restoring the tracked build directory corrected the +checkout. The local drive initially had about 22 MB free. Git sparse checkout and +Cargo's package-scoped cleanup of inactive desktop artifacts recovered capacity. +Automatic review rejected recursive deletion of the extracted preview; it was +left intact. No published package or unique evidence was deleted.