Repair outgoing allocation drag boundary placement and feedback
This commit is contained in:
@@ -210,7 +210,6 @@ impl SemanticPlan {
|
|||||||
pub fn project(&self) -> &Project {
|
pub fn project(&self) -> &Project {
|
||||||
&self.candidate
|
&self.candidate
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
|
||||||
pub fn index(&self) -> &ViewIndex {
|
pub fn index(&self) -> &ViewIndex {
|
||||||
&self.index
|
&self.index
|
||||||
}
|
}
|
||||||
@@ -485,6 +484,57 @@ pub struct PresentationContext {
|
|||||||
pub target: Option<TargetPresentation>,
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct PlacementPlan {
|
pub struct PlacementPlan {
|
||||||
pub diagram: Diagram,
|
pub diagram: Diagram,
|
||||||
@@ -496,8 +546,8 @@ pub struct PlacementPlan {
|
|||||||
commands: Vec<Command>,
|
commands: Vec<Command>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `requested` is the original selected placement position plus pointer motion
|
/// `requested` preserves the frozen pointer offset unless the caller proposes
|
||||||
/// in the frozen transform (therefore preserving the press offset). Snap only
|
/// bounded outward clearance for an outgoing drop. Snap only
|
||||||
/// this root; apply the resulting identical delta to captured Q and its routes.
|
/// this root; apply the resulting identical delta to captured Q and its routes.
|
||||||
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
||||||
pub fn place(
|
pub fn place(
|
||||||
@@ -577,7 +627,7 @@ pub fn place(
|
|||||||
height: selected.size.height.min(62.),
|
height: selected.size.height.min(62.),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if !presentation.viewport.contains(header) {
|
if !presentation.header_visible(header, plan.destination == Destination::OutsidePerformers) {
|
||||||
return Err(Refusal::LayoutRequired(
|
return Err(Refusal::LayoutRequired(
|
||||||
"Make room or adjust the view, then drag again.".into(),
|
"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)?;
|
geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?;
|
||||||
scenes?;
|
scenes?;
|
||||||
geometry::check_headers(&view, &plan.selected, presentation.viewport)?;
|
geometry::check_headers(&view, &plan.selected, presentation, false)?;
|
||||||
Ok(PlacementPlan {
|
Ok(PlacementPlan {
|
||||||
diagram,
|
diagram,
|
||||||
view,
|
view,
|
||||||
|
|||||||
@@ -398,7 +398,12 @@ pub fn place(
|
|||||||
&expanded,
|
&expanded,
|
||||||
)?;
|
)?;
|
||||||
geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?;
|
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 {
|
Ok(PlacementPlan {
|
||||||
diagram,
|
diagram,
|
||||||
view,
|
view,
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ pub(super) fn check_rigid(
|
|||||||
pub(super) fn check_headers(
|
pub(super) fn check_headers(
|
||||||
view: &ProjectedView,
|
view: &ProjectedView,
|
||||||
selected: &BTreeMap<Id, Id>,
|
selected: &BTreeMap<Id, Id>,
|
||||||
viewport: Rect,
|
presentation: PresentationContext,
|
||||||
|
outgoing: bool,
|
||||||
) -> Result<(), Refusal> {
|
) -> Result<(), Refusal> {
|
||||||
for (id, element) in selected {
|
for (id, element) in selected {
|
||||||
let node = view
|
let node = view
|
||||||
@@ -185,7 +186,7 @@ pub(super) fn check_headers(
|
|||||||
height: node.size.height.min(62.),
|
height: node.size.height.min(62.),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if !viewport.contains(header) {
|
if !presentation.header_visible(header, outgoing) {
|
||||||
return Err(Refusal::LayoutRequired(
|
return Err(Refusal::LayoutRequired(
|
||||||
"Make room or adjust the view, then drag again.".into(),
|
"Make room or adjust the view, then drag again.".into(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod clearance;
|
||||||
|
|
||||||
pub const FINISH_EDIT: &str =
|
pub const FINISH_EDIT: &str =
|
||||||
"Finish or cancel the current edit before selecting or moving elements.";
|
"Finish or cancel the current edit before selecting or moving elements.";
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ pub struct Target {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Frozen {
|
pub struct Frozen {
|
||||||
pub targets: Vec<Target>,
|
pub targets: Vec<Target>,
|
||||||
|
pub moving: Vec<Rectangle>,
|
||||||
cards: Vec<Rectangle>,
|
cards: Vec<Rectangle>,
|
||||||
controls: Vec<Rectangle>,
|
controls: Vec<Rectangle>,
|
||||||
edges: Vec<(Point, Point)>,
|
edges: Vec<(Point, Point)>,
|
||||||
@@ -82,14 +85,16 @@ impl Frozen {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let mut targets = Vec::new();
|
let mut targets = Vec::new();
|
||||||
let mut cards = Vec::new();
|
let mut cards = Vec::new();
|
||||||
|
let mut moving = Vec::new();
|
||||||
for n in &view.nodes {
|
for n in &view.nodes {
|
||||||
if affected.contains_key(&n.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let region = Rectangle::new(
|
let region = Rectangle::new(
|
||||||
Point::new(n.position.x, n.position.y),
|
Point::new(n.position.x, n.position.y),
|
||||||
iced::Size::new(n.size.width, n.size.height),
|
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 {
|
let Some(e) = project.elements.get(&n.element) else {
|
||||||
cards.push(region);
|
cards.push(region);
|
||||||
continue;
|
continue;
|
||||||
@@ -172,6 +177,7 @@ impl Frozen {
|
|||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
targets,
|
targets,
|
||||||
|
moving,
|
||||||
cards,
|
cards,
|
||||||
controls,
|
controls,
|
||||||
edges,
|
edges,
|
||||||
@@ -407,7 +413,6 @@ impl Candidate {
|
|||||||
Self::Performers(p) => &p.accepted,
|
Self::Performers(p) => &p.accepted,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
|
||||||
pub fn index(&self) -> &view_options::ViewIndex {
|
pub fn index(&self) -> &view_options::ViewIndex {
|
||||||
match self {
|
match self {
|
||||||
Self::Singleton(p) => p.index(),
|
Self::Singleton(p) => p.index(),
|
||||||
@@ -525,6 +530,8 @@ pub(crate) struct Feedback {
|
|||||||
pub inspection_targets: Vec<usize>,
|
pub inspection_targets: Vec<usize>,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub refused: bool,
|
pub refused: bool,
|
||||||
|
/// Pointer-following outline only; it never authorizes a model change.
|
||||||
|
pub ghost_delta: Option<Vector>,
|
||||||
}
|
}
|
||||||
impl Feedback {
|
impl Feedback {
|
||||||
pub fn inspection_identity(&self) -> String {
|
pub fn inspection_identity(&self) -> String {
|
||||||
@@ -1055,7 +1062,26 @@ impl App {
|
|||||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||||
target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }),
|
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();
|
let label = candidate.label();
|
||||||
if release {
|
if release {
|
||||||
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
|
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
|
||||||
@@ -1082,6 +1108,14 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
let inspection_targets = active.inspection_targets.clone();
|
let inspection_targets = active.inspection_targets.clone();
|
||||||
let frozen = active.frozen.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.active = Some(active);
|
||||||
self.allocation_drag.preview = preview;
|
self.allocation_drag.preview = preview;
|
||||||
self.allocation_drag.feedback = Some(Feedback {
|
self.allocation_drag.feedback = Some(Feedback {
|
||||||
@@ -1092,6 +1126,7 @@ impl App {
|
|||||||
inspection_targets,
|
inspection_targets,
|
||||||
text,
|
text,
|
||||||
refused,
|
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;
|
mounted.settle(&mut f.app).await;
|
||||||
(f, mounted)
|
(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 {
|
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||||
mounted
|
mounted
|
||||||
.geometry(app)
|
.geometry(app)
|
||||||
@@ -481,6 +592,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
|
|||||||
if pan.y == -100. {
|
if pan.y == -100. {
|
||||||
// A genuine clipping refusal in the same held gesture, then
|
// A genuine clipping refusal in the same held gesture, then
|
||||||
// recovery at the original exact point: no camera rebasing.
|
// 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.));
|
let clipped = at(&mut mounted, &f, xy(160., 110.));
|
||||||
mounted
|
mounted
|
||||||
.batch(
|
.batch(
|
||||||
@@ -492,7 +604,19 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
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!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||||
assert_eq!(f.app.project(), &before);
|
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![],
|
inspection_targets: vec![],
|
||||||
text: "A card blocks this drop".into(),
|
text: "A card blocks this drop".into(),
|
||||||
refused: true,
|
refused: true,
|
||||||
|
ghost_delta: None,
|
||||||
};
|
};
|
||||||
assert_eq!(feedback.destination_identity(), "No eligible destination");
|
assert_eq!(feedback.destination_identity(), "No eligible destination");
|
||||||
feedback.text = "Release inside the captured canvas".into();
|
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;
|
feedback.refused = false;
|
||||||
assert_eq!(feedback.destination_identity(), "Outside performers");
|
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 {
|
let Some(frozen) = &feedback.frozen else {
|
||||||
return vec![];
|
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 {
|
for index in &feedback.targets {
|
||||||
let target = &frozen.targets[*index];
|
let target = &frozen.targets[*index];
|
||||||
let region = target.region;
|
let region = target.region;
|
||||||
|
|||||||
+13
-1
@@ -2702,7 +2702,19 @@ performer change with diagram placement. The preview MUST identify the
|
|||||||
destination and preserve readable work and destination identity. Destination
|
destination and preserve readable work and destination identity. Destination
|
||||||
identity is shown outside the canvas, independently of explanatory or refusal
|
identity is shown outside the canvas, independently of explanatory or refusal
|
||||||
text. The outlined target's hit region remains fixed throughout the gesture;
|
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
|
A clean click selects without applying unrelated drafts. A refused work press
|
||||||
with an unapplied property draft MUST preserve its text, caret and focused
|
with an unapplied property draft MUST preserve its text, caret and focused
|
||||||
editor. An accepted drag is one atomic model/source/history transaction:
|
editor. An accepted drag is one atomic model/source/history transaction:
|
||||||
|
|||||||
+13
-1
@@ -2128,7 +2128,19 @@ performer change with diagram placement. The preview MUST identify the
|
|||||||
destination and preserve readable work and destination identity. Destination
|
destination and preserve readable work and destination identity. Destination
|
||||||
identity is shown outside the canvas, independently of explanatory or refusal
|
identity is shown outside the canvas, independently of explanatory or refusal
|
||||||
text. The outlined target's hit region remains fixed throughout the gesture;
|
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
|
A clean click selects without applying unrelated drafts. A refused work press
|
||||||
with an unapplied property draft MUST preserve its text, caret and focused
|
with an unapplied property draft MUST preserve its text, caret and focused
|
||||||
editor. An accepted drag is one atomic model/source/history transaction:
|
editor. An accepted drag is one atomic model/source/history transaction:
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user