Support atomic Allocation group assignment and Arrange gestures
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user