Support atomic Allocation group assignment and Arrange gestures

This commit is contained in:
2026-09-23 20:25:26 -07:00
parent 16b30a6760
commit 495464e84b
14 changed files with 2928 additions and 220 deletions
+558 -128
View File
@@ -1,12 +1,21 @@
//! Allocation work-body input admission. Gesture authority lives here, while
//! the canvas retains ordered native pointer positions and frozen hit geometry.
use super::{App, Message, Page};
use crate::{allocation_drag as plan, route_edit::Context};
use crate::{
allocation_drag as plan,
diagram_selection::Snapshot,
diagram_usage::{Surface, Target as SelectionTarget},
route_edit::Context,
};
use iced::{Point, Rectangle, Task, Vector};
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
use std::{collections::BTreeMap, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
pub const FINISH_EDIT: &str =
"Finish or cancel the current edit before selecting or moving elements.";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stamp {
@@ -65,7 +74,8 @@ pub fn contains(r: Rectangle, p: Point) -> bool {
impl Frozen {
pub fn capture(
project: &Project,
scope: &plan::Scope,
diagram: &Diagram,
affected: &BTreeMap<Id, NodePlacement>,
view: &ProjectedView,
routed: &RoutedView,
zoom: f32,
@@ -73,7 +83,7 @@ impl Frozen {
let mut targets = Vec::new();
let mut cards = Vec::new();
for n in &view.nodes {
if scope.placements.contains_key(&n.id) {
if affected.contains_key(&n.id) {
continue;
}
let region = Rectangle::new(
@@ -84,7 +94,7 @@ impl Frozen {
cards.push(region);
continue;
};
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
if e.kind().is_performer() && e.layer() == diagram.layer {
let mut ancestors = Vec::new();
let mut parent = e.parent;
while let Some(id) = parent {
@@ -95,7 +105,7 @@ impl Frozen {
parent = project.elements.get(&id).and_then(|e| e.parent);
}
let name = project.named(e.id).to_owned();
let collapsed = scope.diagram.nodes[&n.id].collapsed;
let collapsed = diagram.nodes[&n.id].collapsed;
targets.push(Target {
element: e.id,
placement: n.id,
@@ -118,7 +128,7 @@ impl Frozen {
let mut controls: Vec<_> = view
.ports
.iter()
.filter(|port| !scope.placements.contains_key(&port.owner))
.filter(|port| !affected.contains_key(&port.owner))
.map(|port| {
Rectangle::new(
Point::new(port.position.x - radius, port.position.y - radius),
@@ -224,17 +234,23 @@ impl Frozen {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Assign,
Arrange,
}
#[derive(Debug, Clone)]
pub struct Press {
pub(crate) struct Press {
pub stamp: Stamp,
pub token: Id,
pub start: Point,
pub scope: plan::Scope,
pub frozen: Frozen,
pub arrange: bool,
pub selection: Snapshot,
pub target: SelectionTarget,
pub mode: Mode,
}
#[derive(Debug, Clone)]
pub enum Event {
pub(crate) enum Event {
Press(Arc<Press>),
Preview(Stamp, Id, Option<Point>),
Release(Stamp, Id, Option<Point>),
@@ -244,23 +260,266 @@ pub enum Event {
Unfocused,
KeyboardBoundary,
}
pub struct PreviewPlacement {
pub diagram: Diagram,
pub view: ProjectedView,
pub delta: Position,
}
pub struct Preview {
pub token: Id,
pub semantic: Arc<plan::SemanticPlan>,
pub placement: plan::PlacementPlan,
pub candidate: Candidate,
pub placement: PreviewPlacement,
pub routed: Arc<RoutedView>,
}
impl Preview {
pub fn project(&self) -> &Project {
self.candidate.project()
}
#[cfg(test)]
pub fn index(&self) -> &view_options::ViewIndex {
self.candidate.index()
}
}
enum Prepared {
Singleton(Box<plan::Scope>),
Assignment(plan::assignment::Scope),
Arrange(Arc<plan::arrange::Plan>),
Performers(Arc<PerformerPlan>),
Refused(String),
}
#[derive(Clone)]
pub enum Candidate {
Singleton(Arc<plan::SemanticPlan>),
Assignment(Arc<plan::assignment::SemanticPlan>),
Arrange(Arc<plan::arrange::Plan>),
Performers(Arc<PerformerPlan>),
}
enum Placed {
Planned(plan::PlacementPlan),
Performers {
preview: PreviewPlacement,
command: Box<Command>,
requested: Position,
},
}
impl Placed {
fn diagram(&self) -> &Diagram {
match self {
Self::Planned(p) => &p.diagram,
Self::Performers { preview, .. } => &preview.diagram,
}
}
fn view(&self) -> &ProjectedView {
match self {
Self::Planned(p) => &p.view,
Self::Performers { preview, .. } => &preview.view,
}
}
fn into_preview(self) -> PreviewPlacement {
match self {
Self::Planned(p) => PreviewPlacement {
diagram: p.diagram,
view: p.view,
delta: p.delta,
},
Self::Performers { preview, .. } => preview,
}
}
}
/// Ordinary performer movement preserves the established core translation policy.
/// It shares input provenance, not the explicit Arrange geometry restrictions.
pub struct PerformerPlan {
accepted: Project,
index: view_options::ViewIndex,
diagram: Id,
selected: BTreeSet<Id>,
anchor: Id,
affected: BTreeMap<Id, NodePlacement>,
}
impl PerformerPlan {
fn place(&self, requested: Position) -> Result<Placed, plan::Refusal> {
let original = &self.accepted.diagrams[&self.diagram];
let anchor = original.nodes[&self.anchor].position;
let delta = if requested == anchor {
Position { x: 0., y: 0. }
} else {
Position {
x: (requested.x / 10.).round() * 10. - anchor.x,
y: (requested.y / 10.).round() * 10. - anchor.y,
}
};
let command =
diagram_edit::translate_placements(&self.accepted, self.diagram, &self.selected, delta)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?;
let mut diagram = original.clone();
let Command::Batch(edits) = &command else {
unreachable!("translation is an atomic batch")
};
for edit in edits {
match edit {
Command::EditNode {
diagram: id,
original,
node,
} if *id == self.diagram => {
diagram.nodes.insert(*original, node.clone());
}
Command::SetEdgeRoute { diagram: id, route } if *id == self.diagram => {
let stored = diagram
.routes
.iter_mut()
.find(|r| r.edge == route.edge)
.ok_or(plan::Refusal::StaleScope)?;
*stored = route.clone();
}
_ => {
return Err(plan::Refusal::InvalidCandidate(
"Unexpected performer movement edit.".into(),
));
}
}
}
let view = projection::project_diagram_with_index(&self.accepted, &diagram, &self.index);
Ok(Placed::Performers {
preview: PreviewPlacement {
diagram,
view,
delta,
},
command: Box::new(command),
requested,
})
}
}
impl Candidate {
pub fn project(&self) -> &Project {
match self {
Self::Singleton(p) => p.project(),
Self::Assignment(p) => p.project(),
Self::Arrange(p) => p.project(),
Self::Performers(p) => &p.accepted,
}
}
#[cfg(test)]
pub fn index(&self) -> &view_options::ViewIndex {
match self {
Self::Singleton(p) => p.index(),
Self::Assignment(p) => p.index(),
Self::Arrange(p) => p.index(),
Self::Performers(p) => &p.index,
}
}
fn place(
&self,
requested: Position,
presentation: plan::PresentationContext,
) -> Result<Placed, plan::Refusal> {
match self {
Self::Singleton(p) => plan::place(p, requested, presentation).map(Placed::Planned),
Self::Assignment(p) => {
plan::assignment::place(p, requested, presentation).map(Placed::Planned)
}
Self::Arrange(p) => {
plan::arrange::place(p, requested, presentation).map(Placed::Planned)
}
Self::Performers(p) => p.place(requested),
}
}
fn finish(&self, live: &Project, placed: &Placed) -> Result<Option<Command>, plan::Refusal> {
match (self, placed) {
(Self::Singleton(p), Placed::Planned(result)) => plan::finish(p, live, result),
(Self::Assignment(p), Placed::Planned(result)) => {
plan::assignment::finish(p, live, result)
}
(Self::Arrange(p), Placed::Planned(result)) => plan::arrange::finish(p, live, result),
(
Self::Performers(p),
Placed::Performers {
preview,
command,
requested,
},
) => {
if live != &p.accepted {
return Err(plan::Refusal::StaleScope);
}
let checked = p.place(*requested)?;
let Placed::Performers {
preview: checked,
command: checked_command,
..
} = checked
else {
unreachable!()
};
if checked.diagram != preview.diagram
|| checked.view != preview.view
|| checked.delta != preview.delta
|| &checked_command != command
{
return Err(plan::Refusal::StaleScope);
}
let mut expected = p.accepted.clone();
expected.diagrams.insert(p.diagram, checked.diagram);
let actual = prepare_command(live, &checked_command)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?
.project;
if actual != expected {
return Err(plan::Refusal::InvalidCandidate(
"Performer movement would change unrelated records.".into(),
));
}
Ok((actual != *live).then_some(*checked_command))
}
_ => Err(plan::Refusal::StaleScope),
}
}
fn label(&self) -> String {
let (label, warnings) = match self {
Self::Singleton(p) => (p.label.clone(), p.warnings.len()),
Self::Assignment(p) => (p.label().to_owned(), p.warnings().len()),
Self::Arrange(p) => (
format!(
"Arrange {} selected elements · {} affected placements",
p.selected().len(),
p.affected().len()
),
0,
),
Self::Performers(p) => (
format!("Move {} selected performer(s)", p.selected.len()),
0,
),
};
if warnings == 0 {
label
} else {
format!("{label} · {warnings} model warning(s) retained")
}
}
}
struct Active {
press: Arc<Press>,
selection: Snapshot,
crossed: bool,
prepared: Prepared,
frozen: Option<Arc<Frozen>>,
inspection_targets: Vec<usize>,
ids: BTreeMap<Id, Id>,
cache: Vec<(
plan::Destination,
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
)>,
cache: Vec<(plan::Destination, Result<Candidate, plan::Refusal>)>,
}
pub struct Feedback {
pub(crate) struct Feedback {
pub press: Arc<Press>,
pub frozen: Option<Arc<Frozen>>,
ordinary_performers: bool,
pub targets: Vec<usize>,
/// Last captured target names for inspection only; never resolver input.
pub inspection_targets: Vec<usize>,
@@ -273,7 +532,13 @@ impl Feedback {
let names = self
.inspection_targets
.iter()
.map(|index| self.press.frozen.targets[*index].name.as_str())
.filter_map(|index| {
self.frozen
.as_ref()?
.targets
.get(*index)
.map(|t| t.name.as_str())
})
.collect::<Vec<_>>()
.join(" / ");
format!(
@@ -293,7 +558,9 @@ impl Feedback {
/// Captured accepted names, independent of clipped status/refusal prose.
pub fn destination_identity(&self) -> String {
match self.targets.as_slice() {
[] => if self.press.arrange {
[] => if self.ordinary_performers {
"Move performers"
} else if self.press.mode == Mode::Arrange {
"Arrange only"
} else if self.refused {
"No eligible destination"
@@ -301,12 +568,22 @@ impl Feedback {
"Outside performers"
}
.into(),
[index] => self.press.frozen.targets[*index].name.clone(),
[index] => self
.frozen
.as_ref()
.and_then(|f| f.targets.get(*index))
.map(|t| t.name.clone())
.unwrap_or_else(|| "No eligible destination".into()),
indices => format!(
"Ambiguous: {}",
indices
.iter()
.map(|index| self.press.frozen.targets[*index].name.as_str())
.filter_map(|index| self
.frozen
.as_ref()?
.targets
.get(*index)
.map(|t| t.name.as_str()))
.collect::<Vec<_>>()
.join(" / ")
),
@@ -391,6 +668,18 @@ impl App {
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
&& stamp.bounds.width > 0.
&& stamp.bounds.height > 0.
&& stamp.zoom > 0.
&& [
stamp.bounds.x,
stamp.bounds.y,
stamp.bounds.width,
stamp.bounds.height,
stamp.pan.x,
stamp.pan.y,
stamp.zoom,
]
.iter()
.all(|value| value.is_finite())
}
/// Called before general input dispatch. Even equal-valued actions (Fit,
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
@@ -427,20 +716,54 @@ impl App {
pub(super) fn allocation_retire(&mut self) {
self.allocation_drag.retire();
}
fn select_work_clean(&mut self, press: &Press) -> bool {
if !self.allocation_stamp_valid(press.stamp) {
return false;
}
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.selected_bend = None;
self.refresh_draft();
true
fn allocation_selection_valid(&self, snapshot: &Snapshot) -> bool {
self.multiselect_context().is_some_and(|current| {
self.diagram_selection
.as_ref()
.is_some_and(|selection| selection.matches(snapshot, current))
})
}
pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool {
self.allocation_drag.active.is_none()
&& self.allocation_stamp_valid(press.stamp)
&& press.arrange == self.allocation_drag.arrange_next
if self.allocation_drag.active.is_some()
|| !self.allocation_stamp_valid(press.stamp)
|| (press.mode == Mode::Arrange) != self.allocation_drag.arrange_next
|| !self.allocation_selection_valid(&press.selection)
|| press.selection.context().scope.surface
!= Surface::Saved(press.stamp.context.diagram)
|| !press.start.x.is_finite()
|| !press.start.y.is_finite()
|| !contains(press.stamp.bounds, press.start)
{
return false;
}
let view = projection::project_view_with_index(
self.project(),
press.stamp.context.diagram,
self.document.view_index(),
);
super::multiselect::visible(
self.project(),
press.stamp.context.diagram,
&view,
&press.target,
)
}
/// Bare Allocation movement has no lifetime provenance. All actual Allocation
/// placement gestures now enter Press/Active, including ordinary performers.
pub(super) fn allocation_legacy_move(&self, message: &Message) -> bool {
let allocation = |diagram| {
self.project()
.diagrams
.get(&diagram)
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
};
match message {
Message::MoveNode(..) => self.active_diagram.is_some_and(allocation),
Message::DiagramSelection(super::multiselect::Event::Move(snapshot, _)) => {
matches!(snapshot.context().scope.surface, Surface::Saved(diagram) if allocation(diagram))
}
_ => false,
}
}
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
match event {
@@ -451,11 +774,64 @@ impl App {
.is_some_and(|active| active.press.token == *token)
}
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
// Invalid Press packets deliberately report why the view or draft
// changed. Leave their existing validation and feedback intact.
_ => true,
}
}
fn allocation_prepare(&self, press: &Press, selection: &Snapshot) -> Prepared {
let SelectionTarget::Placement(anchor) = &press.target else {
return Prepared::Refused("Move requires only placed work or performers; ports and connections cannot be moved with this selection.".into());
};
let anchor = *anchor;
let Some(ids) = super::multiselect::placements(selection) else {
return Prepared::Refused("Move requires only placed work or performers; remove ports, rows and connections from the selection.".into());
};
let project = self.project();
let diagram = press.stamp.context.diagram;
let d = &project.diagrams[&diagram];
let selected: BTreeMap<_, _> = ids.iter().map(|id| (*id, d.nodes[id].element)).collect();
let result = if press.mode == Mode::Arrange {
plan::arrange::capture(project, diagram, &selected, anchor)
.map(|p| Prepared::Arrange(Arc::new(p)))
} else if selected
.values()
.all(|id| project.elements[id].kind().is_work())
{
if selected.len() == 1 {
plan::capture(project, diagram, selected[&anchor], anchor)
.map(|scope| Prepared::Singleton(Box::new(scope)))
} else {
plan::assignment::capture(project, diagram, &selected, anchor)
.map(Prepared::Assignment)
}
} else if selected
.values()
.all(|id| project.elements[id].kind().is_performer())
{
validate_candidate(project)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))
.map(|_| {
let mut affected = BTreeMap::new();
for id in &ids {
for member in std::iter::once(*id)
.chain(projection::descendant_nodes(project, diagram, *id))
{
affected.insert(member, d.nodes[&member].clone());
}
}
Prepared::Performers(Arc::new(PerformerPlan {
accepted: project.clone(),
index: view_options::ViewIndex::new(project),
diagram,
selected: ids,
anchor,
affected,
}))
})
} else {
Err(plan::Refusal::Ineligible("Assignment requires only work. Arm Arrange next drag to arrange a supported work/performer selection.".into()))
};
result.unwrap_or_else(|reason| Prepared::Refused(refusal(&reason)))
}
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
if !self.allocation_event_envelope(&event) {
return Task::none();
@@ -465,7 +841,8 @@ impl App {
self.allocation_drag.retire();
if self.allocation_drag_clean() {
self.allocation_drag.arrange_next = true;
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
self.status =
"Arrange next drag armed · presentation only · Esc cancels".into();
} else {
self.status = FINISH_EDIT.into();
}
@@ -476,22 +853,13 @@ impl App {
self.allocation_drag.retire();
}
}
Event::Cancel(token) => {
if self
.allocation_drag
.active
.as_ref()
.is_some_and(|a| a.press.token == token)
{
self.allocation_drag.retire();
self.status = "Work drag canceled".into();
}
Event::Cancel(_) => {
self.allocation_drag.retire();
self.status = "Drag canceled".into();
}
Event::Refused(epoch, text) => {
if epoch == self.allocation_drag.epoch {
self.allocation_drag.retire();
self.status = text;
}
Event::Refused(_, text) => {
self.allocation_drag.retire();
self.status = text;
}
Event::Press(press) => {
if self.allocation_drag.active.is_some() {
@@ -505,32 +873,73 @@ impl App {
};
return Task::none();
}
let checked = plan::capture(
self.project(),
press.scope.diagram.id,
press.scope.work,
press.scope.placement,
);
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
self.status = checked
.err()
.as_ref()
.map(refusal)
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
self.allocation_drag.retire();
// A selected-member press has no selection or inspector effect.
// An unselected press replaces selection atomically in this handler.
if !press.selection.targets().contains(&press.target) {
self.multiselect_event(super::multiselect::Event::Choose(
press.selection.context(),
press.target.clone(),
super::multiselect::Choice::Replace,
));
}
let Some(selection) = self.multiselect_snapshot() else {
return Task::none();
};
if !selection.targets().contains(&press.target)
|| (!press.selection.targets().contains(&press.target)
&& selection.targets() != [press.target.clone()])
{
return Task::none();
}
let ids = press
.scope
.leaves
.keys()
.map(|id| (*id, Id::new_v4()))
.collect();
let diagram = press.stamp.context.diagram;
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
if !selection.targets().iter().all(|target| {
super::multiselect::visible(self.project(), diagram, &view, target)
}) {
self.allocation_drag.retire();
self.status = "The view changed. Start this drag again.".into();
return Task::none();
}
let prepared = self.allocation_prepare(&press, &selection);
let (affected, leaf_ids) = match &prepared {
Prepared::Singleton(scope) => (
Some(&scope.placements),
scope.leaves.keys().copied().collect::<Vec<_>>(),
),
Prepared::Assignment(scope) => (
Some(scope.affected()),
scope.leaves().keys().copied().collect(),
),
Prepared::Arrange(plan) => (Some(plan.affected()), Vec::new()),
Prepared::Performers(plan) => (Some(&plan.affected), Vec::new()),
Prepared::Refused(_) => (None, Vec::new()),
};
let frozen = affected.map(|affected| {
let d = &self.project().diagrams[&diagram];
let routed = routing::route_diagram(self.project(), d, &view);
Arc::new(Frozen::capture(
self.project(),
d,
affected,
&view,
&routed,
press.stamp.zoom,
))
});
let ids = leaf_ids.into_iter().map(|id| (id, Id::new_v4())).collect();
self.allocation_drag.arrange_next = false;
self.allocation_drag.preview = None;
self.allocation_drag.feedback = None;
self.allocation_drag.active = Some(Active {
press,
selection,
crossed: false,
prepared,
frozen,
inspection_targets: Vec::new(),
ids,
cache: Vec::new(),
@@ -552,15 +961,16 @@ impl App {
pointer: Option<Point>,
release: bool,
) {
if self
.allocation_drag
.active
.as_ref()
.is_none_or(|a| a.press.token != token)
{
return; // An old packet cannot cancel a newer gesture.
let Some(active) = self.allocation_drag.active.as_ref() else {
return;
};
if active.press.token != token {
return;
}
if !self.allocation_stamp_valid(stamp) {
if !self.allocation_stamp_valid(stamp)
|| !self.allocation_selection_valid(&active.selection)
|| active.press.stamp != stamp
{
self.allocation_drag.retire();
self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into()
@@ -569,17 +979,35 @@ impl App {
};
return;
}
let Some(mut active) = self.allocation_drag.active.take() else {
return;
};
if active.press.token != token || active.press.stamp != stamp {
self.allocation_drag.active = Some(active);
let mut active = self.allocation_drag.active.take().unwrap();
let press = active.press.clone();
active.crossed |= pointer.is_some_and(|p| p.distance(press.start) > 3.);
let in_bounds = pointer
.is_some_and(|p| p.x.is_finite() && p.y.is_finite() && contains(stamp.bounds, p));
if !active.crossed && in_bounds {
if release {
self.multiselect_event(super::multiselect::Event::Collapse(
active.selection,
press.target.clone(),
));
self.allocation_drag.retire();
self.status = "Element selected".into();
} else {
self.allocation_drag.active = Some(active);
}
return;
}
let press = active.press.clone();
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
if moved {
let ordinary_performers = matches!(&active.prepared, Prepared::Performers(_));
let hit = if !in_bounds {
Hit::OutsideViewport
} else if ordinary_performers {
Hit::Destination(plan::Destination::LayoutOnly, None)
} else {
active.frozen.as_ref().map_or(Hit::Occluded, |f| {
f.resolve(stamp, pointer, press.mode == Mode::Arrange)
})
};
if active.crossed {
match &hit {
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
@@ -588,64 +1016,63 @@ impl App {
}
let mut targets = Vec::new();
let mut preview = None;
let result: Result<String, String> = match hit {
Hit::OutsideViewport => Err(if release {
"Work drag canceled outside the canvas."
} else {
"Pointer outside canvas · return to choose a destination; releasing here cancels the drag."
}.into()),
_ if !moved => Ok("Work selected".into()),
let result: Result<String, String> = if matches!(hit, Hit::OutsideViewport) {
Err(if release { "Drag canceled outside the canvas." }
else { "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." }.into())
} else if let Prepared::Refused(reason) = &active.prepared {
Err(reason.clone())
} else {
match hit {
Hit::OutsideViewport => unreachable!(),
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
Hit::Ambiguous(indices) => {
targets = indices;
Err("Two unrelated performers overlap here. Use an exposed region.".into())
targets = indices; Err("Two unrelated performers overlap here. Use an exposed region.".into())
}
Hit::Destination(destination, target) => {
targets.extend(target);
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
result.clone()
} else {
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
active.cache.push((destination, result.clone()));
result
let candidate = if let Some((_, cached)) = active.cache.iter().find(|(d, _)| *d == destination) { cached.clone() }
else {
let candidate = match &active.prepared {
Prepared::Singleton(scope) => plan::plan_semantics(self.project(), scope, destination, &active.ids)
.map(|p| Candidate::Singleton(Arc::new(p))),
Prepared::Assignment(scope) => plan::assignment::plan_semantics(scope, destination, &active.ids)
.map(|p| Candidate::Assignment(Arc::new(p))),
Prepared::Arrange(plan) => Ok(Candidate::Arrange(plan.clone())),
Prepared::Performers(plan) => Ok(Candidate::Performers(plan.clone())),
Prepared::Refused(_) => unreachable!(),
};
active.cache.push((destination, candidate.clone())); candidate
};
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
if stamp.zoom * 16. < 9. {
candidate.map_err(|r| refusal(&r)).and_then(|candidate| {
if !ordinary_performers && stamp.zoom * 16. < 9. {
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
}
let pointer = pointer.unwrap();
let delta = pointer - press.start;
let original = press.scope.placements[&press.scope.placement].position;
let SelectionTarget::Placement(anchor) = &press.target else { unreachable!("supported placement") };
let original = self.project().diagrams[&stamp.context.diagram].nodes[anchor].position;
let delta = pointer.unwrap() - press.start;
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
let presentation = plan::PresentationContext {
viewport: stamp.viewport(), zoom: stamp.zoom,
target: target.map(|i| {
let t = &press.frozen.targets[i];
plan::TargetPresentation { placement: t.placement }
}),
};
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }),
};
let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?;
let label = candidate.label();
if release {
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
if let Some(command) = command {
// Bypass applying selectors and unrelated repair helpers. The
// planner's exact Batch enters the authoritative source pipeline.
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
self.document.execute(command)
.map_err(|e| format!("{e} · retry this drag after correcting the source issue"))?;
// sync refreshes the retained primary once; no anchor promotion.
self.sync();
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.refresh_draft();
self.reconcile_multiselect();
}
} else {
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view()));
preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed }));
}
Ok(label)
})
}
}
};
let refused = result.is_err();
let text = result.unwrap_or_else(|text| text);
@@ -654,10 +1081,13 @@ impl App {
self.allocation_drag.retire();
} else {
let inspection_targets = active.inspection_targets.clone();
let frozen = active.frozen.clone();
self.allocation_drag.active = Some(active);
self.allocation_drag.preview = preview;
self.allocation_drag.feedback = Some(Feedback {
press,
frozen,
ordinary_performers,
targets,
inspection_targets,
text,