Add exact Process selection and atomic column-slot group reorder
Preserve the full-desktop-1 captured source. Qualified previews inspect their exact subject read-only; group movement preserves source order and other columns. Native acceptance and strict-lint payload-size corrections follow separately.
This commit is contained in:
@@ -268,6 +268,7 @@ mod direct_scenario_tests {
|
||||
#[cfg(test)]
|
||||
mod behavior_lifecycle_tests;
|
||||
mod process_canvas;
|
||||
mod process_multiselect;
|
||||
mod state_diagram;
|
||||
#[cfg(test)]
|
||||
mod state_diagram_tests;
|
||||
@@ -818,6 +819,9 @@ impl App {
|
||||
.map(|n| n.id);
|
||||
}
|
||||
self.draft = Draft::from_project(self.project(), self.selected);
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
if self.exact_strings
|
||||
&& let Some(d) = &mut self.draft
|
||||
{
|
||||
@@ -1294,6 +1298,7 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let preview_read_only = self.process_preview_primary().is_some();
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::DiagramSelection(event)
|
||||
@@ -1326,6 +1331,52 @@ impl App {
|
||||
// or allocation ownership before its edit is refused.
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::TopologyCanvas(event) = &message {
|
||||
use crate::behavior::topology::CanvasEvent;
|
||||
if !matches!(
|
||||
event,
|
||||
CanvasEvent::Camera(..) | CanvasEvent::Scrolled(..) | CanvasEvent::Viewport(..)
|
||||
) && self.topology_context() != Some(event.context())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
event,
|
||||
CanvasEvent::Select(..)
|
||||
| CanvasEvent::SelectQualified(..)
|
||||
| CanvasEvent::Remove(..)
|
||||
| CanvasEvent::Reorder { .. }
|
||||
| CanvasEvent::Reconnect { .. }
|
||||
) && self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if self.process_order_noop(&message) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.process_preview_primary().is_some()
|
||||
&& matches!(
|
||||
&message,
|
||||
Message::Field(..)
|
||||
| Message::FieldText(..)
|
||||
| Message::FieldShape(..)
|
||||
| Message::FieldAppend(..)
|
||||
| Message::FieldValuePath(..)
|
||||
| Message::FieldVariant(..)
|
||||
| Message::FieldArray(..)
|
||||
| Message::ElementKind(_)
|
||||
| Message::RelationKind(_)
|
||||
| Message::Apply
|
||||
| Message::Delete
|
||||
| Message::DiagramRemove(_)
|
||||
)
|
||||
{
|
||||
self.status = "Open the referenced definition to edit its contents.".into();
|
||||
return Task::none();
|
||||
}
|
||||
// Captured Text commands validate their origin before they can
|
||||
// invalidate that same origin or pass into existing command handlers.
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
@@ -1420,6 +1471,9 @@ impl App {
|
||||
{
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
|
||||
self.refresh_draft();
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
@@ -2333,6 +2387,9 @@ impl App {
|
||||
self.route_context()
|
||||
};
|
||||
}
|
||||
if request.process.is_some() {
|
||||
request.process = self.topology_context();
|
||||
}
|
||||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Close) => {
|
||||
@@ -2362,7 +2419,10 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(*action, Message::DiagramSelection(_)) {
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
@@ -3215,7 +3275,11 @@ impl App {
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Group model-deletion review is not available yet. Select one element or use Hide selected.".into();
|
||||
self.status = if self.topology_context().is_some() {
|
||||
"Group removal needs an aggregate review. Select one contribution to remove it individually."
|
||||
} else {
|
||||
"Group model-deletion review is not available yet. Select one element or use Hide selected."
|
||||
}.into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.selected != self.project().id && self.apply() {
|
||||
|
||||
@@ -518,9 +518,16 @@ async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shar
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
_, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace
|
||||
)) if actual == &endpoint
|
||||
)));
|
||||
assert_eq!(app.selected, references[0]);
|
||||
assert_eq!(app.selected, f.nodes[0]);
|
||||
assert!(app.draft.is_none(), "Qualified inspection stays read-only");
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
endpoint
|
||||
);
|
||||
assert!(
|
||||
app.behavior
|
||||
.topology
|
||||
|
||||
@@ -319,14 +319,7 @@ impl App {
|
||||
| Target::ScenarioObject(id)
|
||||
| Target::AllocationGrouping { relation: id, .. }
|
||||
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
|
||||
Target::ProcessOccurrence(endpoint) => (
|
||||
endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
None,
|
||||
),
|
||||
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
|
||||
};
|
||||
match usage.surface {
|
||||
Surface::Saved(id) => {
|
||||
|
||||
@@ -881,7 +881,32 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
|
||||
.unwrap()
|
||||
.clone();
|
||||
choose(&mut app, token, row);
|
||||
assert_eq!(app.selected, first);
|
||||
assert_eq!(app.selected, leaf);
|
||||
let context = app.topology_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(850., 460.),
|
||||
)),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(app.process_preview_primary().is_some());
|
||||
assert!(app.draft.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
|
||||
let first_camera = app.behavior.topology.camera_boundary();
|
||||
@@ -898,7 +923,33 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
|
||||
})
|
||||
.unwrap();
|
||||
choose(&mut app, token, row);
|
||||
assert_eq!(app.selected, second);
|
||||
assert_eq!(app.selected, leaf);
|
||||
let context = app.topology_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(850., 460.),
|
||||
)),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(app.process_preview_primary().is_some());
|
||||
assert!(app.draft.is_none());
|
||||
assert_eq!(app.selected_node, None);
|
||||
assert!(app.behavior.topology.camera_boundary() != first_camera);
|
||||
assert_eq!(authored(&app), before);
|
||||
|
||||
@@ -19,6 +19,10 @@ pub(crate) enum Event {
|
||||
Clear(Context),
|
||||
Move(Snapshot, Position),
|
||||
Hide(Snapshot),
|
||||
ProcessReorder(
|
||||
Snapshot,
|
||||
crate::behavior::topology::group_reorder::Destination,
|
||||
),
|
||||
Refuse(Snapshot, String),
|
||||
}
|
||||
|
||||
@@ -33,6 +37,17 @@ pub(crate) fn context(c: crate::route_edit::Context) -> Context {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_context(c: crate::behavior::topology::Context) -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: c.document,
|
||||
surface: Surface::Process(c.owner),
|
||||
},
|
||||
revision: c.revision,
|
||||
epoch: c.epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entire typed set must support a placement action. Never filter a mixture.
|
||||
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
|
||||
snapshot.primary()?;
|
||||
@@ -83,12 +98,17 @@ impl App {
|
||||
self.selected_node,
|
||||
(self.page == Page::Behavior).then(|| self.state_view_identity()),
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
self.topology_context(),
|
||||
self.behavior.topology.selection_address(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
match self.page {
|
||||
Page::Diagram => self.route_context().map(context),
|
||||
Page::Behavior => {
|
||||
if let Some(current) = self.topology_context() {
|
||||
return Some(process_context(current));
|
||||
}
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
@@ -99,6 +119,13 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
if let Some(context) = self.topology_context() {
|
||||
return self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner)
|
||||
.contains(target);
|
||||
}
|
||||
let Some(Context {
|
||||
scope:
|
||||
Scope {
|
||||
@@ -130,11 +157,14 @@ impl App {
|
||||
};
|
||||
match event {
|
||||
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
|
||||
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
|
||||
self.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current))
|
||||
}
|
||||
Event::Move(s, _)
|
||||
| Event::Hide(s)
|
||||
| Event::ProcessReorder(s, _)
|
||||
| Event::Refuse(s, _)
|
||||
| Event::Collapse(s, _) => self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +219,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
if self.topology_context().is_some() {
|
||||
self.reconcile_process_multiselect();
|
||||
return;
|
||||
}
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
self.diagram_selection = None;
|
||||
return;
|
||||
@@ -250,6 +284,10 @@ impl App {
|
||||
}
|
||||
|
||||
fn inspect_multiselect_primary(&mut self) {
|
||||
if self.topology_context().is_some() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
return;
|
||||
}
|
||||
let diagram = self
|
||||
.multiselect_context()
|
||||
.and_then(|c| match c.scope.surface {
|
||||
@@ -293,6 +331,10 @@ impl App {
|
||||
if !self.multiselect_envelope(&event) {
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.process_multiselect_event(event);
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.architecture.is_some()
|
||||
|| self.connection.is_some()
|
||||
@@ -313,6 +355,7 @@ impl App {
|
||||
self.document.view_index(),
|
||||
);
|
||||
match event {
|
||||
Event::ProcessReorder(..) => {}
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
|
||||
@@ -143,21 +143,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
CanvasEvent::SelectQualified(_, endpoint) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& let Ok(resolved) =
|
||||
oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint)
|
||||
{
|
||||
let reference = endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence);
|
||||
self.select(reference);
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
self.multiselect_event(multiselect::Event::Choose(
|
||||
multiselect::process_context(context),
|
||||
crate::diagram_usage::Target::ProcessOccurrence(endpoint),
|
||||
multiselect::Choice::Replace,
|
||||
));
|
||||
}
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
@@ -293,7 +283,8 @@ impl App {
|
||||
occurrence, before, ..
|
||||
} => {
|
||||
match topology::reorder(self.project(), context.owner, occurrence, before) {
|
||||
Ok(command) => {
|
||||
Ok(topology::group_reorder::Plan::Noop) => {}
|
||||
Ok(topology::group_reorder::Plan::Change(command)) => {
|
||||
if self.execute(command) {
|
||||
self.selected = occurrence;
|
||||
self.refresh_draft();
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Exact Process presentation selection and one atomic order transaction.
|
||||
use super::*;
|
||||
use crate::behavior::topology::group_reorder::{Plan, Prepared};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use multiselect::{Choice, Event};
|
||||
|
||||
fn subject(target: &Target) -> Option<Id> {
|
||||
match target {
|
||||
Target::ProcessOccurrence(endpoint) => Some(endpoint.occurrence),
|
||||
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn process_preview_primary(&self) -> Option<&Target> {
|
||||
let context = self.topology_context()?;
|
||||
let selection = self.diagram_selection.as_ref()?;
|
||||
if selection.scope() != multiselect::process_context(context).scope {
|
||||
return None;
|
||||
}
|
||||
selection
|
||||
.primary()
|
||||
.filter(|target| subject(target) == Some(self.selected))
|
||||
.filter(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_process_multiselect(&mut self) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
let current = multiselect::process_context(context);
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
let old = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
let address = self.behavior.topology.selection_address();
|
||||
let focused = self
|
||||
.diagram_focus(Surface::Process(context.owner))
|
||||
.map(|usage| usage.target.clone());
|
||||
let legacy = focused
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
address
|
||||
.as_ref()
|
||||
.filter(|a| {
|
||||
a.owner == context.owner
|
||||
&& (a.endpoint.occurrence == self.selected
|
||||
|| a.endpoint.references.first() == Some(&self.selected))
|
||||
})
|
||||
.map(|a| Target::ProcessOccurrence(a.endpoint.clone()))
|
||||
})
|
||||
.or_else(|| {
|
||||
visible
|
||||
.iter()
|
||||
.find(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
endpoint.references.is_empty() && endpoint.occurrence == self.selected
|
||||
}
|
||||
Target::ProcessLink(id) => *id == self.selected,
|
||||
_ => false,
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
.filter(|target| visible.contains(target));
|
||||
let held = old.as_ref().is_some_and(|target| {
|
||||
subject(target) == Some(self.selected)
|
||||
&& focused.as_ref().is_none_or(|focused| focused == target)
|
||||
&& match target {
|
||||
Target::ProcessOccurrence(endpoint) if !endpoint.references.is_empty() => {
|
||||
address
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.owner == context.owner && a.endpoint == *endpoint)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
});
|
||||
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| visible.contains(target));
|
||||
if held || lost {
|
||||
if selection.primary() != old.as_ref() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |t| visible.contains(t));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect_process_multiselect_primary(&mut self) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
self.behavior.topology.clear_context();
|
||||
self.selected = primary.as_ref().and_then(subject).unwrap_or(context.owner);
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
if let Some(Target::ProcessOccurrence(endpoint)) = primary
|
||||
&& !endpoint.references.is_empty()
|
||||
&& let Ok(resolved) = oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint)
|
||||
{
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
self.refresh_draft();
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_multiselect_event(&mut self, event: Event) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
if let Event::Refuse(_, reason) = &event
|
||||
&& diagram_removal::enabled(self)
|
||||
{
|
||||
self.status = reason.clone();
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self) || !self.behavior.topology.selecting() {
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the process selection.".into();
|
||||
return;
|
||||
}
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
match event {
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible.contains(&target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_process_multiselect();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |t: &Target| visible.contains(t);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
}
|
||||
Choice::Add => {
|
||||
selection.add(target, valid);
|
||||
}
|
||||
Choice::Remove => {
|
||||
selection.remove(&target);
|
||||
}
|
||||
Choice::Toggle => {
|
||||
selection.toggle(target, valid);
|
||||
}
|
||||
Choice::Inspect => {
|
||||
selection.set_primary(&target);
|
||||
}
|
||||
}
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible.contains(&target) {
|
||||
return;
|
||||
}
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |t| visible.contains(t));
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Clear(_) => {
|
||||
self.diagram_selection.as_mut().unwrap().clear();
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Hide(_) => {
|
||||
self.status =
|
||||
"Process cards are model contributions, not placements. Hide is unavailable."
|
||||
.into()
|
||||
}
|
||||
Event::Move(..) => {
|
||||
self.status =
|
||||
"Process positions come from their paths. Reorder within one column instead."
|
||||
.into()
|
||||
}
|
||||
Event::ProcessReorder(snapshot, destination) => {
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
match Prepared::new(self.project(), context.owner, snapshot.targets())
|
||||
.and_then(|prepared| prepared.plan(destination))
|
||||
{
|
||||
Ok(Plan::Noop) => {}
|
||||
Ok(Plan::Change(command)) => {
|
||||
if self.execute(command) {
|
||||
self.inspect_process_multiselect_primary();
|
||||
self.status = format!(
|
||||
"Reordered {} contributions · Undo restores their order",
|
||||
snapshot.targets().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A no-op must not retire a pending reveal or allocate a history entry.
|
||||
pub(super) fn process_order_noop(&self, message: &Message) -> bool {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return false;
|
||||
};
|
||||
match message {
|
||||
Message::DiagramSelection(Event::ProcessReorder(snapshot, destination)) => {
|
||||
Prepared::new(self.project(), context.owner, snapshot.targets())
|
||||
.and_then(|prepared| prepared.plan(*destination))
|
||||
.is_ok_and(|p| matches!(p, Plan::Noop))
|
||||
}
|
||||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Reorder {
|
||||
context: captured,
|
||||
occurrence,
|
||||
before,
|
||||
}) if *captured == context => crate::behavior::topology::reorder(
|
||||
self.project(),
|
||||
context.owner,
|
||||
*occurrence,
|
||||
*before,
|
||||
)
|
||||
.is_ok_and(|p| matches!(p, Plan::Noop)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,777 @@
|
||||
//! Process selection through the real App admission, document and source boundary.
|
||||
use super::*;
|
||||
use crate::behavior::topology::group_reorder::Destination;
|
||||
use crate::behavior::topology::{self, CanvasEvent, CanvasTool};
|
||||
use oa_core::OccurrenceEndpoint;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
nodes: [Id; 5],
|
||||
link: Id,
|
||||
work: Id,
|
||||
other_owner: Id,
|
||||
}
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
|
||||
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let mut element = project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
project.elements[&owner].layer(),
|
||||
name,
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else {
|
||||
unreachable!()
|
||||
};
|
||||
*value = body;
|
||||
let id = element.id;
|
||||
*project = execute_batch(project, &[Command::CreateElement(element)]).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn open(project: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.topology_context().unwrap().owner, owner);
|
||||
assert_eq!(
|
||||
app.multiselect_context().unwrap().scope.surface,
|
||||
Surface::Process(owner)
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let (f, _) = topology::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let mut nodes = vec![];
|
||||
for name in ["a", "u", "b", "v", "c"] {
|
||||
nodes.push(add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
let nodes: [Id; 5] = nodes.try_into().unwrap();
|
||||
let [a, u, b, v, _] = nodes;
|
||||
let link = project.relate(RelationKind::SequenceLink, "a to u", a, u);
|
||||
project.relate(RelationKind::SequenceLink, "b to v", b, v);
|
||||
let other = project
|
||||
.make_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
"Other owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let other_owner = other.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
|
||||
validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
app: open(project, f.owner),
|
||||
owner: f.owner,
|
||||
nodes,
|
||||
link,
|
||||
work: f.work[0],
|
||||
other_owner,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::DiagramSelection(event));
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: Choice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
send(app, Event::Choose(context, target, choice));
|
||||
}
|
||||
fn pair(f: &mut Fixture) {
|
||||
choose(&mut f.app, local(f.nodes[4]), Choice::Replace);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[local(f.nodes[4]), local(f.nodes[0])]
|
||||
);
|
||||
}
|
||||
fn source_matches(app: &App) {
|
||||
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
fn marker(app: &mut App, owner: Id, occurrence: Id) {
|
||||
use crate::diagram_usage as usage;
|
||||
let target = usage::collect(
|
||||
app.project(),
|
||||
occurrence,
|
||||
&app.behavior.topology,
|
||||
&app.behavior.scenario,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Process(owner) && u.target == local(occurrence))
|
||||
.unwrap();
|
||||
let context = usage::Context {
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
subject: occurrence,
|
||||
};
|
||||
app.pending_diagram_reveal = Some((context, target.clone(), app.canvas_epoch));
|
||||
app.diagram_navigation_focus = Some(usage::Focus {
|
||||
context,
|
||||
usage: target,
|
||||
selected: app.selected,
|
||||
});
|
||||
assert!(app.diagram_focus(Surface::Process(owner)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let [a, u, b, v, c] = f.nodes;
|
||||
pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.primary(), Some(&local(a)));
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(b)),
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![b, u, a, v, c];
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.selected, a);
|
||||
let after_selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(after_selection.targets(), selection.targets());
|
||||
assert_eq!(after_selection.primary(), selection.primary());
|
||||
source_matches(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let reopened = Document::from_bundle(after.bundle.clone(), LIMITS).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
source_matches(&f.app);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_noop_group_and_legacy_reorder_preserve_history_camera_and_pending_reveal() {
|
||||
for group in [true, false] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
if group {
|
||||
pair(&mut f);
|
||||
} else {
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
}
|
||||
marker(&mut f.app, f.owner, f.nodes[0]);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let epochs = (f.app.canvas_epoch, f.app.camera_epoch);
|
||||
f.app.status = "Unchanged order feedback".into();
|
||||
let message = if group {
|
||||
Message::DiagramSelection(Event::ProcessReorder(
|
||||
selection.clone(),
|
||||
Destination::Before(f.nodes[4]),
|
||||
))
|
||||
} else {
|
||||
Message::TopologyCanvas(CanvasEvent::Reorder {
|
||||
context: f.app.topology_context().unwrap(),
|
||||
occurrence: f.nodes[0],
|
||||
before: f.nodes[2],
|
||||
})
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!((f.app.canvas_epoch, f.app.camera_epoch), epochs);
|
||||
assert_eq!(f.app.status, "Unchanged order feedback");
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_mixed_link_and_cross_column_groups_refuse_whole_operation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for mixed in [true, false] {
|
||||
let mut f = fixture(layer);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let other = if mixed {
|
||||
Target::ProcessLink(f.link)
|
||||
} else {
|
||||
local(f.nodes[1])
|
||||
};
|
||||
choose(&mut f.app, other.clone(), Choice::Add);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[local(f.nodes[0]), other]);
|
||||
let before = f.app.document.snapshot();
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert!(!f.app.document.can_undo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_stale_document_revision_owner_epoch_and_selection_aba_reject_before_shared_hooks() {
|
||||
for stale_by in ["document", "revision", "owner", "epoch", "ABA"] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
match stale_by {
|
||||
"document" => f.app.canvas_document = Id::new_v4(),
|
||||
"revision" => {
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Describe(f.work, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
"owner" => {
|
||||
f.app.behavior.active_owner = Some(f.other_owner);
|
||||
f.app.selected = f.other_owner;
|
||||
f.app.reconcile_multiselect();
|
||||
}
|
||||
"epoch" => f.app.canvas_epoch += 1,
|
||||
_ => {
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
stale.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
stale.primary()
|
||||
);
|
||||
}
|
||||
}
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let selected = f.app.selected;
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
||||
f.app.status = "Current owner feedback".into();
|
||||
f.app.allocation_drag.arrange_next = true;
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(stale, Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{stale_by}"
|
||||
);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!(f.app.selected, selected);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.document.can_undo(), f.app.document.can_redo()),
|
||||
history
|
||||
);
|
||||
assert_eq!(f.app.status, "Current owner feedback");
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_pending_properties_source_form_tool_and_busy_never_implicitly_apply_or_change_group() {
|
||||
for pending in [
|
||||
"properties",
|
||||
"invalid properties",
|
||||
"source",
|
||||
"form",
|
||||
"tool",
|
||||
"busy",
|
||||
] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
match pending {
|
||||
"properties" => {
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied contribution".into(),
|
||||
));
|
||||
}
|
||||
"invalid properties" => {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/alias".into(), "invalid alias!".into()));
|
||||
}
|
||||
"source" => {
|
||||
let text = format!("{}\n// Unapplied source\n", f.app.source_workspace.text());
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
}
|
||||
"form" => {
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
let _ = f.app.update(Message::Behavior(behavior::Event::Topology(
|
||||
topology::Event::Name("Unsubmitted form".into()),
|
||||
)));
|
||||
assert!(f.app.behavior.has_pending());
|
||||
}
|
||||
"tool" => f.app.behavior.topology.choose(CanvasTool::Connect),
|
||||
_ => f.app.busy = true,
|
||||
}
|
||||
if pending == "source" {
|
||||
assert!(f.app.source_dirty());
|
||||
}
|
||||
if pending.contains("properties") {
|
||||
assert!(f.app.draft_dirty());
|
||||
}
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.source_workspace.bundle();
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let form = f.app.behavior.recovery_snapshot();
|
||||
for event in [
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
||||
Event::Clear(selection.context()),
|
||||
Event::Choose(selection.context(), local(f.nodes[2]), Choice::Replace),
|
||||
] {
|
||||
send(&mut f.app, event);
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{pending}"
|
||||
);
|
||||
assert_eq!(f.app.source_workspace.bundle(), source);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_queued_singleton_selection_remove_reorder_and_reconnect_cannot_retire_current_group() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
marker(&mut f.app, f.owner, f.nodes[0]);
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
f.app.status = "Group owns input".into();
|
||||
for event in [
|
||||
CanvasEvent::Select(context, f.nodes[2]),
|
||||
CanvasEvent::SelectQualified(
|
||||
context,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.nodes[2],
|
||||
},
|
||||
),
|
||||
CanvasEvent::Remove(context, f.nodes[0]),
|
||||
CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: f.nodes[0],
|
||||
before: f.nodes[4],
|
||||
},
|
||||
CanvasEvent::Reconnect {
|
||||
context,
|
||||
link: f.link,
|
||||
end: topology::End::Target,
|
||||
target: f.nodes[4],
|
||||
},
|
||||
] {
|
||||
let _ = f.app.update(Message::TopologyCanvas(event));
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(f.app.status, "Group owns input");
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified_fixture() -> (Fixture, Id, Id, Id, Id) {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
let sequence = project
|
||||
.make_element(Kind::Process, Layer::Oa, "Reusable checks", None)
|
||||
.unwrap();
|
||||
let sequence_id = sequence.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(sequence)]).unwrap();
|
||||
// Collapsed reuse cards still show four preview entries. Keep the tested
|
||||
// leaf beyond that bound so collapsing really removes its exact target.
|
||||
for index in 0..4 {
|
||||
add(
|
||||
&mut project,
|
||||
sequence_id,
|
||||
&format!("Preview prefix {index}"),
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let leaf = add(
|
||||
&mut project,
|
||||
sequence_id,
|
||||
"Shared check",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let first = add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
"First call",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let second = add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
"Second call",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
f.app = open(project, f.owner);
|
||||
let p = f.app.project().clone();
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, second));
|
||||
(f, sequence_id, first, second, leaf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_primary_is_exact_readonly_and_clears_without_semantic_rebinding() {
|
||||
let (mut f, _, first, second, leaf) = qualified_fixture();
|
||||
let p = f.app.project().clone();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
};
|
||||
let target = Target::ProcessOccurrence(endpoint.clone());
|
||||
assert!(f.app.multiselect_target_visible(&target));
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
assert_eq!(
|
||||
f.app.selected, leaf,
|
||||
"Inspect exact leaf, not the first caller or shared work"
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert_eq!(
|
||||
f.app
|
||||
.behavior
|
||||
.topology
|
||||
.selection_address()
|
||||
.unwrap()
|
||||
.endpoint,
|
||||
endpoint
|
||||
);
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
for message in [
|
||||
Message::Field("/name".into(), "Forbidden definition rename".into()),
|
||||
Message::Apply,
|
||||
Message::Delete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert!(!f.app.delete_pending);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
}
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
||||
let mixed = f.app.multiselect_snapshot().unwrap();
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(mixed.clone(), Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), mixed);
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
let current = f.app.multiselect_context().unwrap();
|
||||
send(&mut f.app, Event::Clear(current));
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(f.app.selected, f.owner);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
choose(&mut f.app, target, Choice::Replace);
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
||||
f.app.reconcile_multiselect();
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(f.app.selected, f.owner);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_target_visible(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
|
||||
fn menu(app: &mut App, target: Target, label: &str) -> Message {
|
||||
use crate::context_menu::Entry;
|
||||
use crate::model_menu;
|
||||
let object = match &target {
|
||||
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
||||
_ => panic!("This fixture opens contribution menus"),
|
||||
};
|
||||
let mut request = model_menu::Request::object(object, iced::Point::new(180., 150.));
|
||||
request.process = app.topology_context();
|
||||
request.selection_target = Some(Box::new(target));
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let opened = app
|
||||
.model_menu
|
||||
.as_ref()
|
||||
.expect("Captured Process menu must survive the open epoch change");
|
||||
assert!(opened.valid(app));
|
||||
assert_eq!(opened.request.process, app.topology_context());
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action {
|
||||
label: name,
|
||||
message: Some(message),
|
||||
..
|
||||
} if name == label => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Missing enabled menu action: {label}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_menu_add_uses_rebound_epoch_and_preserves_existing_primary_membership() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let before = f.app.document.snapshot();
|
||||
let old_context = f.app.topology_context().unwrap();
|
||||
let action = menu(&mut f.app, local(f.nodes[4]), "Add to selection");
|
||||
assert_ne!(f.app.topology_context().unwrap().epoch, old_context.epoch);
|
||||
assert_eq!(f.app.selected, f.nodes[0]);
|
||||
let _ = f.app.update(action);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[local(f.nodes[0]), local(f.nodes[4])]);
|
||||
assert_eq!(selection.primary(), Some(&local(f.nodes[4])));
|
||||
assert_eq!(f.app.selected, f.nodes[4]);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_nonprimary_menu_opens_definition_from_caller_and_back_returns() {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let before = f.app.document.snapshot();
|
||||
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
let action = menu(&mut f.app, target, "Open referenced definition");
|
||||
assert_eq!(f.app.selected, f.nodes[0]);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(definition));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_open_current_qualified_primary_and_catalog_navigation_restore_editable_draft() {
|
||||
for catalog in [false, true] {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
if catalog {
|
||||
let _ = f.app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(f.app.page, Page::Catalog);
|
||||
} else {
|
||||
let action = menu(&mut f.app, target, "Open referenced definition");
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
}
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app.draft.is_some(),
|
||||
"Leaving a read-only presentation restores normal property editing"
|
||||
);
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Definition edit stays unapplied".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_usage_navigation_between_two_preview_paths_keeps_exact_link_identity() {
|
||||
use crate::diagram_usage as usage;
|
||||
let (mut f, definition, first, second, leaf) = qualified_fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let next = add(
|
||||
&mut project,
|
||||
definition,
|
||||
"Following check",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let relation = project.relate(RelationKind::SequenceLink, "Reusable path", leaf, next);
|
||||
f.app = open(project, f.owner);
|
||||
let project = f.app.project().clone();
|
||||
assert!(f.app.behavior.topology.expand(&project, f.owner, first));
|
||||
assert!(f.app.behavior.topology.expand(&project, f.owner, second));
|
||||
let before = f.app.document.snapshot();
|
||||
for call in [first, second] {
|
||||
let target = Target::ProcessPreviewLink {
|
||||
relation,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: leaf,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: next,
|
||||
},
|
||||
};
|
||||
let context = usage::Context {
|
||||
document: f.app.canvas_document,
|
||||
revision: f.app.document.revision(),
|
||||
subject: relation,
|
||||
};
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramUsage(usage::Event::Open(context)));
|
||||
let token = f.app.diagram_usages.as_ref().unwrap().token;
|
||||
let rows = usage::collect(
|
||||
f.app.project(),
|
||||
relation,
|
||||
&f.app.behavior.topology,
|
||||
&f.app.behavior.scenario,
|
||||
);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|row| row.surface == Surface::Process(f.owner) && row.target == target)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = f.app.update(Message::DiagramUsagesReady(usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(rows),
|
||||
}));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramUsage(usage::Event::Choose(token, row)));
|
||||
assert_eq!(f.app.selected, relation);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&target)
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ struct Input {
|
||||
pan: Option<(mouse::Button, Point)>,
|
||||
space: bool,
|
||||
bounds: Option<Rectangle>,
|
||||
modifiers: keyboard::Modifiers,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
@@ -83,7 +84,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
(s.document, s.owner, s.revision)
|
||||
!= (self.scope.document, self.scope.owner, self.scope.revision)
|
||||
}) {
|
||||
*state = Input::default();
|
||||
let modifiers = state.modifiers;
|
||||
*state = Input {
|
||||
modifiers,
|
||||
..Input::default()
|
||||
};
|
||||
}
|
||||
if state
|
||||
.scope
|
||||
@@ -143,10 +148,29 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
// Physical modifiers outlive focus, busy-state child replacement,
|
||||
// and semantic revisions. Observing these emits no camera command.
|
||||
state.modifiers = *modifiers;
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = keyboard::Modifiers::default();
|
||||
}
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
// A traversal operation is applied after this Iced event batch. Keep
|
||||
// its focus witness, but retire keyboard admission immediately.
|
||||
if state.focus_pending && matches!(event, Event::Keyboard(_)) {
|
||||
@@ -334,6 +358,20 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
} else {
|
||||
mouse::Cursor::Unavailable
|
||||
};
|
||||
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(_))) {
|
||||
// A canvas may have been rebuilt while focus/commands were denied.
|
||||
// Restore held (or released) Shift before this new pointer gesture.
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&Event::Keyboard(keyboard::Event::ModifiersChanged(state.modifiers)),
|
||||
layout,
|
||||
child_cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
|
||||
@@ -3,6 +3,58 @@ use crate::behavior::canvas_workspace::tests::{Geometry, Mounted};
|
||||
use iced::Vector;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_scenario_passive_modifiers_do_not_acquire_keyboard_focus_or_mutate_model() {
|
||||
use crate::behavior::canvas_workspace::tests::{focused, key};
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1100., 700.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let point = bounds.position() + Vector::new(50., 50.);
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let source = app.document.bundle().clone();
|
||||
let shift = |held| {
|
||||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if held {
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
iced::keyboard::Modifiers::empty()
|
||||
}))
|
||||
};
|
||||
for busy in [false, true, false] {
|
||||
app.busy = busy;
|
||||
mounted.settle(&mut app).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
shift(true),
|
||||
shift(false),
|
||||
key(iced::keyboard::key::Named::ArrowRight),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
|
||||
);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
mounted
|
||||
.pan(&mut app, point, point + Vector::new(-15., 25.))
|
||||
.await;
|
||||
assert!(app.behavior.scenario.camera_boundary() != camera);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing() {
|
||||
use crate::behavior::canvas_workspace::{self, tests::Mounted};
|
||||
|
||||
@@ -5,8 +5,11 @@ use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
pub(crate) mod contextual;
|
||||
mod direct;
|
||||
pub(crate) mod group_reorder;
|
||||
mod usages;
|
||||
mod view;
|
||||
#[cfg(test)]
|
||||
pub(crate) use direct::End;
|
||||
use direct::Input;
|
||||
pub(crate) use direct::{
|
||||
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
|
||||
@@ -909,6 +912,53 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Integer columns shared by rendering and order planning. Edges whose endpoints
|
||||
/// are absent from the displayed order cannot participate in its layout.
|
||||
fn column_ranks(order: &[Id], adjacency: &[(Id, Id)]) -> BTreeMap<Id, usize> {
|
||||
let ids: BTreeSet<_> = order.iter().copied().collect();
|
||||
let adjacency: Vec<_> = adjacency
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(source, target)| ids.contains(source) && ids.contains(target))
|
||||
.collect();
|
||||
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
for &(_, target) in &adjacency {
|
||||
*incoming.get_mut(&target).expect("Displayed endpoint") += 1;
|
||||
}
|
||||
let mut queue: Vec<_> = order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| incoming[id] == 0)
|
||||
.collect();
|
||||
let mut done = BTreeSet::new();
|
||||
let mut offset = 0;
|
||||
while offset < queue.len() {
|
||||
let id = queue[offset];
|
||||
offset += 1;
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
|
||||
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
|
||||
let count = incoming.get_mut(&next).expect("Displayed endpoint");
|
||||
*count = count.saturating_sub(1);
|
||||
if *count == 0 {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preserve deterministic fallback order for loops and cycle-blocked nodes.
|
||||
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
|
||||
for id in order {
|
||||
if !done.contains(id) {
|
||||
ranks.insert(*id, fallback);
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
ranks
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PreviewLink {
|
||||
relation: Id,
|
||||
@@ -1066,6 +1116,7 @@ struct Graph<'a> {
|
||||
selected: Id,
|
||||
selected_preview: Option<OccurrenceEndpoint>,
|
||||
selected_preview_link: Option<PreviewLink>,
|
||||
selection: Option<crate::diagram_selection::Snapshot>,
|
||||
scale: f32,
|
||||
}
|
||||
impl<'a> Graph<'a> {
|
||||
@@ -1084,50 +1135,19 @@ impl<'a> Graph<'a> {
|
||||
) -> Self {
|
||||
let context_index = oa_core::topology_context::TopologyIndex::new(p);
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let displayed: BTreeSet<_> = choices.iter().map(|choice| choice.id).collect();
|
||||
let links: Vec<_> = sequence_links(p, owner)
|
||||
.into_iter()
|
||||
.filter(|link| displayed.contains(&link.source()) && displayed.contains(&link.target()))
|
||||
.collect();
|
||||
let order = legacy_order(p, owner);
|
||||
let adjacency: Vec<_> = links
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
|
||||
.collect();
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
for &(_, target) in &adjacency {
|
||||
*incoming.entry(target).or_default() += 1;
|
||||
}
|
||||
let mut queue: Vec<_> = choices
|
||||
.iter()
|
||||
.filter(|c| incoming[&c.id] == 0)
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let mut done = BTreeSet::new();
|
||||
let mut offset = 0;
|
||||
while offset < queue.len() {
|
||||
let id = queue[offset];
|
||||
offset += 1;
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
|
||||
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
|
||||
let count = incoming.get_mut(&next).expect("Graph endpoint");
|
||||
*count = count.saturating_sub(1);
|
||||
if *count == 0 {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cycles remain explicit edges; deterministic fallback placement must
|
||||
// terminate even while users are building a loop or incomplete graph.
|
||||
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
|
||||
for c in &choices {
|
||||
if !done.contains(&c.id) {
|
||||
ranks.insert(c.id, fallback);
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
let displayed_order: Vec<_> = choices.iter().map(|choice| choice.id).collect();
|
||||
let ranks = column_ranks(&displayed_order, &adjacency);
|
||||
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||
let mut nodes = vec![];
|
||||
let mut size: Size = Size::new(640., 220.);
|
||||
@@ -1238,25 +1258,17 @@ impl<'a> Graph<'a> {
|
||||
}
|
||||
let ordered_paths = order
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let a = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[0])
|
||||
.expect("ordered source")
|
||||
.rect;
|
||||
let b = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[1])
|
||||
.expect("ordered target")
|
||||
.rect;
|
||||
OrderedPath {
|
||||
.filter_map(|pair| {
|
||||
let a = nodes.iter().find(|n| n.id == pair[0])?.rect;
|
||||
let b = nodes.iter().find(|n| n.id == pair[1])?.rect;
|
||||
Some(OrderedPath {
|
||||
source: pair[0],
|
||||
target: pair[1],
|
||||
points: vec![
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut edges = vec![];
|
||||
@@ -1400,6 +1412,7 @@ impl<'a> Graph<'a> {
|
||||
selected,
|
||||
selected_preview: None,
|
||||
selected_preview_link: None,
|
||||
selection: None,
|
||||
scale: 1.,
|
||||
}
|
||||
}
|
||||
@@ -1518,9 +1531,16 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
EdgeLayer::Contextual
|
||||
}
|
||||
}) {
|
||||
let selected = e.id == self.selected
|
||||
|| e.source == self.selected
|
||||
|| e.target == self.selected;
|
||||
let selected = self.selection.as_ref().map_or(
|
||||
e.id == self.selected
|
||||
|| e.source == self.selected
|
||||
|| e.target == self.selected,
|
||||
|selection| {
|
||||
selection
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ProcessLink(e.id))
|
||||
},
|
||||
);
|
||||
if e.incidence {
|
||||
f.stroke(
|
||||
&Path::new(|path| {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Local pointer state and ordinary command proposals for the derived process graph.
|
||||
use super::*;
|
||||
#[path = "direct_selection.rs"]
|
||||
mod selection;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
@@ -445,27 +447,23 @@ pub(crate) fn reconnect(
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
|
||||
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
|
||||
return Err("Reorder contributions in this process only.".into());
|
||||
}
|
||||
let graph = Graph::new(p, owner, id);
|
||||
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
|
||||
if position(id) != position(before) {
|
||||
return Err("Reorder within this column; links determine the path columns.".into());
|
||||
}
|
||||
let mut steps = p.elements[&owner].steps().unwrap().clone();
|
||||
if id != before {
|
||||
steps.retain(|v| *v != id);
|
||||
let index = steps
|
||||
.iter()
|
||||
.position(|v| *v == before)
|
||||
.ok_or("This slot no longer exists.")?;
|
||||
steps.insert(index, id);
|
||||
}
|
||||
let command = Command::SetSteps(owner, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
pub(crate) fn reorder(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
before: Id,
|
||||
) -> Result<super::group_reorder::Plan, String> {
|
||||
super::group_reorder::Prepared::new(
|
||||
p,
|
||||
owner,
|
||||
&[crate::diagram_usage::Target::ProcessOccurrence(
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
},
|
||||
)],
|
||||
)?
|
||||
.plan(super::group_reorder::Destination::Before(before))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -476,6 +474,8 @@ pub(super) struct Input {
|
||||
bounds: Option<Rectangle>,
|
||||
source: Option<Id>,
|
||||
press: Option<Press>,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<selection::GroupPress>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Press {
|
||||
@@ -504,6 +504,7 @@ impl Input {
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
self.group = None;
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
@@ -592,6 +593,9 @@ impl Graph<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if let Some(action) = self.selection_input(state, event, bounds, cursor) {
|
||||
return Some(action);
|
||||
}
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
@@ -835,6 +839,7 @@ impl Graph<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
self.selection_overlay(state, f, bounds);
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
@@ -935,7 +940,12 @@ impl Graph<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if tool == CanvasTool::Select
|
||||
&& self
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.targets().len() == 1)
|
||||
{
|
||||
if let Some(r) = self.handle(self.selected) {
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Exact-target selection and an order gesture over one immutable selection.
|
||||
use super::super::group_reorder::{Destination, Plan, Prepared};
|
||||
use super::*;
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent, process_context};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
pub(super) struct GroupPress {
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
bounds: Rectangle,
|
||||
pan: iced::Vector,
|
||||
scale: f32,
|
||||
crossed: bool,
|
||||
retired: bool,
|
||||
prepared: Result<Prepared, String>,
|
||||
destination: Option<Destination>,
|
||||
plan: Option<Result<Plan, String>>,
|
||||
}
|
||||
|
||||
impl Graph<'_> {
|
||||
fn exact_target_at(&self, point: Point) -> Option<Target> {
|
||||
// Visible singleton reconnect rings are painted over the card/edge.
|
||||
// Shift and context selection must resolve that same topmost target.
|
||||
if self.interaction.is_some_and(|(_, tool, editable)| editable && tool == CanvasTool::Select)
|
||||
&& self.selection.as_ref().is_some_and(|snapshot| {
|
||||
snapshot.targets().len() == 1
|
||||
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
|
||||
})
|
||||
&& let Some((link, _)) = self.endpoint_at(point)
|
||||
{
|
||||
return Some(Target::ProcessLink(link));
|
||||
}
|
||||
if let Some(endpoint) = self.qualified_at(point) {
|
||||
return Some(Target::ProcessOccurrence(endpoint));
|
||||
}
|
||||
for node in &self.nodes {
|
||||
for link in &node.preview_links {
|
||||
if node
|
||||
.preview_link_visible_segment(link)
|
||||
.is_some_and(|[a, b]| {
|
||||
crate::diagram::distance_to_segment(point, a, b) < 5. / self.scale
|
||||
})
|
||||
{
|
||||
return Some(Target::ProcessPreviewLink {
|
||||
relation: link.relation,
|
||||
source: link.source.clone(),
|
||||
target: link.target.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.contextual_edge_at(point)
|
||||
.map(Target::ProcessLink)
|
||||
.or_else(|| {
|
||||
self.node_at(point).map(|id| {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| self.edge_at(point).map(Target::ProcessLink))
|
||||
}
|
||||
|
||||
fn destination_at(&self, point: Point) -> Option<Destination> {
|
||||
let id = self.node_at(point)?;
|
||||
let node = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(if point.y < node.rect.center_y() {
|
||||
Destination::Before(id)
|
||||
} else {
|
||||
Destination::After(id)
|
||||
})
|
||||
}
|
||||
|
||||
fn update_group(&self, group: &mut GroupPress, pointer: Point) {
|
||||
if group.retired {
|
||||
return;
|
||||
}
|
||||
if !pointer.x.is_finite() || !pointer.y.is_finite() || !group.bounds.contains(pointer) {
|
||||
group.retired = true;
|
||||
group.plan = None;
|
||||
return;
|
||||
}
|
||||
group.crossed |= pointer.distance(group.start) >= 3.;
|
||||
let world = Point::new(
|
||||
(pointer.x - group.bounds.x - self.camera_pan.x) / self.scale,
|
||||
(pointer.y - group.bounds.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
let destination = self.destination_at(world);
|
||||
// The captured semantic facts are prepared once. Only a changed object
|
||||
// anchor/side needs another order plan; motion within a slot is free.
|
||||
if destination != group.destination {
|
||||
group.destination = destination;
|
||||
group.plan = destination.map(|destination| match &group.prepared {
|
||||
Ok(prepared) => prepared.plan(destination),
|
||||
Err(reason) => Err(reason.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn selection_input(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
use iced::keyboard::{Event as KeyEvent, Key, key::Named};
|
||||
match event {
|
||||
canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers))
|
||||
| canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. })
|
||||
| canvas::Event::Keyboard(KeyEvent::KeyReleased { modifiers, .. }) => {
|
||||
state.modifiers = *modifiers
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.modifiers = iced::keyboard::Modifiers::empty()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
state.group = None;
|
||||
return None;
|
||||
};
|
||||
let snapshot = self
|
||||
.selection
|
||||
.as_ref()
|
||||
.filter(|s| s.context() == process_context(context));
|
||||
if let Some(group) = &state.group {
|
||||
let cancelled = !editable
|
||||
|| tool != CanvasTool::Select
|
||||
|| snapshot != Some(&group.snapshot)
|
||||
|| group.bounds != bounds
|
||||
|| group.pan != self.camera_pan
|
||||
|| group.scale != self.scale
|
||||
|| matches!(
|
||||
event,
|
||||
canvas::Event::Window(
|
||||
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
||||
) | canvas::Event::Mouse(
|
||||
mouse::Event::CursorLeft
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::ButtonPressed(
|
||||
mouse::Button::Right | mouse::Button::Middle
|
||||
)
|
||||
) | canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
if cancelled {
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
if let Some(group) = &mut state.group {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
self.update_group(group, *position);
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let mut group = state.group.take().unwrap();
|
||||
// An unavailable cursor must never reuse the last inside point.
|
||||
if let Some(pointer) = cursor.position() {
|
||||
self.update_group(&mut group, pointer);
|
||||
} else {
|
||||
group.retired = true;
|
||||
}
|
||||
let result = if group.retired {
|
||||
None
|
||||
} else if !group.crossed {
|
||||
Some(SelectionEvent::Collapse(group.snapshot, group.target))
|
||||
} else if let Err(reason) = group.prepared {
|
||||
Some(SelectionEvent::Refuse(group.snapshot, reason))
|
||||
} else {
|
||||
match (group.destination, group.plan) {
|
||||
(Some(destination), Some(Ok(Plan::Change(_)))) => {
|
||||
Some(SelectionEvent::ProcessReorder(group.snapshot, destination))
|
||||
}
|
||||
(_, Some(Err(reason))) => {
|
||||
Some(SelectionEvent::Refuse(group.snapshot, reason))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
return Some(
|
||||
result
|
||||
.map_or_else(Action::request_redraw, |event| {
|
||||
Action::publish(Message::DiagramSelection(event))
|
||||
})
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => return None,
|
||||
canvas::Event::Keyboard(_) => return Some(Action::capture()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let snapshot = snapshot?;
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Delete),
|
||||
repeat: false,
|
||||
..
|
||||
})
|
||||
) && (snapshot.targets().len() > 1
|
||||
|| snapshot.targets().iter().any(|t| match t {
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
_ => false,
|
||||
}))
|
||||
{
|
||||
return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into()));
|
||||
}
|
||||
let pointer = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
_ => cursor.position().and(state.pointer.or(cursor.position())),
|
||||
}?;
|
||||
if !bounds.contains(pointer) {
|
||||
return None;
|
||||
}
|
||||
let world = Point::new(
|
||||
(pointer.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(pointer.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
);
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
) {
|
||||
state.cancel();
|
||||
let target = self.exact_target_at(world)?;
|
||||
let object = match &target {
|
||||
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
||||
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => *id,
|
||||
_ => return None,
|
||||
};
|
||||
let mut request = crate::model_menu::Request::object(object, pointer);
|
||||
request.selection_target = Some(Box::new(target));
|
||||
request.process = Some(context);
|
||||
return Some(
|
||||
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
if tool == CanvasTool::Connect && snapshot.targets().len() > 1 {
|
||||
state.cancel();
|
||||
return publish(SelectionEvent::Refuse(
|
||||
snapshot.clone(),
|
||||
"Connecting a selection as a group is unavailable. Select one contribution first."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if tool != CanvasTool::Select {
|
||||
return None;
|
||||
}
|
||||
let target = self.exact_target_at(world);
|
||||
if state.modifiers.shift() {
|
||||
state.cancel();
|
||||
return target
|
||||
.and_then(|target| {
|
||||
publish(SelectionEvent::Choose(
|
||||
process_context(context),
|
||||
target,
|
||||
Choice::Toggle,
|
||||
))
|
||||
})
|
||||
.or_else(|| Some(Action::capture()));
|
||||
}
|
||||
let Some(target) = target else {
|
||||
state.cancel();
|
||||
// Clearing and starting the existing background camera pan can
|
||||
// share this press; only semantic object hits capture it.
|
||||
return Some(Action::publish(Message::DiagramSelection(
|
||||
SelectionEvent::Clear(process_context(context)),
|
||||
)));
|
||||
};
|
||||
// The ordinary endpoint gesture is valid only for an exact singleton
|
||||
// local link. A mixed selection must never reconnect just its primary.
|
||||
if snapshot.targets().len() == 1
|
||||
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
|
||||
&& self.endpoint_at(world).is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !snapshot.targets().contains(&target) {
|
||||
state.cancel();
|
||||
return publish(SelectionEvent::Choose(
|
||||
process_context(context),
|
||||
target,
|
||||
Choice::Replace,
|
||||
));
|
||||
}
|
||||
state.cancel();
|
||||
state.context = Some(context);
|
||||
state.tool = tool;
|
||||
state.bounds = Some(bounds);
|
||||
state.pointer = Some(pointer);
|
||||
state.group = Some(GroupPress {
|
||||
snapshot: snapshot.clone(),
|
||||
target,
|
||||
start: pointer,
|
||||
bounds,
|
||||
pan: self.camera_pan,
|
||||
scale: self.scale,
|
||||
crossed: false,
|
||||
retired: false,
|
||||
prepared: Prepared::new(self.project, context.owner, snapshot.targets()),
|
||||
destination: None,
|
||||
plan: None,
|
||||
});
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
|
||||
pub(super) fn selection_overlay(&self, state: &Input, frame: &mut Frame, bounds: Rectangle) {
|
||||
if let Some(selection) = &self.selection {
|
||||
for target in selection.targets() {
|
||||
let primary = selection.primary() == Some(target);
|
||||
let width = if primary { 3.5 } else { 2. };
|
||||
let color = if primary {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(64, 123, 188)
|
||||
};
|
||||
match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
let rect = if endpoint.references.is_empty() {
|
||||
self.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == endpoint.occurrence)
|
||||
.map(|n| n.rect)
|
||||
} else {
|
||||
self.nodes
|
||||
.iter()
|
||||
.find(|n| n.preview.iter().any(|(shown, _)| shown == endpoint))
|
||||
.map(|n| n.endpoint_rect(endpoint))
|
||||
};
|
||||
if let Some(rect) = rect {
|
||||
frame.stroke(
|
||||
&Path::rounded_rectangle(rect.position(), rect.size(), 5.into()),
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
}
|
||||
Target::ProcessPreviewLink {
|
||||
relation,
|
||||
source,
|
||||
target,
|
||||
} => {
|
||||
let link = PreviewLink {
|
||||
relation: *relation,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
};
|
||||
for node in &self.nodes {
|
||||
if node.preview_links.contains(&link)
|
||||
&& let Some([a, b]) = node.preview_link_visible_segment(&link)
|
||||
{
|
||||
frame.stroke(
|
||||
&Path::line(a, b),
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(group) = &state.group else {
|
||||
return;
|
||||
};
|
||||
if group.retired
|
||||
|| !group.crossed
|
||||
|| group.bounds != bounds
|
||||
|| self.selection.as_ref() != Some(&group.snapshot)
|
||||
|| group.pan != self.camera_pan
|
||||
|| group.scale != self.scale
|
||||
{
|
||||
return;
|
||||
}
|
||||
let reason = group
|
||||
.prepared
|
||||
.as_ref()
|
||||
.err()
|
||||
.or_else(|| group.plan.as_ref().and_then(|plan| plan.as_ref().err()));
|
||||
let color = if reason.is_some() {
|
||||
Color::from_rgb8(178, 49, 49)
|
||||
} else {
|
||||
style::TEAL
|
||||
};
|
||||
let mut position = state
|
||||
.pointer
|
||||
.map(|p| {
|
||||
Point::new(
|
||||
(p.x - bounds.x - self.camera_pan.x) / self.scale + 12.,
|
||||
(p.y - bounds.y - self.camera_pan.y) / self.scale + 18.,
|
||||
)
|
||||
})
|
||||
.unwrap_or(Point::ORIGIN);
|
||||
if let Some(destination) = group.destination {
|
||||
let (id, after) = match destination {
|
||||
Destination::Before(id) => (id, false),
|
||||
Destination::After(id) => (id, true),
|
||||
};
|
||||
if let Some(node) = self.nodes.iter().find(|n| n.id == id) {
|
||||
let y = if after {
|
||||
node.rect.y + node.rect.height + 7.
|
||||
} else {
|
||||
node.rect.y - 7.
|
||||
};
|
||||
frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(node.rect.x, y),
|
||||
Point::new(node.rect.x + node.rect.width, y),
|
||||
),
|
||||
Stroke::default().with_color(color).with_width(3.),
|
||||
);
|
||||
position = Point::new(node.rect.x, y + 3.);
|
||||
}
|
||||
}
|
||||
let caption = reason.cloned().unwrap_or_else(|| match &group.plan {
|
||||
Some(Ok(Plan::Noop)) => "Order unchanged".into(),
|
||||
Some(Ok(Plan::Change(_))) => format!(
|
||||
"Insert {} in source order · paths unchanged",
|
||||
group.snapshot.targets().len()
|
||||
),
|
||||
_ => "Point at a card's upper / lower half to insert before / after".into(),
|
||||
});
|
||||
draw_text(frame, caption, position, 12. / self.scale, color);
|
||||
if reason.is_none() {
|
||||
let mut index = 0;
|
||||
// Graph nodes are in original steps order; these numbers describe
|
||||
// the block the exact same cached plan will commit on release.
|
||||
for node in &self.nodes {
|
||||
if group
|
||||
.snapshot
|
||||
.targets()
|
||||
.contains(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: node.id,
|
||||
}))
|
||||
{
|
||||
index += 1;
|
||||
draw_text(
|
||||
frame,
|
||||
format!("{index}"),
|
||||
Point::new(node.rect.x + node.rect.width - 18., node.rect.y + 7.),
|
||||
13.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "direct_selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,401 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::direct_tests::{Fixture, context, fixture};
|
||||
use crate::diagram_selection::Selection;
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
fn peers(layer: Layer) -> Fixture {
|
||||
let mut fixture = fixture(layer);
|
||||
fixture.p = execute_batch(&fixture.p, &[Command::Delete(fixture.link)]).unwrap();
|
||||
fixture
|
||||
}
|
||||
fn graph<'a>(fixture: &'a Fixture, targets: &[Target]) -> Graph<'a> {
|
||||
let context = context(fixture.owner);
|
||||
let mut selection = Selection::new(process_context(context).scope);
|
||||
for target in targets {
|
||||
selection.add(target.clone(), |_| true);
|
||||
}
|
||||
let mut graph = Graph::new(&fixture.p, fixture.owner, fixture.owner);
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
graph.selection = selection.snapshot(process_context(context));
|
||||
graph
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::ORIGIN, Size::new(1800., 1400.))
|
||||
}
|
||||
fn point(graph: &Graph<'_>, id: Id, after: bool) -> Point {
|
||||
let rect = graph.nodes.iter().find(|node| node.id == id).unwrap().rect;
|
||||
Point::new(
|
||||
rect.x + 70.,
|
||||
rect.y + if after { rect.height - 15. } else { 20. },
|
||||
)
|
||||
}
|
||||
fn send(
|
||||
graph: &Graph<'_>,
|
||||
input: &mut Input,
|
||||
event: canvas::Event,
|
||||
point: Point,
|
||||
) -> Option<Message> {
|
||||
graph
|
||||
.input(input, &event, bounds(), mouse::Cursor::Available(point))
|
||||
.and_then(|action| action.into_inner().0)
|
||||
}
|
||||
fn press(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
point,
|
||||
);
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
point,
|
||||
)
|
||||
}
|
||||
fn release(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
point,
|
||||
);
|
||||
send(
|
||||
graph,
|
||||
input,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
point,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_shift_toggle_and_stationary_group_click_defer_primary() {
|
||||
let f = peers(Layer::Oa);
|
||||
let graph = graph(&f, &[local(f.nodes[2]), local(f.nodes[0])]);
|
||||
let mut input = Input::default();
|
||||
let at = point(&graph, f.nodes[2], false);
|
||||
assert!(press(&graph, &mut input, at).is_none());
|
||||
assert!(
|
||||
matches!(release(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Collapse(snapshot, target)))
|
||||
if target == local(f.nodes[2]) && snapshot == graph.selection.clone().unwrap())
|
||||
);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
at,
|
||||
);
|
||||
assert!(
|
||||
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == local(f.nodes[2]))
|
||||
);
|
||||
assert!(input.group.is_none());
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
at,
|
||||
);
|
||||
assert!(!input.modifiers.shift());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drag_uses_midpoint_anchors_and_original_order_and_singleton_after_gap() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = peers(layer);
|
||||
for targets in [
|
||||
vec![local(f.nodes[2]), local(f.nodes[0])],
|
||||
vec![local(f.nodes[0])],
|
||||
] {
|
||||
let graph = graph(&f, &targets);
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
let end = point(&graph, f.nodes[1], true);
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
let Some(Message::DiagramSelection(SelectionEvent::ProcessReorder(
|
||||
snapshot,
|
||||
destination,
|
||||
))) = release(&graph, &mut input, end)
|
||||
else {
|
||||
panic!("admitted after slot");
|
||||
};
|
||||
assert_eq!(snapshot, graph.selection.clone().unwrap());
|
||||
assert_eq!(destination, Destination::After(f.nodes[1]));
|
||||
let Plan::Change(Command::SetSteps(owner, steps)) =
|
||||
Prepared::new(&f.p, f.owner, snapshot.targets())
|
||||
.unwrap()
|
||||
.plan(destination)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("changed steps");
|
||||
};
|
||||
assert_eq!(owner, f.owner);
|
||||
assert_eq!(steps, vec![f.nodes[1], f.nodes[0], f.nodes[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion() {
|
||||
let f = peers(Layer::Oa);
|
||||
let graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
press(&graph, &mut input, start);
|
||||
let away = start + iced::Vector::new(10., 0.);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: away }),
|
||||
away,
|
||||
);
|
||||
assert!(release(&graph, &mut input, start).is_none());
|
||||
assert!(input.group.is_none());
|
||||
press(&graph, &mut input, start);
|
||||
let outside = Point::new(-1., 100.);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position: outside }),
|
||||
outside,
|
||||
);
|
||||
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
|
||||
let f = fixture(Layer::Sa);
|
||||
let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
press(&graph, &mut input, start);
|
||||
assert!(
|
||||
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], true)), Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2)
|
||||
);
|
||||
let key = crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete);
|
||||
let iced::Event::Keyboard(key) = key else {
|
||||
panic!("keyboard");
|
||||
};
|
||||
assert!(matches!(
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
|
||||
));
|
||||
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
|
||||
assert!(matches!(
|
||||
press(&graph, &mut input, start),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_busy_context_bounds_and_escape_retire_captured_gestures() {
|
||||
let f = peers(Layer::Oa);
|
||||
for change in 0..6 {
|
||||
let mut graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
|
||||
let mut input = Input::default();
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
press(&graph, &mut input, start);
|
||||
match change {
|
||||
0 => graph.camera_pan.x += 1.,
|
||||
1 => graph.scale = 2.,
|
||||
2 => graph.interaction.as_mut().unwrap().2 = false,
|
||||
3 => graph.interaction.as_mut().unwrap().0.epoch += 1,
|
||||
4 => {
|
||||
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
) else {
|
||||
panic!("keyboard");
|
||||
};
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), start);
|
||||
}
|
||||
_ => {
|
||||
graph.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::CursorMoved { position: start }),
|
||||
Rectangle::new(Point::new(1., 0.), bounds().size()),
|
||||
mouse::Cursor::Available(start),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
|
||||
assert!(input.group.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_occurrences_and_visible_preview_links_keep_exact_invocation_paths() {
|
||||
let mut f = peers(Layer::Oa);
|
||||
let source =
|
||||
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
|
||||
let mut leaves = vec![];
|
||||
for name in ["One", "Two"] {
|
||||
let leaf = occurrence(
|
||||
&f.p,
|
||||
source_id,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
leaves.push(leaf.id);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(leaf)]).unwrap();
|
||||
}
|
||||
let link = link(&f.p, leaves[0], leaves[1], "");
|
||||
f.p = execute_batch(&f.p, &[link]).unwrap();
|
||||
let mut references = vec![];
|
||||
for name in ["First", "Second"] {
|
||||
let reference = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
},
|
||||
);
|
||||
references.push(reference.id);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(reference)]).unwrap();
|
||||
}
|
||||
let graph = graph(&f, &[]);
|
||||
let mut targets = vec![];
|
||||
for reference in references {
|
||||
let node = graph.nodes.iter().find(|n| n.id == reference).unwrap();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: leaves[0],
|
||||
};
|
||||
let tile = node.endpoint_rect(&endpoint).center();
|
||||
assert_eq!(
|
||||
graph.exact_target_at(tile),
|
||||
Some(Target::ProcessOccurrence(endpoint.clone()))
|
||||
);
|
||||
targets.push(Target::ProcessOccurrence(endpoint));
|
||||
let link = &node.preview_links[0];
|
||||
let [a, b] = node.preview_link_visible_segment(link).unwrap();
|
||||
let at = Point::new((a.x + b.x) / 2., (a.y + b.y) / 2.);
|
||||
let expected = Target::ProcessPreviewLink {
|
||||
relation: link.relation,
|
||||
source: link.source.clone(),
|
||||
target: link.target.clone(),
|
||||
};
|
||||
assert_eq!(graph.exact_target_at(at), Some(expected.clone()));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
at,
|
||||
);
|
||||
assert!(
|
||||
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == expected)
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&graph, &mut input, canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)), at), Some(Message::Menu(crate::model_menu::Event::Open(request))) if request.selection_target.as_deref() == Some(&expected) && request.process == graph.interaction.map(|(context,_,_)| context))
|
||||
);
|
||||
}
|
||||
assert_ne!(targets[0], targets[1]);
|
||||
assert!(Prepared::new(&f.p, f.owner, &targets).is_err());
|
||||
}
|
||||
|
||||
// The endpoint ring is drawn last, over the underlying contribution card.
|
||||
// Probe two pixels inside that card: both semantic shapes occupy the point,
|
||||
// so these tests establish the visible handle's selection precedence.
|
||||
fn handle_point(graph: &Graph<'_>, link: Id, end: End) -> Point {
|
||||
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
match end {
|
||||
End::Source => edge.points[0] + iced::Vector::new(-2., 0.),
|
||||
End::Target => *edge.points.last().unwrap() + iced::Vector::new(2., 0.),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_process_link_snapshot_reconnects_both_ends_but_stationary_release_is_inert() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
for end in [End::Source, End::Target] {
|
||||
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let original = graph.selection.clone().unwrap();
|
||||
let start = handle_point(&graph, f.link, end);
|
||||
let mut input = Input::default();
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
assert!(input.group.is_none());
|
||||
assert!(
|
||||
matches!(input.press, Some(Press::Reconnect { link, end: armed, moved: false, .. }) if link == f.link && armed == end)
|
||||
);
|
||||
assert!(release(&graph, &mut input, start).is_none());
|
||||
assert!(input.press.is_none());
|
||||
assert_eq!(graph.selection.as_ref(), Some(&original));
|
||||
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
let target = point(&graph, f.nodes[2], false);
|
||||
assert!(matches!(release(&graph, &mut input, target),
|
||||
Some(Message::TopologyCanvas(CanvasEvent::Reconnect { context, link, end: actual, target }))
|
||||
if context == graph.interaction.unwrap().0 && link == f.link && actual == end && target == f.nodes[2]));
|
||||
assert!(input.group.is_none());
|
||||
assert_eq!(graph.selection.as_ref(), Some(&original));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_on_visible_singleton_process_link_handle_toggles_that_link_not_underlying_card() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
|
||||
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let start = handle_point(&graph, f.link, end);
|
||||
assert_eq!(graph.node_at(start), Some(underneath));
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
start,
|
||||
);
|
||||
assert!(matches!(press(&graph, &mut input, start),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Choose(_, Target::ProcessLink(id), Choice::Toggle)))
|
||||
if id == f.link));
|
||||
assert!(input.group.is_none() && input.press.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_handle() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
|
||||
let mut graph = graph(&f, &[local(underneath), Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
let snapshot = graph.selection.clone().unwrap();
|
||||
let start = handle_point(&graph, f.link, end);
|
||||
// Multiple selections render no reconnect rings. The visible
|
||||
// selected card owns this point, while the mixed set stays whole.
|
||||
assert_eq!(graph.exact_target_at(start), Some(local(underneath)));
|
||||
let mut input = Input::default();
|
||||
assert!(press(&graph, &mut input, start).is_none());
|
||||
assert!(input.group.is_some() && input.press.is_none());
|
||||
assert!(
|
||||
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], false)),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(captured, _))) if captured == snapshot)
|
||||
);
|
||||
assert_eq!(graph.selection.as_ref(), Some(&snapshot));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,11 +360,12 @@ fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources(
|
||||
assert_eq!(doc.bundle(), &reconnected);
|
||||
doc.undo();
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
let reordered = execute_batch(
|
||||
&f.p,
|
||||
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let group_reorder::Plan::Change(command) =
|
||||
direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()
|
||||
else {
|
||||
panic!("changed order");
|
||||
};
|
||||
let reordered = execute_batch(&f.p, &[command]).unwrap();
|
||||
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
|
||||
assert_eq!(reordered.relations, f.p.relations);
|
||||
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Immutable, bounded order proposals for local Process/FunctionalChain cards.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Destination {
|
||||
Before(Id),
|
||||
After(Id),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Plan {
|
||||
Noop,
|
||||
Change(Command),
|
||||
}
|
||||
|
||||
/// Captured semantic facts only: no project clone, projection or routed scene.
|
||||
/// The adapter must check its document/revision/selection lifetime before commit
|
||||
/// and apply the returned command through the ordinary validated transaction.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prepared {
|
||||
owner: Id,
|
||||
steps: Vec<Id>,
|
||||
adjacency: Vec<(Id, Id)>,
|
||||
legacy: bool,
|
||||
ranks: BTreeMap<Id, usize>,
|
||||
selected: BTreeSet<Id>,
|
||||
column: usize,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub(crate) fn new(project: &Project, owner: Id, targets: &[Target]) -> Result<Self, String> {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.ok_or("Choose a Process or Functional Chain.")?;
|
||||
let steps = element
|
||||
.steps()
|
||||
.ok_or("This owner has no contribution order.")?;
|
||||
let mut ids = BTreeSet::new();
|
||||
for id in steps {
|
||||
if !is_contribution(project, owner, *id) || !ids.insert(*id) {
|
||||
return Err(
|
||||
"Every process step must name a unique local contribution before reordering."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut adjacency: Vec<_> = sequence_links(project, owner)
|
||||
.into_iter()
|
||||
.map(|link| (link.source(), link.target()))
|
||||
.collect();
|
||||
// Unlisted local occurrences are legal incomplete source. Rendering can
|
||||
// omit their links, but order editing must not silently ignore them.
|
||||
if adjacency
|
||||
.iter()
|
||||
.any(|(a, b)| !ids.contains(a) || !ids.contains(b))
|
||||
{
|
||||
return Err("A sequence link refers to an unlisted contribution; repair the process order before reordering.".into());
|
||||
}
|
||||
let legacy = !legacy_order(project, owner).is_empty();
|
||||
if legacy {
|
||||
adjacency.extend(steps.windows(2).map(|pair| (pair[0], pair[1])));
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Err("Select at least one local contribution.".into());
|
||||
}
|
||||
let mut selected = BTreeSet::new();
|
||||
for target in targets {
|
||||
let Target::ProcessOccurrence(endpoint) = target else {
|
||||
return Err(
|
||||
"Reorder requires local contribution cards only; links cannot move with them."
|
||||
.into(),
|
||||
);
|
||||
};
|
||||
if !endpoint.references.is_empty() {
|
||||
return Err("Open the referenced definition to reorder its contents.".into());
|
||||
}
|
||||
if !ids.contains(&endpoint.occurrence) || !selected.insert(endpoint.occurrence) {
|
||||
return Err(
|
||||
"Every selected contribution must occur exactly once in this process.".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let ranks = column_ranks(steps, &adjacency);
|
||||
let first = *selected.first().expect("Nonempty selection admitted");
|
||||
let column = ranks[&first];
|
||||
if selected.iter().any(|id| ranks[id] != column) {
|
||||
return Err("Reorder within one column; links determine the path columns.".into());
|
||||
}
|
||||
Ok(Self {
|
||||
owner,
|
||||
steps: steps.clone(),
|
||||
adjacency,
|
||||
legacy,
|
||||
ranks,
|
||||
selected,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn plan(&self, destination: Destination) -> Result<Plan, String> {
|
||||
let (anchor, after) = match destination {
|
||||
Destination::Before(id) => (id, false),
|
||||
Destination::After(id) => (id, true),
|
||||
};
|
||||
if self.ranks.get(&anchor) != Some(&self.column) {
|
||||
return Err(
|
||||
"Choose a local contribution in the selected column as the destination.".into(),
|
||||
);
|
||||
}
|
||||
// Complete step, adjacency and typed selection admission already ran.
|
||||
if self.selected.contains(&anchor) {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
let indices: Vec<_> = self
|
||||
.steps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, id)| (self.ranks[id] == self.column).then_some(i))
|
||||
.collect();
|
||||
let block: Vec<_> = indices
|
||||
.iter()
|
||||
.map(|&i| self.steps[i])
|
||||
.filter(|id| self.selected.contains(id))
|
||||
.collect();
|
||||
let mut remaining: Vec<_> = indices
|
||||
.iter()
|
||||
.map(|&i| self.steps[i])
|
||||
.filter(|id| !self.selected.contains(id))
|
||||
.collect();
|
||||
let gap = remaining
|
||||
.iter()
|
||||
.position(|id| *id == anchor)
|
||||
.ok_or("The destination slot no longer exists.")?
|
||||
+ usize::from(after);
|
||||
remaining.splice(gap..gap, block);
|
||||
let mut ordered = self.steps.clone();
|
||||
for (index, id) in indices.into_iter().zip(remaining) {
|
||||
ordered[index] = id;
|
||||
}
|
||||
if ordered == self.steps {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
// Legacy adjacency comes from order itself. In valid legacy layouts each
|
||||
// card has its own column; retain the check instead of assuming that.
|
||||
let ranks = if self.legacy {
|
||||
let adjacency: Vec<_> = ordered.windows(2).map(|pair| (pair[0], pair[1])).collect();
|
||||
column_ranks(&ordered, &adjacency)
|
||||
} else {
|
||||
column_ranks(&ordered, &self.adjacency)
|
||||
};
|
||||
if ranks != self.ranks {
|
||||
return Err(
|
||||
"This reorder would change process columns; the whole move was refused.".into(),
|
||||
);
|
||||
}
|
||||
Ok(Plan::Change(Command::SetSteps(self.owner, ordered)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn plan(
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
targets: &[Target],
|
||||
destination: Destination,
|
||||
) -> Result<Plan, String> {
|
||||
Prepared::new(project, owner, targets)?.plan(destination)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,407 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
|
||||
fn columns(project: &Project, owner: Id) -> BTreeMap<Id, usize> {
|
||||
let first = project.elements[&owner].steps().unwrap()[0];
|
||||
Prepared::new(project, owner, &[local(first)])
|
||||
.unwrap()
|
||||
.ranks
|
||||
}
|
||||
|
||||
// Literal order [a,u,b,v,c]; a,b,c are peers. u,v are either downstream
|
||||
// contributions or a separate cycle, so their slots must never change.
|
||||
fn fixture(layer: Layer, cycle: bool) -> (Project, Id, [Id; 5]) {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let mut ids = vec![];
|
||||
for name in ["a", "u", "b", "v", "c"] {
|
||||
let element = occurrence(
|
||||
&project,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
ids.push(element.id);
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
}
|
||||
let ids: [Id; 5] = ids.try_into().unwrap();
|
||||
let [a, u, b, v, _] = ids;
|
||||
let pairs = if cycle {
|
||||
[(u, v), (v, u)]
|
||||
} else {
|
||||
[(a, u), (b, v)]
|
||||
};
|
||||
// fresh_alias must see the first link before generating the second; two
|
||||
// commands built from the same snapshot both choose "sequence_link".
|
||||
for (source, target) in pairs {
|
||||
let command = link(&project, source, target, "");
|
||||
project = execute_batch(&project, &[command]).unwrap();
|
||||
}
|
||||
assert_eq!(project.elements[&f.owner].steps().unwrap(), &ids);
|
||||
(project, f.owner, ids)
|
||||
}
|
||||
|
||||
fn changed(project: &Project, owner: Id, targets: &[Target], drop: Destination) -> Command {
|
||||
let Plan::Change(command) = plan(project, owner, targets, drop).unwrap() else {
|
||||
panic!("Expected an actual order change");
|
||||
};
|
||||
assert!(matches!(&command, Command::SetSteps(id, _) if *id == owner));
|
||||
command
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_reverse_noncontiguous_selection_preserves_project_and_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for cycle in [false, true] {
|
||||
let (project, owner, [a, u, b, v, c]) = fixture(layer, cycle);
|
||||
assert_eq!(
|
||||
columns(&project, owner),
|
||||
BTreeMap::from([
|
||||
(a, 0),
|
||||
(u, 1),
|
||||
(b, 0),
|
||||
(v, if cycle { 2 } else { 1 }),
|
||||
(c, 0)
|
||||
])
|
||||
);
|
||||
// Reverse click order must not reverse the source-ordered a,c block.
|
||||
let command = changed(
|
||||
&project,
|
||||
owner,
|
||||
&[local(c), local(a)],
|
||||
Destination::After(b),
|
||||
);
|
||||
let mut expected = project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![b, u, a, v, c];
|
||||
let result = execute_batch(&project, std::slice::from_ref(&command)).unwrap();
|
||||
assert_eq!(result, expected, "Nothing outside owner steps may change");
|
||||
assert_eq!(columns(&result, owner), columns(&project, owner));
|
||||
let mut doc =
|
||||
Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
doc.execute(command).unwrap();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
let after = doc.bundle().clone();
|
||||
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
assert_eq!(reopened.bundle(), &after);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.project(), &project);
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_end_gaps_and_selected_anchor_noops() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
for (members, drop, expected) in [
|
||||
(
|
||||
vec![local(c), local(b)],
|
||||
Destination::Before(a),
|
||||
vec![b, u, c, v, a],
|
||||
),
|
||||
(
|
||||
vec![local(b), local(a)],
|
||||
Destination::After(c),
|
||||
vec![c, u, a, v, b],
|
||||
),
|
||||
] {
|
||||
let command = changed(&project, owner, &members, drop);
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[command]).unwrap().elements[&owner]
|
||||
.steps()
|
||||
.unwrap(),
|
||||
&expected
|
||||
);
|
||||
}
|
||||
let doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
for (members, drop) in [
|
||||
(vec![local(b), local(c)], Destination::After(a)),
|
||||
(vec![local(c), local(a)], Destination::Before(c)),
|
||||
(vec![local(a), local(c)], Destination::After(a)),
|
||||
(vec![local(c), local(b), local(a)], Destination::After(b)),
|
||||
] {
|
||||
assert!(matches!(
|
||||
plan(doc.project(), owner, &members, drop),
|
||||
Ok(Plan::Noop)
|
||||
));
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
assert!(!doc.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_refuse_entire_invalid_typed_sets_before_noop() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (project, owner, [a, u, _, _, c]) = fixture(layer, true);
|
||||
let foreign = super::super::direct_tests::fixture(layer).nodes[0];
|
||||
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: c,
|
||||
});
|
||||
let relation = *project.relations.keys().next().unwrap();
|
||||
for members in [
|
||||
vec![],
|
||||
vec![local(a), local(a)],
|
||||
vec![local(a), local(u)],
|
||||
vec![local(a), local(foreign)],
|
||||
vec![local(a), qualified],
|
||||
vec![local(a), Target::ProcessLink(relation)],
|
||||
vec![local(a), Target::Placement(c)],
|
||||
] {
|
||||
// A selected anchor must not conceal invalid second members.
|
||||
assert!(plan(&project, owner, &members, Destination::Before(a)).is_err());
|
||||
}
|
||||
assert!(plan(&project, owner, &[local(a)], Destination::After(foreign)).is_err());
|
||||
assert!(plan(&project, a, &[local(a)], Destination::Before(a)).is_err());
|
||||
assert!(
|
||||
plan(
|
||||
&project,
|
||||
owner,
|
||||
&[
|
||||
local(u),
|
||||
local(project.elements[&owner].steps().unwrap()[3])
|
||||
],
|
||||
Destination::Before(u)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_legacy_exchange_order_refuses_cross_rank_group() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let mut ids = vec![];
|
||||
for (name, exchange) in [("first", f.exchange), ("second", next)] {
|
||||
let element = occurrence(
|
||||
&project,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
ids.push(element.id);
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
}
|
||||
assert_eq!(legacy_order(&project, f.owner), ids);
|
||||
assert!(
|
||||
plan(
|
||||
&project,
|
||||
f.owner,
|
||||
&[local(ids[0]), local(ids[1])],
|
||||
Destination::Before(ids[0])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(matches!(
|
||||
plan(
|
||||
&project,
|
||||
f.owner,
|
||||
&[local(ids[1])],
|
||||
Destination::After(ids[1])
|
||||
),
|
||||
Ok(Plan::Noop)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_accept_all_local_card_kinds_and_different_heights() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let source = project
|
||||
.make_element(
|
||||
project.elements[&f.owner].kind(),
|
||||
layer,
|
||||
"Reusable definition",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(source)]).unwrap();
|
||||
let bodies = [
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
},
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
];
|
||||
let mut ids = vec![];
|
||||
for (index, body) in bodies.into_iter().enumerate() {
|
||||
let element = occurrence(&project, f.owner, &format!("Card {index}"), body);
|
||||
ids.push(element.id);
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
}
|
||||
let graph = Graph::new(&project, f.owner, ids[0]);
|
||||
assert_ne!(graph.nodes[0].rect.height, graph.nodes[3].rect.height);
|
||||
let selected = vec![local(ids[3]), local(ids[2]), local(ids[1]), local(ids[0])];
|
||||
let command = changed(&project, f.owner, &selected, Destination::After(ids[4]));
|
||||
let mut expected = project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![ids[4], ids[0], ids[1], ids[2], ids[3]];
|
||||
assert_eq!(execute_batch(&project, &[command]).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_full_order_admission_precedes_selected_anchor_noop() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
let other = project
|
||||
.make_element(project.elements[&owner].kind(), layer, "Other", None)
|
||||
.unwrap();
|
||||
let other_owner = other.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
|
||||
let foreign = occurrence(
|
||||
&project,
|
||||
other_owner,
|
||||
"Foreign local occurrence",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let foreign_id = foreign.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(foreign)]).unwrap();
|
||||
assert!(Prepared::new(&project, owner, &[local(a), local(foreign_id)]).is_err());
|
||||
let prepared = Prepared::new(&project, owner, &[local(a)]).unwrap();
|
||||
assert!(prepared.plan(Destination::Before(foreign_id)).is_err());
|
||||
|
||||
// Corrupt/incomplete imported orders must fail even when only the valid
|
||||
// card is selected and the requested destination would be a no-op.
|
||||
for order in [
|
||||
vec![a, u, b, v, c, c],
|
||||
vec![a, u, b, v, Id::new_v4()],
|
||||
vec![a, u, b, v, foreign_id],
|
||||
vec![a, b, v, c], // local a -> u remains, but u is unlisted
|
||||
] {
|
||||
let mut incomplete = project.clone();
|
||||
*incomplete
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = order;
|
||||
let original = incomplete.clone();
|
||||
assert!(plan(&incomplete, owner, &[local(a)], Destination::Before(a)).is_err());
|
||||
assert_eq!(incomplete, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_renderer_omits_links_to_unlisted_nodes_without_panicking() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
for (omitted, expected_ids, expected_ranks) in [
|
||||
(
|
||||
u,
|
||||
vec![a, b, v, c],
|
||||
BTreeMap::from([(a, 0), (b, 0), (v, 1), (c, 0)]),
|
||||
),
|
||||
(
|
||||
a,
|
||||
vec![u, b, v, c],
|
||||
BTreeMap::from([(u, 0), (b, 0), (v, 1), (c, 0)]),
|
||||
),
|
||||
] {
|
||||
// This omission is core-valid incomplete source, not a renderer-only
|
||||
// manufactured dangling element. The semantic occurrence remains.
|
||||
let incomplete =
|
||||
execute_batch(&project, &[Command::SetSteps(owner, expected_ids.clone())]).unwrap();
|
||||
assert!(incomplete.elements.contains_key(&omitted));
|
||||
let graph = Graph::new(&incomplete, owner, b);
|
||||
assert_eq!(
|
||||
graph.nodes.iter().map(|node| node.id).collect::<Vec<_>>(),
|
||||
expected_ids
|
||||
);
|
||||
assert_eq!(graph.edges.len(), 1);
|
||||
assert_eq!((graph.edges[0].source, graph.edges[0].target), (b, v));
|
||||
for node in &graph.nodes {
|
||||
assert_eq!(node.rect.x, 24. + expected_ranks[&node.id] as f32 * 420.);
|
||||
}
|
||||
assert!(Prepared::new(&incomplete, owner, &[local(b)]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_columns_parallel_links_keep_exact_ranks_and_relation_records() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
|
||||
project = execute_batch(&project, &[link(&project, a, u, "alternative")]).unwrap();
|
||||
assert_eq!(
|
||||
columns(&project, owner),
|
||||
BTreeMap::from([(a, 0), (u, 1), (b, 0), (v, 1), (c, 0)])
|
||||
);
|
||||
let prepared = Prepared::new(&project, owner, &[local(c), local(b)]).unwrap();
|
||||
let Plan::Change(command) = prepared.plan(Destination::Before(a)).unwrap() else {
|
||||
panic!("Expected move");
|
||||
};
|
||||
let result = execute_batch(&project, &[command]).unwrap();
|
||||
assert_eq!(
|
||||
result.elements[&owner].steps().unwrap(),
|
||||
&vec![b, u, c, v, a]
|
||||
);
|
||||
assert_eq!(result.relations, project.relations);
|
||||
assert_eq!(columns(&result, owner), prepared.ranks);
|
||||
assert_eq!(Graph::new(&result, owner, b).edges.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,278 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_process_multiselect_modifiers_survive_focus_busy_and_clear_on_window_loss() {
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use iced::keyboard::{Event as KeyEvent, Modifiers};
|
||||
let mut f = super::super::direct_tests::fixture(Layer::Oa);
|
||||
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
|
||||
let mut app = App::with_project(f.p);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1440., 900.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let at = |app: &App, id| {
|
||||
let graph = Graph::new(app.project(), f.owner, id);
|
||||
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
|
||||
bounds.position()
|
||||
+ app.behavior.topology.pan
|
||||
+ iced::Vector::new(rect.x + 60., rect.y + 20.)
|
||||
* app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.))
|
||||
};
|
||||
let shift = |held| {
|
||||
iced::Event::Keyboard(KeyEvent::ModifiersChanged(if held {
|
||||
Modifiers::SHIFT
|
||||
} else {
|
||||
Modifiers::empty()
|
||||
}))
|
||||
};
|
||||
let first = at(&app, f.nodes[2]);
|
||||
mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(first))
|
||||
.await;
|
||||
mounted.click(&mut app, first).await;
|
||||
let second = at(&app, f.nodes[0]);
|
||||
mounted.click(&mut app, second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
2,
|
||||
"Shift before canvas focus reaches both clicks"
|
||||
);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[shift(false)], mouse::Cursor::Available(second))
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let third = at(&app, f.nodes[1]);
|
||||
mounted.click(&mut app, third).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
1,
|
||||
"Shift released during busy is not resurrected"
|
||||
);
|
||||
app.busy = true;
|
||||
mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(second))
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted.click(&mut app, second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
2,
|
||||
"Shift held through busy survives child lifetime"
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Window(iced::window::Event::Unfocused)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, first).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
1,
|
||||
"real window loss clears held modifiers"
|
||||
);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
assert!(!app.document.can_undo());
|
||||
let pan = app.behavior.topology.pan;
|
||||
let source = app.document.bundle().clone();
|
||||
let blank = bounds.position() + iced::Vector::new(bounds.width - 40., bounds.height - 40.);
|
||||
let end = blank + iced::Vector::new(-25., -20.);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved { position: blank }),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(blank),
|
||||
)
|
||||
.await;
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(app.behavior.topology.pan, pan);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
// Each batch rebuilds the production tree and applies all App messages.
|
||||
// Selection Clear must not retire the camera pan started by its press.
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: end,
|
||||
})],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
app.behavior.topology.pan,
|
||||
pan + iced::Vector::new(-25., -20.)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await;
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(
|
||||
app.behavior.topology.pan,
|
||||
pan + iced::Vector::new(-25., -20.)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera()
|
||||
{
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = super::super::direct_tests::fixture(layer);
|
||||
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
|
||||
let mut app = App::with_project(f.p);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1440., 900.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let graph = Graph::new(app.project(), f.owner, f.owner);
|
||||
let scale = app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
let at = |id, after| {
|
||||
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
|
||||
bounds.position()
|
||||
+ iced::Vector::new(
|
||||
rect.x + 60.,
|
||||
rect.y + if after { rect.height - 15. } else { 20. },
|
||||
) * scale
|
||||
};
|
||||
let points = [
|
||||
at(f.nodes[2], false),
|
||||
at(f.nodes[0], false),
|
||||
at(f.nodes[1], true),
|
||||
];
|
||||
drop(graph);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Keyboard(
|
||||
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::SHIFT),
|
||||
)],
|
||||
mouse::Cursor::Available(points[0]),
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, points[0]).await;
|
||||
mounted.click(&mut app, points[1]).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Keyboard(
|
||||
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::empty()),
|
||||
)],
|
||||
mouse::Cursor::Available(points[1]),
|
||||
)
|
||||
.await;
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
|
||||
let pressed = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
iced::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: points[1],
|
||||
}),
|
||||
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(points[1]),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!pressed
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramSelection(_)))
|
||||
);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
let moved = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: points[2],
|
||||
})],
|
||||
mouse::Cursor::Available(points[2]),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!moved
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramSelection(_)))
|
||||
);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
assert_eq!(
|
||||
(app.behavior.topology.pan, app.behavior.topology.zoom),
|
||||
camera
|
||||
);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(points[2]),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::ProcessReorder(_, _))
|
||||
)),
|
||||
"{messages:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&vec![f.nodes[1], f.nodes[0], f.nodes[2]]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
|
||||
assert_eq!(
|
||||
(app.behavior.topology.pan, app.behavior.topology.zoom),
|
||||
camera
|
||||
);
|
||||
let changed = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() {
|
||||
use crate::behavior::canvas_workspace::{self, tests::Mounted};
|
||||
|
||||
@@ -23,6 +23,38 @@ impl UsageSnapshot {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_address(&self) -> Option<oa_core::topology_context::ContextAddress> {
|
||||
self.selected_context.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn selection_targets(&self, p: &Project, owner: Id) -> Vec<Target> {
|
||||
let graph = self.usage_graph(p, owner);
|
||||
let mut targets = vec![];
|
||||
for node in &graph.nodes {
|
||||
targets.push(Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: node.id,
|
||||
}));
|
||||
targets.extend(
|
||||
node.preview
|
||||
.iter()
|
||||
.map(|(endpoint, _)| Target::ProcessOccurrence(endpoint.clone())),
|
||||
);
|
||||
for link in &node.preview_links {
|
||||
let target = Target::ProcessPreviewLink {
|
||||
relation: link.relation,
|
||||
source: link.source.clone(),
|
||||
target: link.target.clone(),
|
||||
};
|
||||
if graph.usage_bounds(&target).is_some() {
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
targets.extend(graph.edges.iter().map(|edge| Target::ProcessLink(edge.id)));
|
||||
targets
|
||||
}
|
||||
|
||||
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
identity: self.identity,
|
||||
|
||||
@@ -66,7 +66,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
let pending = state.draft.is_some();
|
||||
let ordered = !legacy_order(p, owner).is_empty();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
let clean =
|
||||
!app.busy && !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
@@ -183,6 +184,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.selection = app.multiselect_snapshot();
|
||||
graph.selected_preview = state
|
||||
.qualified_selection(p, owner)
|
||||
.filter(|address| {
|
||||
@@ -195,6 +197,11 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
== app.selected
|
||||
})
|
||||
.map(|address| address.endpoint.clone());
|
||||
if let Some(crate::diagram_usage::Target::ProcessOccurrence(endpoint)) =
|
||||
graph.selection.as_ref().and_then(|s| s.primary())
|
||||
{
|
||||
graph.selected_preview = (!endpoint.references.is_empty()).then(|| endpoint.clone());
|
||||
}
|
||||
graph.selected_preview_link = app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
|
||||
.and_then(|usage| match &usage.target {
|
||||
@@ -255,7 +262,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
event(Event::Shelf(Shelf::Selection)),
|
||||
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
),
|
||||
selection_controls(app)
|
||||
]
|
||||
.spacing(4);
|
||||
let entries = [
|
||||
@@ -321,7 +329,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
)
|
||||
}),
|
||||
);
|
||||
let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11)))
|
||||
let details = scrollable(navigation.push(text("Drag selected local cards to the upper or lower half of a card to insert before or after it in the same column. The group keeps source order; paths stay unchanged. Qualified previews remain read-only.").size(11)))
|
||||
.id("process-details").width(Fill).height(Fill).into();
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
@@ -551,6 +559,42 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::multiselect::Event as SelectionEvent;
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let enabled = !app.busy
|
||||
&& !app.behavior.has_pending()
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
&& app.behavior.topology.tool == CanvasTool::Select;
|
||||
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut controls = row![label(format!("{count} selected"))]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) {
|
||||
controls = controls.push(button("Clear selection").on_press_maybe(
|
||||
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
|
||||
));
|
||||
controls = controls.push(
|
||||
label(format!(
|
||||
"Properties: {}",
|
||||
short(app.project().named(app.selected), 24)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
// These two rows exist even with an empty set so selection never moves the
|
||||
// canvas viewport or changes the camera's interpretation of the pointer.
|
||||
column![
|
||||
controls,
|
||||
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
]
|
||||
.height(56)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
session: Id,
|
||||
actions: iced::Element<'a, Message, iced::Theme, R>,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct Request {
|
||||
pub route_segment: Option<(usize, Position)>,
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub process: Option<crate::behavior::topology::Context>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
@@ -32,6 +33,7 @@ impl Request {
|
||||
route_segment: None,
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
process: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
@@ -61,6 +63,9 @@ impl Opened {
|
||||
|| request
|
||||
.canvas
|
||||
.is_some_and(|c| Some(c) != current_canvas(app))
|
||||
|| request
|
||||
.process
|
||||
.is_some_and(|c| Some(c) != app.topology_context())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -88,6 +93,10 @@ impl Opened {
|
||||
.request
|
||||
.canvas
|
||||
.is_none_or(|c| Some(c) == current_canvas(app))
|
||||
&& self
|
||||
.request
|
||||
.process
|
||||
.is_none_or(|c| Some(c) == app.topology_context())
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
@@ -101,10 +110,24 @@ fn target_matches(app: &App, request: &Request) -> bool {
|
||||
let Some(target) = request.selection_target.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if request.canvas.is_none() || !app.multiselect_target_visible(target) {
|
||||
if (request.canvas.is_none() && request.process.is_none())
|
||||
|| !app.multiselect_target_visible(target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
request.process.is_some()
|
||||
&& request.canvas.is_none()
|
||||
&& request.placement.is_none()
|
||||
&& endpoint.occurrence == request.object
|
||||
}
|
||||
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => {
|
||||
request.process.is_some()
|
||||
&& request.canvas.is_none()
|
||||
&& request.placement.is_none()
|
||||
&& *id == request.object
|
||||
}
|
||||
Target::Placement(id) => {
|
||||
request.placement == Some(*id)
|
||||
&& request
|
||||
@@ -132,7 +155,9 @@ fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
||||
|
||||
fn current_diagram(app: &App) -> Option<Id> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context().map(|context| context.diagram)
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| app.state_context().map(|context| context.diagram))
|
||||
} else {
|
||||
app.active_diagram
|
||||
}
|
||||
@@ -156,6 +181,7 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
request.process = app.topology_context();
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
@@ -233,7 +259,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if request.canvas.is_some()
|
||||
if (request.canvas.is_some() || request.process.is_some())
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent};
|
||||
@@ -311,11 +337,20 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
if request.process.is_some() {
|
||||
entries.push(Entry::title(
|
||||
"Drag contributions within one column to reorder",
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Hide unavailable · cards are model contributions",
|
||||
));
|
||||
} else {
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
}
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
@@ -327,6 +362,35 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if let Some(context) = request.process {
|
||||
let endpoint = match request.selection_target.as_deref() {
|
||||
Some(Target::ProcessOccurrence(endpoint))
|
||||
if !endpoint.references.is_empty() =>
|
||||
{
|
||||
Some(endpoint.clone())
|
||||
}
|
||||
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(endpoint) = endpoint {
|
||||
entries.push(Entry::title("Reused contents · read-only in this process"));
|
||||
entries.push(item(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
context, endpoint,
|
||||
),
|
||||
),
|
||||
true,
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
|
||||
@@ -689,6 +689,34 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn inspector(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
if let Some(target) = app.process_preview_primary() {
|
||||
let endpoint = match target {
|
||||
crate::diagram_usage::Target::ProcessOccurrence(endpoint) => endpoint.clone(),
|
||||
crate::diagram_usage::Target::ProcessPreviewLink { source, .. } => source.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let context = app.topology_context().unwrap();
|
||||
return container(
|
||||
column![
|
||||
text(p.named(app.selected)).size(17),
|
||||
label("Reused contents · read-only here"),
|
||||
label("Open the referenced definition to edit this contribution or connection."),
|
||||
action(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
context, endpoint
|
||||
))
|
||||
),
|
||||
crate::model_menu::trigger(app),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(14)
|
||||
.width(320)
|
||||
.height(Fill)
|
||||
.style(style::panel)
|
||||
.into();
|
||||
}
|
||||
if app.source_dirty() {
|
||||
let mut notice = column![
|
||||
text("Source drafts pending").size(17),
|
||||
|
||||
Reference in New Issue
Block a user