diff --git a/README.md b/README.md index d9eca4a7..94a848e6 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The example opens in an architecture view with work nested inside its performers 1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. 2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version. -3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Select the port and choose **Connect boundary exchange**; the picker identifies compatible ports by owner, name and alias. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions. Use **Create relationship** for allocations, functional exchanges, capability involvement, mission links, state transitions and interface relationships; **Data & interfaces** holds reusable payloads and contracts. +3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts. 4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values. 5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. State machines contain regions and states/modes with guarded transitions, availability and state-action links. Conditions are descriptive, not executable simulation code. Creating records in Behavior or Data preserves your architecture layouts; **Show in this diagram** adds an explicit placement when desired. 6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata. @@ -55,13 +55,15 @@ The example opens in an architecture view with work nested inside its performers The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget. -Drag cards to move on a 10-unit grid, drag empty space to pan, and scroll to zoom. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file. +Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties. -Connections start with orthogonal routes that avoid cards and container headers. Select a connection and drag a line segment to add a bend, or choose **Add bend**. Drag its circles to adjust the route; **Remove bend** removes the selected point, and **Reset routing** restores automatic routing. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Typed ports remain attached to their owning elements. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment—automatic routing is bounded and does not guarantee an overlap-free result. +In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file. + +Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result. ## Edit the same model as text -**Textual model** has per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Projects can use relative includes to organize source into files. For example: +**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example: ```text arcadia 3; diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs index 92fe08b0..5765e4c8 100644 --- a/crates/oa-core/src/routing.rs +++ b/crates/oa-core/src/routing.rs @@ -315,9 +315,10 @@ pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView) } /// Transient route/label editing with unchanged projected nodes and ports. -/// Retains other edges from a settled baseline and scores the selected edge -/// against preceding stable IDs, exactly as a complete solve does. Recompute -/// the complete view after commit, or whenever node/port geometry changes. +/// Retains other edges from a settled baseline and scores the selected path +/// against preceding stable IDs. Labels avoid all currently displayed paths. +/// Recompute the complete view after commit: later paths and automatic labels +/// may settle again. Also recompute whenever node/port geometry changes. pub fn route_edge_preview( project: &Project, diagram: &Diagram, @@ -341,7 +342,6 @@ fn route_diagram_impl( ordered.sort_by_key(|e| e.id); let mut edges = BTreeMap::new(); let mut previous = Vec::new(); - let mut previous_labels = Vec::new(); let mut budget = VIEW_SEARCH_BUDGET; let mut bounds = BoundsBuilder::default(); for node in &view.nodes { @@ -356,7 +356,7 @@ fn route_diagram_impl( }, ); } - for edge in ordered { + for edge in &ordered { if let Some(cached) = preview.and_then(|(baseline, changed)| { (edge.id != changed) .then(|| baseline.edges.get(&edge.id)) @@ -365,10 +365,6 @@ fn route_diagram_impl( for &point in &cached.points { bounds.point(point); } - if !cached.label.text.is_empty() { - bounds.rect(cached.label.bounds.position, cached.label.bounds.size); - previous_labels.push(cached.label.bounds); - } record_segments(&mut previous, &cached.points); edges.insert(edge.id, cached.clone()); continue; @@ -383,19 +379,7 @@ fn route_diagram_impl( let custom_suspended = promoted; let route = if promoted { None } else { stored }; let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone()); - let mut traversable = BTreeSet::new(); - ancestors(edge.source, &nodes, &mut traversable); - ancestors(edge.target, &nodes, &mut traversable); - let obstacles: Vec<_> = rectangles - .iter() - .map(|r| { - let mut r = *r; - if traversable.contains(&r.id) { - r.bottom = r.bottom.min(r.top + HEADER); - } - r.inflate(CLEARANCE) - }) - .collect(); + let obstacles = edge_obstacles(edge, &nodes, &rectangles); let cached = preview.and_then(|(baseline, changed)| { (edge.id == changed) .then(|| baseline.edges.get(&edge.id)) @@ -406,24 +390,11 @@ fn route_diagram_impl( if cached.pins == pins && cached.custom_suspended == custom_suspended { // Label-only editing cannot change the connection beneath the // pointer, including a settled fallback with routing warnings. - let mut updated = cached.clone(); - updated.label = make_label( - project, - edge.id, - &updated.points, - route.and_then(|r| r.label.as_ref()), - &obstacles, - &previous_labels, - ); - for &point in &updated.points { + for &point in &cached.points { bounds.point(point); } - if !updated.label.text.is_empty() { - bounds.rect(updated.label.bounds.position, updated.label.bounds.size); - previous_labels.push(updated.label.bounds); - } - record_segments(&mut previous, &updated.points); - edges.insert(edge.id, updated); + record_segments(&mut previous, &cached.points); + edges.insert(edge.id, cached.clone()); continue; } } @@ -458,28 +429,16 @@ fn route_diagram_impl( .into_iter() .map(|v| if total > 0.0 { v / total } else { 0.0 }) .collect(); - let label = make_label( - project, - edge.id, - &points, - route.and_then(|r| r.label.as_ref()), - &obstacles, - &previous_labels, - ); for &point in &points { bounds.point(point); } - if !label.text.is_empty() { - bounds.rect(label.bounds.position, label.bounds.size); - previous_labels.push(label.bounds); - } record_segments(&mut previous, &points); edges.insert( edge.id, RoutedEdge { id: edge.id, points, - label, + label: empty_label(), pins, pin_fractions, warnings, @@ -488,12 +447,67 @@ fn route_diagram_impl( }, ); } + // Settle all paths before placing text: a later connection can cross an + // earlier label even when the labels themselves do not overlap. This pass + // never changes paths or authored metadata. Previews retain other cached + // labels and avoid the paths actually shown during the gesture. + let mut previous_labels = Vec::new(); + for edge in ordered { + let Some(routed) = edges.get_mut(&edge.id) else { + continue; + }; + if preview.is_none_or(|(baseline, changed)| { + edge.id == changed || !baseline.edges.contains_key(&edge.id) + }) { + let obstacles = edge_obstacles(edge, &nodes, &rectangles); + let authored_label = (!routed.custom_suspended) + .then(|| { + authored + .get(&edge.id) + .and_then(|route| route.label.as_ref()) + }) + .flatten(); + routed.label = make_label( + project, + edge.id, + &routed.points, + authored_label, + &obstacles, + &previous_labels, + &previous, + ); + } + if !routed.label.text.is_empty() { + bounds.rect(routed.label.bounds.position, routed.label.bounds.size); + previous_labels.push(routed.label.bounds); + } + } RoutedView { edges, bounds: bounds.finish(), } } +fn edge_obstacles( + edge: &ProjectedEdge, + nodes: &BTreeMap, + rectangles: &[Rect], +) -> Vec { + let mut traversable = BTreeSet::new(); + ancestors(edge.source, nodes, &mut traversable); + ancestors(edge.target, nodes, &mut traversable); + rectangles + .iter() + .map(|r| { + let mut r = *r; + if traversable.contains(&r.id) { + r.bottom = r.bottom.min(r.top + HEADER); + } + r.inflate(CLEARANCE) + }) + .collect() +} + #[derive(Clone, Copy)] struct Segment { a: Position, @@ -1017,6 +1031,22 @@ fn label_text(project: &Project, edge: Id) -> String { text.to_owned() } } +fn empty_label() -> EdgeLabelGeometry { + EdgeLabelGeometry { + text: String::new(), + fraction: 0.5, + anchor: pos(0.0, 0.0), + center: pos(0.0, 0.0), + bounds: RoutingBounds { + position: pos(0.0, 0.0), + size: Size { + width: 0.0, + height: 0.0, + }, + }, + } +} + fn make_label( project: &Project, edge: Id, @@ -1024,6 +1054,7 @@ fn make_label( authored: Option<&crate::EdgeLabel>, obstacles: &[Rect], previous_labels: &[RoutingBounds], + strokes: &[Segment], ) -> EdgeLabelGeometry { let text = label_text(project, edge); let size = Size { @@ -1048,8 +1079,8 @@ fn make_label( ) }; let mut fraction = authored.map_or(0.5, |l| l.fraction); - // Keep authored placement exact. Automatic labels share the stable-ID - // prefix used for route scoring, so previews and settled solves agree. + // Keep authored placement exact. Automatic labels avoid all visible + // strokes, with earlier stable-ID labels taking precedence. if authored.is_none() && !text.is_empty() { let total = total_length(points); let mut traversed = 0.0; @@ -1093,6 +1124,7 @@ fn make_label( candidate, obstacles, previous_labels, + strokes, best.as_ref().map(|(score, _, _)| *score), &mut comparisons, ) else { @@ -1101,7 +1133,7 @@ fn make_label( if best.as_ref().is_none_or(|(old, _, _)| score < *old) { best = Some((score, candidate_fraction, candidate_offset)); } - if score == (0, 0) { + if score == (0, 0, 0) { break 'candidate; } } @@ -1125,9 +1157,10 @@ fn label_collisions( bounds: RoutingBounds, obstacles: &[Rect], labels: &[RoutingBounds], - best: Option<(usize, usize)>, + strokes: &[Segment], + best: Option<(usize, usize, usize)>, comparisons: &mut usize, -) -> Option<(usize, usize)> { +) -> Option<(usize, usize, usize)> { let mut nodes = 0; for obstacle in obstacles { if *comparisons == 0 { @@ -1138,7 +1171,7 @@ fn label_collisions( nodes += 1; } if best.is_some_and(|score| nodes > score.0) { - return Some((nodes, usize::MAX)); + return Some((nodes, usize::MAX, usize::MAX)); } } // Once a candidate is worse than the current best, additional collisions @@ -1160,11 +1193,35 @@ fn label_collisions( { collisions += 1; if collisions >= label_limit { + return Some((nodes, collisions, usize::MAX)); + } + } + } + let text = Rect { + id: Id::nil(), + left: bounds.position.x, + top: bounds.position.y, + right: bounds.position.x + bounds.size.width, + bottom: bounds.position.y + bounds.size.height, + } + .inflate(3.0); + let stroke_limit = best + .filter(|score| (nodes, collisions) == (score.0, score.1)) + .map_or(usize::MAX, |score| score.2.saturating_add(1)); + let mut crossings = 0; + for stroke in strokes { + if *comparisons == 0 { + return None; + } + *comparisons -= 1; + if text.crosses(stroke.a, stroke.b) { + crossings += 1; + if crossings >= stroke_limit { break; } } } - Some((nodes, collisions)) + Some((nodes, collisions, crossings)) } #[derive(Default)] diff --git a/crates/oa-core/tests/routing_labels.rs b/crates/oa-core/tests/routing_labels.rs new file mode 100644 index 00000000..a6299a5e --- /dev/null +++ b/crates/oa-core/tests/routing_labels.rs @@ -0,0 +1,152 @@ +use oa_core::{projection::project_diagram, routing::*, *}; + +fn crosses(bounds: RoutingBounds, a: Position, b: Position) -> bool { + let left = bounds.position.x; + let top = bounds.position.y; + let right = left + bounds.size.width; + let bottom = top + bounds.size.height; + if a.x == b.x { + a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top + } else { + a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left + } +} + +fn fixture(functional_first: bool) -> (Project, Id, Id, Id) { + let mut project = Project::demo(); + let diagram = project + .diagrams + .values() + .find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation) + .unwrap() + .id; + // Freeze the observed default arrangement independently of random demo + // identities. The two parallel border connections are only 20 units apart. + for node in project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .values_mut() + { + let (x, y, width, height) = match project.elements[&node.element].alias.as_str() { + "vessel_communication_service" => (60., 60., 272., 220.), + "rescue_coordination_system" => (572., 60., 272., 220.), + "transmit_distress_report" => (84., 124., 224., 132.), + "classify_incident_and_alert_operator" => (596., 124., 224., 132.), + alias => panic!("Unexpected demo placement {alias}"), + }; + node.position = Position { x, y }; + node.size = Size { width, height }; + } + let old_fe = project + .relations + .values() + .find(|r| r.alias == "validated_distress_report") + .unwrap() + .id; + let old_boundary = project + .relations + .values() + .find(|r| r.alias == "distress_reporting_connection") + .unwrap() + .id; + let fe = Id::from_u128(if functional_first { 1 } else { 2 }); + let boundary = Id::from_u128(if functional_first { 2 } else { 1 }); + let project = execute_batch( + &project, + &[ + Command::Reidentify(old_fe, fe), + Command::Reidentify(old_boundary, boundary), + ], + ) + .unwrap(); + (project, diagram, fe, boundary) +} + +fn stroke_collisions(project: &Project, routed: &RoutedView) -> Vec { + let mut collisions = Vec::new(); + for edge in routed.edges.values().filter(|e| !e.label.text.is_empty()) { + for other in routed.edges.values().filter(|e| e.id != edge.id) { + for segment in other.points.windows(2) { + if crosses(edge.label.bounds, segment[0], segment[1]) { + collisions.push(format!( + "{} ({}) label {:?} crosses {} ({}) segment {:?}", + edge.label.text, + edge.id, + edge.label.bounds, + project.named(other.id), + other.id, + segment + )); + } + } + } + } + collisions +} + +#[test] +fn demo_architecture_labels_do_not_cover_earlier_or_later_connection_strokes() { + for functional_first in [true, false] { + let (project, diagram, _, _) = fixture(functional_first); + let before = project.clone(); + let d = &project.diagrams[&diagram]; + let view = project_diagram(&project, d); + let routed = route_diagram(&project, d, &view); + let collisions = stroke_collisions(&project, &routed); + assert!(collisions.is_empty(), "{collisions:#?}"); + assert_eq!(project, before); + assert_eq!(routed, route_diagram(&project, d, &view)); + } +} + +#[test] +fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() { + let (mut project, diagram, fe, _) = fixture(true); + let view = project_diagram(&project, &project.diagrams[&diagram]); + let automatic = route_diagram(&project, &project.diagrams[&diagram], &view); + let authored = EdgeLabel { + fraction: 0.5, + offset: Position { x: 0., y: -18. }, + }; + project + .diagrams + .get_mut(&diagram) + .unwrap() + .routes + .push(EdgeRoute { + edge: fe, + waypoints: vec![], + label: Some(authored.clone()), + }); + let authored_snapshot = project.clone(); + let explicit = route_diagram(&project, &project.diagrams[&diagram], &view); + let label = &explicit.edges[&fe].label; + assert_eq!(label.fraction, authored.fraction); + assert_eq!( + label.center, + Position { + x: label.anchor.x + authored.offset.x, + y: label.anchor.y + authored.offset.y + } + ); + assert!( + !stroke_collisions(&project, &explicit).is_empty(), + "The authored label deliberately retains the native overlap" + ); + assert_eq!(project, authored_snapshot); + for (&id, edge) in &automatic.edges { + assert_eq!(edge.points, explicit.edges[&id].points); + assert_eq!(edge.pins, explicit.edges[&id].pins); + assert_eq!(edge.warnings, explicit.edges[&id].warnings); + } + project.diagrams.get_mut(&diagram).unwrap().routes[0].label = None; + let reset = route_edge_preview(&project, &project.diagrams[&diagram], &view, &explicit, fe); + assert_eq!(reset.edges[&fe], automatic.edges[&fe]); + for (&id, edge) in &explicit.edges { + if id != fe { + assert_eq!(reset.edges[&id], *edge); + } + } +} diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 36491d56..cc86954d 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -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, + pub model_menu: Option, + 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 { 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 { + // 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 { 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., diff --git a/crates/oa-desktop/src/connection_edit.rs b/crates/oa-desktop/src/connection_edit.rs new file mode 100644 index 00000000..bd627fae --- /dev/null +++ b/crates/oa-desktop/src/connection_edit.rs @@ -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), + Begin(Tool, Id), + Endpoint(Context, Id), + Restart(Context), +} + +#[derive(Debug, Clone)] +pub struct Session { + pub context: Context, + pub tool: Tool, + pub source: Option, + pub candidates: BTreeSet, +} + +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 + '_ { + 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"); + } +} diff --git a/crates/oa-desktop/src/connection_tool.rs b/crates/oa-desktop/src/connection_tool.rs new file mode 100644 index 00000000..7493df8a --- /dev/null +++ b/crates/oa-desktop/src/connection_tool.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/oa-desktop/src/context_menu.rs b/crates/oa-desktop/src/context_menu.rs new file mode 100644 index 00000000..34a3ef6f --- /dev/null +++ b/crates/oa-desktop/src/context_menu.rs @@ -0,0 +1,1570 @@ +//! Controlled contextual menus. Keep `Popup` around the root even when closed +//! so a dismissal click's release cannot reach the widget underneath it. +//! Each invocation gets a fresh token; action messages should carry that token +//! for application-level context/revision validation. The application closes +//! the menu when handling an action; `on_close` handles dismissal only. +use crate::style; +use iced::advanced::Renderer as _; +use iced::{ + Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme, Vector, + advanced::{ + Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + text::{self, Paragraph as _, Renderer as _}, + widget::{Operation, Tree, tree}, + }, + alignment, keyboard, mouse, touch, window, +}; + +const PAD: f32 = 6.; +const GAP: f32 = 8.; +const ROW: f32 = 34.; + +#[derive(Debug, Clone)] +pub enum Entry { + Title(String), + Separator, + Action { + label: String, + shortcut: Option, + message: Option, + }, +} +impl Entry { + pub fn title(label: impl Into) -> Self { + Self::Title(label.into()) + } + pub fn separator() -> Self { + Self::Separator + } + pub fn action(label: impl Into, message: Message) -> Self { + Self::Action { + label: label.into(), + shortcut: None, + message: Some(message), + } + } + pub fn disabled(label: impl Into) -> Self { + Self::Action { + label: label.into(), + shortcut: None, + message: None, + } + } + pub fn shortcut(mut self, shortcut: impl Into) -> Self { + if let Self::Action { + shortcut: label, .. + } = &mut self + { + *label = Some(shortcut.into()); + } + self + } + fn height(&self) -> f32 { + match self { + Self::Title(_) => 28., + Self::Separator => 9., + Self::Action { .. } => ROW, + } + } + fn enabled(&self) -> bool { + matches!( + self, + Self::Action { + message: Some(_), + .. + } + ) + } +} + +#[derive(Debug, Clone)] +pub struct Menu { + pub token: u64, + /// Absolute window coordinates, as returned by `Area` or the diagram. + pub anchor: Point, + pub entries: Vec>, + pub on_close: Message, +} + +pub struct Popup<'a, Message> { + content: Element<'a, Message>, + menu: Option>, +} +impl<'a, Message> Popup<'a, Message> { + pub fn new(content: impl Into>, menu: Option>) -> Self { + Self { + content: content.into(), + menu, + } + } +} + +#[derive(Default)] +struct State { + token: Option, + focused: Option, + pressed: Option, + scroll: f32, + scroll_drag: Option, + closing: bool, + swallow_mouse_release: Option, + swallow_touch_release: Option, +} +impl State { + fn reconcile(&mut self, menu: Option<&Menu>) { + let token = menu.map(|menu| menu.token); + if self.token != token { + self.token = token; + self.focused = menu.and_then(|menu| menu.entries.iter().position(Entry::enabled)); + self.pressed = None; + self.scroll = 0.; + self.scroll_drag = None; + self.closing = false; + } + if let Some(menu) = menu { + self.focused = self + .focused + .filter(|index| menu.entries.get(*index).is_some_and(Entry::enabled)); + self.pressed = self + .pressed + .filter(|index| menu.entries.get(*index).is_some_and(Entry::enabled)); + } + } + fn swallow_release(&mut self, event: &Event) -> bool { + match event { + Event::Mouse(mouse::Event::ButtonReleased(button)) + if self.swallow_mouse_release == Some(*button) => + { + self.swallow_mouse_release = None; + true + } + Event::Touch( + touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. }, + ) if self.swallow_touch_release == Some(*id) => { + self.swallow_touch_release = None; + true + } + _ => false, + } + } + fn navigate( + &mut self, + entries: &[Entry], + direction: Navigation, + height: f32, + ) { + self.focused = next_enabled(entries, self.focused, direction); + self.pressed = None; + if let Some(index) = self.focused { + let top: f32 = entries[..index].iter().map(Entry::height).sum(); + let bottom = top + entries[index].height(); + if top < self.scroll { + self.scroll = top; + } + if bottom > self.scroll + height { + self.scroll = (bottom - height).max(0.); + } + } + } + fn dismiss(&mut self, menu: &Menu, shell: &mut Shell<'_, Message>) { + if !self.closing { + self.closing = true; + shell.publish(menu.on_close.clone()); + } + } + fn activate( + &mut self, + menu: &Menu, + index: usize, + shell: &mut Shell<'_, Message>, + ) { + if !self.closing + && let Some(Entry::Action { + message: Some(message), + .. + }) = menu.entries.get(index) + { + self.closing = true; + shell.publish(message.clone()); + } + } + fn update( + &mut self, + menu: &Menu, + geometry: Geometry, + event: &Event, + cursor: mouse::Cursor, + shell: &mut Shell<'_, Message>, + ) { + let hit = |point| geometry.hit(&menu.entries, self.scroll, point); + if self.closing { + // Iced can deliver press and release in one event batch before the + // application handles Close. Do not carry that consumed release + // into the next independent click after the controlled menu closes. + self.swallow_release(event); + if input_event(event) { + shell.capture_event(); + } + return; + } + match event { + Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => match key { + keyboard::Key::Named(keyboard::key::Named::Escape) => self.dismiss(menu, shell), + keyboard::Key::Named(keyboard::key::Named::ArrowDown) => { + self.navigate(&menu.entries, Navigation::Next, geometry.inner.height) + } + keyboard::Key::Named(keyboard::key::Named::ArrowUp) => { + self.navigate(&menu.entries, Navigation::Previous, geometry.inner.height) + } + keyboard::Key::Named(keyboard::key::Named::Home) => { + self.navigate(&menu.entries, Navigation::First, geometry.inner.height) + } + keyboard::Key::Named(keyboard::key::Named::End) => { + self.navigate(&menu.entries, Navigation::Last, geometry.inner.height) + } + keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space) => { + if let Some(index) = self.focused { + self.activate(menu, index, shell); + } + } + _ => {} + }, + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if let Some(offset) = self.scroll_drag { + self.scroll = geometry.scroll_for_pointer(*position, offset); + } else { + self.focused = hit(*position).filter(|index| menu.entries[*index].enabled()); + } + } + Event::Mouse(mouse::Event::ButtonPressed(button)) => { + if let Some(point) = cursor.position() { + if !geometry.menu.contains(point) { + self.swallow_mouse_release = Some(*button); + self.dismiss(menu, shell); + } else if *button == mouse::Button::Left { + if let Some(track) = geometry.scroll_track() + && track.contains(point) + { + let thumb = geometry.scroll_thumb(self.scroll).unwrap(); + let offset = if thumb.contains(point) { + point.y - thumb.y + } else { + thumb.height / 2. + }; + self.scroll = geometry.scroll_for_pointer(point, offset); + self.scroll_drag = Some(offset); + self.pressed = None; + } else { + self.pressed = + hit(point).filter(|index| menu.entries[*index].enabled()); + self.focused = self.pressed; + } + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if self.scroll_drag.take().is_none() + && let Some(index) = self.pressed.take() + && cursor.position().and_then(hit) == Some(index) + { + self.activate(menu, index, shell); + } + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + if cursor.is_over(geometry.menu) { + let y = match delta { + mouse::ScrollDelta::Lines { y, .. } => y * ROW, + mouse::ScrollDelta::Pixels { y, .. } => *y, + }; + self.scroll = (self.scroll - y).clamp(0., geometry.max_scroll()); + self.focused = cursor + .position() + .and_then(|point| geometry.hit(&menu.entries, self.scroll, point)) + .filter(|index| menu.entries[*index].enabled()); + self.pressed = None; + } + } + Event::Touch(touch::Event::FingerPressed { id, position }) => { + if !geometry.menu.contains(*position) { + self.swallow_touch_release = Some(*id); + self.dismiss(menu, shell); + } else { + self.pressed = hit(*position).filter(|index| menu.entries[*index].enabled()); + self.focused = self.pressed; + } + } + Event::Touch(touch::Event::FingerLifted { position, .. }) => { + if let Some(index) = self.pressed.take() + && hit(*position) == Some(index) + { + self.activate(menu, index, shell); + } + } + Event::Touch(touch::Event::FingerLost { .. }) => self.pressed = None, + Event::Window(window::Event::Unfocused) => self.dismiss(menu, shell), + _ => {} + } + if input_event(event) { + shell.capture_event(); + shell.request_redraw(); + } + } +} + +fn input_event(event: &Event) -> bool { + matches!( + event, + Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_) + ) +} + +#[derive(Clone, Copy)] +enum Navigation { + First, + Last, + Next, + Previous, +} +fn next_enabled( + entries: &[Entry], + current: Option, + direction: Navigation, +) -> Option { + let count = entries.len(); + let current = current.filter(|index| *index < count); + let start = match direction { + Navigation::First => 0, + Navigation::Last => count.saturating_sub(1), + Navigation::Next => current.map_or(0, |index| (index + 1) % count.max(1)), + Navigation::Previous => current.map_or(count.saturating_sub(1), |index| { + (index + count - 1) % count.max(1) + }), + }; + let backwards = matches!(direction, Navigation::Last | Navigation::Previous); + (0..count) + .map(|step| { + if backwards { + (start + count - step) % count + } else { + (start + step) % count + } + }) + .find(|index| entries[*index].enabled()) +} + +#[derive(Clone, Copy)] +struct Geometry { + menu: Rectangle, + inner: Rectangle, + content_height: f32, +} +impl Geometry { + fn new(anchor: Point, width: f32, content_height: f32, viewport: Size) -> Self { + let width = width.min((viewport.width - 2. * GAP).max(0.)); + let height = (content_height + 2. * PAD).min((viewport.height - 2. * GAP).max(0.)); + let bounded = |value: f32, max: f32| { + if value.is_finite() { + value.clamp(GAP.min(max), max) + } else { + GAP.min(max) + } + }; + let x = bounded(anchor.x, (viewport.width - width - GAP).max(0.)); + let y = bounded(anchor.y, (viewport.height - height - GAP).max(0.)); + let menu = Rectangle { + x, + y, + width, + height, + }; + Self::from_bounds(menu, content_height) + } + fn from_bounds(menu: Rectangle, content_height: f32) -> Self { + let inner = Rectangle { + x: menu.x + PAD, + y: menu.y + PAD, + width: (menu.width - 2. * PAD).max(0.), + height: (menu.height - 2. * PAD).max(0.), + }; + Self { + menu, + inner, + content_height, + } + } + fn max_scroll(self) -> f32 { + (self.content_height - self.inner.height).max(0.) + } + fn row(self, top: f32, height: f32, scroll: f32) -> Rectangle { + Rectangle { + x: self.inner.x, + y: self.inner.y + top - scroll, + width: (self.inner.width - if self.max_scroll() > 0. { 12. } else { 0. }).max(0.), + height, + } + } + fn hit(self, entries: &[Entry], scroll: f32, point: Point) -> Option { + if !self.inner.contains(point) + || self + .scroll_track() + .is_some_and(|track| track.contains(point)) + { + return None; + } + let mut top = 0.; + entries.iter().position(|entry| { + let contains = self.row(top, entry.height(), scroll).contains(point); + top += entry.height(); + contains + }) + } + fn scroll_track(self) -> Option { + (self.max_scroll() > 0.).then_some(Rectangle { + x: self.inner.x + (self.inner.width - 8.).max(0.), + width: 8f32.min(self.inner.width), + ..self.inner + }) + } + fn scroll_thumb(self, scroll: f32) -> Option { + let track = self.scroll_track()?; + let height = (track.height * track.height / self.content_height) + .max(24.) + .min(track.height); + Some(Rectangle { + y: track.y + (track.height - height) * (scroll / self.max_scroll()).clamp(0., 1.), + height, + ..track + }) + } + fn scroll_for_pointer(self, point: Point, offset: f32) -> f32 { + let Some(track) = self.scroll_track() else { + return 0.; + }; + let height = self.scroll_thumb(0.).unwrap().height; + if track.height <= height { + return 0.; + } + ((point.y - track.y - offset) / (track.height - height)).clamp(0., 1.) * self.max_scroll() + } +} + +impl Widget for Popup<'_, Message> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + let mut state = State::default(); + state.reconcile(self.menu.as_ref()); + tree::State::new(state) + } + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + tree.state + .downcast_mut::() + .reconcile(self.menu.as_ref()); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::(); + if self.menu.is_some() && input_event(event) || state.swallow_release(event) { + shell.capture_event(); + return; + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + renderer_style, + layout, + if self.menu.is_some() { + mouse::Cursor::Unavailable + } else { + cursor + }, + viewport, + ); + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + if self.menu.is_some() { + mouse::Interaction::None + } else { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + if let Some(menu) = self.menu.as_ref() { + Some(overlay::Element::new(Box::new(MenuOverlay { + menu, + state: tree.state.downcast_mut(), + }))) + } else { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } + } +} +impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { + fn from(popup: Popup<'a, Message>) -> Self { + Element::new(popup) + } +} + +struct MenuOverlay<'a, Message> { + menu: &'a Menu, + state: &'a mut State, +} +impl MenuOverlay<'_, Message> { + fn geometry(&self, layout: Layout<'_>) -> Geometry { + // Iced reconstructs overlays between layout, update and draw. Only the + // retained Layout owns the measured bounds, never a transient overlay. + Geometry::from_bounds( + layout + .children() + .next() + .expect("Menu overlay layout contains its measured menu bounds") + .bounds(), + self.menu.entries.iter().map(Entry::height).sum(), + ) + } + fn layout_with_font(&mut self, font: iced::Font, bounds: Size) -> layout::Node { + let width = self + .menu + .entries + .iter() + .map(|entry| match entry { + Entry::Title(label) => measure(label, 12., font) + 32., + Entry::Separator => 0., + Entry::Action { + label, shortcut, .. + } => { + measure(label, 13., font) + + shortcut + .as_ref() + .map_or(0., |label| measure(label, 12., font) + 28.) + + 40. + } + }) + .fold(240f32, f32::max) + .min(480.); + let geometry = Geometry::new( + self.menu.anchor, + width, + self.menu.entries.iter().map(Entry::height).sum(), + bounds, + ); + self.state.scroll = self.state.scroll.clamp(0., geometry.max_scroll()); + // The overlay covers the whole window so outside presses are captured. + layout::Node::with_children( + bounds, + vec![layout::Node::new(geometry.menu.size()).move_to(geometry.menu.position())], + ) + } +} +impl overlay::Overlay for MenuOverlay<'_, Message> { + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + self.layout_with_font(renderer.default_font(), bounds) + } + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + let geometry = self.geometry(layout); + self.state.update(self.menu, geometry, event, cursor, shell); + } + fn draw( + &self, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + ) { + let geometry = self.geometry(layout); + renderer.fill_quad( + renderer::Quad { + bounds: geometry.menu, + border: Border { + color: style::LINE, + width: 1., + radius: 8.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.1, 0.15, 0.2, 0.17), + offset: Vector::new(0., 4.), + blur_radius: 12., + }, + ..Default::default() + }, + Color::WHITE, + ); + renderer.with_layer(geometry.inner, |renderer| { + let mut top = 0.; + for (index, entry) in self.menu.entries.iter().enumerate() { + let row = geometry.row(top, entry.height(), self.state.scroll); + top += entry.height(); + if !row.intersects(&geometry.inner) { + continue; + } + match entry { + Entry::Separator => renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: row.x + 8., + y: row.center_y(), + width: (row.width - 16.).max(0.), + height: 1., + }, + ..Default::default() + }, + style::LINE, + ), + Entry::Title(label) => draw_label( + renderer, + label, + 12., + style::MUTED, + Rectangle { + x: row.x + 9., + width: (row.width - 18.).max(0.), + ..row + }, + text::Alignment::Left, + ), + Entry::Action { + label, + shortcut, + message, + } => { + if self.state.focused == Some(index) && message.is_some() { + renderer.fill_quad( + renderer::Quad { + bounds: row, + border: Border { + radius: 5.into(), + ..Default::default() + }, + ..Default::default() + }, + Color::from_rgb8(218, 238, 235), + ); + } + let color = if message.is_some() { + style::INK + } else { + Color { + a: 0.65, + ..style::MUTED + } + }; + let suffix = shortcut.as_ref().map_or(0., |label| { + measure(label, 12., renderer.default_font()).min(row.width * 0.45) + 22. + }); + draw_label( + renderer, + label, + 13., + color, + Rectangle { + x: row.x + 10., + width: (row.width - suffix - 20.).max(0.), + ..row + }, + text::Alignment::Left, + ); + if let Some(shortcut) = shortcut { + draw_label( + renderer, + shortcut, + 12., + if message.is_some() { + style::MUTED + } else { + color + }, + Rectangle { + x: row.x + row.width - suffix, + width: (suffix - 10.).max(0.), + ..row + }, + text::Alignment::Right, + ); + } + } + } + } + if let Some(thumb) = geometry.scroll_thumb(self.state.scroll) { + renderer.fill_quad( + renderer::Quad { + bounds: thumb, + border: Border { + radius: 4.into(), + ..Default::default() + }, + ..Default::default() + }, + style::LINE, + ); + } + }); + } + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + ) -> mouse::Interaction { + let geometry = self.geometry(layout); + if cursor.position().is_some_and(|point| { + geometry + .hit(&self.menu.entries, self.state.scroll, point) + .is_some_and(|index| self.menu.entries[index].enabled()) + || geometry + .scroll_track() + .is_some_and(|track| track.contains(point)) + }) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::None + } + } +} + +fn measure(label: &str, size: f32, font: iced::Font) -> f32 { + ::Paragraph::with_text(text::Text { + content: label, + bounds: Size::INFINITE, + size: size.into(), + line_height: text::LineHeight::default(), + font, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + }) + .min_width() +} +fn ellipsize(label: &str, width: f32, size: f32, font: iced::Font) -> String { + if width <= 0. { + return String::new(); + } + if measure(label, size, font) <= width { + return label.to_owned(); + } + if measure("…", size, font) > width { + return String::new(); + } + let ends: Vec<_> = label + .char_indices() + .map(|(index, _)| index) + .chain(std::iter::once(label.len())) + .collect(); + let (mut low, mut high) = (0, ends.len()); + while low + 1 < high { + let middle = (low + high) / 2; + if measure(&format!("{}…", &label[..ends[middle]]), size, font) <= width { + low = middle; + } else { + high = middle; + } + } + format!("{}…", &label[..ends[low]]) +} +fn draw_label( + renderer: &mut Renderer, + label: &str, + size: f32, + color: Color, + bounds: Rectangle, + align_x: text::Alignment, +) { + let font = renderer.default_font(); + let content = ellipsize(label, bounds.width, size, font); + renderer.fill_text( + text::Text { + content, + bounds: bounds.size(), + size: size.into(), + line_height: text::LineHeight::default(), + font, + align_x, + align_y: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + }, + Point::new( + if align_x == text::Alignment::Right { + bounds.x + bounds.width + } else { + bounds.x + }, + bounds.center_y(), + ), + color, + bounds, + ); +} + +/// Adds a contextual trigger without changing the child's layout or overlays. +/// Right-click anchors at the absolute cursor position. `left` anchors below +/// the child and intercepts its left press before a nested button can consume it. +pub struct Area<'a, Message> { + content: Element<'a, Message>, + on_open: Box Message + 'a>, + button: mouse::Button, +} +impl<'a, Message> Area<'a, Message> { + pub fn new( + content: impl Into>, + on_open: impl Fn(Point) -> Message + 'a, + ) -> Self { + Self { + content: content.into(), + on_open: Box::new(on_open), + button: mouse::Button::Right, + } + } + pub fn left( + content: impl Into>, + on_open: impl Fn(Point) -> Message + 'a, + ) -> Self { + Self { + content: content.into(), + on_open: Box::new(on_open), + button: mouse::Button::Left, + } + } +} +#[derive(Default)] +struct AreaState { + release: Option, +} +impl AreaState { + fn consume_release(&mut self, event: &Event) -> bool { + // The popup may consume the opening release. A new press belongs to + // another click, including one outside this Area after Escape. + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) | Event::Window(window::Event::Unfocused) + ) { + self.release = None; + } + if let Event::Mouse(mouse::Event::ButtonReleased(button)) = event + && self.release == Some(*button) + { + self.release = None; + return true; + } + false + } +} +impl Widget for Area<'_, Message> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(AreaState::default()) + } + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::(); + if state.consume_release(event) { + shell.capture_event(); + return; + } + if let Event::Mouse(mouse::Event::ButtonPressed(button)) = event + && *button == self.button + && cursor.is_over(layout.bounds()) + && cursor.is_over(*viewport) + && let Some(position) = cursor.position() + { + state.release = Some(*button); + let anchor = if self.button == mouse::Button::Left { + Point::new( + layout.bounds().x, + layout.bounds().y + layout.bounds().height, + ) + } else { + position + }; + shell.publish((self.on_open)(anchor)); + shell.capture_event(); + return; + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + renderer_style, + layout, + cursor, + viewport, + ); + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + if self.button == mouse::Button::Left + && cursor.is_over(layout.bounds()) + && cursor.is_over(*viewport) + { + mouse::Interaction::Pointer + } else { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} +impl<'a, Message: 'a> From> for Element<'a, Message> { + fn from(area: Area<'a, Message>) -> Self { + Element::new(area) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn actions_area_does_not_swallow_first_independent_click_after_overlay_consumed_its_release() { + let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)); + let mut state = AreaState { + release: Some(mouse::Button::Left), + }; + // Opening release went to the menu; Escape closed it. The following + // Revert press is outside the Area, so no new release is armed. + assert!( + !state.consume_release(&Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Left + ))) + ); + assert!(!state.consume_release(&release)); + // A coalesced release arriving before the popup mounts is still caught. + state.release = Some(mouse::Button::Left); + assert!(state.consume_release(&release)); + assert!(!state.consume_release(&release)); + } + + fn menu() -> Menu { + Menu { + token: 1, + anchor: Point::new(1060., 660.), + entries: vec![ + Entry::title("Selected element"), + Entry::action("Add port", 11).shortcut("P"), + Entry::disabled("Remove bend"), + Entry::separator(), + Entry::action("Properties", 22), + Entry::disabled("Unavailable action"), + ], + on_close: 99, + } + } + fn geometry(menu: &Menu) -> Geometry { + Geometry::new( + menu.anchor, + 320., + menu.entries.iter().map(Entry::height).sum(), + Size::new(1100., 700.), + ) + } + fn key(named: keyboard::key::Named) -> Event { + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(named), + modified_key: keyboard::Key::Named(named), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }) + } + fn dispatch( + state: &mut State, + menu: &Menu, + event: Event, + point: Option, + ) -> (Vec, bool) { + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + state.update( + menu, + geometry(menu), + &event, + point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available), + &mut shell, + ); + let captured = shell.is_event_captured(); + (messages, captured) + } + fn row_center(menu: &Menu, index: usize, scroll: f32) -> Point { + let top = menu.entries[..index].iter().map(Entry::height).sum(); + geometry(menu) + .row(top, menu.entries[index].height(), scroll) + .center() + } + + #[test] + fn reconstructed_overlay_uses_retained_layout_for_rows_and_hit_testing() { + let menu = menu(); + let mut state = State::default(); + state.reconcile(Some(&menu)); + // Iced lays out one overlay, drops it, then reconstructs the overlay + // for update/draw while retaining the layout tree from the first one. + let node = MenuOverlay { + menu: &menu, + state: &mut state, + } + .layout_with_font(iced::Font::DEFAULT, Size::new(1100., 700.)); + let overlay = MenuOverlay { + menu: &menu, + state: &mut state, + }; + let geometry = overlay.geometry(Layout::new(&node)); + assert_eq!( + geometry.menu.height, + menu.entries.iter().map(Entry::height).sum::() + 2. * PAD + ); + assert_eq!(geometry.menu, node.children()[0].bounds()); + assert!(geometry.menu.width >= 240.); + let top = menu.entries[..4].iter().map(Entry::height).sum(); + let point = geometry.row(top, ROW, 0.).center(); + assert!(geometry.inner.contains(point)); + assert_eq!(geometry.hit(&menu.entries, 0., point), Some(4)); + let mut messages = Vec::new(); + for event in [ + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + // A fresh overlay must not lose geometry even between the two + // halves of a click on a visible action near the bottom. + let overlay = MenuOverlay { + menu: &menu, + state: &mut state, + }; + let mut shell = Shell::new(&mut messages); + overlay.state.update( + &menu, + overlay.geometry(Layout::new(&node)), + &Event::Mouse(event), + mouse::Cursor::Available(point), + &mut shell, + ); + assert!(shell.is_event_captured()); + } + assert_eq!(messages, [22]); + } + + #[test] + fn retained_overlay_layout_keeps_measured_width_and_scrollbar_after_reconstruction() { + let menu = Menu { + entries: (0..90) + .map(|index| { + Entry::action( + format!("Action {index} with a longer contextual description"), + index, + ) + .shortcut("Ctrl+Shift+Enter") + }) + .collect(), + ..menu() + }; + let mut state = State::default(); + state.reconcile(Some(&menu)); + let node = MenuOverlay { + menu: &menu, + state: &mut state, + } + .layout_with_font(iced::Font::DEFAULT, Size::new(1100., 700.)); + let overlay = MenuOverlay { + menu: &menu, + state: &mut state, + }; + let geometry = overlay.geometry(Layout::new(&node)); + assert!( + geometry.menu.width > 280., + "Retain measured label and shortcut width" + ); + assert_eq!(geometry.menu.height, 700. - 2. * GAP); + assert!(geometry.scroll_track().is_some()); + overlay + .state + .navigate(&menu.entries, Navigation::Last, geometry.inner.height); + assert_eq!(overlay.state.scroll, geometry.max_scroll()); + let final_row = geometry.row(89. * ROW, ROW, overlay.state.scroll); + assert_eq!( + geometry.hit(&menu.entries, overlay.state.scroll, final_row.center()), + Some(89) + ); + } + + #[test] + fn keyboard_skips_headers_separators_and_disabled_actions_and_invokes_once() { + let menu = menu(); + let mut state = State::default(); + state.reconcile(Some(&menu)); + assert_eq!(state.focused, Some(1)); + for (key_name, expected) in [ + (keyboard::key::Named::ArrowDown, 4), + (keyboard::key::Named::ArrowDown, 1), + (keyboard::key::Named::ArrowUp, 4), + (keyboard::key::Named::Home, 1), + (keyboard::key::Named::End, 4), + ] { + let (messages, captured) = dispatch(&mut state, &menu, key(key_name), None); + assert!(messages.is_empty()); + assert!(captured); + assert_eq!(state.focused, Some(expected)); + } + assert_eq!( + dispatch(&mut state, &menu, key(keyboard::key::Named::Enter), None), + (vec![22], true) + ); + assert_eq!( + dispatch(&mut state, &menu, key(keyboard::key::Named::Enter), None), + (vec![], true) + ); + } + + #[test] + fn empty_and_disabled_only_menus_still_capture_keys_and_escape_closes() { + for entries in [ + vec![], + vec![ + Entry::title("No selection"), + Entry::separator(), + Entry::disabled("Delete"), + ], + ] { + let menu = Menu { entries, ..menu() }; + let mut state = State::default(); + state.reconcile(Some(&menu)); + for direction in [ + keyboard::key::Named::ArrowUp, + keyboard::key::Named::ArrowDown, + keyboard::key::Named::Home, + keyboard::key::Named::End, + keyboard::key::Named::Enter, + keyboard::key::Named::Delete, + ] { + assert_eq!( + dispatch(&mut state, &menu, key(direction), None), + (vec![], true) + ); + assert_eq!(state.focused, None); + } + assert_eq!( + dispatch(&mut state, &menu, key(keyboard::key::Named::Escape), None), + (vec![99], true) + ); + } + } + + #[test] + fn outside_dismissal_consumes_press_and_release_across_controlled_close() { + let menu = menu(); + let mut state = State::default(); + state.reconcile(Some(&menu)); + let outside = Point::new(20., 20.); + assert_eq!( + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(outside) + ), + (vec![99], true) + ); + state.reconcile::(None); + assert!( + state.swallow_release(&Event::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left + ))) + ); + assert!( + !state.swallow_release(&Event::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left + ))), + "Only the dismissal gesture is suppressed" + ); + state.reconcile(Some(&menu)); + let finger = touch::Finger(4); + assert_eq!( + dispatch( + &mut state, + &menu, + Event::Touch(touch::Event::FingerPressed { + id: finger, + position: outside + }), + None + ), + (vec![99], true) + ); + state.reconcile::(None); + assert!( + state.swallow_release(&Event::Touch(touch::Event::FingerLifted { + id: finger, + position: outside + })) + ); + } + + #[test] + fn coalesced_dismissal_release_does_not_swallow_the_next_independent_click() { + let menu = menu(); + let mut state = State::default(); + state.reconcile(Some(&menu)); + let outside = Some(Point::new(20., 20.)); + assert_eq!( + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + outside + ), + (vec![99], true) + ); + let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)); + assert_eq!( + dispatch(&mut state, &menu, release.clone(), outside), + (vec![], true) + ); + state.reconcile::(None); + assert!(!state.swallow_release(&release)); + } + + #[test] + fn clicking_disabled_or_dragging_off_an_action_never_executes_it() { + let menu = menu(); + let mut state = State::default(); + state.reconcile(Some(&menu)); + let disabled = row_center(&menu, 2, 0.); + let enabled = row_center(&menu, 1, 0.); + for (press, release) in [(disabled, disabled), (enabled, disabled)] { + assert!( + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(press) + ) + .0 + .is_empty() + ); + assert!( + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(release) + ) + .0 + .is_empty() + ); + } + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::CursorMoved { position: enabled }), + Some(enabled), + ); + assert_eq!(state.focused, Some(1)); + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(enabled), + ); + assert_eq!( + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(enabled) + ), + (vec![11], true) + ); + } + + #[test] + fn a_new_context_token_cannot_reuse_the_previous_mouse_press_or_scroll() { + let menu = menu(); + let mut state = State::default(); + state.reconcile(Some(&menu)); + let point = row_center(&menu, 1, 0.); + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(point), + ); + state.scroll = 50.; + let mut replacement = menu.clone(); + replacement.token += 1; + replacement.entries[1] = Entry::action("Different target", 44); + state.reconcile(Some(&replacement)); + assert_eq!(state.scroll, 0.); + assert_eq!(state.pressed, None); + assert_eq!( + dispatch( + &mut state, + &replacement, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(point) + ), + (vec![], true) + ); + } + + #[test] + fn tall_menu_clamps_to_viewport_and_keyboard_wheel_and_scrollbar_reach_the_end() { + let menu = Menu { + entries: (0..90) + .map(|index| Entry::action(format!("Action {index}"), index)) + .collect(), + ..menu() + }; + let geometry = geometry(&menu); + assert!(geometry.menu.x >= GAP && geometry.menu.y >= GAP); + assert!(geometry.menu.x + geometry.menu.width <= 1100. - GAP); + assert!(geometry.menu.y + geometry.menu.height <= 700. - GAP); + let mut state = State::default(); + state.reconcile(Some(&menu)); + dispatch(&mut state, &menu, key(keyboard::key::Named::End), None); + assert_eq!(state.focused, Some(89)); + assert_eq!(state.scroll, geometry.max_scroll()); + let last = row_center(&menu, 89, state.scroll); + assert_eq!(geometry.hit(&menu.entries, state.scroll, last), Some(89)); + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: 2. }, + }), + Some(last), + ); + assert_eq!(state.scroll, geometry.max_scroll() - 2. * ROW); + let track = geometry.scroll_track().unwrap(); + let bottom = Point::new(track.center_x(), track.y + track.height - 1.); + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(bottom), + ); + assert_eq!(state.scroll, geometry.max_scroll()); + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::CursorMoved { + position: track.position(), + }), + Some(track.position()), + ); + assert_eq!(state.scroll, 0.); + assert!( + dispatch( + &mut state, + &menu, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(last) + ) + .0 + .is_empty() + ); + } + + #[test] + fn anchors_outside_the_window_and_nonfinite_input_stay_bounded() { + for anchor in [ + Point::new(-100., -200.), + Point::new(10_000., 10_000.), + Point::new(f32::NAN, f32::INFINITY), + ] { + let geometry = Geometry::new(anchor, 480., 4000., Size::new(1100., 700.)); + assert!(geometry.menu.x.is_finite() && geometry.menu.y.is_finite()); + assert!(geometry.menu.x >= 0. && geometry.menu.y >= 0.); + assert!(geometry.menu.x + geometry.menu.width <= 1100.); + assert!(geometry.menu.y + geometry.menu.height <= 700.); + } + } +} diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 973e1d04..9644e752 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -21,6 +21,12 @@ use std::{ collections::{BTreeMap, BTreeSet}, sync::Arc, }; +#[cfg(test)] +#[path = "diagram/lifecycle_tests.rs"] +mod lifecycle_tests; +#[cfg(test)] +#[path = "diagram/port_hit_tests.rs"] +mod port_hit_tests; pub struct DiagramCanvas<'a> { pub project: &'a Project, @@ -31,14 +37,20 @@ pub struct DiagramCanvas<'a> { pub context: Context, pub selected_bend: Option, pub editable: bool, + pub connection: Option<&'a crate::connection_edit::Session>, } #[derive(Default)] pub struct State { + pointer: Option, + keyboard_focus: bool, drag: Option<(Id, Point, Position)>, preview: Option, - pan_start: Option, + pan_start: Option<(mouse::Button, Point)>, context: Option, route_drag: Option, + connection_press: Option<(Id, Point)>, + connection_hover: Option, + port_drag: Option, cache: RefCell>, } struct Scene { @@ -49,6 +61,7 @@ struct CachedScene { context: Context, preview: Option, route: Option, + port: Option<(Id, PortAnchor)>, scene: Arc, } /// Make room along the inserted node's containment path, moving only colliding @@ -167,6 +180,124 @@ pub fn insertion_adjustments(p: &Project, diagram_id: Id, element: Id) -> Vec { + /// Enlarged hit regions overlap when zoomed out. The closest real glyph + /// wins; selection and stable identity only resolve coincident centers. + fn hit_port<'a>( + &self, + view: &'a ProjectedView, + local: Point, + ) -> Option<&'a projection::ProjectedPort> { + let world = self.world(local); + let radius = (10. / self.zoom).max(10.); + view.ports + .iter() + .filter(|port| { + (world.x - port.position.x).abs() <= radius + && (world.y - port.position.y).abs() <= radius + }) + .min_by(|a, b| { + world + .distance(point(a.position)) + .total_cmp(&world.distance(point(b.position))) + .then_with(|| (a.element != self.selected).cmp(&(b.element != self.selected))) + .then_with(|| a.element.cmp(&b.element)) + .then_with(|| a.owner.cmp(&b.owner)) + }) + } + fn endpoint_at(&self, view: &ProjectedView, local: Point) -> Option { + self.hit_port(view, local).map(|p| p.element).or_else(|| { + self.hit(view, local) + .and_then(|id| view.nodes.iter().find(|n| n.id == id).map(|n| n.element)) + }) + } + fn selection_context_request( + &self, + state: &State, + bounds: Rectangle, + ) -> crate::model_menu::Request { + let scene = self.scene(state); + let mut request = crate::model_menu::Request::object(self.diagram, bounds.center()); + request.canvas = Some(self.context); + let anchor = + if let Some(port) = scene.view.ports.iter().find(|p| p.element == self.selected) { + request.object = port.element; + Some(port.position) + } else if let Some(node) = scene + .view + .nodes + .iter() + .find(|n| n.element == self.selected || n.id == self.selected) + { + request.object = node.element; + request.placement = Some(node.id); + Some(Position { + x: node.position.x + 20., + y: node.position.y + 20., + }) + } else if let Some(edge) = scene.routed.edges.get(&self.selected) { + request.object = edge.id; + request.bend = self + .selected_bend + .filter(|i| !edge.custom_suspended && *i < edge.pins.len()); + if let Some(index) = request.bend { + Some(edge.pins[index]) + } else { + request.route_point = Some(edge.label.anchor); + Some(edge.label.center) + } + } else { + None + }; + if let Some(anchor) = anchor { + request.anchor = Point::new( + bounds.x + anchor.x * self.zoom + self.pan.x, + bounds.y + anchor.y * self.zoom + self.pan.y, + ); + } + request + } + fn context_request( + &self, + state: &State, + local: Point, + bounds: Rectangle, + ) -> crate::model_menu::Request { + let scene = self.scene(state); + let world = self.world(local); + let mut request = crate::model_menu::Request::object( + self.diagram, + Point::new(bounds.x + local.x, bounds.y + local.y), + ); + request.canvas = Some(self.context); + if let Some(edge) = scene.routed.edges.get(&self.selected) + && !edge.custom_suspended + && let Some(index) = edge + .pins + .iter() + .position(|pin| world.distance(point(*pin)) <= 8. / self.zoom) + { + request.object = edge.id; + request.bend = Some(index); + return request; + } + if let Some(port) = self.hit_port(&scene.view, local) { + request.object = port.element; + } else if let Some(node) = self.hit(&scene.view, local) { + request.object = self.project.diagrams[&self.diagram].nodes[&node].element; + request.placement = Some(node); + } else if let Some(edge) = self + .hit_label(&scene.routed, local) + .or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id)) + { + request.object = edge; + request.route_point = oa_core::routing::project_onto_polyline( + &scene.routed.edges[&edge].points, + position(world), + ) + .map(|p| p.point); + } + request + } fn world(&self, p: Point) -> Point { Point::new( (p.x - self.pan.x) / self.zoom, @@ -256,6 +387,11 @@ impl DiagramCanvas<'_> { if let Some(gesture) = &state.route_drag { route_edit::with_route(&mut diagram, gesture.route.clone()); } + if let Some(gesture) = &state.port_drag + && gesture.changed + { + crate::port_edit::with_anchor(&mut diagram, gesture.owner.id, gesture.anchor.clone()); + } diagram } #[cfg(test)] @@ -273,10 +409,16 @@ impl DiagramCanvas<'_> { .as_ref() .filter(|g| g.context == self.context) .map(|g| g.route.clone()); + let port = state + .port_drag + .as_ref() + .filter(|g| g.context == self.context && g.changed) + .map(|g| (g.owner.id, g.anchor.clone())); if let Some(cache) = state.cache.borrow().as_ref() && cache.context == self.context && cache.preview == preview && cache.route == route + && cache.port == port { return cache.scene.clone(); } @@ -308,6 +450,7 @@ impl DiagramCanvas<'_> { context: self.context, preview, route, + port, scene: scene.clone(), }); scene @@ -334,12 +477,57 @@ impl canvas::Program for DiagramCanvas<'_> { cursor: mouse::Cursor, ) -> Option> { if state.context != Some(self.context) { + state.pointer = None; + state.port_drag = None; + state.connection_press = None; + state.connection_hover = None; state.drag = None; state.preview = None; state.route_drag = None; state.pan_start = None; state.context = Some(self.context); } + // Iced passes the final window cursor to every event in a native batch. + // Use ordered positions for presses and releases. A later move in the + // same batch must not turn a stationary click into a drag. Redraw is a + // checkpoint for another drag without an intervening move. + // A context change or a menu opening invalidates this continuity because + // an overlay may have consumed subsequent motion and release events. + let cursor = match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { + state.pointer = Some(*position); + mouse::Cursor::Available(*position) + } + canvas::Event::Mouse(mouse::Event::CursorLeft) => { + state.pointer = None; + cursor + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(_)) + | canvas::Event::Mouse(mouse::Event::ButtonPressed(_)) => { + state.pointer.map_or(cursor, mouse::Cursor::Available) + } + canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { + state.pointer = cursor.position(); + cursor + } + _ => cursor, + }; + if self.connection.is_none() || !self.editable { + state.connection_press = None; + state.connection_hover = None; + } + if self.connection.is_some() { + state.port_drag = None; + state.drag = None; + state.preview = None; + state.route_drag = None; + if state + .pan_start + .is_some_and(|(button, _)| button != mouse::Button::Middle) + { + state.pan_start = None; + } + } if state .route_drag .as_ref() @@ -348,17 +536,123 @@ impl canvas::Program for DiagramCanvas<'_> { { state.route_drag = None; } + if !self.editable { + state.port_drag = None; + } let local = cursor .position() .map(|p| Point::new(p.x - bounds.x, p.y - bounds.y)); + if matches!(event, canvas::Event::Mouse(mouse::Event::ButtonPressed(_))) { + state.keyboard_focus = cursor.is_over(bounds); + } match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) + if cursor.is_over(bounds) => + { + state.pointer = None; + state.port_drag = None; + state.drag = None; + state.preview = None; + state.route_drag = None; + state.pan_start = None; + Some( + Action::publish(Message::Menu(crate::model_menu::Event::Open( + self.context_request(state, local?, bounds), + ))) + .and_capture(), + ) + } + canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key, modifiers, .. + }) if state.keyboard_focus => { + use iced::keyboard::{Key, key::Named}; + match key.as_ref() { + Key::Named(Named::ContextMenu) | Key::Named(Named::F10) + if *key == Key::Named(Named::ContextMenu) || modifiers.shift() => + { + state.pointer = None; + Some( + Action::publish(Message::Menu(crate::model_menu::Event::Open( + self.selection_context_request(state, bounds), + ))) + .and_capture(), + ) + } + Key::Named(key) if self.connection.is_some() && modifiers.is_empty() => { + let delta = match key { + Named::ArrowLeft => Vector::new(80., 0.), + Named::ArrowRight => Vector::new(-80., 0.), + Named::ArrowUp => Vector::new(0., 80.), + Named::ArrowDown => Vector::new(0., -80.), + _ => return None, + }; + Some(Action::publish(Message::Pan(delta)).and_capture()) + } + Key::Named(Named::Insert) + if self.editable && self.connection.is_none() && modifiers.is_empty() => + { + Some(Action::publish(Message::Route(RouteEvent::AddBend)).and_capture()) + } + Key::Named(Named::Delete) + if self.editable + && self.connection.is_none() + && modifiers.is_empty() + && self.selected_bend.is_some() => + { + Some(Action::publish(Message::Route(RouteEvent::RemoveBend)).and_capture()) + } + _ => None, + } + } + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) + if cursor.is_over(bounds) => + { + state.port_drag = None; + state.drag = None; + state.preview = None; + state.route_drag = None; + state.connection_press = None; + state.pan_start = Some((mouse::Button::Middle, local?)); + Some(Action::capture()) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => { + if let Some((mouse::Button::Middle, start)) = state.pan_start { + state.pan_start = None; + return Some(match local { + Some(p) if p != start => { + Action::publish(Message::Pan(p - start)).and_capture() + } + _ => Action::capture(), + }); + } + None + } canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if cursor.is_over(bounds) => { + if state + .pan_start + .is_some_and(|(button, _)| button == mouse::Button::Middle) + { + return Some(Action::capture()); + } let p = local?; let scene = self.scene(state); let view = &scene.view; let world = self.world(p); + if self.connection.is_some() && self.editable { + let event = if let Some(id) = self.endpoint_at(view, p) { + state.connection_press = Some((id, p)); + crate::connection_edit::Event::Endpoint(self.context, id) + } else { + crate::connection_edit::Event::Restart(self.context) + }; + state.drag = None; + state.preview = None; + state.route_drag = None; + state.pan_start = None; + return Some(Action::publish(Message::Connection(event)).and_capture()); + } if self.editable && let Some(edge) = scene.routed.edges.get(&self.selected) && !edge.custom_suspended @@ -402,11 +696,17 @@ impl canvas::Program for DiagramCanvas<'_> { return Some(Action::capture()); } } - if let Some(port) = view.ports.iter().rev().find(|port| { - let radius = (10. / self.zoom).max(10.); - (world.x - port.position.x).abs() <= radius - && (world.y - port.position.y).abs() <= radius - }) { + if let Some(port) = self.hit_port(view, p) { + if self.editable + && let Some(owner) = view.nodes.iter().find(|n| n.id == port.owner) + { + state.port_drag = Some(crate::port_edit::Gesture::new( + self.context, + owner.clone(), + port, + world, + )); + } return Some(Action::publish(Message::Select(port.element)).and_capture()); } if let Some(id) = self.hit(view, p) { @@ -440,12 +740,24 @@ impl canvas::Program for DiagramCanvas<'_> { Some(Action::publish(Message::Select(id)).and_capture()) } } else { - state.pan_start = Some(p); + state.pan_start = Some((mouse::Button::Left, p)); Some(Action::capture()) } } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { let p = local?; + if let Some((button, start)) = state.pan_start { + state.pan_start = Some((button, p)); + return Some(Action::publish(Message::Pan(p - start)).and_capture()); + } + if let Some(gesture) = &mut state.port_drag { + gesture.move_to(self.world(p), self.zoom); + return Some(Action::request_redraw().and_capture()); + } + if self.connection.is_some() { + state.connection_hover = Some(p); + return Some(Action::request_redraw()); + } if let Some(gesture) = &mut state.route_drag { gesture.move_to(self.world(p), self.zoom); return Some(Action::request_redraw().and_capture()); @@ -462,17 +774,58 @@ impl canvas::Program for DiagramCanvas<'_> { }); return Some(Action::request_redraw()); } - if let Some(start) = state.pan_start { - state.pan_start = Some(p); - return Some(Action::publish(Message::Pan(p - start)).and_capture()); - } None } canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { - state.pan_start = None; + if let Some((button, start)) = state.pan_start { + if button == mouse::Button::Middle { + return Some(Action::capture()); + } + state.pan_start = None; + return Some(match local { + Some(p) if p != start => { + Action::publish(Message::Pan(p - start)).and_capture() + } + _ => Action::capture(), + }); + } + if let Some(mut gesture) = state.port_drag.take() { + if let Some(p) = local { + gesture.move_to(self.world(p), self.zoom); + } + if gesture.changed { + return Some( + Action::publish(Message::PortAnchor(crate::port_edit::Event::Commit( + gesture.context, + gesture.owner.id, + gesture.anchor, + ))) + .and_capture(), + ); + } + return Some(Action::capture()); + } + if let Some(session) = self.connection { + if let Some((source, start)) = state.connection_press.take() + && (session.source == Some(source) + || (session.source.is_none() && session.candidates.contains(&source))) + && let Some(p) = local.filter(|p| p.distance(start) > 3.) + && cursor.is_over(bounds) + && let Some(target) = self.endpoint_at(&self.scene(state).view, p) + && target != source + { + return Some( + Action::publish(Message::Connection( + crate::connection_edit::Event::Endpoint(self.context, target), + )) + .and_capture(), + ); + } + return Some(Action::capture()); + } if let Some(mut gesture) = state.route_drag.take() { - // A fast native drag can coalesce the final motion event. - // The release cursor is the authoritative final position. + // `local` uses the ordered pointer position at release, + // before any later motion in the same native event batch. if let Some(p) = local { gesture.move_to(self.world(p), self.zoom); } @@ -548,7 +901,7 @@ impl canvas::Program for DiagramCanvas<'_> { renderer: &Renderer, _: &Theme, bounds: Rectangle, - _: mouse::Cursor, + cursor: mouse::Cursor, ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle( @@ -569,8 +922,17 @@ impl canvas::Program for DiagramCanvas<'_> { } f.translate(self.pan); f.scale(self.zoom); + // Canvas text is painted after paths within each geometry. A separate + // frame keeps the port tooltip above underlying edge labels. Its text + // stays legible at overview zoom levels and within the canvas bounds. + let mut port_label = Frame::new(renderer, bounds.size()); let scene = self.scene(state); let view = &scene.view; + let labeled_port = cursor + .position_in(bounds) + .and_then(|p| self.hit_port(view, p)) + .or_else(|| view.ports.iter().find(|p| p.element == self.selected)) + .map(|p| p.element); let containers = view .nodes .iter() @@ -679,10 +1041,11 @@ impl canvas::Program for DiagramCanvas<'_> { let origin = Point::new(port.position.x - 7., port.position.y - 7.); let glyph = port_glyph(&self.project.elements[&port.element]); let path = if glyph == PortGlyph::Function { + let corners = port_triangle(&self.project.elements[&port.element], port); Path::new(|p| { - p.move_to(origin); - p.line_to(origin + Vector::new(14., 7.)); - p.line_to(origin + Vector::new(0., 14.)); + p.move_to(corners[0]); + p.line_to(corners[1]); + p.line_to(corners[2]); p.close(); }) } else { @@ -708,15 +1071,9 @@ impl canvas::Program for DiagramCanvas<'_> { if matches!(glyph, PortGlyph::Flow(_)) { f.stroke( &Path::new(|p| { - p.move_to(center - Vector::new(4., 0.)); - p.line_to(center + Vector::new(4., 0.)); - p.move_to(center + Vector::new(1., -3.)); - p.line_to(center + Vector::new(4., 0.)); - p.line_to(center + Vector::new(1., 3.)); - if glyph == PortGlyph::Flow(true) { - p.move_to(center + Vector::new(-1., -3.)); - p.line_to(center - Vector::new(4., 0.)); - p.line_to(center + Vector::new(-1., 3.)); + for [a, b] in flow_mark(&self.project.elements[&port.element], port) { + p.move_to(a); + p.line_to(b); } }), Stroke::default().with_color(color), @@ -725,14 +1082,67 @@ impl canvas::Program for DiagramCanvas<'_> { if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) { f.fill(&Path::circle(center, 1.5), color); } - if self.selected == port.element { - f.fill_text(Text { - content: short(self.project.named(port.element), 40), - position: center + Vector::new(12., 9.), - color, + if labeled_port == Some(port.element) { + let name = short(self.project.named(port.element), 40); + let owner = self.project.elements[&port.element] + .parent + .map(|id| short(self.project.named(id), 40)) + .unwrap_or_default(); + let alias = &self.project.elements[&port.element].alias; + let alias = if alias.chars().count() > 48 { + format!( + "@{}…{}", + alias.chars().take(22).collect::(), + alias + .chars() + .skip(alias.chars().count() - 24) + .collect::() + ) + } else { + format!("@{alias}") + }; + let width = ((name + .chars() + .count() + .max(owner.chars().count()) + .max(alias.chars().count()) as f32 + * 7.) + + 16.) + .min((bounds.width - 8.).max(0.)); + let height = 60.; + let anchor = Point::new( + center.x * self.zoom + self.pan.x + 12., + center.y * self.zoom + self.pan.y + 12., + ); + let origin = Point::new( + anchor.x.clamp(4., (bounds.width - width - 4.).max(4.)), + anchor.y.clamp(4., (bounds.height - height - 4.).max(4.)), + ); + let background = + Path::rounded_rectangle(origin, Size::new(width, height), 4.into()); + port_label.fill(&background, Color::WHITE); + port_label.stroke(&background, Stroke::default().with_color(style::LINE)); + port_label.fill_text(Text { + content: name, + position: origin + Vector::new(8., 4.), + color: style::TEAL, size: 12.into(), ..Default::default() }); + port_label.fill_text(Text { + content: owner, + position: origin + Vector::new(8., 22.), + color: style::MUTED, + size: 11.into(), + ..Default::default() + }); + port_label.fill_text(Text { + content: alias, + position: origin + Vector::new(8., 40.), + color: style::MUTED, + size: 11.into(), + ..Default::default() + }); } } if let Some(edge) = scene.routed.edges.get(&self.selected) @@ -756,6 +1166,82 @@ impl canvas::Program for DiagramCanvas<'_> { ); } } + if let Some(session) = self.connection { + let candidate_color = Color::from_rgb8(45, 123, 202); + for node in &view.nodes { + if session.candidates.contains(&node.element) + || session.source == Some(node.element) + { + f.stroke( + &Path::rectangle( + Point::new( + node.position.x - 3. / self.zoom, + node.position.y - 3. / self.zoom, + ), + Size::new( + node.size.width + 6. / self.zoom, + node.size.height + 6. / self.zoom, + ), + ), + Stroke::default() + .with_color(if session.source == Some(node.element) { + style::TEAL + } else { + candidate_color + }) + .with_width(2. / self.zoom), + ); + } + } + for port in &view.ports { + if session.candidates.contains(&port.element) + || session.source == Some(port.element) + { + f.stroke( + &Path::circle(point(port.position), 12. / self.zoom), + Stroke::default() + .with_color(if session.source == Some(port.element) { + style::TEAL + } else { + candidate_color + }) + .with_width(2. / self.zoom), + ); + } + } + if let Some(source) = session.source + && let Some(hover) = state.connection_hover + && let Some(start) = endpoint_position(view, source) + { + let hit = self + .endpoint_at(view, hover) + .filter(|id| session.candidates.contains(id)); + let end = hit + .and_then(|id| endpoint_position(view, id)) + .unwrap_or(position(self.world(hover))); + let mid = (start.x + end.x) / 2.; + let mut stroke = Stroke::default() + .with_color(if hit.is_some() { + style::TEAL + } else { + candidate_color + }) + .with_width(2. / self.zoom); + stroke.line_dash = canvas::LineDash { + segments: &[6., 4.], + offset: 0, + }; + f.stroke( + &polyline(&[ + start, + Position { x: mid, y: start.y }, + Position { x: mid, y: end.y }, + end, + ]), + stroke, + ); + } + } let mut overlay = Frame::new(renderer, bounds.size()); let warning = scene .routed @@ -799,7 +1285,11 @@ impl canvas::Program for DiagramCanvas<'_> { ..Default::default() }); } - vec![f.into_geometry(), overlay.into_geometry()] + vec![ + f.into_geometry(), + port_label.into_geometry(), + overlay.into_geometry(), + ] } fn mouse_interaction( &self, @@ -807,7 +1297,13 @@ impl canvas::Program for DiagramCanvas<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { - if state.drag.is_some() || state.pan_start.is_some() || state.route_drag.is_some() { + if self.connection.is_some() && cursor.is_over(bounds) { + mouse::Interaction::Crosshair + } else if state.drag.is_some() + || state.pan_start.is_some() + || state.route_drag.is_some() + || state.port_drag.is_some() + { mouse::Interaction::Grabbing } else if cursor.is_over(bounds) { mouse::Interaction::Grab @@ -816,6 +1312,21 @@ impl canvas::Program for DiagramCanvas<'_> { } } } +fn endpoint_position(view: &ProjectedView, id: Id) -> Option { + view.ports + .iter() + .find(|p| p.element == id) + .map(|p| p.position) + .or_else(|| { + view.nodes + .iter() + .find(|n| n.element == id) + .map(|n| Position { + x: n.position.x + n.size.width / 2., + y: n.position.y + n.size.height / 2., + }) + }) +} #[derive(Clone, Copy, PartialEq, Eq)] enum PortGlyph { Function, @@ -824,6 +1335,44 @@ enum PortGlyph { Unspecified, Legacy, } +fn port_marker_point( + element: &oa_core::Element, + port: &projection::ProjectedPort, + along: f32, + across: f32, +) -> Point { + let inward = matches!( + port_contract(element), + Some( + PortContract::FunctionInput { .. } + | PortContract::Component { + orientation: ComponentPortOrientation::In, + .. + } + ) + ); + let normal = port.side.normal(); + let direction = Vector::new(normal.x, normal.y) * if inward { -1. } else { 1. }; + point(port.position) + direction * along + Vector::new(-direction.y, direction.x) * across +} +fn port_triangle(element: &oa_core::Element, port: &projection::ProjectedPort) -> [Point; 3] { + [(-7., -7.), (7., 0.), (-7., 7.)] + .map(|(along, across)| port_marker_point(element, port, along, across)) +} +fn flow_mark(element: &oa_core::Element, port: &projection::ProjectedPort) -> Vec<[Point; 2]> { + let mut lines = vec![ + [(-4., 0.), (4., 0.)], + [(1., -3.), (4., 0.)], + [(4., 0.), (1., 3.)], + ]; + if port_glyph(element) == PortGlyph::Flow(true) { + lines.extend([[(-1., -3.), (-4., 0.)], [(-4., 0.), (-1., 3.)]]); + } + lines + .into_iter() + .map(|line| line.map(|(along, across)| port_marker_point(element, port, along, across))) + .collect() +} fn port_glyph(element: &oa_core::Element) -> PortGlyph { match port_contract(element) { @@ -1083,7 +1632,8 @@ pub fn export_svg(p: &Project, diagram: Id) -> String { escape(p.named(port.element)) )); if glyph == PortGlyph::Function { - svg.push_str(&format!("", x-7., y-7.)); + let [a, b, c] = port_triangle(&p.elements[&port.element], port); + svg.push_str(&format!("", a.x, a.y, b.x, b.y, c.x, c.y)); } else { let dash = if glyph == PortGlyph::Legacy { " stroke-dasharray=\"3 2\"" @@ -1092,12 +1642,10 @@ pub fn export_svg(p: &Project, diagram: Id) -> String { }; svg.push_str(&format!("",x-7.,y-7.)); if matches!(glyph, PortGlyph::Flow(_)) { - svg.push_str(&format!("",x-4.)); - if glyph == PortGlyph::Flow(true) { + for [a, b] in flow_mark(&p.elements[&port.element], port) { svg.push_str(&format!( - "", - x - 1., - y - 3. + "", + a.x, a.y, b.x, b.y )); } } else if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) { @@ -1112,6 +1660,10 @@ pub fn export_svg(p: &Project, diagram: Id) -> String { svg } +#[cfg(test)] +#[path = "diagram/pointer_tests.rs"] +mod pointer_tests; + #[cfg(test)] mod tests { use super::*; @@ -1275,6 +1827,7 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, diagram, selected: p.id, zoom: 1., @@ -1346,6 +1899,7 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, diagram, selected: p.id, zoom: 0.6, @@ -1366,6 +1920,28 @@ mod tests { .into_inner() .0; assert!(matches!(selected,Some(Message::Select(id)) if id==port.element)); + let request = canvas.context_request( + &state, + click, + Rectangle::new(Point::new(260., 180.), Size::new(1600., 1000.)), + ); + assert_eq!( + request.object, port.element, + "Right-click keeps the port target where it overlaps its owner's boundary" + ); + assert_eq!(request.placement, None); + assert_eq!(request.anchor, click + Vector::new(260., 180.)); + let selected_port_canvas = DiagramCanvas { + selected: port.element, + ..canvas + }; + assert_eq!( + selected_port_canvas + .selection_context_request(&state, Rectangle::with_size(Size::new(1600., 1000.))) + .object, + port.element, + "Keyboard context uses the selected port identity" + ); assert!( state.drag.is_none(), "Port selection must not drag its parent" @@ -1397,6 +1973,172 @@ mod tests { assert!(svg.contains(&format!("L {} {}", end.x, end.y))); } } + + #[test] + fn connection_gestures_pick_real_ports_and_commit_coalesced_drag_at_every_zoom() { + use crate::{ + connection_edit::{Event, Session}, + connection_tool::Tool, + }; + use iced::widget::canvas::Program; + let p = Project::demo(); + let diagram = p + .diagrams + .values() + .find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation) + .unwrap() + .id; + let view = project_view(&p, diagram); + let source = view + .ports + .iter() + .find(|port| { + matches!( + port_contract(&p.elements[&port.element]), + Some(PortContract::FunctionOutput { .. }) + ) + }) + .unwrap(); + let target = view + .ports + .iter() + .find(|port| Tool::FunctionalExchange.can_connect(&p, source.element, port.element)) + .unwrap(); + for zoom in [0.4, 1., 2.] { + let context = Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }; + let session = Session::new(&p, &view, context, Tool::FunctionalExchange); + let canvas = DiagramCanvas { + project: &p, + diagram, + context, + selected: p.id, + selected_bend: None, + zoom, + pan: Vector::new(20., 30.), + editable: true, + connection: Some(&session), + }; + let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.)); + let at = |port: &oa_core::projection::ProjectedPort| { + mouse::Cursor::Available(Point::new( + bounds.x + port.position.x * zoom + 20., + bounds.y + port.position.y * zoom + 30., + )) + }; + let mut state = State::default(); + let _ = canvas.update( + &mut state, + &canvas::Event::Mouse(mouse::Event::CursorMoved { + position: at(source).position().unwrap(), + }), + bounds, + at(target), + ); + let press = canvas + .update( + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + bounds, + at(target), + ) + .unwrap() + .into_inner() + .0; + assert!( + matches!(press, Some(Message::Connection(Event::Endpoint(c,id))) if c == context && id == source.element) + ); + assert!(state.drag.is_none() && state.route_drag.is_none()); + let _ = canvas.update( + &mut state, + &canvas::Event::Mouse(mouse::Event::CursorMoved { + position: at(target).position().unwrap(), + }), + bounds, + at(target), + ); + // Native backends may deliver the whole drag before the app can + // rebuild with the source-selection message. Both endpoint messages + // still enter the queue in order and must complete one connection. + let batched_release = canvas + .update( + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + at(target), + ) + .unwrap() + .into_inner() + .0; + assert!( + matches!(batched_release, Some(Message::Connection(Event::Endpoint(c,id))) if c == context && id == target.element) + ); + let _ = canvas.update( + &mut state, + &canvas::Event::Mouse(mouse::Event::CursorMoved { + position: at(source).position().unwrap(), + }), + bounds, + at(source), + ); + let _ = canvas.update( + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + bounds, + at(source), + ); + let mut armed = session.clone(); + armed.start(&p, &view, source.element); + let armed_canvas = DiagramCanvas { + connection: Some(&armed), + ..canvas + }; + // Winit updates cursor coordinates through CursorMoved, not the + // following MouseInput. Include that ordered final motion when the + // application rebuilds its session between press and release. + let _ = armed_canvas.update( + &mut state, + &canvas::Event::Mouse(mouse::Event::CursorMoved { + position: at(target).position().unwrap(), + }), + bounds, + at(target), + ); + let release = armed_canvas + .update( + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + at(target), + ) + .unwrap() + .into_inner() + .0; + assert!( + matches!(release, Some(Message::Connection(Event::Endpoint(c,id))) if c == context && id == target.element) + ); + // A tool cancellation or navigation invalidates a queued release. + let cancelled = DiagramCanvas { + connection: None, + context: Context { + epoch: 1, + ..context + }, + ..armed_canvas + }; + let release = cancelled.update( + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + at(target), + ); + assert!(release.is_none()); + } + } fn route_fixture() -> (Project, Id, Id) { let mut p = Project::empty(); let a = p.add(Kind::Activity, "Send", 0., 0.); @@ -1450,9 +2192,24 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, }; let mut state = State::default(); let pin = Point::new(400. * zoom + 150., -100. * zoom + 300.); + let request = + canvas.context_request(&state, pin, Rectangle::with_size(Size::new(1800., 1200.))); + assert_eq!((request.object, request.bend), (edge, Some(0))); + let selected_bend_canvas = DiagramCanvas { + selected_bend: Some(0), + ..canvas + }; + let request = selected_bend_canvas + .selection_context_request(&state, Rectangle::with_size(Size::new(1800., 1200.))); + assert_eq!( + (request.object, request.bend), + (edge, Some(0)), + "Keyboard context preserves the selected bend" + ); let selected = pointer( &canvas, &mut state, @@ -1508,6 +2265,7 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, }; let mut state = State::default(); let pin = Point::new(350., 250.); @@ -1601,6 +2359,7 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, }; let mut state = State::default(); let first = canvas.scene(&state); @@ -1684,6 +2443,7 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, }; let mut state = State::default(); let scene = canvas.scene(&state); @@ -1754,6 +2514,7 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, }; assert!( matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),point(cover)+canvas.pan),Some(Message::SelectNode(id)) if id==node.id) @@ -1783,6 +2544,7 @@ mod tests { }, selected_bend: None, editable: true, + connection: None, }; let span = &routed.edges[&edge].points; let pick = point(oa_core::routing::point_along_polyline(span, 0.4).unwrap()); diff --git a/crates/oa-desktop/src/diagram/lifecycle_tests.rs b/crates/oa-desktop/src/diagram/lifecycle_tests.rs new file mode 100644 index 00000000..409e0f00 --- /dev/null +++ b/crates/oa-desktop/src/diagram/lifecycle_tests.rs @@ -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 { + 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" + ); +} diff --git a/crates/oa-desktop/src/diagram/pointer_tests.rs b/crates/oa-desktop/src/diagram/pointer_tests.rs new file mode 100644 index 00000000..16dd788c --- /dev/null +++ b/crates/oa-desktop/src/diagram/pointer_tests.rs @@ -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 { + 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 { + 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}" + ); + } + } +} diff --git a/crates/oa-desktop/src/diagram/port_hit_tests.rs b/crates/oa-desktop/src/diagram/port_hit_tests.rs new file mode 100644 index 00000000..64848ea5 --- /dev/null +++ b/crates/oa-desktop/src/diagram/port_hit_tests.rs @@ -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); + } +} diff --git a/crates/oa-desktop/src/explorer_view.rs b/crates/oa-desktop/src/explorer_view.rs index 8c0985b3..89dc8103 100644 --- a/crates/oa-desktop/src/explorer_view.rs +++ b/crates/oa-desktop/src/explorer_view.rs @@ -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() } diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index 9ab7910e..49236637 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -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 { 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.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::(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", diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index f912c4f9..c0adaf30 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -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.", diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index d8e6775f..244b0742 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -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)] diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs new file mode 100644 index 00000000..7aae7432 --- /dev/null +++ b/crates/oa-desktop/src/model_menu.rs @@ -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, + pub bend: Option, + pub route_point: Option, + pub canvas: Option, + pub source_file: Option, +} +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), + Close, +} + +pub struct Opened { + pub token: u64, + pub request: Request, + document: Id, + revision: u64, + epoch: u64, + page: Page, + layer: Layer, + diagram: Option, +} +impl Opened { + pub fn new(app: &App, request: Request, token: u64) -> Option { + 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> { + 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" + ); + } +} diff --git a/crates/oa-desktop/src/navigation.rs b/crates/oa-desktop/src/navigation.rs index 23b61260..3439b7e6 100644 --- a/crates/oa-desktop/src/navigation.rs +++ b/crates/oa-desktop/src/navigation.rs @@ -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() } diff --git a/crates/oa-desktop/src/port_edit.rs b/crates/oa-desktop/src/port_edit.rs new file mode 100644 index 00000000..1784b349 --- /dev/null +++ b/crates/oa-desktop/src/port_edit.rs @@ -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); + } +} diff --git a/crates/oa-desktop/src/route_edit.rs b/crates/oa-desktop/src/route_edit.rs index 689d87d1..78ad6343 100644 --- a/crates/oa-desktop/src/route_edit.rs +++ b/crates/oa-desktop/src/route_edit.rs @@ -15,6 +15,7 @@ pub enum Event { SelectBend(Id, usize), Commit(Context, EdgeRoute, Option), AddBend, + AddBendAt(oa_core::Position), RemoveBend, Reset, } diff --git a/crates/oa-desktop/src/source_tree.rs b/crates/oa-desktop/src/source_tree.rs new file mode 100644 index 00000000..d12e2a89 --- /dev/null +++ b/crates/oa-desktop/src/source_tree.rs @@ -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, +} + +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, + files: Vec, + 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 { + 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, + 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::>(), + ["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::>(), + [ + "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.)); + } +} diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index 7d47bca6..4e45e410 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -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 + }] ); } diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index fe8789f3..5799338c 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -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| { + 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::>(); - body = body.push( - pick_list(choices, None::, |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::>(); - 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) diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs index 469e6474..0f8752a0 100644 --- a/crates/oa-desktop/src/workflow.rs +++ b/crates/oa-desktop/src/workflow.rs @@ -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), diff --git a/crates/oa-server/tests/http.rs b/crates/oa-server/tests/http.rs index b282a6ce..2421eec4 100644 --- a/crates/oa-server/tests/http.rs +++ b/crates/oa-server/tests/http.rs @@ -8,6 +8,8 @@ use serde_json::{Value, json}; use std::{path::Path, sync::Arc}; use tokio::{sync::oneshot, task::JoinHandle}; +#[path = "http/port_anchors.rs"] +mod port_anchors; #[path = "http/source_projects.rs"] mod source_projects; diff --git a/crates/oa-server/tests/http/port_anchors.rs b/crates/oa-server/tests/http/port_anchors.rs new file mode 100644 index 00000000..33c42456 --- /dev/null +++ b/crates/oa-server/tests/http/port_anchors.rs @@ -0,0 +1,447 @@ +use super::*; +use oa_core::source_project::{ProjectLimits, SourceBundle, compile_bundle}; +use std::collections::BTreeMap; + +fn source_bundle() -> SourceBundle { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), "// Project rationale\narcadia 3; project anchors \"Port positions\" { include \"participants.arcadia\"; include \"views.arcadia\"; }\n".into()), + ("participants.arcadia".into(), r#"// Port ownership and contracts stay in this file. +sa_system system "System" {} +sa_actor vessel "Vessel" { + sa_port telemetry "Telemetry" { contract { Component { kind Flow; orientation Out; }; }; } + sa_port health "Health" { contract { Component { kind Flow; orientation Out; }; }; } +} +sa_port inbound "Receive" { parent @system; contract { Component { kind Flow; orientation In; }; }; } +"#.into()), + ("views.arcadia".into(), r#"// Placement rationale stays in the view file. +diagram architecture "Architecture" { layer Sa; projection Allocation; } +diagram review "Review" { layer Sa; projection Allocation; } +node actor_view { diagram @architecture; element @vessel; } +node review_view { diagram @review; element @vessel; } +"#.into()), + ]), + }; + compile_bundle(&bundle, ProjectLimits::default()) + .unwrap() + .bundle +} + +fn id(model: &Project, alias: &str) -> Id { + model + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} + +async fn import(server: &TestServer) -> StoredProject { + let response = server + .client + .post(server.url("/projects/import-source-project")) + .json(&source_bundle()) + .send() + .await + .unwrap(); + let status = response.status(); + let value: Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::CREATED, "{value}"); + server + .get(value["resource_id"].as_str().unwrap().parse().unwrap()) + .await +} + +async fn transact( + server: &TestServer, + current: &StoredProject, + commands: Value, + dry_run: bool, +) -> reqwest::Response { + server + .client + .post(server.url(&format!("/projects/{}/transactions", current.resource_id))) + .header("if-match", current.etag()) + .json(&json!({"commands":commands,"dry_run":dry_run})) + .send() + .await + .unwrap() +} + +fn assert_pair(current: &StoredProject) { + let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap(); + assert_eq!(compiled.decoded.project, current.model); + assert_eq!(compiled.bundle, current.source_project); +} + +#[tokio::test] +async fn port_anchor_http_commands_and_source_edits_preserve_per_view_state_after_restart() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let original = import(&server).await; + let model = &original.model; + let (diagram, node, port, other, other_node) = ( + id(model, "architecture"), + id(model, "actor_view"), + id(model, "telemetry"), + id(model, "review"), + id(model, "review_view"), + ); + assert!( + serde_json::to_value(&model.diagrams[&diagram].nodes[&node]) + .unwrap() + .get("port_anchors") + .is_none() + ); + let commands = json!([ + {"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":{"port":port,"side":"Top","offset":0.25}}}, + {"SetPortAnchor":{"diagram":other,"node":other_node,"anchor":{"port":port,"side":"Left","offset":0.75}}} + ]); + let expected = oa_core::execute_batch( + model, + &serde_json::from_value::>(commands.clone()).unwrap(), + ) + .unwrap(); + let preview = transact(&server, &original, commands.clone(), true).await; + assert_eq!(preview.status(), StatusCode::OK); + let preview: Value = preview.json().await.unwrap(); + assert_eq!( + serde_json::from_value::(preview["model"].clone()).unwrap(), + expected + ); + let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap(); + assert_eq!( + compile_bundle(&proposed, ProjectLimits::default()) + .unwrap() + .decoded + .project, + expected + ); + assert_eq!( + server.get(original.resource_id).await.source_project, + original.source_project + ); + + let response = transact(&server, &original, commands.clone(), false).await; + assert_eq!(response.status(), StatusCode::OK); + let mut current = server.get(original.resource_id).await; + assert_eq!(current.revision, original.revision + 1); + assert_eq!(current.model, expected); + assert_eq!(current.model.elements, original.model.elements); + assert_pair(¤t); + for path in ["project.arcadia", "participants.arcadia"] { + assert_eq!( + current.source_project.files[path], + original.source_project.files[path] + ); + } + assert!(current.source_project.files["views.arcadia"].contains("// Placement rationale")); + let no_op = transact(&server, ¤t, commands.clone(), false).await; + assert_eq!(no_op.headers()["etag"], current.etag()); + assert_eq!(no_op.json::().await.unwrap()["changed"], false); + assert_eq!( + transact(&server, &original, commands, false).await.status(), + StatusCode::PRECONDITION_FAILED + ); + + // Source editing changes exactly the same accepted presentation record. + let mut edited = current.source_project.clone(); + let source = edited.files.get_mut("views.arcadia").unwrap(); + assert!(source.contains("offset 0.25;")); + *source = source.replace("offset 0.25;", "offset 0.375;"); + let written = server + .client + .put(server.url(&format!("/projects/{}/source-project", current.resource_id))) + .header("if-match", current.etag()) + .json(&edited) + .send() + .await + .unwrap(); + assert_eq!( + written.status(), + StatusCode::OK, + "{}", + written.text().await.unwrap() + ); + current = server.get(current.resource_id).await; + assert_eq!( + current.model.diagrams[&diagram].nodes[&node].port_anchors[0].offset, + 0.375 + ); + assert_eq!(current.model.diagrams[&other], expected.diagrams[&other]); + assert_eq!(current.source_project, edited); + assert_pair(¤t); + + let source = server + .client + .get(server.url(&format!("/projects/{}/source", current.resource_id))) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert_eq!(oa_core::language::parse(&source).unwrap(), current.model); + server.stop().await; + let server = TestServer::start(directory.path(), None).await; + let reopened = server.get(current.resource_id).await; + assert_eq!( + serde_json::to_value(&reopened).unwrap(), + serde_json::to_value(¤t).unwrap() + ); + let reset = json!([{"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}}]); + assert_eq!( + transact(&server, &reopened, reset.clone(), false) + .await + .status(), + StatusCode::OK + ); + let reset_state = server.get(current.resource_id).await; + assert!( + reset_state.model.diagrams[&diagram].nodes[&node] + .port_anchors + .is_empty() + ); + assert_eq!( + reset_state.model.diagrams[&other], + current.model.diagrams[&other] + ); + assert_pair(&reset_state); + let again = transact(&server, &reset_state, reset, false).await; + assert_eq!(again.headers()["etag"], reset_state.etag()); + assert_eq!(again.json::().await.unwrap()["changed"], false); + server.stop().await; +} + +#[tokio::test] +async fn port_anchor_http_rejects_invalid_records_and_stale_owners_atomically() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let original = import(&server).await; + let model = &original.model; + let (diagram, node, port, actor, system, inbound) = ( + id(model, "architecture"), + id(model, "actor_view"), + id(model, "telemetry"), + id(model, "vessel"), + id(model, "system"), + id(model, "inbound"), + ); + let anchor = json!({"port":port,"side":"Bottom","offset":1}); + assert_eq!( + transact( + &server, + &original, + json!([{"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":anchor}}]), + false + ) + .await + .status(), + StatusCode::OK + ); + let current = server.get(original.resource_id).await; + for (bad, status) in [ + ( + json!({"port":port,"side":"Top","offset":-0.01}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"port":port,"side":"Top","offset":1.01}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"port":inbound,"side":"Top","offset":0.5}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"port":actor,"side":"Top","offset":0.5}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"port":Id::new_v4(),"side":"Top","offset":0.5}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"port":port,"side":"Diagonal","offset":0.5}), + StatusCode::BAD_REQUEST, + ), + (json!({"port":port,"side":"Top"}), StatusCode::BAD_REQUEST), + ( + json!({"port":port,"side":"Top","offset":null}), + StatusCode::BAD_REQUEST, + ), + ( + json!({"port":port,"side":"Top","offset":0.5,"unknown":true}), + StatusCode::BAD_REQUEST, + ), + ] { + let response = transact( + &server, + ¤t, + json!([ + Command::Rename(actor, "Must roll back".into()), + {"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":bad}} + ]), + false, + ) + .await; + assert_eq!( + response.status(), + status, + "{bad}: {}", + response.text().await.unwrap() + ); + } + let mut duplicate = current.model.clone(); + let anchors = &mut duplicate + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .port_anchors; + anchors.push(anchors[0].clone()); + let rejected = server + .client + .put(server.url(&format!("/projects/{}/model", current.resource_id))) + .header("if-match", current.etag()) + .json(&duplicate) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + let reparent = Command::Reparent { + element: port, + parent: Some(system), + }; + assert_eq!( + transact(&server, ¤t, json!([reparent]), false) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + transact( + &server, + ¤t, + json!([{"RemovePortAnchor":{"diagram":diagram,"node":node,"port":inbound}}]), + false + ) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + serde_json::to_value(server.get(current.resource_id).await).unwrap(), + serde_json::to_value(¤t).unwrap() + ); + let remove = json!({"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}}); + assert_eq!( + transact(&server, ¤t, json!([remove, reparent]), false) + .await + .status(), + StatusCode::OK + ); + let moved = server.get(current.resource_id).await; + assert_eq!(moved.model.elements[&port].parent, Some(system)); + assert!( + moved.model.diagrams[&diagram].nodes[&node] + .port_anchors + .is_empty() + ); + assert_pair(&moved); + assert_eq!( + transact(&server, &moved, json!([remove]), false) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + server.stop().await; +} + +#[tokio::test] +async fn port_anchor_served_openapi_matches_serialized_records_and_commands() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let spec: Value = server + .client + .get(server.url("/openapi.json")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let schemas = &spec["components"]["schemas"]; + assert_eq!( + schemas["PortAnchorSide"]["enum"], + json!(["Left", "Right", "Top", "Bottom"]) + ); + assert_eq!( + schemas["PortAnchor"]["required"], + json!(["port", "side", "offset"]) + ); + assert_eq!(schemas["PortAnchor"]["additionalProperties"], false); + assert_eq!(schemas["PortAnchor"]["properties"]["offset"]["minimum"], 0); + assert_eq!(schemas["PortAnchor"]["properties"]["offset"]["maximum"], 1); + assert_eq!( + schemas["NodePlacement"]["properties"]["port_anchors"]["default"], + json!([]) + ); + assert!( + !schemas["NodePlacement"]["required"] + .as_array() + .unwrap() + .contains(&json!("port_anchors")) + ); + let anchor = oa_core::PortAnchor { + port: Id::new_v4(), + side: oa_core::PortAnchorSide::Right, + offset: 0.5, + }; + assert_eq!( + serde_json::to_value(&anchor).unwrap(), + json!({"port":anchor.port,"side":"Right","offset":0.5}) + ); + for (command, name, fields) in [ + ( + Command::SetPortAnchor { + diagram: Id::new_v4(), + node: Id::new_v4(), + anchor: anchor.clone(), + }, + "SetPortAnchor", + json!(["diagram", "node", "anchor"]), + ), + ( + Command::RemovePortAnchor { + diagram: Id::new_v4(), + node: Id::new_v4(), + port: anchor.port, + }, + "RemovePortAnchor", + json!(["diagram", "node", "port"]), + ), + ] { + let record = serde_json::to_value(&command).unwrap(); + assert_eq!( + serde_json::from_value::(record.clone()).unwrap(), + command + ); + let schema = schemas["Command"]["oneOf"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["properties"].get(name).is_some()) + .unwrap(); + assert_eq!(schema["required"], json!([name])); + assert_eq!(schema["properties"][name]["required"], fields); + assert_eq!(schema["properties"][name]["additionalProperties"], false); + for field in fields.as_array().unwrap() { + assert!(record[name].get(field.as_str().unwrap()).is_some()); + } + } + server.stop().await; +} diff --git a/docs/api.html b/docs/api.html index 8b40241c..de022d65 100644 --- a/docs/api.html +++ b/docs/api.html @@ -775,8 +775,8 @@ envelope allows both bounded representations plus small framing overhead. Compressed or encoded request bodies and browser Origin requests are not supported.

Model admission permits at most 5,000 elements plus relations, 20,000 total -persistent records (including the project, views and placements), and 50,000 -ordered step references. Source declaration counts and decoded JSON record +persistent records (including the project, views, placements, routes and manual +port anchors), and 50,000 ordered step references. Source declaration counts and decoded JSON record counts are checked before structural validation. Legacy import counts the occurrences and placements migration will generate before validating or migrating that model. Exceeding these budgets returns 422 model_limit; @@ -910,6 +910,30 @@ nor reserves IDs. A committed response omits the proposed model and source project. Commit must check the revision again. A no-op is successful with changed: false and the original ETag.

+
+

Diagram port positions

+

NodePlacement.port_anchors is an optional list of +{"port":"<port UUID>","side":"Top","offset":0.25} records. Side is +Left, Right, Top, or Bottom; offset is a finite fraction from 0 through 1, +measured from top on vertical edges and from left on horizontal edges. +The port must be a direct child of the placement's semantic element and occur +at most once in its anchor list. Placement is presentation state; it changes +neither semantic ownership nor port direction. Other diagrams are independent.

+

The existing transaction endpoint accepts:

+
{"commands":[{"SetPortAnchor":{
+  "diagram":"<diagram UUID>","node":"<placement UUID>",
+  "anchor":{"port":"<port UUID>","side":"Top","offset":0.25}
+}}]}
+

SetPortAnchor upserts by port UUID. RemovePortAnchor takes +{diagram,node,port} and restores automatic positioning. Repeating a reset +is a no-op only while the referenced port still belongs to that placement's +element; missing or stale owners are rejected. Remove old anchors before +reparenting in the same atomic batch. Both commands support ordinary dry run, +ETag checks and source-project writeback. Rejected batches preserve the model, +authored files and revision. JSON/source imports may omit port_anchors; +it defaults to [] and empty lists are omitted on export. The normative +diagram semantics and source spelling are in language.rst section 5.

+

Contextual process and chain topology

Operational processes and system functional chains can combine work diff --git a/docs/api.rst b/docs/api.rst index 7b475dcd..61c0fbb5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -136,8 +136,8 @@ Compressed or encoded request bodies and browser Origin requests are not supported. Model admission permits at most 5,000 elements plus relations, 20,000 total -persistent records (including the project, views and placements), and 50,000 -ordered step references. Source declaration counts and decoded JSON record +persistent records (including the project, views, placements, routes and manual +port anchors), and 50,000 ordered step references. Source declaration counts and decoded JSON record counts are checked before structural validation. Legacy import counts the occurrences and placements migration will generate before validating or migrating that model. Exceeding these budgets returns ``422 model_limit``; @@ -291,6 +291,34 @@ nor reserves IDs. A committed response omits the proposed model and source project. Commit must check the revision again. A no-op is successful with ``changed: false`` and the original ETag. +Diagram port positions +---------------------- + +``NodePlacement.port_anchors`` is an optional list of +``{"port":"","side":"Top","offset":0.25}`` records. Side is +Left, Right, Top, or Bottom; offset is a finite fraction from 0 through 1, +measured from top on vertical edges and from left on horizontal edges. +The port must be a direct child of the placement's semantic element and occur +at most once in its anchor list. Placement is presentation state; it changes +neither semantic ownership nor port direction. Other diagrams are independent. + +The existing transaction endpoint accepts:: + + {"commands":[{"SetPortAnchor":{ + "diagram":"","node":"", + "anchor":{"port":"","side":"Top","offset":0.25} + }}]} + +``SetPortAnchor`` upserts by port UUID. ``RemovePortAnchor`` takes +``{diagram,node,port}`` and restores automatic positioning. Repeating a reset +is a no-op only while the referenced port still belongs to that placement's +element; missing or stale owners are rejected. Remove old anchors before +reparenting in the same atomic batch. Both commands support ordinary dry run, +ETag checks and source-project writeback. Rejected batches preserve the model, +authored files and revision. JSON/source imports may omit ``port_anchors``; +it defaults to ``[]`` and empty lists are omitted on export. The normative +diagram semantics and source spelling are in ``language.rst`` section 5. + Contextual process and chain topology ------------------------------------- diff --git a/docs/language.html b/docs/language.html index 79fe7b75..5dfa1ed3 100644 --- a/docs/language.html +++ b/docs/language.html @@ -1296,7 +1296,7 @@ capabilities with their involvement, exploitation, include, extend and generalization links. Decomposition and Overview retain general containment; Overview also exposes all relationships. Changing the projection hides inapplicable placements without deleting them or changing semantic records.

-

Ports are calculated boundary anchors of visible owners, with their existing +

Ports are boundary anchors of visible owners, with their existing semantic identity. An explicitly placed port is not rendered as a separate large node. Functional exchanges use their declared source/target ports; boundary exchanges attach to their participant ports even if those ports have @@ -1305,13 +1305,45 @@ may terminate at a visible ancestor without creating a new port or exchange.

node has an alias and identity but no display-name string. Required fields are diagram and element references. Optional fields are position (record with x/y, default 0/0), size (width/height, default 224/94), and -collapsed (false). Node identity is independent of its semantic element. +collapsed (false), and port_anchors (empty list). Node identity is +independent of its semantic element. Coordinates are absolute diagram coordinates. Placement does not alter parent, allocation, or realization. Hiding an element from a diagram must remain distinguishable from deleting it from the model. Changing a node's diagram preserves the placement identity, alias, coordinates, -size, collapse state, and attached artifacts. The destination must accept the -element's layer and must not already contain another placement of that element.

+size, collapse state, manual port anchors, and attached artifacts. The destination +must accept the element's layer and must not already contain another placement +of that element.

+

Each port_anchors entry is a PortAnchor record with required port +reference, side (Left, Right, Top, or Bottom), and offset (finite +binary32 from 0 through 1). It has no separate identity or alias. The port MUST +be an existing Port directly owned by the placement's element. A port MAY +have one manual anchor per placement; different diagrams MAY position it +independently. List order is preserved but has no positional meaning.

+

offset measures a fraction of the complete projected owner edge, from top +on Left/Right or from left on Top/Bottom. Zero and one denote its corners. +The side is presentation data and MUST NOT change the port's contract, +orientation, ownership, allocations, or connected exchanges. Absent anchors use +automatic positioning. Empty lists are omitted in canonical JSON/source; +earlier schema-3 input without this field hydrates as [].

+
node actor_view {
+    diagram @architecture; element @vessel;
+    port_anchors [{ port @communication; side Top; offset 0.25; }];
+}
+

SetPortAnchor { diagram, node, anchor } upserts by port identity and retains +unrelated entries. RemovePortAnchor { diagram, node, port } restores +automatic positioning; it is a no-op only for an existing direct child port +of that placement. Unknown fields, duplicate port references, non-port or +foreign-owner references, and invalid offsets are structural errors. Each +anchor counts as one record against ParseLimits.max_records.

+

These commands use the same atomic history, conditional REST transactions, and +source writeback as other presentation edits. Source references resolve across +files, and anchor comments follow port identity. Reidentifying a port updates +anchor references; deleting it removes its anchors. Reparenting MUST repair +old-owner anchors in the same transaction, for example by removing them before +the Reparent command. Moving, resizing, arranging, or folding an owner retains +its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept +port_anchors; their explicit migration supplies an empty list.

Each routes entry is an EdgeRoute record with a required edge reference to an existing Relation or Trace, waypoints (default []), and label (default none). Route records have no separate UUID or alias. An edge occurs @@ -1615,7 +1647,8 @@ preserve authored comments and whitespace on Apply while inserting missing identities. Explicit canonical export remains a separate operation. Project update/export distinctions are described below. Camera position, selection, source cursor, and undo history are not stored -model fields. Layout and collapse state inside NodePlacement are model data.

+model fields. Layout, collapse state, and manual port anchors inside +NodePlacement are model data.

The pure format_bundle exporter uses the same typed declaration writer as format. It creates a small entry file, layer/category modules, separate activity/function roots and behavior contexts, and separate diagrams with their diff --git a/docs/language.rst b/docs/language.rst index b9267221..73f31a5a 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -638,7 +638,7 @@ generalization links. Decomposition and Overview retain general containment; Overview also exposes all relationships. Changing the projection hides inapplicable placements without deleting them or changing semantic records. -Ports are calculated boundary anchors of visible owners, with their existing +Ports are boundary anchors of visible owners, with their existing semantic identity. An explicitly placed port is not rendered as a separate large node. Functional exchanges use their declared source/target ports; boundary exchanges attach to their participant ports even if those ports have @@ -648,13 +648,52 @@ may terminate at a visible ancestor without creating a new port or exchange. ``node`` has an alias and identity but no display-name string. Required fields are ``diagram`` and ``element`` references. Optional fields are ``position`` (record with x/y, default 0/0), ``size`` (width/height, default 224/94), and -``collapsed`` (false). Node identity is independent of its semantic element. +``collapsed`` (false), and ``port_anchors`` (empty list). Node identity is +independent of its semantic element. Coordinates are absolute diagram coordinates. Placement does not alter parent, allocation, or realization. Hiding an element from a diagram must remain distinguishable from deleting it from the model. Changing a node's diagram preserves the placement identity, alias, coordinates, -size, collapse state, and attached artifacts. The destination must accept the -element's layer and must not already contain another placement of that element. +size, collapse state, manual port anchors, and attached artifacts. The destination +must accept the element's layer and must not already contain another placement +of that element. + +Each ``port_anchors`` entry is a PortAnchor record with required ``port`` +reference, ``side`` (Left, Right, Top, or Bottom), and ``offset`` (finite +binary32 from 0 through 1). It has no separate identity or alias. The port MUST +be an existing Port directly owned by the placement's ``element``. A port MAY +have one manual anchor per placement; different diagrams MAY position it +independently. List order is preserved but has no positional meaning. + +``offset`` measures a fraction of the complete projected owner edge, from top +on Left/Right or from left on Top/Bottom. Zero and one denote its corners. +The side is presentation data and MUST NOT change the port's contract, +orientation, ownership, allocations, or connected exchanges. Absent anchors use +automatic positioning. Empty lists are omitted in canonical JSON/source; +earlier schema-3 input without this field hydrates as ``[]``. + +.. code-block:: text + + node actor_view { + diagram @architecture; element @vessel; + port_anchors [{ port @communication; side Top; offset 0.25; }]; + } + +``SetPortAnchor { diagram, node, anchor }`` upserts by port identity and retains +unrelated entries. ``RemovePortAnchor { diagram, node, port }`` restores +automatic positioning; it is a no-op only for an existing direct child port +of that placement. Unknown fields, duplicate port references, non-port or +foreign-owner references, and invalid offsets are structural errors. Each +anchor counts as one record against ``ParseLimits.max_records``. + +These commands use the same atomic history, conditional REST transactions, and +source writeback as other presentation edits. Source references resolve across +files, and anchor comments follow port identity. Reidentifying a port updates +anchor references; deleting it removes its anchors. Reparenting MUST repair +old-owner anchors in the same transaction, for example by removing them before +the Reparent command. Moving, resizing, arranging, or folding an owner retains +its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept +``port_anchors``; their explicit migration supplies an empty list. Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label`` @@ -986,7 +1025,8 @@ preserve authored comments and whitespace on Apply while inserting missing identities. Explicit canonical export remains a separate operation. Project update/export distinctions are described below. Camera position, selection, source cursor, and undo history are not stored -model fields. Layout and collapse state inside NodePlacement are model data. +model fields. Layout, collapse state, and manual port anchors inside +NodePlacement are model data. The pure ``format_bundle`` exporter uses the same typed declaration writer as ``format``. It creates a small entry file, layer/category modules, separate diff --git a/docs/openapi.json b/docs/openapi.json index 5536a8d2..08d4fabf 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3615,6 +3615,32 @@ ], "additionalProperties": false }, + "PortAnchorSide": { + "type": "string", + "enum": ["Left", "Right", "Top", "Bottom"], + "description": "Presentation side of the owner placement; independent of semantic port direction." + }, + "PortAnchor": { + "type": "object", + "description": "Manual position of a direct child Port on one owner placement. Each port occurs at most once per placement; this record has no separate identity.", + "properties": { + "port": { + "type": "string", + "format": "uuid" + }, + "side": { + "$ref": "#/components/schemas/PortAnchorSide" + }, + "offset": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Finite binary32 fraction of the complete owner edge, measured from top on Left/Right and from left on Top/Bottom." + } + }, + "required": ["port", "side", "offset"], + "additionalProperties": false + }, "NodePlacement": { "type": "object", "properties": { @@ -3639,6 +3665,14 @@ }, "collapsed": { "type": "boolean" + }, + "port_anchors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PortAnchor" + }, + "default": [], + "description": "Per-placement manual port positions. Omitted and empty both select automatic positioning; empty lists are omitted on serialization. Duplicate port references are invalid." } }, "required": [ @@ -4734,6 +4768,42 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "SetPortAnchor": { + "type": "object", + "description": "Upsert manual port position by port identity on an existing owner placement, preserving unrelated entries. Port must be a direct child of the placement's semantic element.", + "properties": { + "diagram": { "type": "string", "format": "uuid" }, + "node": { "type": "string", "format": "uuid" }, + "anchor": { "$ref": "#/components/schemas/PortAnchor" } + }, + "required": ["diagram", "node", "anchor"], + "additionalProperties": false + } + }, + "required": ["SetPortAnchor"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "RemovePortAnchor": { + "type": "object", + "description": "Restore automatic positioning for an existing direct child Port. No-op when that valid owner placement has no manual anchor for the port; wrong or stale ownership is rejected.", + "properties": { + "diagram": { "type": "string", "format": "uuid" }, + "node": { "type": "string", "format": "uuid" }, + "port": { "type": "string", "format": "uuid" } + }, + "required": ["diagram", "node", "port"], + "additionalProperties": false + } + }, + "required": ["RemovePortAnchor"], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/docs/scope.md b/docs/scope.md index b8ea1681..06803192 100644 --- a/docs/scope.md +++ b/docs/scope.md @@ -1,20 +1,20 @@ -# V2 scope and tradeoffs +# Current scope and tradeoffs -V2 implements a general systems-engineering workbench spanning Operational Analysis and System Analysis. The normative [language and editing contract](language.rst) defines the supported Studio profile. It does not claim full Arcadia, Eclipse Capella or SysML conformance. [Acceptance](acceptance.md), [validation](validation.md) and [current state](../harness/STATE.md) record what has actually been exercised; implementation and verification are distinct. +Arcadia Studio implements a systems-engineering workbench spanning Operational Analysis and System Analysis. The normative [language and editing contract](language.rst) defines the supported Studio profile. It does not claim full Arcadia, Eclipse Capella or SysML conformance. The [first-release plan](../harness/PLAN.md) remains incomplete; [current state](../harness/STATE.md) records current implementation and verification. The [V2 acceptance](acceptance.md) and [validation](validation.md) records describe the earlier baseline, not acceptance of every subsequent change. ## Supported modeling profile | Area | Implemented semantics and authoring | |---|---| -| Operational Analysis | Entities, actors, roles, activities, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement | +| Operational Analysis | Entities, actors, roles, activities, missions, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement | | System Analysis | One System of Interest, external Actors, functions, missions, capabilities and functional chains; functional decomposition independent of the system boundary; allocation, functional/boundary exchanges and mission-capability links | -| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, ports, interface contents, provided/required contracts and exchange allocations | -| Behavior | Stable ordered exchange occurrences, repeated semantic exchanges, scenario lifelines and exchange/action/state occurrences, explicit ALT/PAR/LOOP/OPTIONAL fragments and branches, pre/postconditions | -| States and conditions | Participant-owned state machines, regions, state/mode/initial/final nodes, trigger/guard/effect transitions, descriptive and linked entry/do/exit work, availability and constraints | +| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations | +| Behavior | Process/chain work and exchange contributions, control topology and sequence reuse; scenario lifelines, correlated calls/replies, timers, action/state occurrences, ALT/PAR/LOOP/OPTIONAL fragments, scenario reuse and pre/postconditions | +| States and conditions | Participant-owned state machines, nested regions, states/modes and typed pseudostates, signal/time/change events, trigger/guard/effect transitions, linked entry/do/exit work, availability and constraints | | Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references | -| Views and editing | Multiple independent diagrams, decomposition/allocation nesting, collapse, dragging, pan/zoom/Fit/Arrange, SVG export, model explorer, typed property controls, exact strings, UUID/alias editing, history and source navigation | -| Text and storage | Canonical `arcadia 2` language, persistent aliases/UUIDs, typed constructors, highlighting/live lint, recoverable invalid drafts, text/JSON equality, deterministic V1 migration and atomic saves | -| Automation | Rust REST service for project/source/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage | +| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties | +| Text and storage | `arcadia 3` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery | +| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage | Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically. @@ -24,9 +24,11 @@ Graphical and textual parity applies to the persistent fields of this profile. D - Work decomposition and performer allocation are independent. Only leaves have direct allocations. A parent drawn within one performer can be a computed leaf summary, not an additional allocation. Role sharing does not create several direct performers for one function. - Nesting expresses semantic containment or the selected allocation projection. Placement movement does not change ownership or allocation. Hide and delete have different effects. - Counterpart creation copies mapped typed content and required references; existing-target updates compare only names/descriptions against a reviewed baseline. They do not synchronize arbitrary structure, reverse-engineer operational needs or automatically overwrite the other perspective. Scope follows ownership/reference dependencies, not every incident relationship. -- Processes/chains use ordered exchange occurrences. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile. -- OA boundary exchanges are a simplified operational-communication representation. Capability grouping is containment, not include/extend/generalization. Data/item references remain within their layer and can have explicit transitioned counterparts. -- Stable aliases are retained; comments and formatting normalize on Apply. Artifact references retain author-provided URI/revision/fragment values without fetching or executing resources. +- Processes/chains use contributions and explicit sequence links; imported ordered exchange paths retain their implied order until topology conversion. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile. +- Preserved OA boundary exchanges use Legacy ports. Capability grouping is containment; include, extend and generalization use separate typed relationships in OA and SA. Data/item references remain within their layer and can have explicit transitioned counterparts. +- Applying a source project preserves authored comments and formatting while assigning missing identities. Graphical updates preserve unaffected source bytes; canonical export is a separate operation. JSON does not preserve comments or source-file organization. See the [source contract](language.rst) for comment ownership and update rules. +- Port position and connection routing belong to each diagram and do not change semantic ownership or endpoints. Folding may temporarily promote an edge to a visible container; expansion restores its authored route. [Routing verification](../harness/release-routing-verification.md) records accepted behavior and measured limits. +- Artifact references retain author-provided URI/revision/fragment values without fetching or executing resources. ## Capella-inspired improvement targets @@ -34,20 +36,24 @@ These are concrete design responses, not claims that every Capella user experien | Concern | Studio response | Remaining tradeoff | |---|---|---| -| Dense workbench and difficult onboarding | Task workspaces, OA/SA context, focused properties, guided example and explicit relation/transition builders | Broader usability studies, assistive technology and localization remain | -| Noisy architecture pictures | Nested ownership/allocation views, independent placements and collapse | Manual edge routing and very large diagram optimization remain | -| Hard-to-review model files | Readable typed source, stable aliases/UUIDs, canonical ordering and one specification | Comment preservation, semantic merges and an LSP are absent | +| Dense workbench and difficult onboarding | Task workspaces, OA/SA context, contextual menus, direct architecture connections, searchable guidance and relation/transition builders | Direct behavior gestures and Home/recent-project workflows remain release work | +| Noisy architecture pictures | Nested ownership/allocation views, independent placements, collapse, automatic routing, editable bends/labels and port positions | Dense diagrams may need manual adjustment; routing has a bounded solve budget | +| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent | | Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation | | Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter | | UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export | +## Required first-release work + +Direct state/scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts. + ## Remaining boundaries - Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export. -- Advanced capability include/extend/generalization and complete Capella communication/scenario variants beyond the specified Studio profile. -- Autosave/crash recovery, recent-project management, semantic diff/merge, comment-preserving source edits, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock. -- Direct desktop connection to hosted models, collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits. -- Editable edge routing, production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers. +- Complete Capella communication/scenario variants beyond the specified Studio profile. +- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock. +- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented. +- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers. - Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case. Windows and Linux builds are supported. Documented Docker/systemd deployment recipes are available for the service, but a recipe is not evidence of a running remote deployment. Platform, package and GUI verification must be reported from the acceptance record rather than inferred from source or CI configuration. diff --git a/harness/DECISIONS.md b/harness/DECISIONS.md index a89838ed..8c9725cf 100644 --- a/harness/DECISIONS.md +++ b/harness/DECISIONS.md @@ -35,3 +35,6 @@ - Recovery state is independent of source diagnostics. Interrupted Save All journals remain discoverable after lint, typing, missing entry files or first-save failure. Recovery validates current bytes against known transaction states; it does not promise atomic multi-file visibility or cross-process filesystem locking. Source/property draft guards prevent one representation from silently replacing pending edits in the other. - Production source3 nesting expresses semantic element ownership only. Extend the existing parser and source index with real lexical owners/body spans, rather than adopting the experiment's parallel parser. Flat canonical interchange remains valid. Structural writeback composes declaration pieces after ordinary token edits and uses original occurrence provenance through temporary UUID collisions. This preserves independent parent/child moves, authored files, comments and exact history while allowing new children to be emitted inside their owners. Allocation and ordered occurrence membership remain explicit. - A hosted revision owns the accepted model and stabilized source bundle together. Store one atomic envelope, use the same core writer for commands and previews, and advance the ETag for comment-only edits. Old model-only envelopes acquire deterministic source in memory without a read-time write or revision change. A detached source sidecar would create inconsistent revisions and unsafe restart behavior. Accepted model equality alone is insufficient for a no-op when command provenance deletes/recreates a declaration; this was reproduced and corrected in desktop and REST adapters. +- Contextual actions use a controlled Iced overlay and captured semantic target plus document/revision/view context. Menus reuse existing handlers, and opening a menu does not select/apply another draft. Canvas focus scopes routing keys; Actions is an accessible alternative to right-click. Source files use a folder tree with exact paths and aggregate draft/diagnostic markers. +- Port anchors are per-placement presentation records with four sides and normalized offsets. Automatic sides use port direction and visible peer geometry; explicit anchors do not change owner minimum dimensions. Canvas, routing, Fit and SVG share projected anchors and side normals. Graphical reparent batches old-owner anchor cleanup with semantic reparent; raw source and API remain strict and require explicit repair. +- Direct connections use explicit Select/move and exchange palette tools. Compatible visible endpoints are highlighted; click-click or drag creates one ordinary validated command. Function-card gestures use the existing automatic port planner. Tool invocation, blank-canvas restart, Escape and model/view changes invalidate old gesture contexts. No project clone or whole-model compile occurs per highlighted candidate; final commit still validates the complete model/source change. diff --git a/harness/PLAN.md b/harness/PLAN.md index 15f39071..c6ce27e1 100644 --- a/harness/PLAN.md +++ b/harness/PLAN.md @@ -14,7 +14,9 @@ The current forms are a closed typed-record adapter, not a second metamodel. Exp ## Milestones and measurable acceptance -Nested source ownership and persistent REST bundles are implemented and under final native acceptance. Per-file repair/recovery passed at 1100×700; production parsing, exact structural writeback and coupled service revisions passed independent review and Windows/Linux suites. Root's Windows nested reparent/source/Undo/Redo/Save/reopen passed; minimum-size source-to-graphics review and rebuilt Linux trial remain. Preserve the existing grammar/updater and separate allocation/order semantics. Next, use the updated direct-state core/desktop maps to freeze one small persisted-canvas contract before implementation. +Nested source ownership and persistent REST bundles are implemented and verified: Windows minimum-size source-to-graphics, nested reparent/source/Undo/Redo/Save/reopen, recovery, rebuilt Linux source/history/Save/reopen, independent review and both platform suites passed. Preserve the existing grammar/updater and separate allocation/order semantics. + +Current user-directed sequence: finish native contextual-menu/source-tree acceptance; then ship draggable per-placement port sides and intelligent automatic placement; then direct diagram connection tools for OA interactions, functional exchanges and boundary exchanges. Port placement is presentation state, and drawing an exchange uses the existing typed semantic command boundary. Select/move and Connect are explicit modes; connection targets highlight only compatible visible endpoints. A completed drag commits once, retains text/REST parity, and supports Undo/Redo/Save/reopen. Verify all four port sides, nested owners, routing/pins, compatible and incompatible endpoints, zoom, Escape, stale gestures, and minimum Windows/Linux layouts. The independently tested state-core slice is preserved in its worktree until these latest usability priorities are integrated; all broader release milestones remain required. The user-directed actor/system port and model-navigation milestone is accepted: visible Add port → Connect boundary exchange, grouped roots, focused containment, breadcrumbs, contextual search and paging, clear full-depth outline, and separate allocations. Native Windows authoring/save/reopen, 251-root paging, 12-level navigation, 1440×900/1100×700 inspection, rebuilt Linux save/reopen and single-line font correction passed. Independent requirements, correctness, native UI and simplicity findings are addressed; release-explorer-usability.md records exact evidence and remaining performance boundaries. Resume source recovery acceptance and the release milestones below; the full scope remains intact. diff --git a/harness/STATE.md b/harness/STATE.md index 56a1f356..eaf80944 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -8,18 +8,18 @@ The editable connection-routing milestone is accepted at `23535b4` with viewport ## Completed foundations -Nested source/REST integration checkpoint: production parser and iterative structural writer are implemented; REST persists the accepted bundle and model in one ETag envelope. Final workspace runs passed 422 Windows and 425 Linux tests; strict workspace Clippy, formatting and Windows/Linux builds passed. Actual server import, comment-only revision, cross-file reparent, stale ETag and exact restart/conditional GET passed. Owner-opening-line comment reassignment and equal-model delete/recreate provenance defects were reproduced, fixed and reverified. Independent parser/writer/model-source boundary reviews found no remaining material findings; fresh spawn was unavailable because of the thread limit, so workers reviewed one another's artifacts. Strict language/API reST rendering passed after correcting an API heading underline. Root's Windows nested reparent/arrange/source/Undo/Redo/Save/reopen passed with 22 objects,13 links,0 checks. Independent minimum-size/source-created port QA is active; actual rebuilt Linux nesting trial remains. Evidence: release-source-integration.md. +Nested source/REST integration checkpoint: production parser and iterative structural writer are implemented; REST persists the accepted bundle and model in one ETag envelope. Final workspace runs passed 422 Windows and 425 Linux tests; strict workspace Clippy, formatting and Windows/Linux builds passed. Actual server import, comment-only revision, cross-file reparent, stale ETag and exact restart/conditional GET passed. Owner-opening-line comment reassignment and equal-model delete/recreate provenance defects were reproduced, fixed and reverified. Independent parser/writer/model-source boundary reviews found no remaining material findings; fresh spawn was unavailable because of the thread limit, so workers reviewed one another's artifacts. Strict language/API reST rendering passed after correcting an API heading underline. Root's Windows nested reparent/arrange/source/Undo/Redo/Save/reopen passed with 22 objects,13 links,0 checks. Independent minimum-size/source-created port QA passed. Root also verified Linux nested source edit/Undo/Redo/Save/reopen; the Linux instance closed normally with an empty log. Evidence: release-source-integration.md. -Current continuation: previous goal turn made concrete progress at `d364d3c` (native-verified explorer/port usability). Root reread the complete authoritative objective. Native source recovery acceptance passed: comment-preserving reopen, graphical rename/writeback/Undo, inactive-file syntax repair, mixed interrupted-save rollback, first-save recovery with retained draft/retry, and included-file repair/Save/reopen at 1100×700. The prominent recovery banner closed normally with an empty runtime log. All implementation ownership is released to root. `explorer_usability_review` alone owns native input for its bounded QA; `source_document_review` and `source_integration_map` are mapping direct-state core/desktop contracts read-only. No blocker; release scope is unchanged. +Historical start of this continuation (superseded by the active checkpoint below): nested source/REST is committed at `4e8575b`. Independent Windows source-created Standard actor port, boundary-exchange entry, Undo/Redo and Save/reopen passed at 1100×700; the isolated QA window closed normally. Linux native nesting acceptance passed (source comment/history/save/reopen and rendered hierarchy; cross-file graphical reparent was exercised on Windows). Original Windows window 6228978 contains unsaved user work: do not save, reset or close it. Root again owns native input. The state-core worker owns only oa-core in isolated worktree `D:/CodexBuilds/Capella2-state-diagram-core`, branch `codex/state-diagram-core`; root integrates after review. The latest user request prioritizes contextual right-click menus and removes permanent bend buttons. Root owns desktop integration; explorer_usability_review owns only the new reusable context_menu.rs; source_integration_map maps existing actions read-only. Native source recovery acceptance already passed; no blocker and broader release scope is unchanged. - The user-directed explorer/port usability milestone is accepted. Add port is visible on participants, and selected component ports expose Connect boundary exchange. The browser uses grouped roots, focused direct children, breadcrumbs, paged contextual search and Locate; the outline keeps real depth, guides and single-line labels with full-name tooltips. Root created two Standard ports and a Boundary Exchange, exercised Undo/Redo, Save/reopen, duplicate-name search, 251-root paging and 12-level navigation. Independent native review and root's final Windows build passed at 1100×700; actual rebuilt Linux save/reopen and font-fix inspection passed. Full Windows workspace tests passed 382, followed by all 138 desktop tests; Linux full workspace passed 386. Strict checks and final Windows/Linux builds passed. Slow picker, stale reparent focus, deep-row cost, collapsed header sizing, scroll reset and font wrapping findings are fixed. Exact scope, hashes and evidence: release-explorer-usability.md. -- Source integration checkpoint `3c87576` passed full366 Windows tests; its draft-history cap follow-up passed122 desktop tests and strict workspace Clippy. Root ran native build SHA256 `FCA6140E835B17AE4FAB003B0DAC38D7D820DD36C7559F536BA3CC607E30FE51`: opened two included buffers, typed separate rationale comments, switched with drafts retained, Apply all, model Undo/Redo, then Save All to `ui-source-project/native-source-trial.arcadia`. The entry is1496 bytes with20 other included files; both authored comments are present on disk and the UI reports all changes saved,41objects/25links/0checks. Runtime log is empty; the window closed normally (exec session99563 exited0). Native source reopen/failure/minimum-size acceptance is still required. -- Fresh source integration review at 3c87576 reproduced property-draft loss and diagnostic-only recovery state. Both are fixed in the current app: guards retain both drafts, and durable pending_recovery survives typing/lint, missing entries and initial Save failure. Four real-journal recovery tests and source/property collision tests pass in the full workspace run. The bounded reviews are complete; all agents released file/native ownership to root. Frozen source probe evidence: D:/CodexBuilds/Capella2-source-app-independent/REVIEW.md. The explorer Linux trial additionally verified failed Save to a missing directory retained the model and successful retry saved all files; interrupted-journal UI recovery still needs native acceptance. +- Source integration checkpoint `3c87576` passed full366 Windows tests; its draft-history cap follow-up passed122 desktop tests and strict workspace Clippy. Root ran native build SHA256 `FCA6140E835B17AE4FAB003B0DAC38D7D820DD36C7559F536BA3CC607E30FE51`: opened two included buffers, typed separate rationale comments, switched with drafts retained, Apply all, model Undo/Redo, then Save All to `ui-source-project/native-source-trial.arcadia`. The entry is1496 bytes with20 other included files; both authored comments are present on disk and the UI reports all changes saved,41objects/25links/0checks. Runtime log is empty; the window closed normally (exec session99563 exited0). Those initially pending native source reopen/failure/minimum-size checks were subsequently completed at the nested-source checkpoint above. +- Fresh source integration review at 3c87576 reproduced property-draft loss and diagnostic-only recovery state. Both are fixed in the current app: guards retain both drafts, and durable pending_recovery survives typing/lint, missing entries and initial Save failure. Four real-journal recovery tests and source/property collision tests pass in the full workspace run. The bounded reviews are complete; all agents released file/native ownership to root. Frozen source probe evidence: D:/CodexBuilds/Capella2-source-app-independent/REVIEW.md. The explorer Linux trial additionally verified failed Save to a missing directory retained the model and successful retry saved all files; interrupted-journal UI recovery was subsequently verified in the source-recovery milestone above. - Branch `codex/release1-engineering`; baseline `93ba83d`, hierarchy `c7101b9`, drag selection `2ef16dc`, typed core/schema 3 merge `757ab88`, alias and batch lifecycle corrections `e41b8de`. - Typed desktop integration `fa096ce` provides contextual function/component ports, FE endpoint and exchange-item choices, generation, resolution, split and synchronization with semantic previews. Legacy migration, Standard/Flow distinctions and independent orientation are retained. Independent findings and native-discovered no-op synchronization and SA-only initial-perspective defects were fixed. - Native typed-port trials created real function endpoints and exchanges, supplementary output/items, and a Standard component implementation with two port allocations and an exchange allocation. Undo/redo and save passed with zero model checks. Reviewed reopen entered SA correctly; unchanged synchronization disabled Apply. The long-reference review modal was inspected at 1440×900 and 1100×700. Evidence and fixtures are under `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-typed-ports` and `ui-typed-ports-reviewed`. -- Source bundle foundation `06b3384` and token-preserving updater `dbc8971` passed 160 core tests, including 30 source-project tests, and strict core Clippy at that checkpoint. This is a pure core foundation; production editor, filesystem Save All/conflict handling and REST bundles remain to be integrated. +- Source bundle foundation `06b3384` and token-preserving updater `dbc8971` passed 160 core tests, including 30 source-project tests, and strict core Clippy at that checkpoint. This was the initial core-only checkpoint; production editor, filesystem Save All/conflict handling and REST bundles were subsequently integrated at 4e8575b. - Isolated source authoring comparison `c5788fc` and nested writeback experiment `2dd17aa` passed 23 experiment tests and strict Clippy. An independent executable probe at `D:/CodexBuilds/Capella2-source-writeback-independent/probe.log` reproduced positional list-comment misattachment after item reorder, rejection of a valid description edit on port shorthand, and unsupported occurrence deletion. The same comment-reorder probe retained attachment in the production updater. Complete model equality alone does not protect comment meaning. Extend the production parser/span index and shared updater for selected nesting/shorthand constructs; do not copy the experiment into a parallel production parser/writer. Freeze comment ownership, exact source Undo and full edit/delete support before adoption. `experiments/source-authoring/WRITEBACK.md` retains the bounded evidence; production syntax is not yet adopted. - R1 process topology, R2 mission/capability, R4 scenarios and R5 events/states are implemented at the semantic/form level. Full native authoring acceptance and direct behavior canvas gestures remain required; release-state-gesture-design.md records the proposed first vertical slice. @@ -52,14 +52,23 @@ Original Capella was operated in a disposable workspace. Native creation demonst Earlier Studio windows disappeared during concurrent work. Fresh inventory confirmed absence and no matching application-crash event was observed; the earlier unsaved routing trial was not recovered. It is superseded by the explicitly saved reviewed fixture, not retroactively claimed as recovered. +## Active drawing integration checkpoint + +Meaningful progress continues: direct connection tools, movable per-diagram port anchors, contextual menus and source folders are implemented. Core commits93fd32c (feasibility scaling) and35133de (port anchors) are integrated. Full Windows workspace passed485 tests (264core,185desktop,36server); full Linux passed488 (267core,185desktop,36server). Final ordered-cursor follow-up passed189 desktop tests on both platforms, strict workspace Clippy and rebuilt desktop binaries. Earlier full runs precede those four additional lifecycle regressions. API/port anchor HTTP tests and strict reST generation passed. Detailed scopes and saved fixtures are in release-port-drawing.md and release-context-menus.md. + +Fresh independent correctness review reproduced post-release mouse motion in one native batch creating a port move or exchange after a stationary click. Ordered CursorMoved coordinates are now authoritative for press and release; tests cover both failures, same-batch drags, stationary-source continuity, menu epochs, middle/arrow pan and fresh-context Reset/Draw. Nearest-glyph hit testing fixes incorrect port identity at overview zoom. Port hover shows name, owner and alias in a separate screen-space frame; no label-text overprint. Fresh requirements/architecture review found no further material issue after these fixes and scope-document correction. + +Root operated final Windows binary `ui-port-drawing-final/arcadia-studio.exe`, SHA2569A7FF074296D49A714AD664848170D717832A28964719A903B45ABE100BBAA1F, window328292, exec68098. Fast FE port-to-port drag, first Bottom port drag, repeated Left drag, arrow panning with source retained offscreen, target completion and single Undo passed with0checks. It currently contains the isolated unsaved41object/26link model with Left port anchor and first FE. Native input is EXCLUSIVELY delegated to drawing_requirements for Save/reopen, Standard boundary drawing, Function-card auto endpoints, menu lifecycle and minimum-size acceptance. Root must not issue native input until handoff. Current Linux binary SHA25623CEAE18F53736B9B38A94E4185C45D6639825A3775DEB3FB814938B9CD00387 is built/tested; native follow-up pending. + +Previous46EE7BD9 trial closed normally (exec90275 exit0) after saving its Bottom anchor/FE fixture, reopening the DE6BD100 Left-anchor/FE/Flow-boundary fixture, SVG export and1100x700 inspection. Saved fixtures and precise results are in release-port-drawing.md. Protected user window7210312 was absent after the interrupted turn; root did not close it. If it returns, do not save/reset/close it. Earlier6228978 likewise absent; no recovery claim. + +Parallel release work: drawing_correctness reviews preserved state-core commit a63654f against current anchors/routing. drawing_linux owns behavior draft lifecycle implementation only in a new isolated worktree, excluding app.rs. All broader first-release requirements remain active and incomplete. ## Exact next actions -1. Explorer/port acceptance is complete; preserve release-explorer-usability.md and the saved native/large-model fixtures. Finish native source comment-preserving reopen/error/recovery acceptance under release-source-integration.md. - Source integration is committed at 3c87576; recovery/property guard follow-ups are implemented and tested with the explorer milestone. Identity provenance and conflict-aware Save All foundations are committed at 86bf36b/271f062. Root verified multi-file editing, Apply, Undo/Redo and Save natively; comment-preserving reopen and interrupted recovery still require runtime evidence. Preserve these tests/evidence rather than restarting integration. Then extend production nested syntax and REST bundles; do not promote the experiment unchanged. -2. Linux routing acceptance is complete. Independent verification passed all 324 workspace tests, strict Clippy, formatting and optimized desktop/server builds at `23535b4`. Root's actual Linux/WSLg bend, label, Undo/Redo, Save and reopen passed. That run exposed a dialog-induced viewport recenter, fixed in `366ce44`: all 105 Windows desktop tests and strict desktop Clippy passed, Windows/Linux rebuilds passed, and root's rebuilt Linux native trial preserved the viewport throughout Save. `release-routing-linux.md` records exact scopes/hashes; final saved fixture is `ui-routing-viewport/linux-routing.arcadia` (33,546 bytes). The Linux test window was closed normally after saving. No full 325-test Linux invocation is claimed. -3. Implement direct state/scenario/process gestures, R3 semantic drift and reviewed reconciliation, R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag workflows, navigable offline guidance and normative reST/API completion. These are required release work, not non-blocking limitations of a finished release. -4. Run fresh requirements, correctness, native UI/UX and architecture reviews for the complete release; address material findings. Verify current Windows/Linux/MSRV builds, runtime workflows, documentation, packages and checksums before completion. - +1. Finish independent native acceptance of the frozen9A7FF074 Windows build, then root verifies the frozen23CEAE18 Linux build. Cursor, pan, nearest-hit and tooltip fixes have passed automated verification and root's current native drawing/port/pan workflow. Preserve exact binary scopes while broader implementation continues. +2. Finish direct-drawing acceptance: default/minimum-size Windows and Linux, existing-port FE and Function-card auto endpoints, Flow/Standard boundary links, invalid/cancelled/folded targets, Save/reopen and SVG. Record exact scopes in release-port-drawing.md, update menu/source-tree acceptance and commit the coherent UX milestone. +3. Integrate preserved state-core commit `a63654f` from isolated worktree only after this user-directed usability milestone. Implement direct state/scenario/process gestures, R3 semantic drift and reviewed reconciliation, R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag workflows, offline guidance and normative contracts. All remain required release work. +4. Complete independent requirements, correctness, native UI/UX and architecture reviews for the entire release; address material findings. Verify current Windows/Linux/MSRV builds, runtime workflows, documentation, packages and checksums before completing the broad goal. ## Environment and reproducibility Windows Rust 1.98.1; `CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e`. Release logs and native fixtures are under `D:/CodexBuilds/Capella2-release1-01a06e2e`. Use `cargo test --locked --workspace`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, and `cargo build --locked -p oa-desktop -p oa-server`. Generate the routing example with `cargo run --locked -p oa-core --example routing_workshop`; historical migration goldens stay frozen. diff --git a/harness/TASK_LEDGER.md b/harness/TASK_LEDGER.md index a0515a03..4e1dc5bf 100644 --- a/harness/TASK_LEDGER.md +++ b/harness/TASK_LEDGER.md @@ -27,7 +27,7 @@ System engineers can develop a coherent OA→SA system model, including iterativ - Independent core review reproduced alias reservation and same-transaction allocation replacement defects; fixes have focused regression evidence. Typed desktop integration `fa096ce` passed full checks and native creation/generation, undo/redo, save/reopen and no-op synchronization trials. Long-reference previews were inspected at 1440×900 and 1100×700; detailed evidence is in STATE.md. - Per-diagram pins and labels, source/API parity and shared orthogonal routing are implemented in `4e5a88d`, `a7c6f96`, `68b1eff` and `30c7db1`. `65fa9ab` resolves all six reproduced default SA label-overlap pairs. Native OA/SA editing, movement/undo, fold restoration, save/reopen, SVG inspection and short gestures at 1100×700 passed. Routing code/native acceptance and independent native follow-up are complete at `23535b4`; exact scope is recorded in release-routing-verification.md. - The source experiment passed 23 tests, but an independent probe reproduced positional item-comment misattachment, shorthand description rejection and unsupported deletion. Whole-model equality does not establish source-comment fidelity. The production updater retained comment attachment on the same reorder probe; extend its parser/updater instead of copying the experimental parallel implementation. -- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The source editor retains modular accepted source; nested element syntax is implemented and under native acceptance. Port/exchange/steps shorthand remains an independent future implementation slice required by the current authoring strategy. +- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The source editor retains modular accepted source; nested element syntax and cross-file graphical updates passed Windows/Linux native acceptance at4e8575b. Port/exchange/steps shorthand remains an independent future implementation slice required by the current authoring strategy. - Independent source review exposed declaration/comment reassignment during UUID changes and swaps, Linux FIFO hangs during disk admission, file-mode changes on save, and overlong detached-file receipt lifetimes. Command provenance, pre-open regular-file admission, permission retention and saved-detach receipt retirement resolve the reproduced cases. Exact probes and test scopes are in STATE.md. ## Assumptions to test @@ -54,3 +54,5 @@ System engineers can develop a coherent OA→SA system model, including iterativ Accept native per-file editing, safe project saves and exact source Undo, then extend production source parsing/updating using the independent probe findings as regressions and integrate REST bundles. Linux routing is accepted with actual WSLg Save/reopen; all11 current Linux disk integration tests also pass. Full release Linux acceptance remains open. Prototype persisted direct behavior gestures next. Continue R3 reconciliation, R6 data contracts, lifecycle/recovery, hosted editing, offline guidance and fresh Windows/Linux release acceptance; no subsystem checkpoint constitutes release completion. Meaningful progress now includes verified drag and typed-port authoring, accepted editable routing with native save/reopen, group movement and minimum-size evidence, and tested source-bundle/nested-writeback foundations with independent limitations exposed. Production source and direct behavior usability remain open engineering tasks; feature counts alone do not establish completion. + +Current user-directed drawing evidence: palette creation and draggable per-placement port anchors passed scoped native Windows trials with Save/reopen; full485 Windows and488 Linux workspace suites passed before final pointer-release and hover refinements. Fresh independent review exposed post-release motion turning clicks into edits, nearest-port mispicking at overview zoom, and missing target identity feedback. The first two have production regressions and fixes; named owner/alias hover details and source-preserving pan are implemented, with final native acceptance pending. See release-port-drawing.md. The preceding goal turn made concrete progress through implementation, saved source/SVG artifacts, actual runtime verification and reproduced findings; it was not a wait or no-progress turn. Direct behavior and the broader release requirements remain active. \ No newline at end of file diff --git a/harness/release-context-menus.md b/harness/release-context-menus.md new file mode 100644 index 00000000..5b7b0e3f --- /dev/null +++ b/harness/release-context-menus.md @@ -0,0 +1,23 @@ +# Contextual menus and source tree + +## Outcome and implementation + +The latest user priorities are discoverable, contextual actions without a crowded Properties column, no permanent bend-button row, and a source-file folder tree. Preserve the accepted port creation workflow: participants retain a primary Add port button, and component ports retain Connect boundary exchange. Actions and right-click menus expose secondary operations. Existing forms and core commands remain the semantic boundary; menus do not introduce a command registry or change stored models. + +`context_menu.rs` supplies a controlled Iced overlay and right/left press anchors. It clamps to the viewport, scrolls long menus, skips disabled rows during keyboard navigation, and captures outside-dismissal press/release without click-through. `model_menu.rs` captures target identity plus document/revision/view context. Opening a menu does not select another object or apply a pending draft. Invocation validates the captured context before using existing guarded handlers. Canvas hit testing shares the rendered port/route geometry; Add bend here uses the clicked world point. Insert/Delete are scoped to canvas focus. Shift+F10/Menu uses selected identity with geometry only for the popup anchor. + +`source_tree.rs` groups exact logical file paths, retains duplicate basenames and aggregate draft/diagnostic indicators on collapsed folders, and uses the explorer's 32-pixel rows and 16-pixel guides. Collapse/Locate/file navigation are presentation state only. File closing remains restricted to clean detached buffers and never deletes disk files. + +## Current verification and remaining acceptance + +Independent reusable popup tests and strict Clippy passed (8 tests); source tree tests passed (6 tests). Root is running integrated desktop regressions. Independent review identified stale menu tokens dismissing a newer menu and keyboard menus losing selected port/bend identity; both fixes are implemented, with focused regressions and revalidation active. Initial compile errors were corrected; an obsolete modeling-picker type was removed after strict warning review. + +Native acceptance still required on the new binary: unselected actor Add port, selected port connection, edge/bend insertion/removal/reset, Undo/Redo, menu dismissal and keyboard focus, minimum-size clamping/scrolling, folder collapse/reveal/diagnostics, and Save/reopen. Preserve original Windows window 6228978, which contains user edits. Root alone owns native input. The isolated state-core commit `a63654f` is not merged until this user-directed desktop milestone is reviewed and accepted. + +The first native menu trial on binary `6A28BCB0…` reproduced a blank 280×12 strip on actor right-click. Iced reconstructs overlay instances while retaining layout; geometry held only in the transient layout instance was lost before draw/update. The reusable widget owner is fixing this root cause and adding a reconstruction regression; no UI acceptance is claimed for that build. The same native trial verified source-folder expansion/file selection and identified unnecessary horizontal scrolling at depth1; root adjusted shallow row widths to match the explorer's approach while preserving true depth for larger trees. + +Follow-up binary `1B430A5822563B76699F421EA2C50EC23D0A5A47582BABC49598751683978F99` fixes overlay reconstruction by deriving geometry from retained layout children; 10 reusable widget tests pass including reconstruction regressions. Integrated162 desktop tests, strict Clippy and build pass. Root native default-size actor/explorer Add port and typed Boundary exchange creation passed,43objects/26links/0checks. Add bend here, Insert, Delete, Shift+F10/keyboard Remove, Reset routing and Undo passed; outside dismissal did not select the underlying object. One first drag was unchanged; a repeat succeeded, and three independent production-widget first-drag/coalesced-release probes passed without reproducing a code defect. Save All wrote the isolated authored/project.arcadia with both Standard ports, Coordination interface and the restored FE detour. Independent reopen/minimum-size/source-tree QA is active. Native minimum/menu acceptance is not yet complete. + +Independent native review on1B430 passed reopen,1100x700 Actions clamping and keyboard use, source/property draft gates, outside dismissal, input Delete isolation, duplicate basenames and seven-deep source navigation. It reproduced Actions -> Escape swallowing the next Revert click. Root fixed the Area opening-release lifecycle and added a regression; rebuilt177-test drawing binary contains the fix, native revalidation still pending. Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e/context-menu-independent-native-review.md. + +Root reverified the Actions -> Escape -> first Revert fix natively on DE6BD100: edited the selected FE name, observed all modeling actions disabled, pressed Escape, clicked Revert once, and captured the restored name and Property draft reverted status. This closes the independent first-click finding. The final pointer-batch follow-up still requires its own runtime acceptance. diff --git a/harness/release-port-drawing.md b/harness/release-port-drawing.md new file mode 100644 index 00000000..6972f003 --- /dev/null +++ b/harness/release-port-drawing.md @@ -0,0 +1,83 @@ +# Direct connections and port placement + +## User workflow and decisions + +Architecture has explicit Select/move and perspective-specific drawing tools: +OA Interaction, SA Functional exchange and SA Boundary exchange. Select a source +and a highlighted target, or drag between them. Function cards generate/reuse +typed ports through the existing atomic planner; explicit port hits retain the +chosen identity. Boundary links use actual compatible component ports. Middle +drag and canvas-focused arrows pan while retaining the chosen source. Escape +cancels; empty space restarts. Hover details identify ports, owners and aliases. + +Port sides and normalized positions belong to each diagram placement, preserving +semantic ownership. Dragging snaps to the owner's border; automatic placement +uses direction, connected peers and available border space. Right-click Reset +automatic position restores the derived anchor. Core commands, source bundles, +JSON/REST and SVG use the same model and projected geometry. Each completed +gesture makes one source-aware history operation. Menus and source-file folders +are covered separately in release-context-menus.md. + +The bounded automatic router still permits dense-model crossings. Authored bends +remain available; no perfect-layout guarantee is made. Input direction and carried +items are not guessed from geometry. Drawing creates an empty carried-item list; +Properties supplies the name and contract editing workflow. + +## Findings resolved during integration + +- Shared feasibility queries previously scanned unrelated interface roles for + every candidate. Commit93fd32c reduced the measured4000-port/4000-role picker + from804ms to3.169ms optimized without relaxing final validation. +- Per-placement port anchors are integrated in35133de. Owner changes repair + graphical anchors atomically; raw source/API reject invalid owner references. +- Automatic labels now avoid other edge strokes as well as nodes/labels, with + bounded deterministic placement. Both source-ID orders have regression tests. +- Iced supplies a batch-final window cursor for every event. Ordered motion is + now authoritative for both press and release, preserving stationary-source + drags and preventing later motion from converting a click into a model edit. + Menu transitions invalidate cached pointer state through captured epochs. +- Enlarged port hit areas overlap at overview zoom. All three picking paths now + choose the nearest glyph, with stable tie-breaking for coincident ports. +- Port labels use a separate screen-space frame, avoiding text overprint and + keeping target identification legible while zoomed out. + +## Observed verification and remaining acceptance + +Full Windows workspace passed485 tests (264 core,185 desktop,36 server); full +Linux passed488 (267 core,185 desktop,36 server), with strict Linux workspace +Clippy and optimized desktop/server builds. These full runs precede the last +ordered-release/hover changes. Four subsequent production lifecycle tests and +nine external lifecycle probes passed. Final189-test desktop runs, strict +workspace Clippy and rebuilds passed on both Windows and Linux. Native acceptance +is still in progress; do not infer it from automated suites. + +Root operated DE6BD100 and46EE7BD9 Windows builds through Computer Use. OA palette +click-click creation/history, SA direct FE/Flow boundary links, fast port-to-port +drag, four port sides across trials, automatic reset/Undo, draft/menu guards and +save/reopen passed. The46EE build was inspected at1100x700, rejected an input port +as an outgoing source, and cancelled without dirtying the saved model. Its window +closed normally, exit0. The final ordered-pointer/hover/pan build still needs +native acceptance on both platforms; middle-button dragging is unavailable in +the native tool, so use the actual keyboard-pan path plus production mouse probes. + +Saved evidence under `D:/CodexBuilds/Capella2-release1-01a06e2e/`: + +- `ui-port-drawing/authored/project.arcadia`:41objects,27links,0checks; Left anchor, + FE and Flow boundary link. Reopened on46EE and exported `project.svg` (5050bytes). + Root inspected its raster rendering for port direction, nesting and routes; + the renderer's fallback font differs from the application. +- `ui-port-drawing-reviewed/authored/project.arcadia`:41objects,26links,0checks; + Bottom anchor and fast-drawn FE, source bytes inspected after native Save. +- `port-drawing-final-tests.log`, `port-drawing-final-linux-*.log` and independent + review/probe folders retain exact command scopes. + +Root's final Windows build9A7FF074 passed fast FE drag, first Bottom port drag, +repeated Left drag, readable hover name/owner/alias, arrow pan with source retained +offscreen, target completion and one Undo. Current native window328292 has the +isolated41object/26link model; independent native reviewer owns it exclusively +for the remaining Windows tasks. Linux final build23CEAE18 passed189 desktop +tests and strict workspace Clippy but still needs native verification. + +Pending: final menu lifecycle, Function-card auto endpoint creation, Standard +boundary drawing, minimum layout, save/reopen and Linux native workflow. +The broader first-release objective remains incomplete. diff --git a/harness/release-source-integration.md b/harness/release-source-integration.md index e13b6cfb..47646662 100644 --- a/harness/release-source-integration.md +++ b/harness/release-source-integration.md @@ -209,3 +209,6 @@ saved project with 22 objects, 13 links, zero checks and all changes saved; the SA allocation view retained the same participant/function ports and exchanges. Minimum-size follow-up, independent native review and actual rebuilt Linux nesting trial are still pending. +# Linux nested source acceptance — 2026-09-06 + +Root operated the optimized Linux desktop SHA256 `C794426BD91980B2384B92884176542B2B081FA7B4ADE8ADA5D7EECEB72D6351` through Computer Use. The isolated six-file fixture was on the native Linux filesystem at `/home/sorokinoleg/.cache/arcadia/nested-source-native-20260906/project.arcadia`. Native Open loaded 22 objects, 13 links and zero checks. OA allocated hierarchy and SA system/actor nesting, real function-port triangles and component-port squares were visually inspected. Textual model opened the nested `sa/reporting.arcadia` declarations with highlighting, stable UUIDs and comments. A comment edit was applied, model Undo restored the original accepted file and clean state, Redo restored the edit, Save all completed, and native Open reopened the entry. The comment and nested source were personally verified after reopening, with all changes saved and zero checks. The Linux validation window closed normally; the original Windows window containing user edits was untouched. This is source/comment and rendered hierarchy evidence; the cross-file graphical reparent workflow was exercised on Windows, not repeated on Linux. Save latency was not benchmarked. diff --git a/harness/release-state-gesture-design.md b/harness/release-state-gesture-design.md index fcf50d0e..13f6416a 100644 --- a/harness/release-state-gesture-design.md +++ b/harness/release-state-gesture-design.md @@ -8,7 +8,8 @@ Connection routing became the user's next priority after this design handoff. Reuse Diagram/NodePlacement and existing atomic commands. Prefer a typed `ProjectionKind::StateMachine { machine: Id }` over desktop-only coordinates or a second persisted behavior-layout store. Existing projection strings remain -valid. Proposed source: `projection StateMachine { machine @operating_modes; };`. +valid. Use the existing constructor syntax: +`projection { StateMachine { machine @operating_modes; }; };`. The context references a same-layer machine and scopes placements to its machine, regions and vertices. Explicit creation initializes existing contents in one transaction; rendering must not dirty a model. Missing placements remain legal @@ -85,3 +86,39 @@ two regions, a nested composite and guarded transitions. Try invalid edges into Initial, invalid history reconnection, Escape mid-drag, a folded composite, root-Event selection and save/reopen. Record task completion, discoverability, ownership clarity and recoverability; no such native result is claimed here. + +## Current-interface review (2026-09-06) + +Independent core and desktop maps confirm the payload projection remains the +smallest persisted boundary. Preserve unit-variant JSON strings; the new value +is `{"StateMachine":{"machine":"UUID"}}`. A machine context is required for +empty and partial views; inferring it from placements cannot represent an empty +diagram, while an optional unrelated Diagram context adds invalid combinations. +Use one narrow state-view membership predicate and preserve existing projections' +legal hidden placements. Dedicated views disappear with their machine; moving +elements across machines must repair incompatible views/routes in the same Batch +or reject atomically. + +The shared router now owns route pins, label placement and grouped translation. +An executed probe found that merely retaining an External self-transition yields +an 18 px out-and-back stub. Add genuine self-loop and state-symbol endpoint +geometry in the shared routing/projection path; Entry/Exit snapping must precede +draw/hit testing. Internal transitions remain rows, and folded descendants must +not create false visible self-transitions. Probe: +`D:/CodexBuilds/Capella2-source-document-contract/examples/state_projection_boundary_probe.rs`. + +Before adding gestures, close existing behavior draft gaps: Start/Edit must not +overwrite an unresolved form, Submit must retain it until Document source/model +commit succeeds, and Save/Close/navigation must account for behavior drafts. +Canvas events carry the existing document/diagram/revision/epoch context and +recheck it before acceptance. Generic MoveNode message delivery selects the +current active diagram and is insufficient for delayed behavior gestures. + +Keep Select, State/symbol, Region, Transition and Fit in a compact toolbar. +Create inside an explicitly highlighted eligible owner, place missing ancestry +in the same command batch, and expose unplaced semantic elements. Retain the +behavior view independently of selection, including root Events. Reconnect +clones the relation and changes one endpoint; preserve all other data and reject +invalid cardinalities. Keep form editing in a bounded panel so the canvas remains +usable at 1100×700. These are decision-ready interfaces; no implementation or +native gesture acceptance is claimed.