Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
@@ -82,6 +82,13 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
PortAnchor(crate::port_edit::Event),
|
||||
Connection(crate::connection_edit::Event),
|
||||
Menu(crate::model_menu::Event),
|
||||
CreateContextElement(Kind, bool),
|
||||
SourceTree(crate::source_tree::Event),
|
||||
SourceTreeCollapse,
|
||||
SourceTreeLocate,
|
||||
ExactStrings(bool),
|
||||
Page(Page),
|
||||
Guide(crate::guide::Event),
|
||||
@@ -176,6 +183,10 @@ pub enum Message {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
menu_sequence: u64,
|
||||
pub canvas_document: Id,
|
||||
pub canvas_epoch: u64,
|
||||
pub selected_bend: Option<(Id, usize)>,
|
||||
@@ -260,6 +271,10 @@ impl App {
|
||||
let explorer_index = crate::explorer::Index::new(&project, layer);
|
||||
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
|
||||
Self {
|
||||
connection: None,
|
||||
model_menu: None,
|
||||
source_tree: Default::default(),
|
||||
menu_sequence: 0,
|
||||
canvas_document: Id::new_v4(),
|
||||
canvas_epoch: 0,
|
||||
selected_bend: None,
|
||||
@@ -346,6 +361,7 @@ impl App {
|
||||
self.lint_requested = None;
|
||||
}
|
||||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
self.source_select_line = false;
|
||||
let source = self.source_workspace.text().to_owned();
|
||||
self.source_navigation.refresh(&source);
|
||||
@@ -465,7 +481,10 @@ impl App {
|
||||
.and_then(|d| Ok((d.command()?, d.resulting_id()?)));
|
||||
match result.and_then(|(command, id)| {
|
||||
self.document
|
||||
.execute(command)
|
||||
.execute(crate::port_edit::repair_owner_change(
|
||||
self.document.project(),
|
||||
command,
|
||||
))
|
||||
.map(|_| id)
|
||||
.map_err(|e| e.to_string())
|
||||
}) {
|
||||
@@ -505,6 +524,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
fn execute(&mut self, command: Command) -> bool {
|
||||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(id) = selected {
|
||||
@@ -577,13 +597,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
crate::views::view(self)
|
||||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
||||
.into()
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
iced::event::listen_with(|event, _, _| {
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers: mods,
|
||||
@@ -593,6 +614,9 @@ impl App {
|
||||
return None;
|
||||
};
|
||||
if key == keyboard::Key::Named(keyboard::key::Named::Escape) {
|
||||
if status == iced::event::Status::Captured {
|
||||
return None;
|
||||
}
|
||||
return Some(Message::Escape);
|
||||
}
|
||||
if key == keyboard::Key::Named(keyboard::key::Named::F1) {
|
||||
@@ -624,6 +648,32 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Global subscriptions also receive keys consumed by an overlay.
|
||||
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
||||
self.model_menu = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::none();
|
||||
}
|
||||
if self.model_menu.is_some()
|
||||
&& !matches!(
|
||||
message,
|
||||
Message::Menu(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::SourceViewport
|
||||
| Message::Request(Pending::Close(_))
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
message,
|
||||
Message::WindowResized(_) | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.model_menu = None;
|
||||
}
|
||||
if self.source_dirty()
|
||||
&& matches!(
|
||||
message,
|
||||
@@ -723,6 +773,14 @@ impl App {
|
||||
let entering_catalog =
|
||||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||||
let mut task = self.handle(message);
|
||||
if self.connection.as_ref().is_some_and(|session| {
|
||||
Some(session.context) != self.route_context()
|
||||
|| self.page != Page::Diagram
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
}) {
|
||||
self.connection = None;
|
||||
}
|
||||
if previous_layer != self.layer {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.explorer_focus = None;
|
||||
@@ -758,7 +816,135 @@ impl App {
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::PortAnchor(event) => {
|
||||
use crate::port_edit::Event;
|
||||
let (captured, node, port, command) = match event {
|
||||
Event::Commit(c, node, anchor) => (
|
||||
c,
|
||||
node,
|
||||
anchor.port,
|
||||
Command::SetPortAnchor {
|
||||
diagram: c.diagram,
|
||||
node,
|
||||
anchor,
|
||||
},
|
||||
),
|
||||
Event::Reset(c, node, port) => (
|
||||
c,
|
||||
node,
|
||||
port,
|
||||
Command::RemovePortAnchor {
|
||||
diagram: c.diagram,
|
||||
node,
|
||||
port,
|
||||
},
|
||||
),
|
||||
};
|
||||
if self.route_context() != Some(captured)
|
||||
|| self.page != Page::Diagram
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| self.connection.is_some()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let view = project_view(self.project(), captured.diagram);
|
||||
if !view
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == port && p.owner == node)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.execute(command) {
|
||||
self.selected_node = None;
|
||||
self.status = "Port position updated in this diagram · Undo restores its previous position".into();
|
||||
}
|
||||
}
|
||||
Message::Connection(event) => self.edit_connection(event),
|
||||
Message::SourceTree(event) => match event {
|
||||
crate::source_tree::Event::ToggleFolder(path) => self.source_tree.toggle(&path),
|
||||
crate::source_tree::Event::OpenFile(path) => {
|
||||
return self.update(Message::SourceFile(path));
|
||||
}
|
||||
crate::source_tree::Event::FileMenu(path, anchor) => {
|
||||
let mut request = crate::model_menu::Request::object(self.project().id, anchor);
|
||||
request.source_file = Some(path);
|
||||
return self.update(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
}
|
||||
},
|
||||
Message::SourceTreeCollapse => self.source_tree.collapse_all(),
|
||||
Message::SourceTreeLocate => {
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
return iced::widget::operation::scroll_to(
|
||||
"source-files",
|
||||
crate::source_tree::active_offset(
|
||||
&self.source_workspace,
|
||||
&self.source_diagnostics,
|
||||
&self.source_tree,
|
||||
),
|
||||
);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Open(request)) => {
|
||||
// Validate the originating canvas before advancing its input
|
||||
// epoch. Actions are then generated with the refreshed context,
|
||||
// while pointer history hidden behind the menu becomes stale.
|
||||
let Some(opened) = crate::model_menu::Opened::new(
|
||||
self,
|
||||
request,
|
||||
self.menu_sequence.wrapping_add(1),
|
||||
) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.connection = None;
|
||||
self.menu_sequence = opened.token;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let mut request = opened.request;
|
||||
if request.canvas.is_some() {
|
||||
request.canvas = self.route_context();
|
||||
}
|
||||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Close) => {
|
||||
if self.model_menu.take().is_some() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Invoke(token, action)) => {
|
||||
if self
|
||||
.model_menu
|
||||
.as_ref()
|
||||
.is_none_or(|menu| menu.token != token)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(menu) = self.model_menu.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
if menu.token != token || !menu.valid(self) {
|
||||
return Task::none();
|
||||
}
|
||||
if menu.request.source_file.is_some() {
|
||||
return self.update(*action);
|
||||
}
|
||||
if self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
self.selected_node = menu.request.placement;
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
}
|
||||
Message::CreateContextElement(kind, child) => {
|
||||
self.create_kind = kind;
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.connection.take().is_some() {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
self.source_navigation.open = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
@@ -1315,6 +1501,7 @@ impl App {
|
||||
if self.apply()
|
||||
&& let Some(diagram) = self.active_diagram
|
||||
{
|
||||
let selected = self.selected;
|
||||
if let Some(node) = self
|
||||
.project()
|
||||
.diagrams
|
||||
@@ -1324,7 +1511,7 @@ impl App {
|
||||
{
|
||||
self.selected_node = Some(node);
|
||||
self.status = "Element is already shown in this diagram".into();
|
||||
return Task::none();
|
||||
return self.handle(Message::RevealInDiagram(diagram, selected));
|
||||
}
|
||||
let mut p = self.project().clone();
|
||||
let node = p.place(diagram, self.selected, Position { x: 60., y: 60. });
|
||||
@@ -1333,9 +1520,24 @@ impl App {
|
||||
.get(&diagram)
|
||||
.and_then(|d| d.nodes.get(&node))
|
||||
.cloned()
|
||||
&& self.execute(Command::CreateNode { diagram, node: n })
|
||||
{
|
||||
self.selected_node = Some(node);
|
||||
let mut commands = vec![Command::CreateNode { diagram, node: n }];
|
||||
for owner in p.diagrams[&diagram].nodes.values().filter(|n| {
|
||||
n.collapsed
|
||||
&& projection::descendant_nodes(&p, diagram, n.id).contains(&node)
|
||||
}) {
|
||||
let mut expanded = owner.clone();
|
||||
expanded.collapsed = false;
|
||||
commands.push(Command::EditNode {
|
||||
diagram,
|
||||
original: owner.id,
|
||||
node: expanded,
|
||||
});
|
||||
}
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
self.selected_node = Some(node);
|
||||
return self.handle(Message::RevealInDiagram(diagram, selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2356,8 +2558,111 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
|
||||
use crate::connection_edit::{Event, Session};
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
if matches!(event, Event::Choose(None)) {
|
||||
self.connection = None;
|
||||
self.status =
|
||||
"Select · drag elements, ports and connection routes to arrange the diagram".into();
|
||||
return;
|
||||
}
|
||||
if self.page != Page::Diagram || self.source_dirty() || self.draft_dirty() {
|
||||
self.status = "Apply or revert pending edits before drawing a connection.".into();
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
Event::Begin(tool, source) => {
|
||||
self.edit_connection(Event::Choose(Some(tool)));
|
||||
if let Some(context) = self.route_context() {
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
}
|
||||
}
|
||||
Event::Choose(Some(tool)) => {
|
||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||
return;
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.route_context().unwrap();
|
||||
self.connection = Some(Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
}
|
||||
Event::Endpoint(captured, id) => {
|
||||
if captured != context {
|
||||
return;
|
||||
}
|
||||
let Some(mut session) = self.connection.take() else {
|
||||
return;
|
||||
};
|
||||
if session.context != context {
|
||||
return;
|
||||
}
|
||||
if !session.candidates.contains(&id) {
|
||||
self.status =
|
||||
"Choose a highlighted compatible endpoint · Esc cancels drawing".into();
|
||||
self.connection = Some(session);
|
||||
return;
|
||||
}
|
||||
if let Some(source) = session.source {
|
||||
match session.tool.command(self.project(), source, id) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected_node = None;
|
||||
self.inspector_tab = Default::default();
|
||||
self.status = format!(
|
||||
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
|
||||
session.tool.label()
|
||||
);
|
||||
} else {
|
||||
self.connection = Some(session);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
self.connection = Some(session);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
session.start(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
id,
|
||||
);
|
||||
self.select(id);
|
||||
self.status = if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
|
||||
} else {
|
||||
format!(
|
||||
"From {} · choose a highlighted target · Esc cancels",
|
||||
self.project().named(id)
|
||||
)
|
||||
};
|
||||
self.connection = Some(session);
|
||||
}
|
||||
}
|
||||
Event::Restart(captured) => {
|
||||
if captured == context
|
||||
&& let Some(session) = &self.connection
|
||||
{
|
||||
self.edit_connection(Event::Choose(Some(session.tool)));
|
||||
}
|
||||
}
|
||||
Event::Choose(None) => {}
|
||||
}
|
||||
}
|
||||
fn edit_route(&mut self, event: crate::route_edit::Event) {
|
||||
use crate::route_edit::{Event, insert_pin, stored_route};
|
||||
if self.connection.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
@@ -2402,7 +2707,7 @@ impl App {
|
||||
route,
|
||||
}
|
||||
}
|
||||
Event::AddBend => {
|
||||
Event::AddBend | Event::AddBendAt(_) => {
|
||||
if route.waypoints.len() >= 128 {
|
||||
return;
|
||||
}
|
||||
@@ -2421,7 +2726,12 @@ impl App {
|
||||
|s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
|
||||
len(a).total_cmp(&len(b))
|
||||
});
|
||||
let point = if let Some(segment) = segment {
|
||||
let point = if let Event::AddBendAt(point) = event {
|
||||
if !point.x.is_finite() || !point.y.is_finite() {
|
||||
return;
|
||||
}
|
||||
point
|
||||
} else if let Some(segment) = segment {
|
||||
Position {
|
||||
x: (segment[0].x + segment[1].x) / 2.,
|
||||
y: (segment[0].y + segment[1].y) / 2.,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! A palette operation is transient; only a completed connection enters history.
|
||||
use crate::{connection_tool::Tool, route_edit::Context};
|
||||
use oa_core::{Id, Project, projection::ProjectedView};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Choose(Option<Tool>),
|
||||
Begin(Tool, Id),
|
||||
Endpoint(Context, Id),
|
||||
Restart(Context),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub context: Context,
|
||||
pub tool: Tool,
|
||||
pub source: Option<Id>,
|
||||
pub candidates: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(project: &Project, view: &ProjectedView, context: Context, tool: Tool) -> Self {
|
||||
let candidates = visible(view)
|
||||
.filter(|id| tool.can_start(project, *id))
|
||||
.collect();
|
||||
Self {
|
||||
context,
|
||||
tool,
|
||||
source: None,
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, project: &Project, view: &ProjectedView, source: Id) {
|
||||
self.source = Some(source);
|
||||
let compatible = self.tool.targets(project, source);
|
||||
self.candidates = visible(view).filter(|id| compatible.contains(id)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
|
||||
view.nodes
|
||||
.iter()
|
||||
.map(|n| n.element)
|
||||
.chain(view.ports.iter().map(|p| p.element))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{App, Message, Page};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Send command", 0., 0.);
|
||||
let b = p.add(Kind::Function, "Receive command", 400., 0.);
|
||||
let hidden = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Hidden work", None)
|
||||
.unwrap();
|
||||
(App::with_project(p), a, b, hidden)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Connection(event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_endpoint_drawing_commits_ports_and_exchange_once_with_exact_history() {
|
||||
let (mut app, a, b, hidden) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
assert!(
|
||||
!app.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&hidden)
|
||||
);
|
||||
let context = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(context, a));
|
||||
send(&mut app, Event::Endpoint(context, hidden));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let relation = &app.project().relations[&app.selected];
|
||||
assert_eq!(relation.kind(), RelationKind::FunctionalExchange);
|
||||
assert_eq!((relation.source(), relation.target()), (a, b));
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(*app.document.bundle(), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_navigation_revision_and_dirty_drafts_cannot_commit_a_stale_connection() {
|
||||
let (mut app, a, b, _) = fixture();
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
let context = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(context, a));
|
||||
let _ = app.update(Message::Escape);
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.project().relations.is_empty());
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
let current = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(current, a));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert_eq!(
|
||||
app.connection.as_ref().unwrap().source,
|
||||
Some(a),
|
||||
"A stale click must leave the newer drawing session intact"
|
||||
);
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Route(crate::route_edit::Event::AddBend));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.connection.is_some());
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert!(app.connection.is_none());
|
||||
let _ = app.update(Message::Page(Page::Diagram));
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.project().relations.is_empty());
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Field("/name".into(), "Pending rename".into()));
|
||||
assert!(app.draft_dirty());
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.project().named(a), "Send command");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
//! Semantic endpoints for direct canvas connections. Gesture state and visible
|
||||
//! hit targets belong to the canvas; ordinary core commands own model changes.
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Interaction,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||
match layer {
|
||||
Layer::Oa => &[Self::Interaction],
|
||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Interaction",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Click a source activity, then a target activity.",
|
||||
Self::FunctionalExchange => {
|
||||
"Click an output port or source function, then an input port or target function. Function cards use automatic ports."
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
"Click a source component port, then a port on another participant. Flow goes from Out to In; Standard is unoriented."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||
match self {
|
||||
Self::Interaction => project
|
||||
.elements
|
||||
.get(&source)
|
||||
.is_some_and(|e| e.layer() == Layer::Oa && e.kind() == Kind::Activity),
|
||||
Self::FunctionalExchange => function_endpoint(project, source, true).is_some(),
|
||||
Self::BoundaryExchange => component_endpoint(project, source)
|
||||
.is_some_and(|contract| component_allows(contract, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap feasibility for highlighting candidates. The caller intersects this
|
||||
/// with the actual projected nodes and ports; hidden objects are not hits.
|
||||
pub fn can_connect(self, project: &Project, source: Id, target: Id) -> bool {
|
||||
if !self.can_start(project, source) {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::Interaction => {
|
||||
validation::can_connect(project, RelationKind::Interaction, source, target)
|
||||
}
|
||||
Self::FunctionalExchange => {
|
||||
let (Some((source, _)), Some((target, _))) = (
|
||||
function_endpoint(project, source, true),
|
||||
function_endpoint(project, target, false),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
validation::can_connect(project, RelationKind::FunctionalExchange, source, target)
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
component_endpoint(project, target).is_some()
|
||||
&& validation::can_connect_boundary(
|
||||
project,
|
||||
source,
|
||||
target,
|
||||
boundary_oriented(project, source),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn targets(self, project: &Project, source: Id) -> BTreeSet<Id> {
|
||||
if !self.can_start(project, source) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
project
|
||||
.elements
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|target| self.can_connect(project, source, *target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return one validated history operation without modifying the input.
|
||||
/// FE endpoint generation uses the same atomic planner as the form workflow.
|
||||
/// No carried items are inferred from a port's allowed item declarations.
|
||||
pub fn command(self, project: &Project, source: Id, target: Id) -> Result<Command, ModelError> {
|
||||
if !self.can_connect(project, source, target) {
|
||||
return Err(ModelError(format!(
|
||||
"Choose compatible endpoints for {}. {}",
|
||||
self.label(),
|
||||
self.hint()
|
||||
)));
|
||||
}
|
||||
let id = Id::new_v4();
|
||||
if self == Self::FunctionalExchange {
|
||||
let (source, source_port) = function_endpoint(project, source, true)
|
||||
.expect("Functional source was checked above");
|
||||
let (target, target_port) = function_endpoint(project, target, false)
|
||||
.expect("Functional target was checked above");
|
||||
let name = engineering_name(project, self, source, target);
|
||||
return plan_functional_exchange(
|
||||
project,
|
||||
&FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "exchange", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
source,
|
||||
target,
|
||||
source_port,
|
||||
target_port,
|
||||
items: Vec::new(),
|
||||
},
|
||||
)
|
||||
.map(Command::Batch);
|
||||
}
|
||||
let name = engineering_name(project, self, source, target);
|
||||
let body = match self {
|
||||
Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
|
||||
Self::BoundaryExchange => RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: boundary_oriented(project, source),
|
||||
items: Vec::new(),
|
||||
},
|
||||
Self::FunctionalExchange => unreachable!("Functional planner returned above"),
|
||||
};
|
||||
let command = Command::CreateRelation(Relation {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "relation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body,
|
||||
});
|
||||
prepare_command(project, &command)?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clicked port determines its own function owner. It cannot be paired with
|
||||
/// a different remembered owner or silently substituted by automatic selection.
|
||||
fn function_endpoint(project: &Project, id: Id, outgoing: bool) -> Option<(Id, PortChoice)> {
|
||||
let element = project.elements.get(&id)?;
|
||||
if element.layer() != Layer::Sa {
|
||||
return None;
|
||||
}
|
||||
if element.kind() == Kind::Function {
|
||||
return Some((id, PortChoice::Auto));
|
||||
}
|
||||
match (port_contract(element)?, outgoing) {
|
||||
(PortContract::FunctionOutput { .. }, true)
|
||||
| (PortContract::FunctionInput { .. }, false) => {}
|
||||
_ => return None,
|
||||
}
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
(owner.layer() == Layer::Sa && owner.kind() == Kind::Function)
|
||||
.then_some((owner.id, PortChoice::Reuse(id)))
|
||||
}
|
||||
|
||||
fn component_endpoint(project: &Project, id: Id) -> Option<&PortContract> {
|
||||
let element = project.elements.get(&id)?;
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
if element.layer() != Layer::Sa || owner.layer() != Layer::Sa || !owner.kind().is_performer() {
|
||||
return None;
|
||||
}
|
||||
let contract = port_contract(element)?;
|
||||
match contract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
}
|
||||
| PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
} => Some(contract),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary_oriented(project: &Project, source: Id) -> bool {
|
||||
matches!(
|
||||
component_endpoint(project, source),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> String {
|
||||
let endpoint = |id| {
|
||||
if tool == Tool::BoundaryExchange
|
||||
&& let Some(owner) = project.elements.get(&id).and_then(|e| e.parent)
|
||||
{
|
||||
format!("{} / {}", project.named(owner), project.named(id))
|
||||
} else {
|
||||
project.named(id).to_owned()
|
||||
}
|
||||
};
|
||||
let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
|
||||
"—"
|
||||
} else {
|
||||
"→"
|
||||
};
|
||||
format!(
|
||||
"{}: {} {arrow} {}",
|
||||
tool.label(),
|
||||
endpoint(source),
|
||||
endpoint(target)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
|
||||
|
||||
fn element(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
|
||||
fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(p, id, owner, name, contract).unwrap();
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
}
|
||||
|
||||
fn flow(orientation: ComponentPortOrientation) -> PortContract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> PortContract {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn output() -> PortContract {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn created(project: &Project, command: &Command) -> (Project, Relation) {
|
||||
let prepared = prepare_command(project, command).unwrap();
|
||||
let relation = prepared.project.relations[&prepared.selected.unwrap()].clone();
|
||||
(prepared.project, relation)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_interaction_targets_only_distinct_oa_activities() {
|
||||
let mut p = Project::empty();
|
||||
let source = element(&mut p, Kind::Activity, Layer::Oa, "Send report");
|
||||
let target = element(&mut p, Kind::Activity, Layer::Oa, "Assess report");
|
||||
let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let before = p.clone();
|
||||
assert_eq!(Tool::for_layer(Layer::Oa), &[Tool::Interaction]);
|
||||
assert_eq!(
|
||||
Tool::Interaction.targets(&p, source),
|
||||
BTreeSet::from([target])
|
||||
);
|
||||
for invalid in [source, actor, function, Id::new_v4()] {
|
||||
assert!(Tool::Interaction.command(&p, source, invalid).is_err());
|
||||
}
|
||||
let command = Tool::Interaction.command(&p, source, target).unwrap();
|
||||
assert!(matches!(command, Command::CreateRelation(_)));
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert_eq!(relation.body.endpoints(), (source, target));
|
||||
assert_eq!(relation.name, "Interaction: Send report → Assess report");
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let source = port(
|
||||
&mut p,
|
||||
a,
|
||||
"Reports out",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![report],
|
||||
},
|
||||
);
|
||||
let target = port(
|
||||
&mut p,
|
||||
b,
|
||||
"Results in",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![result],
|
||||
},
|
||||
);
|
||||
let other = port(&mut p, a, "Other output", output());
|
||||
let before = p.clone();
|
||||
let command = Tool::FunctionalExchange
|
||||
.command(&p, source, target)
|
||||
.unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 1));
|
||||
let (accepted, relation) = created(&p, &command);
|
||||
assert!(matches!(relation.body, RelationBody::FunctionalExchange {
|
||||
source: s, target: t, source_port: sp, target_port: tp, ref items,
|
||||
} if (s,t,sp,tp) == (a,b,source,target) && items.is_empty()));
|
||||
assert_eq!(accepted.elements, p.elements);
|
||||
assert_eq!(relation.name, "Functional exchange: Detect → Assess");
|
||||
for (s, t) in [
|
||||
(target, source),
|
||||
(source, other),
|
||||
(source, a),
|
||||
(source, source),
|
||||
] {
|
||||
assert!(!Tool::FunctionalExchange.can_connect(&p, s, t));
|
||||
assert!(Tool::FunctionalExchange.command(&p, s, t).is_err());
|
||||
}
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let out = port(&mut p, a, "Report out", output());
|
||||
let input = port(&mut p, b, "Report in", input());
|
||||
let (_, reused) = created(&p, &Tool::FunctionalExchange.command(&p, a, b).unwrap());
|
||||
assert!(
|
||||
matches!(reused.body, RelationBody::FunctionalExchange { source_port, target_port, .. }
|
||||
if (source_port,target_port) == (out,input))
|
||||
);
|
||||
let second = port(&mut p, a, "Other report out", output());
|
||||
let command = Tool::FunctionalExchange.command(&p, a, input).unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 2));
|
||||
let (accepted, generated) = created(&p, &command);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = generated.body
|
||||
else {
|
||||
panic!("Expected functional exchange")
|
||||
};
|
||||
assert_eq!(target_port, input);
|
||||
assert!(![out, second].contains(&source_port));
|
||||
assert_eq!(accepted.elements[&source_port].parent, Some(a));
|
||||
assert_eq!(accepted.elements.len(), p.elements.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "Response system");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Dispatcher");
|
||||
let mut endpoints = Vec::new();
|
||||
for owner in [a, b] {
|
||||
for orientation in [
|
||||
ComponentPortOrientation::Out,
|
||||
ComponentPortOrientation::In,
|
||||
ComponentPortOrientation::InOut,
|
||||
ComponentPortOrientation::Unset,
|
||||
] {
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
&format!("Flow {orientation:?}"),
|
||||
flow(orientation),
|
||||
));
|
||||
}
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
"Control",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
));
|
||||
}
|
||||
for source in &endpoints {
|
||||
let oriented = boundary_oriented(&p, *source);
|
||||
let targets = Tool::BoundaryExchange.targets(&p, *source);
|
||||
for target in &endpoints {
|
||||
let expected = validation::can_connect_boundary(&p, *source, *target, oriented);
|
||||
assert_eq!(targets.contains(target), expected, "{source} → {target}");
|
||||
let command = Tool::BoundaryExchange.command(&p, *source, *target);
|
||||
assert_eq!(command.is_ok(), expected);
|
||||
if let Ok(command) = command {
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert!(
|
||||
matches!(relation.body, RelationBody::BoundaryExchange { oriented: value, .. } if value == oriented)
|
||||
);
|
||||
assert!(relation.name.contains(" / "));
|
||||
assert_eq!(relation.name.contains('→'), oriented);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let standard = PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
};
|
||||
let source = port(&mut p, a, "Service", standard.clone());
|
||||
let target = port(&mut p, b, "Required service", standard);
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
|
||||
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
|
||||
for (interface, item) in [(provided, report), (required, result)] {
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides reporting",
|
||||
source,
|
||||
provided,
|
||||
);
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Requires results",
|
||||
target,
|
||||
required,
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let before = p.clone();
|
||||
assert!(Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(validation::can_connect_boundary(&p, source, target, false));
|
||||
let (accepted, relation) = created(
|
||||
&p,
|
||||
&Tool::BoundaryExchange.command(&p, source, target).unwrap(),
|
||||
);
|
||||
let warnings: Vec<_> = validation::validate(&accepted)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.code == "unsatisfied_interface_requirement")
|
||||
.collect();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(warnings[0].severity, validation::Severity::Warning);
|
||||
assert_eq!(warnings[0].element, Some(requirement));
|
||||
assert_eq!(warnings[0].field.as_deref(), Some("target"));
|
||||
|
||||
let mut carrying = relation.clone();
|
||||
let RelationBody::BoundaryExchange { items, .. } = &mut carrying.body else {
|
||||
unreachable!()
|
||||
};
|
||||
items.push(result);
|
||||
assert!(prepare_command(&p, &Command::CreateRelation(carrying.clone())).is_err());
|
||||
assert_eq!(p, before, "Rejected carried items never mutate the input");
|
||||
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides results",
|
||||
source,
|
||||
required,
|
||||
);
|
||||
let prepared = prepare_command(&p, &Command::CreateRelation(carrying)).unwrap();
|
||||
assert!(
|
||||
validation::validate(&prepared.project)
|
||||
.iter()
|
||||
.all(|issue| issue.code != "unsatisfied_interface_requirement")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_port_owners_layers_and_contract_kinds_are_not_candidates() {
|
||||
let mut p = Project::empty();
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Function");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let activity = element(&mut p, Kind::Activity, Layer::Oa, "Activity");
|
||||
let function_on_system = port(&mut p, system, "Wrong function owner", output());
|
||||
let component_on_function = port(
|
||||
&mut p,
|
||||
function,
|
||||
"Wrong component owner",
|
||||
flow(ComponentPortOrientation::Out),
|
||||
);
|
||||
let oa_function_port = port(&mut p, activity, "Wrong layer", output());
|
||||
let malformed_standard = port(
|
||||
&mut p,
|
||||
system,
|
||||
"Wrong Standard orientation",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
);
|
||||
for source in [
|
||||
function_on_system,
|
||||
component_on_function,
|
||||
oa_function_port,
|
||||
malformed_standard,
|
||||
] {
|
||||
for tool in Tool::for_layer(Layer::Sa) {
|
||||
assert!(!tool.can_start(&p, source));
|
||||
assert!(tool.targets(&p, source).is_empty());
|
||||
assert!(tool.command(&p, source, function).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_connections_share_exact_source_history_and_model_parity() {
|
||||
let mut p = Project::empty();
|
||||
let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident");
|
||||
let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident");
|
||||
let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report");
|
||||
let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let actor = element(&mut p, Kind::SystemActor, Layer::Sa, "Actor");
|
||||
let boundary_a = port(&mut p, system, "Send", flow(ComponentPortOrientation::Out));
|
||||
let boundary_b = port(&mut p, actor, "Receive", flow(ComponentPortOrientation::In));
|
||||
let mut bundle = format_bundle(&p);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Preserve engineering rationale exactly.\n");
|
||||
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
for (tool, source, target) in [
|
||||
(Tool::Interaction, oa_a, oa_b),
|
||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||
] {
|
||||
let before = document.bundle().clone();
|
||||
let original = document.project().clone();
|
||||
let revision = document.revision();
|
||||
let command = tool.command(document.project(), source, target).unwrap();
|
||||
let expected = prepare_command(document.project(), &command).unwrap();
|
||||
let selected = document.execute(command).unwrap();
|
||||
assert_eq!(selected, expected.selected);
|
||||
assert_eq!(document.project(), &expected.project);
|
||||
assert_eq!(document.revision(), revision + 1);
|
||||
assert_eq!(
|
||||
compile_bundle(document.bundle(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected.project
|
||||
);
|
||||
let changed = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert_eq!(document.project(), &original);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let source = port(&mut p, a, "Send", flow(ComponentPortOrientation::Out));
|
||||
let target = port(&mut p, b, "Receive", flow(ComponentPortOrientation::In));
|
||||
assert!(Tool::BoundaryExchange.can_connect(&p, source, target));
|
||||
// An indexed key alone is not proof of a live model identity. Preserve
|
||||
// the reference check even when inspecting an inconsistent input model.
|
||||
p.elements.get_mut(&target).unwrap().id = Id::new_v4();
|
||||
let before = p.clone();
|
||||
assert!(!validation::can_connect_boundary(&p, source, target, true));
|
||||
assert!(!Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(Tool::BoundaryExchange.command(&p, source, target).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_command_validation_rejects_invalid_models_without_mutation() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Activity, Layer::Oa, "Send");
|
||||
let b = element(&mut p, Kind::Activity, Layer::Oa, "Receive");
|
||||
p.elements.get_mut(&a).unwrap().alias = p.elements[&b].alias.clone();
|
||||
let before = p.clone();
|
||||
assert!(
|
||||
Tool::Interaction.can_connect(&p, a, b),
|
||||
"Candidate highlighting checks the proposed link; commit checks the whole model"
|
||||
);
|
||||
assert!(Tool::Interaction.command(&p, a, b).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
connection_edit,
|
||||
connection_tool::Tool,
|
||||
context_menu::Entry,
|
||||
diagram::{DiagramCanvas, State},
|
||||
model_menu, port_edit,
|
||||
};
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Vector, keyboard, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 200.);
|
||||
let b = p.add(Kind::Function, "Receive", 500., 200.);
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
p.elements.insert(
|
||||
output,
|
||||
make_port(
|
||||
&p,
|
||||
output,
|
||||
a,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.elements.insert(
|
||||
input,
|
||||
make_port(
|
||||
&p,
|
||||
input,
|
||||
b,
|
||||
"Receive",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
app.pan = Vector::new(20., 30.);
|
||||
(app, output, input)
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
fn screen(app: &App, id: Id) -> Point {
|
||||
let v = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let p = v.ports.iter().find(|p| p.element == id).unwrap().position;
|
||||
Point::new(bounds().x + app.pan.x + p.x, bounds().y + app.pan.y + p.y)
|
||||
}
|
||||
fn event(c: &DiagramCanvas<'_>, s: &mut State, e: mouse::Event, p: Point) -> Option<Message> {
|
||||
c.update(
|
||||
s,
|
||||
&canvas::Event::Mouse(e),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(p),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn choose(app: &App, caption: &str) -> Message {
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("No action {caption}"))
|
||||
}
|
||||
fn send(app: &mut App, m: Message) {
|
||||
let _ = app.update(m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_and_arrow_panning_preserve_source_and_history() {
|
||||
for armed in [false, true] {
|
||||
let (mut app, a, _) = fixture();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))),
|
||||
);
|
||||
let context = app.route_context().unwrap();
|
||||
if armed {
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Endpoint(context, a)),
|
||||
);
|
||||
}
|
||||
let before = app.document.bundle().clone();
|
||||
let pan = app.pan;
|
||||
let mut state = State::default();
|
||||
let start = screen(&app, a);
|
||||
let end = start + Vector::new(140., 70.);
|
||||
let c = canvas(&app);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert_eq!(app.pan, pan + Vector::new(140., 70.));
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, armed.then_some(a));
|
||||
let e = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowRight),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let message = canvas(&app)
|
||||
.update(&mut state, &e, bounds(), mouse::Cursor::Available(end))
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.pan, pan + Vector::new(60., 70.));
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, context);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_reset_and_draw_use_fresh_open_context() {
|
||||
let (mut app, a, b) = fixture();
|
||||
let c = app.route_context().unwrap();
|
||||
let v = projection::project_view(app.project(), c.diagram);
|
||||
let owner = v.ports.iter().find(|p| p.element == a).unwrap().owner;
|
||||
send(
|
||||
&mut app,
|
||||
Message::PortAnchor(port_edit::Event::Commit(
|
||||
c,
|
||||
owner,
|
||||
PortAnchor {
|
||||
port: a,
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.4,
|
||||
},
|
||||
)),
|
||||
);
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let mut request = model_menu::Request::object(a, screen(&app, a));
|
||||
request.canvas = app.route_context();
|
||||
let old = request.canvas.unwrap();
|
||||
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
|
||||
assert_ne!(app.route_context().unwrap().epoch, old.epoch);
|
||||
let action = choose(&app, "Reset automatic position");
|
||||
send(&mut app, action);
|
||||
assert!(
|
||||
app.project().diagrams[&c.diagram].nodes[&owner]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let mut request = model_menu::Request::object(a, screen(&app, a));
|
||||
request.canvas = app.route_context();
|
||||
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
|
||||
let caption = model_menu::popup(&app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
Entry::Action { label, .. } if label.starts_with("Draw functional exchange") => {
|
||||
Some(label.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let action = choose(&app, &caption);
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
|
||||
let context = app.route_context().unwrap();
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, context);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Endpoint(context, b)),
|
||||
);
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
let accepted = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(&accepted, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_after_release_must_not_move_clicked_port() {
|
||||
let (mut app, a, _) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let start = screen(&app, a);
|
||||
let end = start + Vector::new(-120., -150.);
|
||||
let mut state = State::default();
|
||||
let c = canvas(&app);
|
||||
// Iced batches all window events until AboutToWait and passes the final
|
||||
// window cursor to every event, even when the final motion follows release.
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert!(
|
||||
app.document.bundle() == &before,
|
||||
"Moving the cursor after release must not author a port anchor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_after_source_click_release_must_not_create_connection() {
|
||||
let (mut app, a, b) = fixture();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))),
|
||||
);
|
||||
let start = screen(&app, a);
|
||||
let end = screen(&app, b);
|
||||
let mut state = State::default();
|
||||
let c = canvas(&app);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert!(
|
||||
app.project().relations.is_empty(),
|
||||
"Hover after releasing the source is not a connection commit"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
connection_edit,
|
||||
connection_tool::Tool,
|
||||
diagram::{DiagramCanvas, State},
|
||||
model_menu, port_edit,
|
||||
};
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Vector, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 200.);
|
||||
let b = p.add(Kind::Function, "Receive", 500., 200.);
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
p.elements.insert(
|
||||
output,
|
||||
make_port(
|
||||
&p,
|
||||
output,
|
||||
a,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.elements.insert(
|
||||
input,
|
||||
make_port(
|
||||
&p,
|
||||
input,
|
||||
b,
|
||||
"Receive",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
(
|
||||
{
|
||||
let mut app = App::with_project(p);
|
||||
app.pan = Vector::new(20., 30.);
|
||||
app
|
||||
},
|
||||
output,
|
||||
input,
|
||||
)
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
|
||||
}
|
||||
fn screen(p: Position, zoom: f32) -> Point {
|
||||
Point::new(bounds().x + p.x * zoom + 20., bounds().y + p.y * zoom + 30.)
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
fn event(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> Option<Message> {
|
||||
c.update(
|
||||
state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn batch(app: &App, state: &mut State, start: Point, end: Point, zoom: f32) -> Vec<Message> {
|
||||
let c = canvas(app, zoom);
|
||||
[
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, state, e, end))
|
||||
.collect()
|
||||
}
|
||||
fn assert_history(app: &mut App, before: &oa_core::source_project::SourceBundle, revision: u64) {
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let after = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_fe_ports_commit_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let at = |id| {
|
||||
screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == id)
|
||||
.unwrap()
|
||||
.position,
|
||||
zoom,
|
||||
)
|
||||
};
|
||||
let messages = batch(&app, &mut State::default(), at(output), at(input), zoom);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(
|
||||
matches!(&messages[0],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==output)
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[1],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==input)
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(
|
||||
matches!(app.project().relations[&app.selected].body,RelationBody::FunctionalExchange{source_port,target_port,..}if source_port==output&&target_port==input)
|
||||
);
|
||||
assert!(app.connection.is_none());
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_port_drag_commits_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, _) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let dest = projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35);
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(port.position, zoom),
|
||||
screen(dest, zoom),
|
||||
zoom,
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
|
||||
assert!(
|
||||
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
|
||||
);
|
||||
let node = owner.id;
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(app.project().relations.is_empty());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()].nodes[&node].port_anchors[0].port,
|
||||
output
|
||||
);
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let a = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == output)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let b = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let _ = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: a },
|
||||
a,
|
||||
);
|
||||
let right = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
a,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(right);
|
||||
assert!(app.model_menu.is_some());
|
||||
// The overlay consumes the right release, movement to B, outside press and
|
||||
// its release. None of those events reach the underlying canvas.
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Close));
|
||||
let press = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
b,
|
||||
)
|
||||
.unwrap();
|
||||
let release = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
b,
|
||||
);
|
||||
let picked = match &press {
|
||||
Message::Select(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let release_debug = format!("{release:?}");
|
||||
let _ = app.update(press);
|
||||
if let Some(message) = release {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
picked,
|
||||
Some(input),
|
||||
"Click at B picked another port; following release: {release_debug}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"A stationary click after closing a menu must not edit port geometry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
|
||||
let (mut app, output, _) = fixture();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let a = screen(port.position, 1.);
|
||||
let b = screen(
|
||||
projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35),
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let selected = batch(&app, &mut state, a, a, 1.);
|
||||
for message in selected {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.selected, output);
|
||||
// No new motion to A occurs: the previous selection ended there. Winit
|
||||
// batches press(A), motion(B), release(B) with cursor B on every event.
|
||||
let c = canvas(&app, 1.);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, b))
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
|
||||
"Stationary source was lost between batches: {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(messages.last(),Some(Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))) if anchor.port==output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_and_stale_epoch_block_queued_connection_and_port_commits() {
|
||||
for is_connection in [false, true] {
|
||||
for barrier in ["menu", "modal", "epoch"] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
if is_connection {
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
}
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let out = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let target = if is_connection {
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position
|
||||
} else {
|
||||
projection::port_anchor_position(
|
||||
view.nodes.iter().find(|n| n.id == out.owner).unwrap(),
|
||||
PortAnchorSide::Top,
|
||||
0.35,
|
||||
)
|
||||
};
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(out.position, 1.),
|
||||
screen(target, 1.),
|
||||
1.,
|
||||
);
|
||||
match barrier {
|
||||
"menu" => {
|
||||
let _ = app.update(model_menu::open(output, Point::ORIGIN));
|
||||
}
|
||||
"modal" => {
|
||||
let _ = app.update(Message::AddRelation);
|
||||
}
|
||||
_ => {
|
||||
let _ = app.update(Message::Escape);
|
||||
}
|
||||
}
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"{barrier}; connection={is_connection}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use super::*;
|
||||
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project,
|
||||
diagram,
|
||||
selected: Id::nil(),
|
||||
zoom,
|
||||
pan: Vector::new(17., 23.),
|
||||
context: Context {
|
||||
document: project.id,
|
||||
diagram,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn local(canvas: &DiagramCanvas<'_>, position: Position) -> Point {
|
||||
Point::new(
|
||||
canvas.pan.x + position.x * canvas.zoom,
|
||||
canvas.pan.y + position.y * canvas.zoom,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
|
||||
let project = Project::empty();
|
||||
let diagram = Id::new_v4();
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
|
||||
let mut view = ProjectedView {
|
||||
ports: (0..2)
|
||||
.map(|i| projection::ProjectedPort {
|
||||
element: Id::from_u128(i + 10),
|
||||
owner: diagram,
|
||||
position: Position {
|
||||
x: 100. + if horizontal { i as f32 * 28. } else { 0. },
|
||||
y: 100. + if horizontal { 0. } else { i as f32 * 28. },
|
||||
},
|
||||
side,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for zoom in [0.0001, 0.35, 1., 3.] {
|
||||
let canvas = canvas(&project, diagram, zoom);
|
||||
for _ in 0..2 {
|
||||
for port in &view.ports {
|
||||
assert_eq!(
|
||||
canvas.endpoint_at(&view, local(&canvas, port.position)),
|
||||
Some(port.element),
|
||||
"Exact port center must win at zoom {zoom} on {side:?}"
|
||||
);
|
||||
}
|
||||
view.ports.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_ports_keep_selection_then_use_stable_identity() {
|
||||
let project = Project::empty();
|
||||
let diagram = Id::new_v4();
|
||||
let a = Id::from_u128(10);
|
||||
let b = Id::from_u128(20);
|
||||
let mut view = ProjectedView {
|
||||
ports: [b, a]
|
||||
.into_iter()
|
||||
.map(|element| projection::ProjectedPort {
|
||||
element,
|
||||
owner: diagram,
|
||||
position: Position { x: 100., y: 100. },
|
||||
side: PortAnchorSide::Right,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut canvas = canvas(&project, diagram, 0.35);
|
||||
let at = local(&canvas, view.ports[0].position);
|
||||
for _ in 0..2 {
|
||||
canvas.selected = Id::nil();
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(a));
|
||||
canvas.selected = b;
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(b));
|
||||
view.ports.reverse();
|
||||
}
|
||||
assert!(canvas.hit_port(&view, Point::new(-100., -100.)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
let mut project = Project::empty();
|
||||
let group = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Response", None)
|
||||
.unwrap();
|
||||
let owner = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Send report", Some(group))
|
||||
.unwrap();
|
||||
let ports = [Id::from_u128(10), Id::from_u128(20)];
|
||||
for (i, id) in ports.into_iter().enumerate() {
|
||||
let port = make_port(
|
||||
&project,
|
||||
id,
|
||||
owner,
|
||||
&format!("Output {i}"),
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.elements.insert(id, port);
|
||||
}
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
project.place(diagram, group, Position { x: 60., y: 60. });
|
||||
let node = project.place(diagram, owner, Position { x: 100., y: 140. });
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors = ports
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, port)| PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.4 + i as f32 * 0.1,
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
let canvas = canvas(&project, diagram, 0.35);
|
||||
let view = project_view(&project, diagram);
|
||||
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
|
||||
for port in &view.ports {
|
||||
let at = local(&canvas, port.position);
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(port.element));
|
||||
let mut state = State::default();
|
||||
let cursor = mouse::Cursor::Available(Point::new(bounds.x + at.x, bounds.y + at.y));
|
||||
let press = canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
)
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
|
||||
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
|
||||
let menu = canvas.context_request(&state, at, bounds);
|
||||
assert_eq!(menu.object, port.element);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,10 @@ pub fn sidebar(app: &App) -> View<'_, Message> {
|
||||
.style(style::card),
|
||||
tooltip::Position::Right,
|
||||
));
|
||||
content = content.push(outline);
|
||||
let id = entry.id;
|
||||
content = content.push(crate::context_menu::Area::new(outline, move |anchor| {
|
||||
crate::model_menu::open(id, anchor)
|
||||
}));
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
@@ -202,27 +205,30 @@ fn item<'a>(app: &'a App, id: Id, context: bool) -> View<'a, Message> {
|
||||
info = info.push(label(path));
|
||||
}
|
||||
}
|
||||
button(
|
||||
row![
|
||||
info.width(Fill),
|
||||
label(if count == 0 {
|
||||
"Details".into()
|
||||
} else {
|
||||
format!("{count} children")
|
||||
}),
|
||||
text("›").size(20)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
crate::context_menu::Area::new(
|
||||
button(
|
||||
row![
|
||||
info.width(Fill),
|
||||
label(if count == 0 {
|
||||
"Details".into()
|
||||
} else {
|
||||
format!("{count} children")
|
||||
}),
|
||||
text("›").size(20)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(12)
|
||||
.on_press(Message::Explore(Some(id)))
|
||||
.style(if app.selected == id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
move |anchor| crate::model_menu::open(id, anchor),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(12)
|
||||
.on_press(Message::Explore(Some(id)))
|
||||
.style(if app.selected == id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ impl Draft {
|
||||
.values()
|
||||
.find_map(|d| d.nodes.get(&selected).map(|n| (d.id, n)))?;
|
||||
let mut value = json!(node);
|
||||
value["port_anchors"] = json!(node.port_anchors);
|
||||
value["diagram"] = json!(diagram);
|
||||
(RecordKind::Node(diagram), value)
|
||||
};
|
||||
@@ -222,6 +223,8 @@ impl Draft {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else if path.ends_with("port_anchors") {
|
||||
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
@@ -387,6 +390,7 @@ fn reference(path: &str) -> bool {
|
||||
| "represented"
|
||||
| "exchange"
|
||||
| "edge"
|
||||
| "port"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
@@ -516,6 +520,17 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
|
||||
|
||||
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
|
||||
let owner = value
|
||||
.get("element")
|
||||
.and_then(|id| serde_json::from_value::<Id>(id.clone()).ok());
|
||||
return project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port && e.parent == owner)
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
}
|
||||
let element = (draft.kind == RecordKind::Element)
|
||||
.then(|| serde_json::from_value::<Element>(value.clone()).ok())
|
||||
.flatten()
|
||||
@@ -999,6 +1014,7 @@ fn unit_body(path: &str, value: &Value) -> bool {
|
||||
fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> {
|
||||
let field = path.rsplit('/').next().unwrap_or_default();
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"projection" => &[
|
||||
"Overview",
|
||||
|
||||
@@ -299,9 +299,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Route connections around the model",
|
||||
&[
|
||||
"Select an architecture connection. Drag a line segment to add a pinned bend, or choose Add bend; drag its circles to shape a detour. Select a circle and choose Remove bend to release that pin. Drag the connection label independently. Escape cancels a gesture; Undo reverses one completed drag.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Reset routing clears the selected connection’s pins and label position in this view. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -333,7 +333,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Include, extend or specialize a capability",
|
||||
&[
|
||||
"Select a capability in the diagram or explorer, then open Modeling actions at the top of its properties. Include capability identifies behavior that is always part of it; choose the included capability as the target.",
|
||||
"Select a capability in the diagram or explorer, then open Actions at the top of its properties. Include capability identifies behavior that is always part of it; choose the included capability as the target.",
|
||||
"Extend capability connects this extending capability to the extended capability. Enter the condition under which the extension adds behavior. An empty condition remains editable and appears in Model checks.",
|
||||
"Specialize capability connects a specific capability to a more general one. Inclusion and specialization cannot form cycles.",
|
||||
"The relationship preview names both ends and generates a readable name. Apply adds one undoable change; Cancel preserves the model. Include and extend labels remain visible in the diagram.",
|
||||
@@ -368,10 +368,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Create a typed exchange",
|
||||
&[
|
||||
"Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.",
|
||||
"Select the source, choose + Relationship, then Interaction for OA activities or Functional exchange for SA functions. Choose the compatible target and apply the relationship.",
|
||||
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
|
||||
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
|
||||
"For a participant boundary, create compatible ports and a Boundary exchange. Exchange allocation associates that boundary exchange with the functional exchange it carries.",
|
||||
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -518,7 +518,7 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"In System Analysis, select a System or System Actor and choose Add port in Model explorer or Properties. Name it, choose Standard or Flow, then Create port. Standard ports describe interfaces; Flow ports have their own input/output orientation. Functions use separate input/output port actions.",
|
||||
"Use Interface provision and Interface requirement relationships to state who provides or needs the contract.",
|
||||
"Select the component port and choose Connect boundary exchange. Choose the other participant's compatible port; owner names and aliases distinguish repeated port names. Create the matching port first if none exists. Exchange orientation is separate from Flow port orientation. Connect carried functional exchanges using Exchange allocation.",
|
||||
"Select a visible component port and choose Draw boundary exchange, then click or drag to a highlighted compatible port. The diagram palette offers the same tool. If the endpoints are outside the diagram, Configure boundary exchange opens the form with owner names and aliases. Create the matching port first if needed. Exchange orientation is separate from Flow port orientation. Exchange allocation associates carried functional exchanges with the boundary connection.",
|
||||
"Link the detailed interface-control document, CAD connector or schematic through an Artifact reference when an external artifact supplies additional evidence.",
|
||||
],
|
||||
),
|
||||
@@ -613,7 +613,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
&[
|
||||
"Open Textual model and choose a source file, or choose Source in Properties to locate the selected record in its owning file. Switching files preserves each draft.",
|
||||
"Open Textual model and expand folders to choose a source file, or choose Actions > Open source in Properties to locate the selected record. Switching files preserves each draft. Collapsed folders show modified-file and diagnostic counts; Locate reveals the active file. Right-click a file for its actions.",
|
||||
"Use Find / go to, Ctrl+F or Ctrl+G to navigate. Literal search is case sensitive. Enter/F3 selects the next match, Shift+F3 selects the previous match, and results wrap. Go to line accepts the displayed one-based line number.",
|
||||
"Edit the draft. Syntax highlighting distinguishes source structure; live lint reports errors with line and column information.",
|
||||
"Choose Apply all to parse and validate all reachable included files. A successful application is one model change; graphical views and Properties then reflect it. Finish or revert source drafts before editing graphical properties.",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
mod app;
|
||||
mod behavior;
|
||||
mod connection_edit;
|
||||
mod connection_tool;
|
||||
mod context_menu;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
@@ -8,11 +11,14 @@ mod explorer_view;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
mod model_menu;
|
||||
mod navigation;
|
||||
mod port_edit;
|
||||
mod port_preview;
|
||||
mod port_workflow;
|
||||
mod route_edit;
|
||||
mod source_navigation;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,7 @@ pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
|
||||
);
|
||||
}
|
||||
if count == 0 {
|
||||
content = content.push(text("No semantic connections yet. Add relationships from Properties as the model develops.").size(12).color(style::MUTED));
|
||||
content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED));
|
||||
}
|
||||
content.into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Border dragging changes diagram presentation, never semantic ownership.
|
||||
use crate::route_edit::{Context, point, position};
|
||||
use iced::Point;
|
||||
use oa_core::{projection::*, *};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Commit(Context, Id, PortAnchor),
|
||||
Reset(Context, Id, Id),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Gesture {
|
||||
pub context: Context,
|
||||
pub owner: ProjectedNode,
|
||||
pub anchor: PortAnchor,
|
||||
pub start: Point,
|
||||
pub changed: bool,
|
||||
}
|
||||
impl Gesture {
|
||||
pub fn new(context: Context, owner: ProjectedNode, port: &ProjectedPort, start: Point) -> Self {
|
||||
let anchor = PortAnchor {
|
||||
port: port.element,
|
||||
side: port.side,
|
||||
offset: port_anchor_offset(&owner, port.side, port.position),
|
||||
};
|
||||
Self {
|
||||
context,
|
||||
owner,
|
||||
anchor,
|
||||
start,
|
||||
changed: false,
|
||||
}
|
||||
}
|
||||
pub fn move_to(&mut self, cursor: Point, zoom: f32) {
|
||||
if !self.changed && cursor.distance(self.start) * zoom < 3. {
|
||||
return;
|
||||
}
|
||||
self.changed = true;
|
||||
let distance = |side| {
|
||||
let offset = port_anchor_offset(&self.owner, side, position(cursor));
|
||||
cursor.distance(point(port_anchor_position(&self.owner, side, offset)))
|
||||
};
|
||||
let nearest = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
|
||||
.unwrap();
|
||||
// Six screen pixels of hysteresis avoids flickering between corner sides.
|
||||
if distance(self.anchor.side) > distance(nearest) + 6. / zoom {
|
||||
self.anchor.side = nearest;
|
||||
}
|
||||
self.anchor.offset =
|
||||
(port_anchor_offset(&self.owner, self.anchor.side, position(cursor)) * 100.).round()
|
||||
/ 100.;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_anchor(diagram: &mut Diagram, node: Id, anchor: PortAnchor) {
|
||||
if let Some(node) = diagram.nodes.get_mut(&node) {
|
||||
if let Some(previous) = node.port_anchors.iter_mut().find(|a| a.port == anchor.port) {
|
||||
*previous = anchor;
|
||||
} else {
|
||||
node.port_anchors.push(anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphical ownership edits intentionally reset anchors left on the old owner.
|
||||
/// Raw source/API edits remain strict and must explicitly repair their anchors.
|
||||
pub fn repair_owner_change(project: &Project, command: Command) -> Command {
|
||||
let change = match &command {
|
||||
Command::Reparent { element, parent } => Some((*element, *parent)),
|
||||
Command::EditElement(original, element) => Some((*original, element.parent)),
|
||||
_ => None,
|
||||
};
|
||||
let Some((port, parent)) = change.filter(|(id, parent)| {
|
||||
project
|
||||
.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent != *parent)
|
||||
}) else {
|
||||
return command;
|
||||
};
|
||||
let mut commands: Vec<_> = project
|
||||
.diagrams
|
||||
.values()
|
||||
.flat_map(|d| {
|
||||
d.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
Some(n.element) != parent && n.port_anchors.iter().any(|a| a.port == port)
|
||||
})
|
||||
.map(move |n| Command::RemovePortAnchor {
|
||||
diagram: d.id,
|
||||
node: n.id,
|
||||
port,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if commands.is_empty() {
|
||||
return command;
|
||||
}
|
||||
commands.push(command);
|
||||
Command::Batch(commands)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{App, Message};
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 100.);
|
||||
let b = p.add(Kind::Function, "Alternate owner", 500., 100.);
|
||||
let port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
port,
|
||||
a,
|
||||
"Output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(port, element);
|
||||
(App::with_project(p), a, b, port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_border_drags_commit_exact_preview_and_restore_automatic_anchors_in_history() {
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let (mut app, owner, _, port_id) = fixture();
|
||||
let context = app.route_context().unwrap();
|
||||
let view = project_view(app.project(), context.diagram);
|
||||
let node = view.nodes.iter().find(|n| n.element == owner).unwrap();
|
||||
let port = view.ports.iter().find(|p| p.element == port_id).unwrap();
|
||||
let mut gesture = Gesture::new(context, node.clone(), port, point(port.position));
|
||||
let end = port_anchor_position(node, side, 0.25);
|
||||
gesture.move_to(point(end), 0.7);
|
||||
assert_eq!(gesture.anchor.side, side);
|
||||
assert_eq!(gesture.anchor.offset, 0.25);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::PortAnchor(Event::Commit(
|
||||
context,
|
||||
node.id,
|
||||
gesture.anchor,
|
||||
)));
|
||||
let after = app.document.bundle().clone();
|
||||
let projected = project_view(app.project(), context.diagram);
|
||||
assert_eq!(
|
||||
projected.nodes.iter().find(|n| n.id == node.id).unwrap(),
|
||||
node
|
||||
);
|
||||
assert_eq!(
|
||||
projected
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.element == port_id)
|
||||
.unwrap()
|
||||
.position,
|
||||
end
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(*app.document.bundle(), after);
|
||||
let latest = app.route_context().unwrap();
|
||||
let _ = app.update(Message::PortAnchor(Event::Reset(latest, node.id, port_id)));
|
||||
assert!(
|
||||
app.project().diagrams[&context.diagram].nodes[&node.id]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphical_port_reparent_clears_old_anchors_in_the_same_history_operation() {
|
||||
let (mut app, owner, next, port) = fixture();
|
||||
let context = app.route_context().unwrap();
|
||||
let node = project_view(app.project(), context.diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == owner)
|
||||
.unwrap()
|
||||
.id;
|
||||
let _ = app.update(Message::PortAnchor(Event::Commit(
|
||||
context,
|
||||
node,
|
||||
PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.5,
|
||||
},
|
||||
)));
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Command(Command::Reparent {
|
||||
element: port,
|
||||
parent: Some(next),
|
||||
}));
|
||||
assert_eq!(app.document.revision(), revision + 1, "{}", app.status);
|
||||
assert_eq!(app.project().elements[&port].parent, Some(next));
|
||||
assert!(
|
||||
app.project().diagrams[&context.diagram].nodes[&node]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), before);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub enum Event {
|
||||
SelectBend(Id, usize),
|
||||
Commit(Context, EdgeRoute, Option<usize>),
|
||||
AddBend,
|
||||
AddBendAt(oa_core::Position),
|
||||
RemoveBend,
|
||||
Reset,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
//! Source paths are navigation groups, not model containment or include ownership.
|
||||
//! The workspace retains every buffer; expanding folders never changes its files.
|
||||
use crate::{context_menu, source_workspace::Workspace, style};
|
||||
use iced::{
|
||||
Element, Fill, Point,
|
||||
widget::{Space, button, column, container, row, scrollable, text, tooltip},
|
||||
};
|
||||
use oa_core::source_project::SourceDiagnostic;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const ROW_HEIGHT: f32 = 32.;
|
||||
const INDENT: f32 = 16.;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
ToggleFolder(String),
|
||||
OpenFile(String),
|
||||
FileMenu(String, Point),
|
||||
}
|
||||
|
||||
/// Presentation only. Folder keys are exact logical prefixes without a final `/`.
|
||||
/// Start compact; explicit file navigation reveals that file's ancestors.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
expanded: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn toggle(&mut self, path: &str) {
|
||||
if !self.expanded.remove(path) {
|
||||
self.expanded.insert(path.into());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reveal(&mut self, file: &str) {
|
||||
for (offset, _) in file.match_indices('/') {
|
||||
self.expanded.insert(file[..offset].into());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collapse_all(&mut self) {
|
||||
self.expanded.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RowKind {
|
||||
Folder {
|
||||
expanded: bool,
|
||||
file_count: usize,
|
||||
},
|
||||
File {
|
||||
entry: bool,
|
||||
detached: bool,
|
||||
closable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Original logical file path or exact folder prefix. Never normalized here.
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub depth: usize,
|
||||
pub kind: RowKind,
|
||||
pub selected: bool,
|
||||
pub contains_active: bool,
|
||||
pub dirty_files: usize,
|
||||
pub diagnostics: usize,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn tooltip(&self) -> String {
|
||||
let mut lines = vec![match &self.kind {
|
||||
RowKind::Folder { .. } => format!("{}/", self.path),
|
||||
RowKind::File { .. } => self.path.clone(),
|
||||
}];
|
||||
match self.kind {
|
||||
RowKind::Folder { file_count, .. } => {
|
||||
lines.push(format!(
|
||||
"{file_count} open source file{}",
|
||||
if file_count == 1 { "" } else { "s" }
|
||||
));
|
||||
if self.contains_active {
|
||||
lines.push("Contains the active source file".into());
|
||||
}
|
||||
}
|
||||
RowKind::File {
|
||||
entry, detached, ..
|
||||
} => {
|
||||
lines.push(if detached {
|
||||
"Detached buffer · closing never deletes its file".into()
|
||||
} else if entry {
|
||||
"Project entry".into()
|
||||
} else {
|
||||
"Included source file".into()
|
||||
});
|
||||
if self.selected {
|
||||
lines.push("Active source file".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.dirty_files > 0 {
|
||||
lines.push(format!(
|
||||
"{} modified source draft{}",
|
||||
self.dirty_files,
|
||||
if self.dirty_files == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if self.diagnostics > 0 {
|
||||
lines.push(format!(
|
||||
"{} source diagnostic{}",
|
||||
self.diagnostics,
|
||||
if self.diagnostics == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Folder {
|
||||
name: String,
|
||||
folders: BTreeMap<String, usize>,
|
||||
files: Vec<usize>,
|
||||
file_count: usize,
|
||||
dirty_files: usize,
|
||||
diagnostics: usize,
|
||||
contains_active: bool,
|
||||
}
|
||||
|
||||
enum Pending {
|
||||
Folder(usize, String, usize),
|
||||
File(usize, usize),
|
||||
}
|
||||
|
||||
/// Build a flat arena and traverse only expanded folders. The arena avoids
|
||||
/// recursive construction, traversal and drop for deeply nested logical paths.
|
||||
pub fn rows(workspace: &Workspace, diagnostics: &[SourceDiagnostic], state: &State) -> Vec<Row> {
|
||||
let mut issue_counts = BTreeMap::<&str, usize>::new();
|
||||
for issue in diagnostics {
|
||||
*issue_counts.entry(&issue.path).or_default() += 1;
|
||||
}
|
||||
let mut folders = vec![Folder::default()];
|
||||
let mut files = Vec::with_capacity(workspace.files.len());
|
||||
for (path, buffer) in &workspace.files {
|
||||
let mut parts = path.split('/').peekable();
|
||||
let mut current = 0;
|
||||
let mut ancestors = vec![0];
|
||||
let mut name = "";
|
||||
while let Some(part) = parts.next() {
|
||||
if parts.peek().is_none() {
|
||||
name = part;
|
||||
break;
|
||||
}
|
||||
current = match folders[current].folders.get(part).copied() {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
let index = folders.len();
|
||||
folders.push(Folder {
|
||||
name: part.into(),
|
||||
..Default::default()
|
||||
});
|
||||
folders[current].folders.insert(part.into(), index);
|
||||
index
|
||||
}
|
||||
};
|
||||
ancestors.push(current);
|
||||
}
|
||||
let detached = !workspace.reachable.contains(path);
|
||||
let dirty_files = usize::from(buffer.dirty());
|
||||
let diagnostics = issue_counts.get(path.as_str()).copied().unwrap_or(0);
|
||||
let selected = workspace.active == *path;
|
||||
for index in ancestors {
|
||||
let folder = &mut folders[index];
|
||||
folder.file_count += 1;
|
||||
folder.dirty_files += dirty_files;
|
||||
folder.diagnostics += diagnostics;
|
||||
folder.contains_active |= selected;
|
||||
}
|
||||
folders[current].files.push(files.len());
|
||||
files.push(Row {
|
||||
path: path.clone(),
|
||||
name: name.into(),
|
||||
depth: 0,
|
||||
kind: RowKind::File {
|
||||
entry: workspace.entry == *path,
|
||||
detached,
|
||||
closable: detached && !buffer.dirty(),
|
||||
},
|
||||
selected,
|
||||
contains_active: selected,
|
||||
dirty_files,
|
||||
diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
let mut pending = Vec::new();
|
||||
append_children(&mut pending, &folders, &files, 0, "", 0);
|
||||
let mut result = Vec::new();
|
||||
while let Some(next) = pending.pop() {
|
||||
match next {
|
||||
Pending::File(index, depth) => {
|
||||
let mut row = files[index].clone();
|
||||
row.depth = depth;
|
||||
result.push(row);
|
||||
}
|
||||
Pending::Folder(index, path, depth) => {
|
||||
let folder = &folders[index];
|
||||
let expanded = state.expanded.contains(&path);
|
||||
result.push(Row {
|
||||
path: path.clone(),
|
||||
name: folder.name.clone(),
|
||||
depth,
|
||||
kind: RowKind::Folder {
|
||||
expanded,
|
||||
file_count: folder.file_count,
|
||||
},
|
||||
selected: false,
|
||||
contains_active: folder.contains_active,
|
||||
dirty_files: folder.dirty_files,
|
||||
diagnostics: folder.diagnostics,
|
||||
});
|
||||
if expanded {
|
||||
append_children(&mut pending, &folders, &files, index, &path, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Locate the active file after `State::reveal`, retaining one ancestor row of
|
||||
/// context. If it is intentionally collapsed, locate its visible folder instead.
|
||||
pub fn active_offset(
|
||||
workspace: &Workspace,
|
||||
diagnostics: &[SourceDiagnostic],
|
||||
state: &State,
|
||||
) -> iced::widget::operation::AbsoluteOffset {
|
||||
let rows = rows(workspace, diagnostics, state);
|
||||
let active = rows
|
||||
.iter()
|
||||
.position(|row| row.selected)
|
||||
.or_else(|| rows.iter().rposition(|row| row.contains_active));
|
||||
active.map_or_else(iced::widget::operation::AbsoluteOffset::default, |index| {
|
||||
iced::widget::operation::AbsoluteOffset {
|
||||
x: rows[index].depth.saturating_sub(1) as f32 * INDENT,
|
||||
y: (index as f32 * ROW_HEIGHT - ROW_HEIGHT).max(0.),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn append_children(
|
||||
pending: &mut Vec<Pending>,
|
||||
folders: &[Folder],
|
||||
files: &[Row],
|
||||
parent: usize,
|
||||
prefix: &str,
|
||||
depth: usize,
|
||||
) {
|
||||
let folder = &folders[parent];
|
||||
let mut children = folder.files.clone();
|
||||
children.sort_by_cached_key(|index| {
|
||||
(
|
||||
files[*index].name.to_lowercase(),
|
||||
files[*index].path.clone(),
|
||||
)
|
||||
});
|
||||
pending.extend(
|
||||
children
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|index| Pending::File(index, depth)),
|
||||
);
|
||||
let mut children: Vec<_> = folder.folders.values().copied().collect();
|
||||
children.sort_by_cached_key(|index| {
|
||||
(
|
||||
folders[*index].name.to_lowercase(),
|
||||
folders[*index].name.clone(),
|
||||
)
|
||||
});
|
||||
pending.extend(children.into_iter().rev().map(|index| {
|
||||
let name = &folders[index].name;
|
||||
let path = if prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{prefix}/{name}")
|
||||
};
|
||||
Pending::Folder(index, path, depth)
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn view<'a>(
|
||||
workspace: &Workspace,
|
||||
diagnostics: &[SourceDiagnostic],
|
||||
state: &State,
|
||||
) -> Element<'a, Event> {
|
||||
let mut content = column![].spacing(0);
|
||||
for entry in rows(workspace, diagnostics, state) {
|
||||
let hint = entry.tooltip();
|
||||
let (symbol, action, muted) = match entry.kind {
|
||||
RowKind::Folder { expanded, .. } => (
|
||||
if expanded { "⌄" } else { "›" },
|
||||
Event::ToggleFolder(entry.path.clone()),
|
||||
false,
|
||||
),
|
||||
RowKind::File {
|
||||
entry: is_entry,
|
||||
detached,
|
||||
..
|
||||
} => (
|
||||
if is_entry {
|
||||
"◆"
|
||||
} else if detached {
|
||||
"○"
|
||||
} else {
|
||||
"·"
|
||||
},
|
||||
Event::OpenFile(entry.path.clone()),
|
||||
detached,
|
||||
),
|
||||
};
|
||||
let caption = row![
|
||||
text(symbol).size(15).width(18),
|
||||
text(entry.name)
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(text::Wrapping::None)
|
||||
.color(if muted { style::MUTED } else { style::INK }),
|
||||
text(if entry.dirty_files > 0 { "*" } else { "" })
|
||||
.size(12)
|
||||
.color(style::TEAL),
|
||||
text(if entry.diagnostics > 0 { "!" } else { "" })
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
text(match entry.kind {
|
||||
RowKind::Folder { file_count, .. } => file_count.to_string(),
|
||||
RowKind::File { .. } => String::new(),
|
||||
})
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
let selected = entry.selected
|
||||
|| (entry.contains_active
|
||||
&& matches!(
|
||||
entry.kind,
|
||||
RowKind::Folder {
|
||||
expanded: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
let control = button(caption)
|
||||
.width(210. - entry.depth.min(2) as f32 * INDENT)
|
||||
.height(ROW_HEIGHT)
|
||||
.padding([7, 6])
|
||||
.clip(true)
|
||||
.on_press(action)
|
||||
.style(if selected {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
});
|
||||
let control: Element<'a, Event> = if matches!(entry.kind, RowKind::File { .. }) {
|
||||
let path = entry.path;
|
||||
context_menu::Area::new(control, move |point| Event::FileMenu(path.clone(), point))
|
||||
.into()
|
||||
} else {
|
||||
control.into()
|
||||
};
|
||||
content = content.push(
|
||||
row![
|
||||
Space::new().width(entry.depth as f32 * INDENT),
|
||||
Space::new().width(8),
|
||||
container(Space::new())
|
||||
.width(1)
|
||||
.height(ROW_HEIGHT)
|
||||
.style(|_| container::Style {
|
||||
background: Some(style::LINE.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
Space::new().width(7),
|
||||
tooltip(
|
||||
control,
|
||||
container(text(hint).size(12)).padding(8).style(style::card),
|
||||
tooltip::Position::Right,
|
||||
),
|
||||
]
|
||||
.height(ROW_HEIGHT)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
}
|
||||
if workspace.files.is_empty() {
|
||||
content = content.push(
|
||||
text("No source files are open.")
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
scrollable(content)
|
||||
.id("source-files")
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::{language, source_project::SourceBundle, validation::Severity};
|
||||
|
||||
fn workspace(paths: &[&str]) -> Workspace {
|
||||
Workspace::new(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: paths
|
||||
.iter()
|
||||
.map(|p| ((*p).into(), "// source\n".into()))
|
||||
.collect(),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn diagnostic(path: &str) -> SourceDiagnostic {
|
||||
SourceDiagnostic {
|
||||
path: path.into(),
|
||||
diagnostic: language::Diagnostic {
|
||||
severity: Severity::Error,
|
||||
code: "test".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 1,
|
||||
column: 1,
|
||||
message: "Source needs repair".into(),
|
||||
span: language::Span::default(),
|
||||
},
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_compact_and_sorted_before_files_without_losing_duplicate_basenames() {
|
||||
let workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"z.arcadia",
|
||||
"A.arcadia",
|
||||
"sa/parts/shared.arcadia",
|
||||
"oa/shared.arcadia",
|
||||
"sa/other.arcadia",
|
||||
"sa/Parts/another.arcadia",
|
||||
]);
|
||||
let mut state = State::default();
|
||||
let compact = rows(&workspace, &[], &state);
|
||||
assert_eq!(
|
||||
compact
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["oa", "sa", "A.arcadia", "project.arcadia", "z.arcadia"]
|
||||
);
|
||||
state.reveal("sa/parts/shared.arcadia");
|
||||
state.reveal("oa/shared.arcadia");
|
||||
let expanded = rows(&workspace, &[], &state);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"oa",
|
||||
"oa/shared.arcadia",
|
||||
"sa",
|
||||
"sa/Parts",
|
||||
"sa/parts",
|
||||
"sa/parts/shared.arcadia",
|
||||
"sa/other.arcadia",
|
||||
"A.arcadia",
|
||||
"project.arcadia",
|
||||
"z.arcadia"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.iter()
|
||||
.filter(|row| row.name == "shared.arcadia")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.iter()
|
||||
.find(|row| row.path == "sa/parts/shared.arcadia")
|
||||
.unwrap()
|
||||
.depth,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_folders_retain_active_dirty_and_diagnostic_markers() {
|
||||
let mut workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"sa/parts/first.arcadia",
|
||||
"sa/second.arcadia",
|
||||
]);
|
||||
workspace.active = "sa/parts/first.arcadia".into();
|
||||
workspace
|
||||
.files
|
||||
.get_mut(&workspace.active)
|
||||
.unwrap()
|
||||
.text
|
||||
.push_str("// draft\n");
|
||||
let issues = [
|
||||
diagnostic(&workspace.active),
|
||||
diagnostic("sa/second.arcadia"),
|
||||
diagnostic("missing.arcadia"),
|
||||
];
|
||||
let mut state = State::default();
|
||||
let compact = rows(&workspace, &issues, &state);
|
||||
let folder = compact.iter().find(|row| row.path == "sa").unwrap();
|
||||
assert!(folder.contains_active);
|
||||
assert!(!folder.selected);
|
||||
assert_eq!((folder.dirty_files, folder.diagnostics), (1, 2));
|
||||
assert!(folder.tooltip().contains("Contains the active source file"));
|
||||
state.reveal(&workspace.active);
|
||||
let expanded = rows(&workspace, &issues, &state);
|
||||
let active = expanded.iter().find(|row| row.selected).unwrap();
|
||||
assert_eq!(active.path, workspace.active);
|
||||
assert_eq!((active.dirty_files, active.diagnostics), (1, 1));
|
||||
assert!(active.tooltip().contains("1 modified source draft"));
|
||||
state.collapse_all();
|
||||
assert_eq!(rows(&workspace, &issues, &state), compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_buffers_keep_paths_and_only_clean_buffers_are_closable() {
|
||||
let mut workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"archive/clean.arcadia",
|
||||
"archive/draft.arcadia",
|
||||
]);
|
||||
workspace.reachable.retain(|path| path == "project.arcadia");
|
||||
workspace
|
||||
.files
|
||||
.get_mut("archive/draft.arcadia")
|
||||
.unwrap()
|
||||
.text
|
||||
.push_str("// retain\n");
|
||||
let mut state = State::default();
|
||||
state.toggle("archive");
|
||||
let all = rows(&workspace, &[], &state);
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|row| row.path == "archive/clean.arcadia")
|
||||
.unwrap()
|
||||
.kind,
|
||||
RowKind::File {
|
||||
entry: false,
|
||||
detached: true,
|
||||
closable: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|row| row.path == "archive/draft.arcadia")
|
||||
.unwrap()
|
||||
.kind,
|
||||
RowKind::File {
|
||||
entry: false,
|
||||
detached: true,
|
||||
closable: false
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|row| row.path == "project.arcadia")
|
||||
.unwrap()
|
||||
.kind,
|
||||
RowKind::File {
|
||||
entry: true,
|
||||
detached: false,
|
||||
closable: false
|
||||
}
|
||||
);
|
||||
state.toggle("archive");
|
||||
assert_eq!(rows(&workspace, &[], &state).len(), 2);
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_unicode_spaces_and_file_folder_name_collisions_are_not_normalized() {
|
||||
let paths = [
|
||||
"project.arcadia",
|
||||
"Réponse équipe/États du système.arcadia",
|
||||
"same.arcadia",
|
||||
"same.arcadia/nested.arcadia",
|
||||
];
|
||||
let workspace = workspace(&paths);
|
||||
let mut state = State::default();
|
||||
for path in paths {
|
||||
state.reveal(path);
|
||||
}
|
||||
let all = rows(&workspace, &[], &state);
|
||||
let file_paths: BTreeSet<_> = all
|
||||
.iter()
|
||||
.filter(|row| matches!(row.kind, RowKind::File { .. }))
|
||||
.map(|row| row.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(file_paths, paths.into_iter().collect());
|
||||
assert_eq!(
|
||||
all.iter().filter(|row| row.path == "same.arcadia").count(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
all.iter()
|
||||
.find(|row| row.name == "États du système.arcadia")
|
||||
.unwrap()
|
||||
.tooltip()
|
||||
.starts_with("Réponse équipe/États du système.arcadia\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_paths_build_expand_and_drop_on_a_small_stack() {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(|| {
|
||||
let file = format!("{}leaf.arcadia", "folder/".repeat(600));
|
||||
let workspace = workspace(&["project.arcadia", &file]);
|
||||
let mut state = State::default();
|
||||
assert_eq!(rows(&workspace, &[], &state).len(), 2);
|
||||
state.reveal(&file);
|
||||
let all = rows(&workspace, &[], &state);
|
||||
let leaf = all.iter().find(|row| row.path == file).unwrap();
|
||||
assert_eq!(leaf.depth, 600);
|
||||
assert_eq!(all.len(), 602);
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_scroll_offset_uses_the_visible_order_and_retains_ancestor_context() {
|
||||
let mut workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"a/first.arcadia",
|
||||
"b/inner/deeper/active.arcadia",
|
||||
]);
|
||||
workspace.active = "b/inner/deeper/active.arcadia".into();
|
||||
let mut state = State::default();
|
||||
state.reveal("a/first.arcadia");
|
||||
state.reveal(&workspace.active);
|
||||
let offset = active_offset(&workspace, &[], &state);
|
||||
assert_eq!((offset.x, offset.y), (32., 128.));
|
||||
state.toggle("b/inner");
|
||||
let offset = active_offset(&workspace, &[], &state);
|
||||
assert_eq!((offset.x, offset.y), (0., 64.));
|
||||
workspace.files.clear();
|
||||
let offset = active_offset(&workspace, &[], &state);
|
||||
assert_eq!((offset.x, offset.y), (0., 0.));
|
||||
}
|
||||
}
|
||||
@@ -1648,6 +1648,15 @@ fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
|
||||
);
|
||||
assert_eq!(
|
||||
p.diagrams[&diagram].routes[0].waypoints,
|
||||
vec![Position { x: 242., y: 192. }]
|
||||
vec![Position {
|
||||
x: 242.,
|
||||
y: view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| p.elements[&port.element].parent == Some(a))
|
||||
.unwrap()
|
||||
.position
|
||||
.y
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
+88
-299
@@ -41,22 +41,6 @@ pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn port_role(element: &oa_core::Element) -> String {
|
||||
match port_contract(element) {
|
||||
Some(PortContract::FunctionInput { .. }) => "Input".into(),
|
||||
Some(PortContract::FunctionOutput { .. }) => "Output".into(),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
}) => "Standard".into(),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
}) => format!("Flow · {orientation:?}"),
|
||||
_ => "Legacy".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let header = container(
|
||||
@@ -411,6 +395,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.filter(|(edge, _)| *edge == app.selected)
|
||||
.map(|(_, i)| i),
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
connection: app.connection.as_ref(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
@@ -439,7 +424,20 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.center(Fill)
|
||||
.into()
|
||||
};
|
||||
let mut tools = column![toolbar, modes].spacing(8);
|
||||
let choose = |tool: Option<crate::connection_tool::Tool>| {
|
||||
let active = app.connection.as_ref().map(|s| s.tool) == tool;
|
||||
button(text(tool.map_or("Select / move", |t| t.label())).size(12))
|
||||
.padding([8, 10])
|
||||
.on_press(Message::Connection(crate::connection_edit::Event::Choose(
|
||||
tool,
|
||||
)))
|
||||
.style(if active { button::primary } else { style::nav })
|
||||
};
|
||||
let mut palette = row![choose(None)].spacing(4);
|
||||
for tool in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||
palette = palette.push(choose(Some(*tool)));
|
||||
}
|
||||
let mut tools = column![toolbar, modes, palette].spacing(8);
|
||||
if let Some(diagram) = app.active_diagram {
|
||||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||||
@@ -451,39 +449,24 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||||
});
|
||||
let enabled = edge.is_some() && !promoted && !app.draft_dirty() && !app.source_dirty();
|
||||
let route = app.project().diagrams[&diagram]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == app.selected);
|
||||
let bend = app.selected_bend.filter(|(edge, index)| {
|
||||
*edge == app.selected && route.is_some_and(|r| *index < r.waypoints.len())
|
||||
});
|
||||
// Keep canvas origin/size stable when switching between a node and edge.
|
||||
tools = tools.push(
|
||||
row![
|
||||
button("Add bend").padding(8).on_press_maybe(
|
||||
(enabled && route.is_none_or(|r| r.waypoints.len() < 128))
|
||||
.then_some(Message::Route(crate::route_edit::Event::AddBend))
|
||||
),
|
||||
button("Remove bend").padding(8).on_press_maybe(
|
||||
(enabled && bend.is_some())
|
||||
.then_some(Message::Route(crate::route_edit::Event::RemoveBend))
|
||||
),
|
||||
button("Reset routing").padding(8).on_press_maybe(
|
||||
(enabled && route.is_some())
|
||||
.then_some(Message::Route(crate::route_edit::Event::Reset))
|
||||
),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
tools = tools.push(label(if promoted {
|
||||
let hint = if let Some(session) = &app.connection {
|
||||
if session.source.is_some() {
|
||||
if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
|
||||
} else {
|
||||
"Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels"
|
||||
}
|
||||
} else {
|
||||
session.tool.hint()
|
||||
}
|
||||
} else if promoted {
|
||||
"Expand endpoint containers to edit this connection route"
|
||||
} else if edge.is_some() {
|
||||
"Drag a line to add a bend · drag circles or the label · Esc cancels"
|
||||
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
|
||||
} else {
|
||||
"Drag to arrange · select a connection to edit its route · scroll to zoom"
|
||||
}));
|
||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
tools = tools.push(container(label(hint)).height(30));
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
@@ -554,7 +537,7 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
row![
|
||||
label("PROPERTIES"),
|
||||
Space::new().width(Fill),
|
||||
action("Source", Message::SourceForSelection)
|
||||
crate::model_menu::trigger(app)
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
]
|
||||
@@ -622,83 +605,47 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if e.kind() == Kind::Port {
|
||||
body = body.push(label(
|
||||
"Shown automatically on the boundary of its visible owner.",
|
||||
));
|
||||
} else if app
|
||||
.active_diagram
|
||||
.and_then(|id| p.diagrams.get(&id))
|
||||
.is_some_and(|d| !d.projection.includes(e.kind()))
|
||||
{
|
||||
body = body.push(label(
|
||||
"This element is outside the selected view's content.",
|
||||
));
|
||||
body = body.push(action(
|
||||
"Use all-relationships view",
|
||||
Message::Projection(ProjectionKind::Overview),
|
||||
));
|
||||
} else if let Some(node) = app.selected_node {
|
||||
body = body.push(
|
||||
row![
|
||||
action("Placement", Message::Select(node)),
|
||||
action("Fold / unfold", Message::ToggleCollapse),
|
||||
action("Hide", Message::HideSelected)
|
||||
]
|
||||
.spacing(2),
|
||||
);
|
||||
} else {
|
||||
body = body.push(action("Show in this diagram", Message::PlaceSelected));
|
||||
}
|
||||
}
|
||||
let kind = p.elements.get(&app.selected).map(Element::kind);
|
||||
let port_actions = crate::port_workflow::actions(p, app.selected);
|
||||
if !port_actions.is_empty() {
|
||||
body = body.push(label("PORTS & EXCHANGES"));
|
||||
for port_action in port_actions {
|
||||
use crate::port_workflow::Action;
|
||||
if matches!(port_action, Action::Flow | Action::Standard) {
|
||||
continue;
|
||||
}
|
||||
let caption = match port_action {
|
||||
Action::Component => "+ Add port…",
|
||||
Action::Input => "+ Input port…",
|
||||
Action::Output => "+ Output port…",
|
||||
_ => port_action.label(),
|
||||
};
|
||||
body = body.push(action(caption, Message::PortAction(port_action)).width(Fill));
|
||||
}
|
||||
if matches!(kind, Some(Kind::System | Kind::SystemActor)) {
|
||||
body = body.push(label(
|
||||
"Ports belong to this participant and connect boundary exchanges.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let ports: Vec<_> = p
|
||||
.children(app.selected)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
if !ports.is_empty() {
|
||||
body = body.push(label(format!("OWNED PORTS · {}", ports.len())));
|
||||
for port in ports {
|
||||
body = body.push(
|
||||
action(
|
||||
&format!("{} · {}", port.name, port_role(port)),
|
||||
Message::Select(port.id),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
// Keep a discoverable primary action; secondary actions live in Actions.
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::System | Kind::SystemActor))
|
||||
{
|
||||
body = body.push(
|
||||
button(text("+ Add port…").size(12))
|
||||
.padding(9)
|
||||
.width(Fill)
|
||||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||||
);
|
||||
}
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. })))
|
||||
{
|
||||
let draw = app.page == Page::Diagram
|
||||
&& crate::connection_tool::Tool::BoundaryExchange.can_start(p, app.selected)
|
||||
&& app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(p, diagram))
|
||||
.any(|id| id == app.selected)
|
||||
});
|
||||
body = body.push(
|
||||
button(text("Connect boundary exchange…").size(12))
|
||||
.padding(10)
|
||||
.width(Fill)
|
||||
.on_press(Message::AddRelationKind(
|
||||
button(
|
||||
text(if draw {
|
||||
"Draw boundary exchange"
|
||||
} else {
|
||||
"Configure boundary exchange…"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.padding(9)
|
||||
.width(Fill)
|
||||
.on_press(if draw {
|
||||
Message::Connection(crate::connection_edit::Event::Begin(
|
||||
crate::connection_tool::Tool::BoundaryExchange,
|
||||
app.selected,
|
||||
))
|
||||
} else {
|
||||
Message::AddRelationKind(
|
||||
RelationKind::BoundaryExchange,
|
||||
p.elements.get(&app.selected).is_some_and(|e| {
|
||||
matches!(
|
||||
@@ -709,62 +656,8 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
})
|
||||
)
|
||||
}),
|
||||
)),
|
||||
);
|
||||
body = body.push(label(
|
||||
"Choose the other participant's port in the next step.",
|
||||
));
|
||||
}
|
||||
let contextual: &[(&str, RelationKind, bool)] = match kind {
|
||||
Some(Kind::OperationalMission | Kind::Mission) => &[
|
||||
(
|
||||
"Exploit capability…",
|
||||
RelationKind::MissionCapability,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant…",
|
||||
RelationKind::MissionInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
Some(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,
|
||||
),
|
||||
],
|
||||
_ => &[],
|
||||
};
|
||||
if !contextual.is_empty() {
|
||||
let choices = contextual
|
||||
.iter()
|
||||
.map(|(label, kind, incoming)| crate::workflow::RelationAction {
|
||||
label,
|
||||
kind: *kind,
|
||||
incoming: *incoming,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
body = body.push(
|
||||
pick_list(choices, None::<crate::workflow::RelationAction>, |choice| {
|
||||
Message::AddRelationKind(choice.kind, choice.incoming)
|
||||
})
|
||||
.placeholder("Modeling actions…")
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(r) = p.relations.get(&app.selected) {
|
||||
@@ -788,82 +681,6 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
if let Some(d) = &app.draft {
|
||||
body = body.push(forms::view(d, p));
|
||||
}
|
||||
let links = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.source() == app.selected || r.target() == app.selected)
|
||||
.collect::<Vec<_>>();
|
||||
if !links.is_empty() {
|
||||
body = body
|
||||
.push(Space::new().height(6))
|
||||
.push(label("RELATED ELEMENTS"));
|
||||
for r in links {
|
||||
let other = if r.source() == app.selected {
|
||||
r.target()
|
||||
} else {
|
||||
r.source()
|
||||
};
|
||||
body = body.push(
|
||||
container(
|
||||
column![
|
||||
action(
|
||||
&format!("{} · {}", r.kind(), short(&r.name, 24)),
|
||||
Message::Select(r.id)
|
||||
)
|
||||
.width(Fill),
|
||||
action(
|
||||
&format!("→ {}", short(p.named(other), 34)),
|
||||
Message::Select(other)
|
||||
)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.style(style::subtle),
|
||||
);
|
||||
}
|
||||
}
|
||||
for t in p
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == app.selected || t.sa == app.selected)
|
||||
{
|
||||
let other = if t.oa == app.selected { t.sa } else { t.oa };
|
||||
body = body.push(
|
||||
action(
|
||||
&format!(
|
||||
"{}: {}",
|
||||
if t.oa == app.selected {
|
||||
"Realized by"
|
||||
} else {
|
||||
"Realizes"
|
||||
},
|
||||
short(p.named(other), 29)
|
||||
),
|
||||
Message::Select(other),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if p.layer_of(app.selected).is_some() {
|
||||
body = body
|
||||
.push(action("+ Relationship", Message::AddRelation).width(Fill))
|
||||
.push(
|
||||
button(text("Create / update counterpart…").size(12))
|
||||
.padding(10)
|
||||
.on_press(Message::StartTransition)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
body = body.push(action("+ Artifact reference", Message::AddArtifact).width(Fill));
|
||||
if app.selected != p.id {
|
||||
body = body.push(
|
||||
button(text("Delete from model…").size(11))
|
||||
.padding(9)
|
||||
.style(button::danger)
|
||||
.on_press(Message::Delete),
|
||||
);
|
||||
}
|
||||
container(
|
||||
column![
|
||||
tabs,
|
||||
@@ -912,7 +729,7 @@ fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
.color(style::kind_color(kind)),
|
||||
);
|
||||
for e in elements {
|
||||
content = content.push(
|
||||
content = content.push(crate::context_menu::Area::new(
|
||||
button(
|
||||
row![
|
||||
column![
|
||||
@@ -942,7 +759,8 @@ fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
style::nav
|
||||
})
|
||||
.on_press(Message::Select(e.id)),
|
||||
);
|
||||
move |anchor| crate::model_menu::open(e.id, anchor),
|
||||
));
|
||||
}
|
||||
}
|
||||
scrollable(content.padding(24)).into()
|
||||
@@ -1036,58 +854,29 @@ fn traces(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn source_files(app: &App) -> View<'_, Message> {
|
||||
let workspace = &app.source_workspace;
|
||||
let mut files = column![].spacing(3);
|
||||
for (path, buffer) in &workspace.files {
|
||||
let detached = !workspace.reachable.contains(path);
|
||||
let errors = app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.path == *path)
|
||||
.count();
|
||||
let filename = path.rsplit('/').next().unwrap_or(path);
|
||||
let detail: String = if detached {
|
||||
"Detached · retained on disk".into()
|
||||
} else if path == &workspace.entry {
|
||||
"Project entry".into()
|
||||
} else {
|
||||
path.rsplit_once('/')
|
||||
.map_or("Included source", |(parent, _)| parent)
|
||||
.into()
|
||||
};
|
||||
let caption = format!(
|
||||
"{}{}{}",
|
||||
if buffer.dirty() { "* " } else { "" },
|
||||
short(filename, 27),
|
||||
if errors > 0 { " !" } else { "" }
|
||||
);
|
||||
files = files.push(
|
||||
button(column![text(caption).size(12), label(detail)].spacing(3))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.on_press(Message::SourceFile(path.clone()))
|
||||
.style(if &workspace.active == path {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
);
|
||||
if detached && !buffer.dirty() {
|
||||
files = files.push(
|
||||
action(
|
||||
"Close detached buffer",
|
||||
Message::SourceCloseFile(path.clone()),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
let panel = column![
|
||||
text("SOURCE FILES").size(13),
|
||||
row![
|
||||
text("SOURCE FILES").size(13).width(Fill),
|
||||
crate::context_menu::Area::left(
|
||||
action("Actions ▾", Message::Menu(crate::model_menu::Event::Close)),
|
||||
move |anchor| Message::SourceTree(crate::source_tree::Event::FileMenu(
|
||||
workspace.active.clone(),
|
||||
anchor
|
||||
))
|
||||
)
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
label(format!(
|
||||
"{} files · one system model",
|
||||
workspace.files.len()
|
||||
)),
|
||||
scrollable(files).height(Fill),
|
||||
row![
|
||||
action("Collapse all", Message::SourceTreeCollapse),
|
||||
action("Locate", Message::SourceTreeLocate)
|
||||
]
|
||||
.spacing(3),
|
||||
crate::source_tree::view(workspace, &app.source_diagnostics, &app.source_tree)
|
||||
.map(Message::SourceTree),
|
||||
action("Load includes", Message::SourceLoadIncludes).width(Fill),
|
||||
text_input("oa/new-file.arcadia", &workspace.new_path)
|
||||
.size(12)
|
||||
|
||||
@@ -13,18 +13,6 @@ use iced::{
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RelationAction {
|
||||
pub label: &'static str,
|
||||
pub kind: RelationKind,
|
||||
pub incoming: bool,
|
||||
}
|
||||
impl std::fmt::Display for RelationAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Port(crate::port_workflow::Event),
|
||||
|
||||
Reference in New Issue
Block a user