673 lines
25 KiB
Rust
673 lines
25 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, route_edit::Context};
|
|
use iced::{Point, Rectangle, Task, Vector};
|
|
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
|
|
use std::{collections::BTreeMap, sync::Arc};
|
|
|
|
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
|
|
|
|
#[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,
|
|
scope: &plan::Scope,
|
|
view: &ProjectedView,
|
|
routed: &RoutedView,
|
|
zoom: f32,
|
|
) -> Self {
|
|
let mut targets = Vec::new();
|
|
let mut cards = Vec::new();
|
|
for n in &view.nodes {
|
|
if scope.placements.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() == scope.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 = scope.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| !scope.placements.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)]
|
|
pub struct Press {
|
|
pub stamp: Stamp,
|
|
pub token: Id,
|
|
pub start: Point,
|
|
pub scope: plan::Scope,
|
|
pub frozen: Frozen,
|
|
pub arrange: bool,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub 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 Preview {
|
|
pub token: Id,
|
|
pub semantic: Arc<plan::SemanticPlan>,
|
|
pub placement: plan::PlacementPlan,
|
|
pub routed: Arc<RoutedView>,
|
|
}
|
|
struct Active {
|
|
press: Arc<Press>,
|
|
inspection_targets: Vec<usize>,
|
|
ids: BTreeMap<Id, Id>,
|
|
cache: Vec<(
|
|
plan::Destination,
|
|
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
|
|
)>,
|
|
}
|
|
pub struct Feedback {
|
|
pub press: Arc<Press>,
|
|
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()
|
|
.map(|index| self.press.frozen.targets[*index].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.press.arrange {
|
|
"Arrange only"
|
|
} else if self.refused {
|
|
"No eligible destination"
|
|
} else {
|
|
"Outside performers"
|
|
}
|
|
.into(),
|
|
[index] => self.press.frozen.targets[*index].name.clone(),
|
|
indices => format!(
|
|
"Ambiguous: {}",
|
|
indices
|
|
.iter()
|
|
.map(|index| self.press.frozen.targets[*index].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
|
|
.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.
|
|
}
|
|
/// 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(..)
|
|
) {
|
|
self.allocation_drag.retire();
|
|
}
|
|
}
|
|
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 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
|
|
}
|
|
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
|
|
}
|
|
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,
|
|
// Invalid Press packets deliberately report why the view or draft
|
|
// changed. Leave their existing validation and feedback intact.
|
|
_ => true,
|
|
}
|
|
}
|
|
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 next drag armed · moves work only · Esc cancels".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(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::Refused(epoch, text) => {
|
|
if epoch == self.allocation_drag.epoch {
|
|
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();
|
|
}
|
|
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();
|
|
return Task::none();
|
|
}
|
|
let ids = press
|
|
.scope
|
|
.leaves
|
|
.keys()
|
|
.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,
|
|
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,
|
|
) {
|
|
if self
|
|
.allocation_drag
|
|
.active
|
|
.as_ref()
|
|
.is_none_or(|a| a.press.token != token)
|
|
{
|
|
return; // An old packet cannot cancel a newer gesture.
|
|
}
|
|
if !self.allocation_stamp_valid(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 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);
|
|
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 {
|
|
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> = 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()),
|
|
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 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
|
|
};
|
|
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
|
|
if 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 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())
|
|
};
|
|
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))?;
|
|
self.sync();
|
|
self.selected = press.scope.work;
|
|
self.selected_node = Some(press.scope.placement);
|
|
self.refresh_draft();
|
|
}
|
|
} else {
|
|
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
|
|
preview = Some(Arc::new(Preview { token, semantic, placement, 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();
|
|
self.allocation_drag.active = Some(active);
|
|
self.allocation_drag.preview = preview;
|
|
self.allocation_drag.feedback = Some(Feedback {
|
|
press,
|
|
targets,
|
|
inspection_targets,
|
|
text,
|
|
refused,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "allocation_drag_tests.rs"]
|
|
mod tests;
|