1223 lines
47 KiB
Rust
1223 lines
47 KiB
Rust
//! Contextual actions reuse the ordinary guarded application commands.
|
|
use crate::{
|
|
app::{App, Message, Page},
|
|
context_menu::{Entry, Menu},
|
|
port_workflow, route_edit,
|
|
};
|
|
use iced::Point;
|
|
use oa_core::*;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Request {
|
|
pub anchor: Point,
|
|
pub object: Id,
|
|
pub placement: Option<Id>,
|
|
pub selection_target: Option<Box<crate::diagram_usage::Target>>,
|
|
pub bend: Option<usize>,
|
|
pub route_point: Option<Position>,
|
|
pub route_segment: Option<(usize, Position)>,
|
|
pub creation_point: Option<Position>,
|
|
pub canvas: Option<route_edit::Context>,
|
|
pub process: Option<Box<crate::behavior::topology::Context>>,
|
|
pub source_file: Option<String>,
|
|
}
|
|
impl Request {
|
|
pub fn object(object: Id, anchor: Point) -> Self {
|
|
Self {
|
|
anchor,
|
|
object,
|
|
placement: None,
|
|
selection_target: None,
|
|
bend: None,
|
|
route_point: None,
|
|
route_segment: None,
|
|
creation_point: None,
|
|
canvas: None,
|
|
process: None,
|
|
source_file: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Open(Request),
|
|
Invoke(u64, Box<Message>),
|
|
Close,
|
|
}
|
|
|
|
pub struct Opened {
|
|
pub token: u64,
|
|
pub request: Request,
|
|
document: Id,
|
|
revision: u64,
|
|
epoch: u64,
|
|
page: Page,
|
|
layer: Layer,
|
|
diagram: Option<Id>,
|
|
}
|
|
impl Opened {
|
|
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
|
if !app.project().contains(request.object)
|
|
|| !target_matches(app, &request)
|
|
|| request
|
|
.canvas
|
|
.is_some_and(|c| Some(c) != current_canvas(app))
|
|
|| request
|
|
.process
|
|
.as_deref()
|
|
.is_some_and(|c| Some(*c) != app.topology_context())
|
|
{
|
|
return None;
|
|
}
|
|
Some(Self {
|
|
token,
|
|
request,
|
|
document: app.canvas_document,
|
|
revision: app.document.revision(),
|
|
epoch: app.canvas_epoch,
|
|
page: app.page,
|
|
layer: app.layer,
|
|
diagram: current_diagram(app),
|
|
})
|
|
}
|
|
pub fn valid(&self, app: &App) -> bool {
|
|
self.document == app.canvas_document
|
|
&& self.revision == app.document.revision()
|
|
&& self.epoch == app.canvas_epoch
|
|
&& self.page == app.page
|
|
&& self.layer == app.layer
|
|
&& self.diagram == current_diagram(app)
|
|
&& app.project().contains(self.request.object)
|
|
&& target_matches(app, &self.request)
|
|
&& self
|
|
.request
|
|
.canvas
|
|
.is_none_or(|c| Some(c) == current_canvas(app))
|
|
&& self
|
|
.request
|
|
.process
|
|
.as_deref()
|
|
.is_none_or(|c| Some(*c) == app.topology_context())
|
|
&& self
|
|
.request
|
|
.source_file
|
|
.as_ref()
|
|
.is_none_or(|path| app.source_workspace.files.contains_key(path))
|
|
}
|
|
}
|
|
|
|
fn target_matches(app: &App, request: &Request) -> bool {
|
|
use crate::diagram_usage::Target;
|
|
let Some(target) = request.selection_target.as_deref() else {
|
|
return true;
|
|
};
|
|
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
|
|
.canvas
|
|
.and_then(|context| app.project().diagrams.get(&context.diagram))
|
|
.and_then(|diagram| diagram.nodes.get(id))
|
|
.is_some_and(|node| node.element == request.object || node.id == request.object)
|
|
}
|
|
Target::Port { port, .. } => request.placement.is_none() && *port == request.object,
|
|
Target::Edge(id) => request.placement.is_none() && *id == request.object,
|
|
Target::StateRow { relation, .. } => {
|
|
request.placement.is_none() && *relation == request.object
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
|
if app.page == Page::Behavior {
|
|
app.state_context()
|
|
} else {
|
|
app.route_context()
|
|
}
|
|
}
|
|
|
|
fn current_diagram(app: &App) -> Option<Id> {
|
|
if app.page == Page::Behavior {
|
|
app.topology_context()
|
|
.map(|context| context.owner)
|
|
.or_else(|| app.state_context().map(|context| context.diagram))
|
|
} else {
|
|
app.active_diagram
|
|
}
|
|
}
|
|
|
|
pub fn open(object: Id, anchor: Point) -> Message {
|
|
Message::Menu(Event::Open(Request::object(object, anchor)))
|
|
}
|
|
|
|
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
|
crate::context_menu::Area::left(
|
|
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
|
|
move |anchor| {
|
|
let mut request = Request::object(app.selected, anchor);
|
|
if app.page == Page::Diagram {
|
|
request.placement = app.selected_node;
|
|
request.bend = app
|
|
.selected_bend
|
|
.filter(|(id, _)| *id == app.selected)
|
|
.map(|(_, i)| i);
|
|
request.canvas = app.route_context();
|
|
} else if app.page == Page::Behavior {
|
|
request.canvas = app.state_context();
|
|
request.process = app.topology_context().map(Box::new);
|
|
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 {
|
|
crate::diagram_usage::Target::Placement(id) => {
|
|
if let Some(node) = request
|
|
.canvas
|
|
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
|
.and_then(|d| d.nodes.get(id))
|
|
{
|
|
request.object = node.element;
|
|
}
|
|
Some(*id)
|
|
}
|
|
_ => None,
|
|
};
|
|
} else if let Some(context) = request.canvas
|
|
&& let Some(node) = app.project().diagrams[&context.diagram]
|
|
.nodes
|
|
.values()
|
|
.find(|node| node.element == app.selected || node.id == app.selected)
|
|
{
|
|
request.object = node.element;
|
|
request.placement = Some(node.id);
|
|
}
|
|
}
|
|
Message::Menu(Event::Open(request))
|
|
},
|
|
)
|
|
.into()
|
|
}
|
|
|
|
pub fn popup(app: &App) -> Option<Menu<Message>> {
|
|
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
|
|
let request = &opened.request;
|
|
let p = app.project();
|
|
let id = request.object;
|
|
let clean = !app.busy
|
|
&& (request.source_file.is_some()
|
|
|| (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()));
|
|
let item = |caption: &str, message: Message, enabled: bool| {
|
|
if clean && enabled {
|
|
Entry::action(
|
|
caption,
|
|
Message::Menu(Event::Invoke(opened.token, Box::new(message))),
|
|
)
|
|
} else {
|
|
Entry::disabled(caption)
|
|
}
|
|
};
|
|
if let Some(path) = &request.source_file {
|
|
let buffer = app.source_workspace.files.get(path)?;
|
|
let entries = vec![
|
|
Entry::title(path),
|
|
item("Open file", Message::SourceFile(path.clone()), true),
|
|
item("Reveal active file", Message::SourceTreeLocate, true),
|
|
Entry::separator(),
|
|
item(
|
|
"Close detached buffer",
|
|
Message::SourceCloseFile(path.clone()),
|
|
!app.source_workspace.reachable.contains(path) && !buffer.dirty(),
|
|
),
|
|
Entry::title(if app.source_workspace.reachable.contains(path) {
|
|
"Included files remain part of the project"
|
|
} else if buffer.dirty() {
|
|
"Apply or revert this file before closing"
|
|
} else {
|
|
"Closing leaves the file on disk"
|
|
}),
|
|
];
|
|
return Some(Menu {
|
|
token: opened.token,
|
|
anchor: request.anchor,
|
|
entries,
|
|
on_close: Message::Menu(Event::Close),
|
|
});
|
|
}
|
|
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
|
if (request.canvas.is_some() || request.process.is_some())
|
|
&& let Some(context) = app.multiselect_context()
|
|
{
|
|
use crate::app::multiselect::{Choice, Event as SelectionEvent};
|
|
use crate::diagram_usage::Target;
|
|
let view = request
|
|
.canvas
|
|
.map(|context| context.diagram)
|
|
.map(|d| projection::project_view_with_index(p, d, app.document.view_index()));
|
|
let target = request
|
|
.selection_target
|
|
.as_deref()
|
|
.cloned()
|
|
.or_else(|| request.placement.map(Target::Placement))
|
|
.or_else(|| {
|
|
view.as_ref()?
|
|
.ports
|
|
.iter()
|
|
.find(|port| port.element == id)
|
|
.map(|port| Target::Port {
|
|
port: port.element,
|
|
placement: port.owner,
|
|
})
|
|
})
|
|
.or_else(|| {
|
|
view.as_ref()?
|
|
.edges
|
|
.iter()
|
|
.any(|edge| edge.id == id)
|
|
.then_some(Target::Edge(id))
|
|
});
|
|
if let Some(target) = target {
|
|
let selected = app
|
|
.diagram_selection
|
|
.as_ref()
|
|
.is_some_and(|s| s.contains(&target));
|
|
entries.push(item(
|
|
if selected {
|
|
"Remove from selection"
|
|
} else {
|
|
"Add to selection"
|
|
},
|
|
Message::DiagramSelection(SelectionEvent::Choose(
|
|
context,
|
|
target.clone(),
|
|
if selected {
|
|
Choice::Remove
|
|
} else {
|
|
Choice::Add
|
|
},
|
|
)),
|
|
true,
|
|
));
|
|
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
|
|
&& selected
|
|
{
|
|
entries.push(Entry::title(format!(
|
|
"{} selected",
|
|
snapshot.targets().len()
|
|
)));
|
|
entries.push(item(
|
|
"Inspect this element",
|
|
Message::DiagramSelection(SelectionEvent::Choose(
|
|
context,
|
|
target.clone(),
|
|
Choice::Inspect,
|
|
)),
|
|
true,
|
|
));
|
|
entries.push(item(
|
|
"Select only this element",
|
|
Message::DiagramSelection(SelectionEvent::Choose(
|
|
context,
|
|
target,
|
|
Choice::Replace,
|
|
)),
|
|
true,
|
|
));
|
|
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)",
|
|
));
|
|
return Some(Menu {
|
|
token: opened.token,
|
|
anchor: request.anchor,
|
|
entries,
|
|
on_close: Message::Menu(Event::Close),
|
|
});
|
|
}
|
|
entries.push(Entry::separator());
|
|
if let Some(context) = request.process.as_deref().copied() {
|
|
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
|
|
&& let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
|
{
|
|
if entries.len() > 1 {
|
|
entries.extend(menu.entries.into_iter().skip(1));
|
|
menu.entries = entries;
|
|
}
|
|
return Some(menu);
|
|
}
|
|
if !clean {
|
|
entries.push(Entry::title("Apply or revert pending edits first"));
|
|
}
|
|
|
|
if request.canvas.is_some() && app.active_diagram == Some(id) {
|
|
entries.extend([
|
|
item("View properties", Message::EditProperties(id), true),
|
|
item("Duplicate view", Message::DuplicateDiagram(id), true),
|
|
item("Fit diagram", Message::Fit, true),
|
|
item(
|
|
"Arrange elements",
|
|
Message::Command(Command::Layout(id)),
|
|
true,
|
|
),
|
|
item("Export SVG…", Message::Export, true),
|
|
Entry::separator(),
|
|
]);
|
|
for kind in crate::app::architecture_authoring::kinds(app) {
|
|
entries.push(item(
|
|
&format!("Create {} here…", kind.label()),
|
|
Message::Architecture(crate::app::architecture_authoring::Event::Start(
|
|
kind,
|
|
request.creation_point,
|
|
)),
|
|
true,
|
|
));
|
|
}
|
|
entries.push(item(
|
|
"Add existing here…",
|
|
Message::Architecture(crate::app::architecture_authoring::Event::Existing(
|
|
request.creation_point,
|
|
)),
|
|
true,
|
|
));
|
|
} else {
|
|
if request.route_point.is_some()
|
|
|| request.bend.is_some()
|
|
|| (request.canvas.is_some() && p.relations.contains_key(&id))
|
|
{
|
|
let diagram = app.active_diagram.and_then(|d| p.diagrams.get(&d));
|
|
let view = diagram.map(|d| projection::project_view(p, d.id));
|
|
let projected = view
|
|
.as_ref()
|
|
.and_then(|v| v.edges.iter().find(|e| e.id == id));
|
|
let editable = diagram.zip(projected).is_some_and(|(d, e)| {
|
|
projection::edge_endpoint_placements(p, d, id)
|
|
.is_none_or(|(s, t)| s == e.source && t == e.target)
|
|
});
|
|
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
|
|
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
|
|
for (end, choose, reset) in [
|
|
(
|
|
EdgeEnd::Source,
|
|
"Move source attachment",
|
|
"Reset source attachment",
|
|
),
|
|
(
|
|
EdgeEnd::Target,
|
|
"Move target attachment",
|
|
"Reset target attachment",
|
|
),
|
|
] {
|
|
let eligible = editable
|
|
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
|
|
entries.push(item(
|
|
choose,
|
|
Message::Route(route_edit::Event::ChooseAttachment(end)),
|
|
eligible,
|
|
));
|
|
entries.push(item(
|
|
reset,
|
|
Message::Route(route_edit::Event::ResetAttachment(end)),
|
|
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
|
|
));
|
|
}
|
|
}
|
|
if let Some(point) = request.route_point {
|
|
entries.push(
|
|
item(
|
|
"Add bend here",
|
|
Message::Route(route_edit::Event::AddBendAt(point)),
|
|
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
|
)
|
|
.shortcut("Insert"),
|
|
);
|
|
} else {
|
|
entries.push(
|
|
item(
|
|
"Add bend",
|
|
Message::Route(route_edit::Event::AddBend),
|
|
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
|
)
|
|
.shortcut("Insert"),
|
|
);
|
|
}
|
|
if request.bend.is_some() {
|
|
entries.push(
|
|
item(
|
|
"Remove bend",
|
|
Message::Route(route_edit::Event::RemoveBend),
|
|
editable,
|
|
)
|
|
.shortcut("Delete"),
|
|
);
|
|
}
|
|
entries.push(item(
|
|
"Reset routing",
|
|
Message::Route(route_edit::Event::Reset),
|
|
editable && route.is_some(),
|
|
));
|
|
if !editable {
|
|
entries.push(Entry::title("Expand endpoint containers to edit routing"));
|
|
}
|
|
entries.push(Entry::separator());
|
|
}
|
|
entries.push(item("Properties", Message::EditProperties(id), true));
|
|
entries.push(item("Open source", Message::SourceForSelection, true));
|
|
entries.push(item(
|
|
"Show in diagrams…",
|
|
app.diagram_usage_message(id),
|
|
true,
|
|
));
|
|
entries.push(item(
|
|
"Connections & references",
|
|
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
|
true,
|
|
));
|
|
if let Some(e) = p.elements.get(&id) {
|
|
if e.kind() == Kind::Port
|
|
&& app.page == Page::Diagram
|
|
&& let Some(context) = app.route_context()
|
|
&& let Some(port) = projection::project_view(p, context.diagram)
|
|
.ports
|
|
.iter()
|
|
.find(|port| port.element == id)
|
|
{
|
|
let manual = p.diagrams[&context.diagram].nodes[&port.owner]
|
|
.port_anchors
|
|
.iter()
|
|
.any(|anchor| anchor.port == id);
|
|
entries.push(item(
|
|
"Reset automatic position",
|
|
Message::PortAnchor(crate::port_edit::Event::Reset(context, port.owner, id)),
|
|
manual,
|
|
));
|
|
}
|
|
if app.page == Page::Diagram {
|
|
for tool in crate::connection_tool::Tool::for_layer(e.layer()) {
|
|
if tool.can_start(p, id) {
|
|
entries.push(item(
|
|
&format!("Draw {}", tool.label().to_lowercase()),
|
|
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
|
app.active_diagram.is_some_and(|diagram| {
|
|
crate::connection_edit::visible(&projection::project_view(
|
|
p, diagram,
|
|
))
|
|
.any(|element| element == id)
|
|
}),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if let Some(owner) = e.parent {
|
|
entries.push(item("Open owner", Message::Select(owner), true));
|
|
}
|
|
entries.push(Entry::separator());
|
|
for action in port_workflow::actions(p, id) {
|
|
if matches!(
|
|
action,
|
|
port_workflow::Action::Flow | port_workflow::Action::Standard
|
|
) {
|
|
continue;
|
|
}
|
|
let caption = match action {
|
|
port_workflow::Action::Component => "Add port…",
|
|
port_workflow::Action::Input => "Add input port…",
|
|
port_workflow::Action::Output => "Add output port…",
|
|
_ => action.label(),
|
|
};
|
|
entries.push(item(caption, Message::PortAction(action), true));
|
|
}
|
|
if let Some(PortContract::Component { kind, orientation }) = port_contract(e) {
|
|
entries.push(item(
|
|
"Configure boundary exchange…",
|
|
Message::AddRelationKind(
|
|
RelationKind::BoundaryExchange,
|
|
*kind == ComponentPortKind::Flow
|
|
&& *orientation == ComponentPortOrientation::In,
|
|
),
|
|
true,
|
|
));
|
|
}
|
|
let work = if e.layer() == Layer::Oa {
|
|
Kind::Activity
|
|
} else {
|
|
Kind::Function
|
|
};
|
|
if e.kind().is_performer() && request.canvas.is_some() {
|
|
if crate::app::architecture_authoring::kinds(app).contains(&work) {
|
|
entries.push(item(
|
|
if e.layer() == Layer::Oa {
|
|
"Add activity…"
|
|
} else {
|
|
"Add function…"
|
|
},
|
|
Message::Architecture(crate::app::architecture_authoring::Event::Allocate(
|
|
work,
|
|
id,
|
|
request.creation_point,
|
|
)),
|
|
true,
|
|
));
|
|
}
|
|
} else if e.kind().is_performer() || e.kind().is_work() {
|
|
entries.push(item(
|
|
if e.kind().is_performer() {
|
|
"Create allocated work"
|
|
} else {
|
|
"Create child work"
|
|
},
|
|
Message::CreateContextElement(work, true),
|
|
true,
|
|
));
|
|
} else if validation::can_parent(p, app.create_kind, e.layer(), Some(id))
|
|
&& app.create_kind != Kind::Port
|
|
{
|
|
entries.push(item(
|
|
&format!("Create child {}", app.create_kind.label()),
|
|
Message::CreateContextElement(app.create_kind, true),
|
|
true,
|
|
));
|
|
}
|
|
for (caption, kind, incoming) in relation_actions(e.kind()) {
|
|
entries.push(item(
|
|
caption,
|
|
Message::AddRelationKind(*kind, *incoming),
|
|
true,
|
|
));
|
|
}
|
|
if let Some(node) = request.placement.and_then(|node| {
|
|
app.active_diagram
|
|
.and_then(|d| p.diagrams.get(&d))?
|
|
.nodes
|
|
.get(&node)
|
|
}) {
|
|
entries.push(Entry::separator());
|
|
entries.push(item(
|
|
if node.collapsed {
|
|
"Unfold contents"
|
|
} else {
|
|
"Fold contents"
|
|
},
|
|
Message::ToggleCollapse,
|
|
true,
|
|
));
|
|
entries.push(item("Placement properties", Message::Select(node.id), true));
|
|
entries.push(
|
|
item("Hide from this diagram", Message::HideSelected, true).shortcut("Delete"),
|
|
);
|
|
} else if e.kind() != Kind::Port
|
|
&& let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d))
|
|
{
|
|
entries.push(item(
|
|
"Show in this diagram",
|
|
Message::PlaceSelected,
|
|
(d.layer == e.layer() || d.projection == ProjectionKind::Traceability)
|
|
&& d.projection.includes(e.kind()),
|
|
));
|
|
}
|
|
}
|
|
if let Some(r) = p.relations.get(&id) {
|
|
for action in port_workflow::actions(p, id) {
|
|
entries.push(item(action.label(), Message::PortAction(action), true));
|
|
}
|
|
entries.push(item(
|
|
"Open source endpoint",
|
|
Message::Select(r.source()),
|
|
true,
|
|
));
|
|
entries.push(item(
|
|
"Open target endpoint",
|
|
Message::Select(r.target()),
|
|
true,
|
|
));
|
|
}
|
|
entries.push(Entry::separator());
|
|
if p.layer_of(id).is_some() {
|
|
entries.push(item("Create relationship…", Message::AddRelation, true));
|
|
entries.push(item(
|
|
if p.layer_of(id) == Some(Layer::Oa) {
|
|
"Create System counterpart…"
|
|
} else {
|
|
"Create Operational counterpart…"
|
|
},
|
|
Message::StartTransition,
|
|
true,
|
|
));
|
|
entries.push(item(
|
|
"Review counterpart changes…",
|
|
Message::StartSemanticReview(id),
|
|
p.traces.values().any(|t| t.oa == id || t.sa == id),
|
|
));
|
|
}
|
|
entries.push(item(
|
|
"Attach artifact reference…",
|
|
Message::AddArtifact,
|
|
true,
|
|
));
|
|
if id != p.id {
|
|
entries.push(
|
|
item("Delete from model…", Message::Delete, true).shortcut(
|
|
if request.canvas.is_some_and(|context| context.diagram != id)
|
|
&& (request.placement.is_some()
|
|
|| p.relations.contains_key(&id)
|
|
|| p.elements
|
|
.get(&id)
|
|
.is_some_and(|element| element.kind() == Kind::Port))
|
|
{
|
|
if request.placement.is_some() {
|
|
"Shift+Delete"
|
|
} else {
|
|
"Delete / Shift+Delete"
|
|
}
|
|
} else if app.selected == id
|
|
&& (app.topology_context().is_some_and(|context| {
|
|
app.behavior.topology.selecting()
|
|
&& app.behavior.topology.removal_target(p, context.owner, id)
|
|
}) || app.scenario_context().is_some_and(|context| {
|
|
app.behavior.scenario.canvas_tool(context.scenario)
|
|
== crate::behavior::scenario::CanvasTool::Select
|
|
&& crate::behavior::scenario::removal_target(
|
|
p,
|
|
context.scenario,
|
|
id,
|
|
)
|
|
}))
|
|
{
|
|
"Delete / Shift+Delete"
|
|
} else {
|
|
""
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
Some(Menu {
|
|
token: opened.token,
|
|
anchor: request.anchor,
|
|
entries,
|
|
on_close: Message::Menu(Event::Close),
|
|
})
|
|
}
|
|
|
|
fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] {
|
|
match kind {
|
|
Kind::Entity | Kind::Actor => &[(
|
|
"Configure communication means…",
|
|
RelationKind::CommunicationMean,
|
|
false,
|
|
)],
|
|
Kind::OperationalMission | Kind::Mission => &[
|
|
(
|
|
"Exploit capability…",
|
|
RelationKind::MissionCapability,
|
|
false,
|
|
),
|
|
(
|
|
"Involve participant…",
|
|
RelationKind::MissionInvolvement,
|
|
true,
|
|
),
|
|
],
|
|
Kind::Capability | Kind::SystemCapability => &[
|
|
(
|
|
"Include capability…",
|
|
RelationKind::CapabilityInclude,
|
|
false,
|
|
),
|
|
("Extend capability…", RelationKind::CapabilityExtend, false),
|
|
(
|
|
"Specialize capability…",
|
|
RelationKind::CapabilityGeneralization,
|
|
false,
|
|
),
|
|
(
|
|
"Involve participant or work…",
|
|
RelationKind::CapabilityInvolvement,
|
|
true,
|
|
),
|
|
],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
include!("model_menu_traceability_tests.rs");
|
|
fn send(app: &mut App, message: Message) {
|
|
let _ = app.update(message);
|
|
}
|
|
fn choose(app: &App, caption: &str) -> Message {
|
|
popup(app)
|
|
.unwrap()
|
|
.entries
|
|
.into_iter()
|
|
.find_map(|entry| match entry {
|
|
Entry::Action { label, message, .. } if label == caption => message,
|
|
_ => None,
|
|
})
|
|
.unwrap_or_else(|| panic!("Expected enabled menu action {caption}"))
|
|
}
|
|
#[test]
|
|
fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() {
|
|
use crate::behavior::state_edit;
|
|
let mut project = Project::empty();
|
|
let machine = project
|
|
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
|
.unwrap();
|
|
let region = project
|
|
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
|
.unwrap();
|
|
let state = project
|
|
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
|
|
.unwrap();
|
|
let architecture = project.ensure_diagram(Layer::Oa);
|
|
project.place(architecture, state, Position { x: 80., y: 90. });
|
|
let first = state_edit::create_view(&project, machine).unwrap();
|
|
let first_id = first.diagram;
|
|
project = execute_batch(&project, &[first.command]).unwrap();
|
|
let second = state_edit::create_view(&project, machine).unwrap();
|
|
let second_id = second.diagram;
|
|
project = execute_batch(&project, &[second.command]).unwrap();
|
|
let placement = project.diagrams[&first_id]
|
|
.nodes
|
|
.values()
|
|
.find(|node| node.element == state)
|
|
.unwrap()
|
|
.id;
|
|
let mut app = App::with_project(project.clone());
|
|
send(&mut app, Message::Select(machine));
|
|
send(&mut app, Message::Page(Page::Behavior));
|
|
app.behavior.diagram.diagram = Some(first_id);
|
|
app.active_diagram = Some(architecture);
|
|
let open_state = |app: &mut App| {
|
|
let mut request = Request::object(state, Point::ORIGIN);
|
|
request.canvas = app.state_context();
|
|
request.placement = Some(placement);
|
|
send(app, Message::Menu(Event::Open(request)));
|
|
};
|
|
|
|
// A canvas menu from Architecture is stale on the state page even when
|
|
// the architecture diagram itself remains the active architecture view.
|
|
let mut wrong = Request::object(state, Point::ORIGIN);
|
|
wrong.canvas = app.route_context();
|
|
assert!(Opened::new(&app, wrong, 1).is_none());
|
|
open_state(&mut app);
|
|
let fold = choose(&app, "Fold contents");
|
|
send(&mut app, fold);
|
|
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
|
|
assert_eq!(app.selected, state, "Folding retains semantic selection");
|
|
assert_eq!(app.page, Page::Behavior);
|
|
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
|
assert_eq!(app.active_diagram, Some(architecture));
|
|
assert_eq!(
|
|
app.project().diagrams[&architecture],
|
|
project.diagrams[&architecture]
|
|
);
|
|
assert_eq!(
|
|
app.project().diagrams[&second_id],
|
|
project.diagrams[&second_id]
|
|
);
|
|
assert_eq!(app.project().elements, project.elements);
|
|
open_state(&mut app);
|
|
let unfold = choose(&app, "Unfold contents");
|
|
send(&mut app, unfold);
|
|
assert_eq!(app.selected, state, "Unfolding retains semantic selection");
|
|
assert_eq!(app.project(), &project);
|
|
assert_eq!(app.active_diagram, Some(architecture));
|
|
send(&mut app, Message::Undo);
|
|
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project(), &project);
|
|
|
|
open_state(&mut app);
|
|
let stale = choose(&app, "Fold contents");
|
|
app.behavior.diagram.diagram = Some(second_id);
|
|
send(&mut app, stale);
|
|
assert_eq!(app.project(), &project);
|
|
app.behavior.diagram.diagram = Some(first_id);
|
|
open_state(&mut app);
|
|
let hide = choose(&app, "Hide from this diagram");
|
|
send(&mut app, hide);
|
|
assert!(
|
|
!app.project().diagrams[&first_id]
|
|
.nodes
|
|
.contains_key(&placement)
|
|
);
|
|
assert_eq!(
|
|
app.project().diagrams[&architecture],
|
|
project.diagrams[&architecture]
|
|
);
|
|
assert_eq!(
|
|
app.project().diagrams[&second_id],
|
|
project.diagrams[&second_id]
|
|
);
|
|
assert_eq!(app.project().elements, project.elements);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project(), &project);
|
|
|
|
// Placement properties deliberately select a placement record, but an
|
|
// accepted numeric edit must keep the Behavior view's own context.
|
|
let source_before = app.document.bundle().clone();
|
|
open_state(&mut app);
|
|
let properties = choose(&app, "Placement properties");
|
|
send(&mut app, properties);
|
|
assert_eq!(app.selected, placement);
|
|
assert_eq!(
|
|
app.draft.as_ref().unwrap().kind,
|
|
crate::forms::RecordKind::Node(first_id)
|
|
);
|
|
assert_eq!(app.page, Page::Behavior);
|
|
assert_eq!(app.active_diagram, Some(architecture));
|
|
let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25;
|
|
send(
|
|
&mut app,
|
|
Message::Field("/position/x".into(), x.to_string()),
|
|
);
|
|
assert!(app.draft_dirty());
|
|
send(&mut app, Message::Apply);
|
|
assert!(!app.draft_dirty());
|
|
assert_eq!(app.page, Page::Behavior);
|
|
assert_eq!(app.behavior.active_owner, Some(machine));
|
|
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
|
assert_eq!(
|
|
app.active_diagram,
|
|
Some(architecture),
|
|
"Placement Apply must not replace the Architecture view"
|
|
);
|
|
let mut expected = project.clone();
|
|
expected
|
|
.diagrams
|
|
.get_mut(&first_id)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&placement)
|
|
.unwrap()
|
|
.position
|
|
.x = x;
|
|
assert_eq!(app.project(), &expected);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.project(), &project);
|
|
assert_eq!(app.document.bundle(), &source_before);
|
|
assert_eq!(app.page, Page::Behavior);
|
|
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
|
assert_eq!(app.active_diagram, Some(architecture));
|
|
}
|
|
|
|
#[test]
|
|
fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() {
|
|
let mut project = Project::empty();
|
|
let machine = project
|
|
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
|
.unwrap();
|
|
let region = project
|
|
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
|
.unwrap();
|
|
let entry = project
|
|
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region))
|
|
.unwrap();
|
|
let mut app = App::with_project(project);
|
|
send(&mut app, Message::Select(entry));
|
|
send(&mut app, Message::Page(Page::Behavior));
|
|
send(&mut app, open(entry, Point::ORIGIN));
|
|
let start = choose(&app, "New transition with details…");
|
|
send(&mut app, start);
|
|
assert_eq!(app.behavior.pending_owner(), Some(machine));
|
|
send(
|
|
&mut app,
|
|
Message::Behavior(crate::behavior::Event::States(
|
|
crate::behavior::states::Event::Cancel,
|
|
)),
|
|
);
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), "Unapplied entry".into()),
|
|
);
|
|
let before = app.draft.as_ref().unwrap().materialize().unwrap();
|
|
send(&mut app, open(entry, Point::ORIGIN));
|
|
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
|
entry,
|
|
Entry::Action {
|
|
message: Some(_),
|
|
..
|
|
}
|
|
)));
|
|
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn right_click_targets_actor_without_changing_selection_until_action() {
|
|
let mut app = App::with_project(Project::demo());
|
|
let actor = app
|
|
.project()
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::SystemActor)
|
|
.unwrap()
|
|
.id;
|
|
let selected = app.selected;
|
|
let model = app.project().clone();
|
|
send(&mut app, open(actor, Point::new(240., 180.)));
|
|
assert_eq!(app.selected, selected);
|
|
assert_eq!(app.project(), &model);
|
|
let action = choose(&app, "Add port…");
|
|
send(&mut app, action);
|
|
assert_eq!(app.selected, actor);
|
|
assert!(matches!(
|
|
&app.workflow,
|
|
Some(crate::workflow::Workflow::Ports(_))
|
|
));
|
|
assert!(app.model_menu.is_none());
|
|
assert_eq!(
|
|
app.project(),
|
|
&model,
|
|
"Opening the existing creation workflow does not create a port"
|
|
);
|
|
send(&mut app, Message::Workflow(crate::workflow::Event::Apply));
|
|
assert_eq!(app.project().elements.len(), model.elements.len() + 1);
|
|
assert_eq!(app.project().elements[&app.selected].parent, Some(actor));
|
|
assert_eq!(app.project().elements[&app.selected].kind(), Kind::Port);
|
|
}
|
|
#[test]
|
|
fn menu_preserves_pending_properties_and_rejects_delayed_mutations() {
|
|
let mut app = App::with_project(Project::demo());
|
|
let target = app.project().elements.keys().next().copied().unwrap();
|
|
let selected = app.selected;
|
|
let model = app.project().clone();
|
|
send(
|
|
&mut app,
|
|
Message::Field("/name".into(), "Unapplied project title".into()),
|
|
);
|
|
assert!(app.draft_dirty());
|
|
let draft = app.draft.as_ref().unwrap().materialize().unwrap();
|
|
send(&mut app, open(target, Point::ORIGIN));
|
|
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
|
entry,
|
|
Entry::Action {
|
|
message: Some(_),
|
|
..
|
|
}
|
|
)));
|
|
let token = app.model_menu.as_ref().unwrap().token;
|
|
send(
|
|
&mut app,
|
|
Message::Menu(Event::Invoke(token, Box::new(Message::Delete))),
|
|
);
|
|
assert_eq!(app.selected, selected);
|
|
assert_eq!(app.project(), &model);
|
|
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), draft);
|
|
assert!(!app.delete_pending);
|
|
}
|
|
#[test]
|
|
fn late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context() {
|
|
let mut app = App::with_project(Project::demo());
|
|
let id = app.selected;
|
|
send(&mut app, open(id, Point::ORIGIN));
|
|
let old = choose(&app, "Open source");
|
|
send(&mut app, open(id, Point::new(200., 80.)));
|
|
let current = app.model_menu.as_ref().unwrap().token;
|
|
send(&mut app, old);
|
|
assert_eq!(app.model_menu.as_ref().unwrap().token, current);
|
|
let stale = choose(&app, "Open source");
|
|
app.canvas_epoch += 1;
|
|
send(&mut app, stale);
|
|
assert_eq!(app.page, Page::Diagram);
|
|
assert!(app.model_menu.is_none());
|
|
}
|
|
#[test]
|
|
fn pending_source_allows_file_navigation_but_disables_model_actions() {
|
|
let mut app = App::with_project(Project::demo());
|
|
send(&mut app, Message::Page(Page::Text));
|
|
send(
|
|
&mut app,
|
|
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
|
|
iced::widget::text_editor::Edit::Insert('x'),
|
|
)),
|
|
);
|
|
assert!(app.source_dirty());
|
|
let original = app.source_editor.text();
|
|
let file = app.source_workspace.active.clone();
|
|
let mut request = Request::object(app.project().id, Point::ORIGIN);
|
|
request.source_file = Some(file.clone());
|
|
send(&mut app, Message::Menu(Event::Open(request)));
|
|
let action = choose(&app, "Open file");
|
|
send(&mut app, action);
|
|
assert_eq!(app.source_editor.text(), original);
|
|
assert_eq!(app.source_workspace.active, file);
|
|
let id = app.project().id;
|
|
send(&mut app, open(id, Point::ORIGIN));
|
|
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
|
entry,
|
|
Entry::Action {
|
|
message: Some(_),
|
|
..
|
|
}
|
|
)));
|
|
}
|
|
#[test]
|
|
fn add_bend_here_uses_context_point_and_one_source_aware_history_change() {
|
|
let mut p = Project::empty();
|
|
let a = p.add(Kind::Activity, "Send", 0., 0.);
|
|
let b = p.add(Kind::Activity, "Receive", 650., 0.);
|
|
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
let mut app = App::with_project(p);
|
|
let original = app.document.bundle().clone();
|
|
let point = Position { x: 420., y: -80. };
|
|
let mut request = Request::object(edge, Point::new(540., 360.));
|
|
request.canvas = app.route_context();
|
|
request.route_point = Some(point);
|
|
send(&mut app, Message::Menu(Event::Open(request)));
|
|
let action = choose(&app, "Add bend here");
|
|
send(&mut app, action);
|
|
assert_eq!(app.selected, edge);
|
|
assert_eq!(app.selected_bend, Some((edge, 0)));
|
|
assert_eq!(
|
|
app.project().diagrams[&diagram].routes[0].waypoints,
|
|
vec![point]
|
|
);
|
|
let changed = app.document.bundle().clone();
|
|
assert_ne!(original, changed);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(app.document.bundle(), &original);
|
|
send(&mut app, Message::Redo);
|
|
assert_eq!(app.document.bundle(), &changed);
|
|
}
|
|
|
|
#[test]
|
|
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
|
|
let mut project = Project::empty();
|
|
let source = project.add(Kind::Activity, "Send", 0., 0.);
|
|
let target = project.add(Kind::Activity, "Receive", 650., 0.);
|
|
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
|
|
let mut app = App::with_project(project);
|
|
let before = app.document.bundle().clone();
|
|
let mut request = Request::object(edge, Point::ORIGIN);
|
|
request.canvas = app.route_context();
|
|
send(&mut app, Message::Menu(Event::Open(request.clone())));
|
|
let action = choose(&app, "Move target attachment");
|
|
send(&mut app, action);
|
|
assert_eq!(
|
|
app.selected_attachment,
|
|
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
|
|
);
|
|
assert_eq!(app.document.bundle(), &before);
|
|
request.canvas = app.route_context();
|
|
send(&mut app, Message::Menu(Event::Open(request)));
|
|
let stale = choose(&app, "Move source attachment");
|
|
send(&mut app, Message::Escape);
|
|
send(&mut app, stale);
|
|
assert!(
|
|
app.selected_attachment
|
|
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
|
|
);
|
|
assert_eq!(app.document.bundle(), &before);
|
|
}
|
|
|
|
#[test]
|
|
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
|
|
let mut p = Project::empty();
|
|
let parent = p.add(Kind::Activity, "Respond", 0., 0.);
|
|
let child = p
|
|
.add_element(Kind::Activity, Layer::Oa, "Assess", Some(parent))
|
|
.unwrap();
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
let parent_node = p.diagrams[&diagram]
|
|
.nodes
|
|
.values()
|
|
.find(|n| n.element == parent)
|
|
.unwrap()
|
|
.id;
|
|
p.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&parent_node)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
let mut app = App::with_project(p);
|
|
let original = app.document.bundle().clone();
|
|
send(&mut app, Message::Explore(Some(child)));
|
|
send(&mut app, open(child, Point::new(90., 300.)));
|
|
let action = choose(&app, "Show in this diagram");
|
|
send(&mut app, action);
|
|
assert_eq!(app.page, Page::Diagram);
|
|
assert_eq!(app.selected, child);
|
|
assert!(
|
|
app.selected_node
|
|
.is_some_and(|node| app.project().diagrams[&diagram].nodes[&node].element == child)
|
|
);
|
|
assert!(!app.project().diagrams[&diagram].nodes[&parent_node].collapsed);
|
|
assert!(
|
|
projection::project_view(app.project(), diagram)
|
|
.nodes
|
|
.iter()
|
|
.any(|n| n.element == child)
|
|
);
|
|
send(&mut app, Message::Undo);
|
|
assert_eq!(
|
|
app.document.bundle(),
|
|
&original,
|
|
"One Undo restores both placement and folded ancestors"
|
|
);
|
|
}
|
|
}
|