Add direct diagram connections, movable ports, and contextual authoring

This commit is contained in:
2026-09-06 02:13:49 +03:00
parent 35133de650
commit fef2e0dbce
39 changed files with 7076 additions and 499 deletions
+642
View File
@@ -0,0 +1,642 @@
//! 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 bend: Option<usize>,
pub route_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub source_file: Option<String>,
}
impl Request {
pub fn object(object: Id, anchor: Point) -> Self {
Self {
anchor,
object,
placement: None,
bend: None,
route_point: None,
canvas: 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)
|| request
.canvas
.is_some_and(|c| Some(c) != app.route_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: app.active_diagram,
})
}
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 == app.active_diagram
&& app.project().contains(self.request.object)
&& self
.request
.source_file
.as_ref()
.is_none_or(|path| app.source_workspace.files.contains_key(path))
}
}
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();
}
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()));
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 !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("Fit diagram", Message::Fit, true),
item(
"Arrange elements",
Message::Command(Command::Layout(id)),
true,
),
item("Export SVG…", Message::Export, true),
Entry::separator(),
item(
&format!("Create {} at root", app.create_kind.label()),
Message::Add(false),
validation::can_parent(p, app.create_kind, app.layer, None),
),
item("Browse existing elements…", Message::Explore(None), 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(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(
"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() || 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));
} 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.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(
"Create / update counterpart…",
Message::StartTransition,
true,
));
}
entries.push(item(
"Attach artifact reference…",
Message::AddArtifact,
true,
));
if id != p.id {
entries.push(item("Delete from model…", Message::Delete, true));
}
}
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::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::*;
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 right_click_targets_actor_without_changing_selection_until_action() {
let mut app = App::new();
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::new();
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::new();
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::new();
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 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"
);
}
}