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:
2026-09-23 05:39:10 -07:00
parent bed7f825fd
commit 1711bff650
25 changed files with 3993 additions and 145 deletions
+66 -2
View File
@@ -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
+1 -8
View File
@@ -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);
+48 -5
View File
@@ -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;
+7 -16
View File
@@ -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};
+74 -54
View File
@@ -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>,
+72 -8
View File
@@ -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
+28
View File
@@ -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),