Repair outgoing allocation drag boundary placement and feedback

This commit is contained in:
2026-09-24 22:47:30 -07:00
parent 715036e132
commit 1a669130fb
12 changed files with 1047 additions and 16 deletions
+54 -4
View File
@@ -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<TargetPresentation>,
}
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<Command>,
}
/// `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(),
));
@@ -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,
@@ -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,
@@ -170,7 +170,8 @@ pub(super) fn check_rigid(
pub(super) fn check_headers(
view: &ProjectedView,
selected: &BTreeMap<Id, Id>,
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(),
));
+40 -5
View File
@@ -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<Target>,
pub moving: Vec<Rectangle>,
cards: Vec<Rectangle>,
controls: Vec<Rectangle>,
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<usize>,
pub text: String,
pub refused: bool,
/// Pointer-following outline only; it never authorizes a model change.
pub ghost_delta: Option<Vector>,
}
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,
});
}
}
@@ -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<Self> {
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<Id, NodePlacement>,
anchor: Id,
requested: Position,
motion: (Point, Point),
) -> Vec<Position> {
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<Bounds> = 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::<f32>; 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()
);
}
}
}
@@ -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);
@@ -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());
}
@@ -243,6 +243,21 @@ impl canvas::Program<Message> 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;