1109 lines
42 KiB
Rust
1109 lines
42 KiB
Rust
//! 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,
|
|
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, BTreeSet},
|
|
sync::Arc,
|
|
};
|
|
|
|
pub const FINISH_EDIT: &str =
|
|
"Finish or cancel the current edit before selecting or moving elements.";
|
|
const ARRANGE_ARMED: &str = "Arrange next drag armed · presentation only · Esc cancels";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Stamp {
|
|
pub context: Context,
|
|
pub epoch: u64,
|
|
pub snapshot: usize,
|
|
pub bounds: Rectangle,
|
|
pub pan: Vector,
|
|
pub zoom: f32,
|
|
}
|
|
impl Stamp {
|
|
pub fn world(self, screen: Point) -> Point {
|
|
Point::new(
|
|
(screen.x - self.bounds.x - self.pan.x) / self.zoom,
|
|
(screen.y - self.bounds.y - self.pan.y) / self.zoom,
|
|
)
|
|
}
|
|
pub fn viewport(self) -> plan::Rect {
|
|
let p = self.world(Point::new(self.bounds.x, self.bounds.y));
|
|
plan::Rect {
|
|
position: Position { x: p.x, y: p.y },
|
|
size: Size {
|
|
width: self.bounds.width / self.zoom,
|
|
height: self.bounds.height / self.zoom,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Target {
|
|
pub element: Id,
|
|
pub placement: Id,
|
|
pub region: Rectangle,
|
|
pub name: String,
|
|
pub ancestors: Vec<Id>,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct Frozen {
|
|
pub targets: Vec<Target>,
|
|
cards: Vec<Rectangle>,
|
|
controls: Vec<Rectangle>,
|
|
edges: Vec<(Point, Point)>,
|
|
radius: f32,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Hit {
|
|
Destination(plan::Destination, Option<usize>),
|
|
OutsideViewport,
|
|
Occluded,
|
|
Ambiguous(Vec<usize>),
|
|
}
|
|
pub fn contains(r: Rectangle, p: Point) -> bool {
|
|
p.x >= r.x && p.y >= r.y && p.x < r.x + r.width && p.y < r.y + r.height
|
|
}
|
|
impl Frozen {
|
|
pub fn capture(
|
|
project: &Project,
|
|
diagram: &Diagram,
|
|
affected: &BTreeMap<Id, NodePlacement>,
|
|
view: &ProjectedView,
|
|
routed: &RoutedView,
|
|
zoom: f32,
|
|
) -> Self {
|
|
let mut targets = Vec::new();
|
|
let mut cards = Vec::new();
|
|
for n in &view.nodes {
|
|
if affected.contains_key(&n.id) {
|
|
continue;
|
|
}
|
|
let region = Rectangle::new(
|
|
Point::new(n.position.x, n.position.y),
|
|
iced::Size::new(n.size.width, n.size.height),
|
|
);
|
|
let Some(e) = project.elements.get(&n.element) else {
|
|
cards.push(region);
|
|
continue;
|
|
};
|
|
if e.kind().is_performer() && e.layer() == diagram.layer {
|
|
let mut ancestors = Vec::new();
|
|
let mut parent = e.parent;
|
|
while let Some(id) = parent {
|
|
if ancestors.contains(&id) {
|
|
break;
|
|
}
|
|
ancestors.push(id);
|
|
parent = project.elements.get(&id).and_then(|e| e.parent);
|
|
}
|
|
let name = project.named(e.id).to_owned();
|
|
let collapsed = diagram.nodes[&n.id].collapsed;
|
|
targets.push(Target {
|
|
element: e.id,
|
|
placement: n.id,
|
|
region: if collapsed {
|
|
Rectangle {
|
|
height: region.height.min(62.),
|
|
..region
|
|
}
|
|
} else {
|
|
region
|
|
},
|
|
name,
|
|
ancestors,
|
|
});
|
|
} else {
|
|
cards.push(region);
|
|
}
|
|
}
|
|
let radius = (10. / zoom).max(10.);
|
|
let mut controls: Vec<_> = view
|
|
.ports
|
|
.iter()
|
|
.filter(|port| !affected.contains_key(&port.owner))
|
|
.map(|port| {
|
|
Rectangle::new(
|
|
Point::new(port.position.x - radius, port.position.y - radius),
|
|
iced::Size::new(radius * 2., radius * 2.),
|
|
)
|
|
})
|
|
.collect();
|
|
let mut edges = Vec::new();
|
|
for edge in routed.edges.values() {
|
|
if let Some(relation) = project.relations.get(&edge.id) {
|
|
for end in routing::association_end_geometry(relation, edge) {
|
|
if !end.label.text.is_empty() {
|
|
controls.push(Rectangle::new(
|
|
Point::new(end.label.bounds.position.x, end.label.bounds.position.y),
|
|
iced::Size::new(
|
|
end.label.bounds.size.width,
|
|
end.label.bounds.size.height,
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if !edge.label.text.is_empty() {
|
|
controls.push(Rectangle::new(
|
|
Point::new(edge.label.bounds.position.x, edge.label.bounds.position.y),
|
|
iced::Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
|
|
));
|
|
}
|
|
for pin in &edge.pins {
|
|
controls.push(Rectangle::new(
|
|
Point::new(pin.x - 8. / zoom, pin.y - 8. / zoom),
|
|
iced::Size::new(16. / zoom, 16. / zoom),
|
|
));
|
|
}
|
|
for points in edge.points.windows(2) {
|
|
edges.push((
|
|
Point::new(points[0].x, points[0].y),
|
|
Point::new(points[1].x, points[1].y),
|
|
));
|
|
}
|
|
}
|
|
Self {
|
|
targets,
|
|
cards,
|
|
controls,
|
|
edges,
|
|
radius: 6. / zoom,
|
|
}
|
|
}
|
|
pub fn resolve(&self, stamp: Stamp, screen: Option<Point>, arrange: bool) -> Hit {
|
|
let Some(screen) = screen.filter(|p| contains(stamp.bounds, *p)) else {
|
|
return Hit::OutsideViewport;
|
|
};
|
|
let p = stamp.world(screen);
|
|
// Drag status lives in the toolbar, outside this actual canvas viewport.
|
|
if self
|
|
.controls
|
|
.iter()
|
|
.chain(&self.cards)
|
|
.any(|r| contains(*r, p))
|
|
|| self.edges.iter().any(|(a, b)| {
|
|
let d = *b - *a;
|
|
let length = d.x * d.x + d.y * d.y;
|
|
let t = if length == 0. {
|
|
0.
|
|
} else {
|
|
(((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length).clamp(0., 1.)
|
|
};
|
|
p.distance(*a + d * t) <= self.radius
|
|
})
|
|
{
|
|
return Hit::Occluded;
|
|
}
|
|
if arrange {
|
|
return Hit::Destination(plan::Destination::LayoutOnly, None);
|
|
}
|
|
let hits: Vec<_> = self
|
|
.targets
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, t)| contains(t.region, p))
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
for (i, a) in hits.iter().enumerate() {
|
|
for b in &hits[i + 1..] {
|
|
let a = &self.targets[*a];
|
|
let b = &self.targets[*b];
|
|
if !a.ancestors.contains(&b.element) && !b.ancestors.contains(&a.element) {
|
|
return Hit::Ambiguous(hits);
|
|
}
|
|
}
|
|
}
|
|
match hits
|
|
.into_iter()
|
|
.max_by_key(|i| self.targets[*i].ancestors.len())
|
|
{
|
|
Some(i) => Hit::Destination(
|
|
plan::Destination::Performer(self.targets[i].element),
|
|
Some(i),
|
|
),
|
|
None => Hit::Destination(plan::Destination::OutsidePerformers, None),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Mode {
|
|
Assign,
|
|
Arrange,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct Press {
|
|
pub stamp: Stamp,
|
|
pub token: Id,
|
|
pub start: Point,
|
|
pub selection: Snapshot,
|
|
pub target: SelectionTarget,
|
|
pub mode: Mode,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) enum Event {
|
|
Press(Arc<Press>),
|
|
Preview(Stamp, Id, Option<Point>),
|
|
Release(Stamp, Id, Option<Point>),
|
|
Refused(u64, String),
|
|
Cancel(Id),
|
|
ArrangeNext,
|
|
Unfocused,
|
|
KeyboardBoundary,
|
|
}
|
|
pub struct PreviewPlacement {
|
|
pub diagram: Diagram,
|
|
pub view: ProjectedView,
|
|
pub delta: Position,
|
|
}
|
|
|
|
pub struct Preview {
|
|
pub token: Id,
|
|
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<Candidate, plan::Refusal>)>,
|
|
}
|
|
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>,
|
|
pub text: String,
|
|
pub refused: bool,
|
|
}
|
|
impl Feedback {
|
|
pub fn inspection_identity(&self) -> String {
|
|
if self.refused && self.targets.is_empty() && !self.inspection_targets.is_empty() {
|
|
let names = self
|
|
.inspection_targets
|
|
.iter()
|
|
.filter_map(|index| {
|
|
self.frozen
|
|
.as_ref()?
|
|
.targets
|
|
.get(*index)
|
|
.map(|t| t.name.as_str())
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" / ");
|
|
format!(
|
|
"{}\nLast target{}: {}\nReturn to the canvas to choose a destination; releasing here cancels the drag.",
|
|
self.destination_identity(),
|
|
if self.inspection_targets.len() > 1 {
|
|
"s (ambiguous)"
|
|
} else {
|
|
""
|
|
},
|
|
names
|
|
)
|
|
} else {
|
|
self.destination_identity()
|
|
}
|
|
}
|
|
/// Captured accepted names, independent of clipped status/refusal prose.
|
|
pub fn destination_identity(&self) -> String {
|
|
match self.targets.as_slice() {
|
|
[] => if self.ordinary_performers {
|
|
"Move performers"
|
|
} else if self.press.mode == Mode::Arrange {
|
|
"Arrange only"
|
|
} else if self.refused {
|
|
"No eligible destination"
|
|
} else {
|
|
"Outside performers"
|
|
}
|
|
.into(),
|
|
[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()
|
|
.filter_map(|index| self
|
|
.frozen
|
|
.as_ref()?
|
|
.targets
|
|
.get(*index)
|
|
.map(|t| t.name.as_str()))
|
|
.collect::<Vec<_>>()
|
|
.join(" / ")
|
|
),
|
|
}
|
|
}
|
|
}
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
pub epoch: u64,
|
|
pub arrange_next: bool,
|
|
active: Option<Active>,
|
|
pub preview: Option<Arc<Preview>>,
|
|
pub feedback: Option<Feedback>,
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
pub struct Capability<'a> {
|
|
pub state: &'a State,
|
|
pub clean: bool,
|
|
pub snapshot: usize,
|
|
}
|
|
impl State {
|
|
fn retire(&mut self) {
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
self.active = None;
|
|
self.preview = None;
|
|
self.feedback = None;
|
|
self.arrange_next = false;
|
|
}
|
|
}
|
|
pub fn refusal(r: &plan::Refusal) -> String {
|
|
match r {
|
|
plan::Refusal::Ineligible(s) | plan::Refusal::InvalidCandidate(s) | plan::Refusal::LayoutRequired(s) => s.clone(),
|
|
plan::Refusal::StaleScope => "The view changed. Start this drag again.".into(),
|
|
plan::Refusal::MixedLayoutRequired => "This work has a distributed or mixed assignment. Drag a specific leaf; arranging this group needs a layout step.".into(),
|
|
plan::Refusal::RoleExitRequired { message, .. } => message.clone(),
|
|
plan::Refusal::MissingAllocationId(_) => "Assignment identity expired. Start this drag again.".into(),
|
|
plan::Refusal::CollateralChangeRequired(records) => format!(
|
|
"Assignment needs dependent edits to {}. Resolve these before dragging again.",
|
|
records.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ")
|
|
),
|
|
}
|
|
}
|
|
impl App {
|
|
pub fn allocation_drag_clean(&self) -> bool {
|
|
self.allocation_input_owner()
|
|
&& !self.draft_dirty()
|
|
&& !self.source_dirty()
|
|
&& !self.behavior.has_pending()
|
|
}
|
|
pub(crate) fn allocation_input_owner(&self) -> bool {
|
|
self.page == Page::Diagram
|
|
&& !self.context_help
|
|
&& self
|
|
.active_diagram
|
|
.and_then(|id| self.project().diagrams.get(&id))
|
|
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
|
|
&& self.architecture.is_none()
|
|
&& self.connection.is_none()
|
|
&& self.model_menu.is_none()
|
|
&& !self.reference_input_active()
|
|
&& !self.busy
|
|
&& !self.hosted.visible
|
|
&& self.workflow.is_none()
|
|
&& self.pending.is_none()
|
|
&& self.file_prompt.is_none()
|
|
&& !self.delete_pending
|
|
&& self.diagram_usages.is_none()
|
|
}
|
|
pub fn allocation_capability(&self) -> Capability<'_> {
|
|
Capability {
|
|
state: &self.allocation_drag,
|
|
clean: self.allocation_drag_clean(),
|
|
snapshot: Arc::as_ptr(&self.document.snapshot()) as usize,
|
|
}
|
|
}
|
|
fn allocation_stamp_valid(&self, stamp: Stamp) -> bool {
|
|
self.allocation_drag_clean()
|
|
&& self.route_context() == Some(stamp.context)
|
|
&& self.allocation_drag.epoch == stamp.epoch
|
|
&& Arc::as_ptr(&self.document.snapshot()) as usize == stamp.snapshot
|
|
&& self.pan == stamp.pan
|
|
&& self.zoom == stamp.zoom
|
|
&& 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.
|
|
pub(super) fn allocation_before(&mut self, message: &Message) {
|
|
if !matches!(
|
|
message,
|
|
Message::WorkDrag(_)
|
|
| Message::CheckpointTick
|
|
| Message::CheckpointWritten(..)
|
|
| Message::Lint(_)
|
|
| Message::Linted(..)
|
|
| Message::CanvasResized(_)
|
|
| Message::WindowResized(_)
|
|
| Message::SourceViewport(_)
|
|
| Message::SourceSymbolsReady(_)
|
|
| Message::SourceRenameReady(_)
|
|
| Message::DiagramUsagesReady(_)
|
|
| Message::DiagramRevealViewport(..)
|
|
) {
|
|
let clear_armed_status =
|
|
self.allocation_drag.arrange_next && self.status == ARRANGE_ARMED;
|
|
self.allocation_drag.retire();
|
|
if clear_armed_status {
|
|
self.status = "Arrange cancelled".into();
|
|
}
|
|
}
|
|
}
|
|
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
|
|
(
|
|
self.route_context(),
|
|
self.pan,
|
|
self.zoom,
|
|
self.canvas_size,
|
|
self.window_size,
|
|
self.allocation_drag_clean(),
|
|
Arc::as_ptr(&self.document.snapshot()) as usize,
|
|
)
|
|
}
|
|
pub(super) fn allocation_retire(&mut self) {
|
|
self.allocation_drag.retire();
|
|
}
|
|
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 {
|
|
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 {
|
|
Event::Cancel(token) | Event::Preview(_, token, _) | Event::Release(_, token, _) => {
|
|
self.allocation_drag
|
|
.active
|
|
.as_ref()
|
|
.is_some_and(|active| active.press.token == *token)
|
|
}
|
|
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
|
|
_ => 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();
|
|
}
|
|
match event {
|
|
Event::ArrangeNext => {
|
|
self.allocation_drag.retire();
|
|
if self.allocation_drag_clean() {
|
|
self.allocation_drag.arrange_next = true;
|
|
self.status = ARRANGE_ARMED.into();
|
|
} else {
|
|
self.status = FINISH_EDIT.into();
|
|
}
|
|
}
|
|
Event::Unfocused => self.allocation_drag.retire(),
|
|
Event::KeyboardBoundary => {
|
|
if self.allocation_drag.active.is_some() {
|
|
self.allocation_drag.retire();
|
|
}
|
|
}
|
|
Event::Cancel(_) => {
|
|
self.allocation_drag.retire();
|
|
self.status = "Drag canceled".into();
|
|
}
|
|
Event::Refused(_, text) => {
|
|
self.allocation_drag.retire();
|
|
self.status = text;
|
|
}
|
|
Event::Press(press) => {
|
|
if self.allocation_drag.active.is_some() {
|
|
return Task::none();
|
|
}
|
|
if !self.allocation_press_owns_current_view(&press) {
|
|
self.status = if self.allocation_drag_clean() {
|
|
"The view changed. Start this drag again.".into()
|
|
} else {
|
|
FINISH_EDIT.into()
|
|
};
|
|
return Task::none();
|
|
}
|
|
// 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 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(),
|
|
});
|
|
}
|
|
Event::Preview(stamp, token, pointer) => {
|
|
self.allocation_motion(stamp, token, pointer, false)
|
|
}
|
|
Event::Release(stamp, token, pointer) => {
|
|
self.allocation_motion(stamp, token, pointer, true)
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
fn allocation_motion(
|
|
&mut self,
|
|
stamp: Stamp,
|
|
token: Id,
|
|
pointer: Option<Point>,
|
|
release: bool,
|
|
) {
|
|
let Some(active) = self.allocation_drag.active.as_ref() else {
|
|
return;
|
|
};
|
|
if active.press.token != token {
|
|
return;
|
|
}
|
|
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()
|
|
} else {
|
|
FINISH_EDIT.into()
|
|
};
|
|
return;
|
|
}
|
|
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 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(),
|
|
_ => {}
|
|
}
|
|
}
|
|
let mut targets = Vec::new();
|
|
let mut preview = None;
|
|
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())
|
|
}
|
|
Hit::Destination(destination, target) => {
|
|
targets.extend(target);
|
|
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
|
|
};
|
|
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 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| 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 {
|
|
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.reconcile_multiselect();
|
|
}
|
|
} else {
|
|
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);
|
|
self.status = text.clone();
|
|
if release {
|
|
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,
|
|
refused,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "allocation_drag_tests.rs"]
|
|
mod tests;
|