Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu 316dc559d7 Reset state camera and tools only when navigation changes view
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-06 03:19:18 +03:00
sorokin.oleg.edu 5ce3eefdbf Document direct state diagram authoring workflows 2026-09-06 03:04:29 +03:00
sorokin.oleg.edu e0b22108a8 Add persisted state diagram authoring and context-bound gestures 2026-09-06 03:02:38 +03:00
sorokin.oleg.edu af31c8a11d Add atomic planners for direct state diagram editing 2026-09-06 02:40:26 +03:00
sorokin.oleg.edu 2ece6e3ab9 Merge commit 'ad7feb5' into codex/state-integration 2026-09-06 02:39:53 +03:00
sorokin.oleg.edu ad7feb523a Keep port identity labels above diagram text without consuming gestures 2026-09-06 02:39:18 +03:00
sorokin.oleg.edu e5660b8902 Guard and preserve behavior forms across document lifecycle 2026-09-06 02:35:28 +03:00
sorokin.oleg.edu 53cd096a56 Add state diagram form and representation contract parity 2026-09-06 02:33:18 +03:00
sorokin.oleg.edu 24595810a1 Preserve behavior drafts until document commit succeeds 2026-09-06 02:23:48 +03:00
sorokin.oleg.edu e7a81637dd Add persisted state-machine diagram contexts and shared geometry 2026-09-06 02:23:31 +03:00
sorokin.oleg.edu fef2e0dbce Add direct diagram connections, movable ports, and contextual authoring 2026-09-06 02:13:49 +03:00
sorokin.oleg.edu 35133de650 Add persisted per-view port anchors and connection-aware border placement 2026-09-06 01:20:28 +03:00
sorokin.oleg.edu 93fd32c684 Keep port endpoint feasibility independent of model-wide advisory scans 2026-09-06 01:20:28 +03:00
68 changed files with 17379 additions and 1549 deletions
+11 -5
View File
@@ -45,9 +45,9 @@ 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.
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
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.
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
@@ -55,13 +55,19 @@ 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.
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
## 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;
+12 -2
View File
@@ -66,7 +66,14 @@ impl ParseLimits {
project
.diagrams
.values()
.map(|d| d.nodes.len().saturating_add(d.routes.len()))
.map(|d| {
d.nodes.len().saturating_add(d.routes.len()).saturating_add(
d.nodes
.values()
.map(|n| n.port_anchors.len())
.sum::<usize>(),
)
})
.sum::<usize>(),
)
.saturating_add(project.artifact_links.len())
@@ -349,6 +356,8 @@ fn reference_field(key: &str) -> bool {
| "sa"
| "diagram"
| "edge"
| "port"
| "machine"
)
}
fn enum_field(key: &str) -> bool {
@@ -368,6 +377,7 @@ fn enum_field(key: &str) -> bool {
| "viewpoint"
| "message_kind"
| "transition_kind"
| "side"
)
}
@@ -1663,7 +1673,7 @@ pub(crate) fn resolve_declarations(
if !fields.contains_key("element") {
return Err(conversion_error("Missing required field 'element'.".into()));
}
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, false);
+44
View File
@@ -1190,6 +1190,7 @@ impl RelationBody {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ProjectionKind {
Overview,
Decomposition,
@@ -1197,10 +1198,21 @@ pub enum ProjectionKind {
Traceability,
FunctionalDataflow,
Capabilities,
StateMachine { machine: Id },
}
impl ProjectionKind {
pub fn state_machine(self) -> Option<Id> {
match self {
Self::StateMachine { machine } => Some(machine),
_ => None,
}
}
pub fn includes(self, kind: Kind) -> bool {
match self {
Self::StateMachine { .. } => {
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
}
Self::Allocation => kind.is_performer() || kind.is_work(),
Self::FunctionalDataflow => kind.is_work(),
Self::Capabilities => {
@@ -1221,6 +1233,7 @@ impl ProjectionKind {
impl std::fmt::Display for ProjectionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::StateMachine { .. } => "State machine",
Self::Allocation => "Architecture · allocated work",
Self::FunctionalDataflow => "Functional dataflow",
Self::Capabilities => "Missions & capabilities",
@@ -1264,6 +1277,34 @@ pub struct EdgeLabel {
pub offset: Position,
}
pub const MAX_ROUTE_POINTS: usize = 128;
/// Presentation side; independent of the semantic port direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum PortAnchorSide {
Left,
Right,
Top,
Bottom,
}
impl PortAnchorSide {
pub fn normal(self) -> Position {
match self {
Self::Left => Position { x: -1., y: 0. },
Self::Right => Position { x: 1., y: 0. },
Self::Top => Position { x: 0., y: -1. },
Self::Bottom => Position { x: 0., y: 1. },
}
}
}
/// Manual position of a semantic child port on one owner placement.
/// Absence from the placement's list selects automatic positioning.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PortAnchor {
pub port: Id,
pub side: PortAnchorSide,
/// Fraction of the complete edge, from top (vertical) or left (horizontal).
pub offset: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodePlacement {
@@ -1273,6 +1314,8 @@ pub struct NodePlacement {
pub position: Position,
pub size: Size,
pub collapsed: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub port_anchors: Vec<PortAnchor>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ArtifactRelation {
@@ -1660,6 +1703,7 @@ impl Project {
height: 94.,
},
collapsed: false,
port_anchors: Vec::new(),
};
if let Some(d) = self.diagrams.get_mut(&diagram) {
d.nodes.insert(id, node);
+48 -28
View File
@@ -847,6 +847,11 @@ fn legacy_allows(e: &Element, outgoing: bool) -> bool {
matches!(e.shared(), Some(SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, .. }) }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out })
}
fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mut Vec<Issue>) {
// Every carried-item constraint is a subset check. An empty exchange cannot
// violate one, so endpoint feasibility need not scan all interface roles.
if r.body.items().is_empty() {
return;
}
match port.shared() {
Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
@@ -1060,34 +1065,6 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
"This boundary exchange retains unresolved generic port semantics.",
);
}
for (port, peer) in [(a, b), (b, a)] {
if !matches!(
port_contract(port),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
})
) {
continue;
}
for required in p.relations.values().filter(|role| {
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
}) {
if !p.relations.values().any(|role| {
role.kind() == RelationKind::InterfaceProvision
&& role.source() == peer.id
&& role.target() == required.target()
}) {
warning(
out,
required.id,
"unsatisfied_interface_requirement",
"target",
"The connected partner has not declared this required interface as provided.",
);
}
}
}
}
RelationBody::PortAllocation { source, target } => {
let (Some(component), Some(function)) =
@@ -1167,6 +1144,49 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
_ => {}
}
}
/// Whole-model advisory checks. Missing peer declarations are warnings, so they
/// must not make endpoint feasibility scan the relation graph per candidate.
pub(crate) fn validate_boundary_interface_requirements(
p: &Project,
r: &Relation,
out: &mut Vec<Issue>,
) {
let RelationBody::BoundaryExchange { source, target, .. } = &r.body else {
return;
};
let (Some(a), Some(b)) = (p.elements.get(source), p.elements.get(target)) else {
return;
};
for (port, peer) in [(a, b), (b, a)] {
if !matches!(
port_contract(port),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
})
) {
continue;
}
for required in p.relations.values().filter(|role| {
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
}) {
if !p.relations.values().any(|role| {
role.kind() == RelationKind::InterfaceProvision
&& role.source() == peer.id
&& role.target() == required.target()
}) {
warning(
out,
required.id,
"unsatisfied_interface_requirement",
"target",
"The connected partner has not declared this required interface as provided.",
);
}
}
}
}
pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target()))
else {
+118 -122
View File
@@ -1,6 +1,13 @@
pub use crate::model::PortAnchorSide;
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod port_layout;
pub use port_layout::{port_anchor_offset, port_anchor_position};
#[path = "projection/state.rs"]
mod state;
pub(crate) use state::node_size as state_node_size;
pub use state::{state_header_height, state_machine_contains, state_symbol_size};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectedNode {
@@ -32,34 +39,31 @@ pub struct ProjectedPort {
pub position: Position,
pub side: PortAnchorSide,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortAnchorSide {
Left,
Right,
Bottom,
}
/// Semantic fallback for an unconnected port. Neutral ports have no preferred
/// side; the projected view resolves their placement using peers and capacity.
/// A preferred side never constrains an authored anchor.
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
return None;
};
Some(match contract {
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
match contract {
PortContract::FunctionInput { .. } => Some(PortAnchorSide::Left),
PortContract::FunctionOutput { .. } => Some(PortAnchorSide::Right),
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
} => PortAnchorSide::Left,
} => Some(PortAnchorSide::Left),
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
} => PortAnchorSide::Right,
} => Some(PortAnchorSide::Right),
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
Some(PortDirection::In) => PortAnchorSide::Left,
Some(PortDirection::Out) => PortAnchorSide::Right,
_ => PortAnchorSide::Bottom,
Some(PortDirection::In) => Some(PortAnchorSide::Left),
Some(PortDirection::Out) => Some(PortAnchorSide::Right),
_ => None,
},
_ => PortAnchorSide::Bottom,
})
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProjectedView {
@@ -67,38 +71,11 @@ pub struct ProjectedView {
pub edges: Vec<ProjectedEdge>,
pub ports: Vec<ProjectedPort>,
}
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
.into_iter()
.map(|side| {
p.children(element)
.filter(|port| port_anchor_side(port) == Some(side))
.count()
})
.max()
.unwrap_or(0);
if count > 0 {
size.height = size.height.max(76. + 28. * (count + 1) as f32);
}
let bottom = p
.children(element)
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
.count();
if bottom > 0 {
size.width = size.width.max(28. * (bottom + 1) as f32);
}
size
pub(crate) fn minimum_node_size(p: &Project, element: Id, size: Size) -> Size {
port_layout::minimum_size(p, element, size)
}
pub(crate) fn port_footer(p: &Project, element: Id) -> f32 {
let count = p
.children(element)
.filter(|e| e.kind() == Kind::Port)
.count();
if count == 0 {
24.
} else {
28. * count as f32 + 24.
}
pub(crate) fn port_footer(_p: &Project, _element: Id) -> f32 {
24.
}
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
@@ -106,9 +83,13 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
.nodes
.values()
.filter(|n| {
p.elements
.get(&n.element)
.is_some_and(|e| e.kind() != Kind::Port && d.projection.includes(e.kind()))
p.elements.get(&n.element).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& d.projection
.state_machine()
.is_none_or(|machine| state_machine_contains(p, machine, e.id))
})
})
.map(|n| (n.element, n.id))
.collect();
@@ -218,6 +199,11 @@ pub fn translated_routes(
let (source, target) = edge_endpoint_placements(p, d, route.edge)?;
let source_delta = deltas.get(&source)?;
let target_delta = deltas.get(&target)?;
if d.projection.state_machine().is_some()
&& !state::boundary_moves_with(p, d, route.edge, source_delta, deltas)
{
return None;
}
if source_delta != target_delta
|| (source_delta.x == 0. && source_delta.y == 0.)
|| route.waypoints.is_empty()
@@ -248,7 +234,12 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
let Some(e) = p.elements.get(&n.element) else {
continue;
};
if e.kind() == Kind::Port || !d.projection.includes(e.kind()) {
if e.kind() == Kind::Port
|| !d.projection.includes(e.kind())
|| d.projection
.state_machine()
.is_some_and(|machine| !state_machine_contains(p, machine, e.id))
{
continue;
}
if d.projection != ProjectionKind::Traceability && e.layer() != d.layer {
@@ -282,6 +273,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
n.size
},
);
let size = if d.projection.state_machine().is_some() {
state::node_size(p, e, size, n.collapsed)
} else {
size
};
nodes.push(ProjectedNode {
id: n.id,
element: n.element,
@@ -300,24 +296,9 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
}
nodes.sort_by_key(|n| (n.depth, n.id));
for index in (0..nodes.len()).rev() {
let child = nodes[index].clone();
if let Some(parent) = child
.visual_parent
.and_then(|id| nodes.iter().position(|n| n.id == id))
{
let footer = port_footer(p, nodes[parent].element);
let p = &mut nodes[parent];
let right =
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
let bottom =
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
p.position.x = p.position.x.min(child.position.x - 24.);
// Reserve two readable header lines above the first nested card.
p.position.y = p.position.y.min(child.position.y - 64.);
p.size.width = right - p.position.x;
p.size.height = bottom - p.position.y;
}
enclose_with_headers(p, &mut nodes, d.projection.state_machine().is_some());
if d.projection.state_machine().is_some() {
state::snap_boundaries(p, &mut nodes);
}
let visible: BTreeSet<_> = nodes.iter().map(|n| n.id).collect();
let placed: BTreeMap<_, _> = d.nodes.values().map(|n| (n.element, n.id)).collect();
@@ -341,62 +322,28 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
None
};
let mut ports = Vec::new();
for node in &nodes {
let mut children: Vec<_> = p
.children(node.element)
.filter(|e| e.kind() == Kind::Port)
.collect();
children.sort_by_key(|e| (&e.alias, e.id));
for anchor_side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Bottom,
] {
let side: Vec<_> = children
.iter()
.filter(|e| port_anchor_side(e) == Some(anchor_side))
.collect();
for (index, port) in side.iter().enumerate() {
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
let y = if p.elements[&node.element].kind().is_performer()
|| nodes
.iter()
.any(|child| child.visual_parent == Some(node.id))
{
node.position.y + node.size.height - 24. - index as f32 * 28.
} else {
node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction
};
let position = match anchor_side {
PortAnchorSide::Left => Position {
x: node.position.x,
y,
},
PortAnchorSide::Right => Position {
x: node.position.x + node.size.width,
y,
},
PortAnchorSide::Bottom => Position {
x: node.position.x + node.size.width * fraction,
y: node.position.y + node.size.height,
},
};
ports.push(ProjectedPort {
element: port.id,
owner: node.id,
position,
side: anchor_side,
});
}
}
}
let visible_ports: BTreeSet<_> = p
.elements
.values()
.filter(|e| {
e.kind() == Kind::Port
&& e.parent
.and_then(|id| placed.get(&id))
.is_some_and(|id| visible.contains(id))
})
.map(|e| e.id)
.collect();
let mut edges = Vec::new();
for r in p.relations.values() {
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
continue;
}
let included = match d.projection {
ProjectionKind::StateMachine { machine } => matches!(&r.body,
RelationBody::StateTransition { transition_kind, .. }
if *transition_kind != TransitionKind::Internal
&& state_machine_contains(p, machine, r.source())
&& state_machine_contains(p, machine, r.target())),
ProjectionKind::Allocation => matches!(
r.kind(),
RelationKind::Interaction
@@ -422,7 +369,12 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
continue;
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& source != target
&& (source != target
|| (d.projection.state_machine().is_some()
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| n.id == source && n.element == r.source())))
{
let (source_port, target_port) = match &r.body {
RelationBody::FunctionalExchange {
@@ -446,8 +398,8 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
..
}
),
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
source_port: source_port.filter(|id| visible_ports.contains(id)),
target_port: target_port.filter(|id| visible_ports.contains(id)),
});
}
}
@@ -468,9 +420,53 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
}
}
// Dedicated state views contain state symbols, not semantic component ports.
// Port layout would re-enclose already snapped boundary vertices.
let ports = if d.projection.state_machine().is_some() {
Vec::new()
} else {
port_layout::layout(p, d, &mut nodes, &edges)
};
ProjectedView {
nodes,
edges,
ports,
}
}
fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
enclose_with_headers(p, nodes, false);
}
fn enclose_with_headers(p: &Project, nodes: &mut [ProjectedNode], state_view: bool) {
for index in (0..nodes.len()).rev() {
let child = nodes[index].clone();
if state_view
&& p.elements
.get(&child.element)
.is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint))
{
continue;
}
if let Some(parent) = child
.visual_parent
.and_then(|id| nodes.iter().position(|n| n.id == id))
{
let footer = port_footer(p, nodes[parent].element);
let header = if state_view {
state_header_height(p, nodes[parent].element)
} else {
64.
};
let p = &mut nodes[parent];
let right =
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
let bottom =
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
p.position.x = p.position.x.min(child.position.x - 24.);
// Reserve two readable header lines above the first nested card.
p.position.y = p.position.y.min(child.position.y - header);
p.size.width = right - p.position.x;
p.size.height = bottom - p.position.y;
}
}
}
@@ -0,0 +1,383 @@
//! Presentation-only port anchors. Automatic choices depend on owner geometry,
//! never on routed elbows, so routing cannot feed back into layout.
use super::*;
const GAP: f32 = 28.;
const CORNER: f32 = 18.;
const SIDES: [PortAnchorSide; 4] = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
];
pub fn port_anchor_position(node: &ProjectedNode, side: PortAnchorSide, offset: f32) -> Position {
let (x, y) = match side {
PortAnchorSide::Left => (0., node.size.height * offset),
PortAnchorSide::Right => (node.size.width, node.size.height * offset),
PortAnchorSide::Top => (node.size.width * offset, 0.),
PortAnchorSide::Bottom => (node.size.width * offset, node.size.height),
};
Position {
x: node.position.x + x,
y: node.position.y + y,
}
}
pub fn port_anchor_offset(node: &ProjectedNode, side: PortAnchorSide, position: Position) -> f32 {
let value = match side {
PortAnchorSide::Left | PortAnchorSide::Right => {
(position.y - node.position.y) / node.size.height
}
PortAnchorSide::Top | PortAnchorSide::Bottom => {
(position.x - node.position.x) / node.size.width
}
};
value.clamp(0., 1.)
}
fn center(node: &ProjectedNode) -> Position {
Position {
x: node.position.x + node.size.width / 2.,
y: node.position.y + node.size.height / 2.,
}
}
#[derive(Clone)]
struct Choice {
port: Id,
side: PortAnchorSide,
automatic_side: PortAnchorSide,
manual: Option<f32>,
peer: Option<Position>,
}
fn facing(node: &ProjectedNode, peer: Position) -> Option<PortAnchorSide> {
let c = center(node);
let dx = (peer.x - c.x) / node.size.width;
let dy = (peer.y - c.y) / node.size.height;
if dx.abs().max(dy.abs()) < 0.001 {
return None;
}
if dx.abs() >= dy.abs() {
Some(if dx < 0. {
PortAnchorSide::Left
} else {
PortAnchorSide::Right
})
} else {
Some(if dy < 0. {
PortAnchorSide::Top
} else {
PortAnchorSide::Bottom
})
}
}
fn clear_exit(
node: &ProjectedNode,
side: PortAnchorSide,
peers: &BTreeSet<Id>,
by_id: &BTreeMap<Id, &ProjectedNode>,
) -> bool {
let a = port_anchor_position(node, side, 0.5);
let normal = side.normal();
let b = Position {
x: a.x + normal.x * crate::routing::PORT_STUB,
y: a.y + normal.y * crate::routing::PORT_STUB,
};
peers.iter().all(|peer| {
let other = by_id[peer];
// A containing endpoint's body is traversable, just as in routing.
let mut ancestor = node.visual_parent;
for _ in 0..by_id.len() {
let Some(id) = ancestor else {
break;
};
if id == *peer {
return true;
}
ancestor = by_id.get(&id).and_then(|n| n.visual_parent);
}
let padding = crate::routing::CLEARANCE;
let left = other.position.x - padding;
let right = other.position.x + other.size.width + padding;
let top = other.position.y - padding;
let bottom = other.position.y + other.size.height + padding;
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)
}
})
}
#[derive(Default)]
struct Peers {
x: f64,
y: f64,
count: usize,
owners: BTreeSet<Id>,
}
impl Peers {
fn center(&self) -> Position {
Position {
x: (self.x / self.count as f64) as f32,
y: (self.y / self.count as f64) as f32,
}
}
}
pub(super) fn minimum_size(p: &Project, element: Id, mut size: Size) -> Size {
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
let mut neutral = 0;
for port in p.children(element).filter(|p| p.kind() == Kind::Port) {
if let Some(side) = port_anchor_side(port) {
*counts.get_mut(&side).unwrap() += 1;
} else {
neutral += 1;
}
}
for _ in 0..neutral {
let side = *SIDES.iter().min_by_key(|s| counts[s]).unwrap();
*counts.get_mut(&side).unwrap() += 1;
}
let vertical = counts[&PortAnchorSide::Left].max(counts[&PortAnchorSide::Right]);
let horizontal = counts[&PortAnchorSide::Top].max(counts[&PortAnchorSide::Bottom]);
if vertical > 0 {
size.height = size.height.max(76. + GAP * (vertical + 1) as f32);
}
if horizontal > 0 {
size.width = size.width.max(GAP * (horizontal + 1) as f32);
}
size
}
pub(super) fn layout(
p: &Project,
d: &Diagram,
nodes: &mut [ProjectedNode],
edges: &[ProjectedEdge],
) -> Vec<ProjectedPort> {
let by_id: BTreeMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect();
let mut peers: BTreeMap<(Id, Id), Peers> = BTreeMap::new();
for edge in edges {
// Port allocation is implementation traceability, not a communication
// peer. It must not turn an external boundary port toward its own work.
if !p.relations.get(&edge.id).is_some_and(|r| {
matches!(
r.kind(),
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
)
}) {
continue;
}
for (owner, port, other) in [
(edge.source, edge.source_port, edge.target),
(edge.target, edge.target_port, edge.source),
] {
if let (Some(port), Some(other)) = (port, by_id.get(&other)) {
let c = center(other);
let entry = peers.entry((owner, port)).or_default();
entry.x += f64::from(c.x);
entry.y += f64::from(c.y);
entry.count += 1;
entry.owners.insert(other.id);
}
}
}
let mut children: BTreeMap<Id, Vec<&Element>> = BTreeMap::new();
for port in p.elements.values().filter(|e| e.kind() == Kind::Port) {
if let Some(parent) = port.parent {
children.entry(parent).or_default().push(port);
}
}
let mut choices = BTreeMap::<Id, Vec<Choice>>::new();
for node in nodes.iter() {
let anchors: BTreeMap<_, _> = d.nodes[&node.id]
.port_anchors
.iter()
.map(|a| (a.port, a))
.collect();
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
// Compute the automatic capacity independently of manual overrides.
// Moving a port must not resize its owner or flip unrelated ports.
for port in children.get(&node.element).into_iter().flatten() {
let connected = peers.get(&(node.id, port.id));
let peer = connected.map(Peers::center);
let clear =
|side| connected.is_none_or(|peers| clear_exit(node, side, &peers.owners, &by_id));
let anchor = anchors.get(&port.id);
let side = {
peer.and_then(|v| facing(node, v))
.filter(|side| clear(*side))
}
.or_else(|| port_anchor_side(port).filter(|side| clear(*side)))
.or_else(|| {
SIDES
.iter()
.filter(|side| clear(**side))
.min_by_key(|s| counts[s])
.copied()
})
.unwrap_or_else(|| *SIDES.iter().min_by_key(|s| counts[s]).unwrap());
*counts.get_mut(&side).unwrap() += 1;
choices.entry(node.id).or_default().push(Choice {
port: port.id,
side: anchor.map_or(side, |a| a.side),
automatic_side: side,
manual: anchor.map(|a| a.offset),
peer,
});
}
}
// One capacity pass followed by semantic enclosure. Side choices stay frozen
// throughout this pass; expanded parents cannot cause layout oscillation.
for node in nodes.iter_mut() {
for side in SIDES {
let count = choices
.get(&node.id)
.into_iter()
.flatten()
.filter(|c| c.automatic_side == side)
.count();
if count == 0 {
continue;
}
let minimum = 2. * CORNER + GAP * count.saturating_sub(1) as f32;
match side {
PortAnchorSide::Left | PortAnchorSide::Right => {
node.size.height = node.size.height.max(minimum)
}
PortAnchorSide::Top | PortAnchorSide::Bottom => {
node.size.width = node.size.width.max(minimum)
}
}
}
}
super::enclose(p, nodes);
let mut result = Vec::new();
for node in nodes.iter() {
let Some(choices) = choices.get(&node.id) else {
continue;
};
for side in SIDES {
let mut automatic: Vec<_> = choices
.iter()
.filter(|c| c.side == side && c.manual.is_none())
.collect();
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
automatic.sort_by(|a, b| {
let axis = |c: &Choice| c.peer.map(|p| if horizontal { p.x } else { p.y });
match (axis(a), axis(b)) {
(Some(a), Some(b)) => a.total_cmp(&b),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
}
.then_with(|| a.port.cmp(&b.port))
});
let length = if horizontal {
node.size.width
} else {
node.size.height
};
let mut fixed = Vec::new();
for choice in choices.iter().filter(|c| c.side == side) {
if let Some(offset) = choice.manual {
fixed.push(offset * length);
result.push(ProjectedPort {
element: choice.port,
owner: node.id,
side,
position: port_anchor_position(node, side, offset),
});
}
}
let positions = automatic_positions(length, &fixed, automatic.len());
for (choice, distance) in automatic.into_iter().zip(positions) {
result.push(ProjectedPort {
element: choice.port,
owner: node.id,
side,
position: port_anchor_position(node, side, distance / length),
});
}
}
}
result
}
/// Allocate free intervals around authored anchors. If manual anchors are too
/// crowded to satisfy the desired gap, retain them and degrade spacing evenly.
fn automatic_positions(length: f32, fixed: &[f32], count: usize) -> Vec<f32> {
if count == 0 {
return Vec::new();
}
let mut fixed = fixed.to_vec();
fixed.sort_by(f32::total_cmp);
let mut gap = GAP;
let intervals = loop {
let mut start = CORNER.min(length / 2.);
let end = (length - CORNER).max(start);
let mut free = Vec::new();
for &anchor in &fixed {
if anchor - gap >= start {
free.push((start, (anchor - gap).min(end)));
}
start = start.max(anchor + gap);
if start > end {
break;
}
}
if start <= end {
free.push((start, end));
}
free.retain(|(a, b)| b >= a);
let capacity: usize = free
.iter()
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
.sum();
if capacity >= count {
break free;
}
gap /= 2.;
if gap < 0.001 {
return (0..count)
.map(|i| length * (i + 1) as f32 / (count + 1) as f32)
.collect();
}
};
let capacities: Vec<_> = intervals
.iter()
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
.collect();
let total: usize = capacities.iter().sum();
let mut allocated: Vec<_> = capacities
.iter()
.map(|capacity| (count as u128 * *capacity as u128 / total as u128) as usize)
.collect();
let mut remainder: Vec<_> = capacities
.iter()
.enumerate()
.map(|(i, capacity)| (count as u128 * *capacity as u128 % total as u128, i))
.collect();
remainder.sort_by(|(a, i), (b, j)| b.cmp(a).then_with(|| i.cmp(j)));
let unassigned = count - allocated.iter().sum::<usize>();
for (_, index) in remainder.into_iter().take(unassigned) {
allocated[index] += 1;
}
intervals
.into_iter()
.zip(allocated)
.flat_map(|((a, b), count)| {
(0..count).map(move |i| {
if count == 1 {
(a + b) / 2.
} else {
a + (b - a) * i as f32 / (count - 1) as f32
}
})
})
.collect()
}
+139
View File
@@ -0,0 +1,139 @@
//! State-specific presentation policies over the shared persisted placements.
use super::*;
/// Membership of a dedicated state view. Other projection kinds may retain
/// unrelated hidden placements and must not acquire this additional restriction.
pub fn state_machine_contains(p: &Project, machine: Id, element: Id) -> bool {
let Some(owner) = p
.elements
.get(&machine)
.filter(|e| e.kind() == Kind::StateMachine)
else {
return false;
};
p.elements.get(&element).is_some_and(|e| {
e.layer() == owner.layer()
&& (matches!(e.kind(), Kind::StateMachine | Kind::Region) || e.kind().is_state())
&& validation::ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
})
}
/// Fixed body bounds for UML-style state symbols. Their authored position is
/// the top-left of this body; labels and larger pointer targets are derived UI.
/// A State or Mode uses its authored rectangular size instead.
pub fn state_symbol_size(kind: Kind) -> Option<Size> {
let (width, height) = match kind {
Kind::Initial => (16., 16.),
Kind::Junction => (10., 10.),
Kind::Fork | Kind::Join => (8., 36.),
Kind::Choice | Kind::ShallowHistory | Kind::DeepHistory => (28., 28.),
Kind::Final | Kind::EntryPoint | Kind::ExitPoint | Kind::Terminate => (24., 24.),
_ => return None,
};
Some(Size { width, height })
}
/// Reserved content before nested Regions. The same boundary is used by layout,
/// routing and the state editor's action/internal-transition rows.
pub fn state_header_height(p: &Project, element: Id) -> f32 {
let Some(e) = p.elements.get(&element) else {
return 64.;
};
if !e.kind().is_stable_state() {
return 64.;
}
let actions = matches!(e.shared(), Some(SharedBody::State { entry, exit, do_activity, .. })
if !entry.is_empty() || !exit.is_empty() || !do_activity.is_empty());
let internal = p
.relations
.values()
.filter(|r| {
matches!(&r.body,
RelationBody::StateTransition { source, transition_kind: TransitionKind::Internal, .. }
if *source == element)
})
.count();
64. + if actions { 28. } else { 0. } + internal as f32 * 56.
}
pub(crate) fn node_size(p: &Project, e: &Element, size: Size, collapsed: bool) -> Size {
if let Some(size) = state_symbol_size(e.kind()) {
return size;
}
Size {
width: size.width.max(if e.kind().is_stable_state() {
184.
} else {
224.
}),
height: size.height.max(if collapsed {
94.
} else {
state_header_height(p, e.id) + 20.
}),
}
}
pub(super) fn snap_boundaries(p: &Project, nodes: &mut [ProjectedNode]) {
let geometry: BTreeMap<_, _> = nodes
.iter()
.map(|n| (n.element, (n.position, n.size)))
.collect();
for node in nodes {
let Some(e) = p.elements.get(&node.element) else {
continue;
};
if !matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint) {
continue;
}
let boundary = e
.parent
.and_then(|id| p.elements.get(&id))
.and_then(|region| region.parent);
let Some((position, size)) = boundary.and_then(|id| geometry.get(&id)) else {
// Partial views may omit the containing boundary. Do not invent a
// placement or silently attach the symbol to a different container.
continue;
};
node.position.x = position.x
+ if e.kind() == Kind::ExitPoint {
size.width
} else {
0.
}
- node.size.width / 2.;
let top = position.y + boundary.map_or(64., |id| state_header_height(p, id));
let bottom = (position.y + size.height - node.size.height - 12.).max(top);
node.position.y = node.position.y.clamp(top, bottom);
}
}
// Entry/Exit positions are attached to a containing State/Machine, not the
// Region that owns them semantically. Moving only that Region changes the
// symbol's authored position but does not translate its snapped boundary.
pub(super) fn boundary_moves_with(
p: &Project,
d: &Diagram,
edge: Id,
delta: &Position,
deltas: &BTreeMap<Id, Position>,
) -> bool {
let Some(r) = p.relations.get(&edge) else {
return true;
};
[r.source(), r.target()].into_iter().all(|id| {
let Some(e) = p.elements.get(&id) else {
return true;
};
if !matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint) {
return true;
}
let boundary = e
.parent
.and_then(|id| p.elements.get(&id))
.and_then(|region| region.parent);
boundary
.and_then(|id| d.nodes.values().find(|n| n.element == id))
.is_none_or(|node| deltas.get(&node.id) == Some(delta))
})
}
+20
View File
@@ -181,12 +181,22 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
*machine = new;
}
for route in &mut d.routes {
if route.edge == old {
route.edge = new;
}
}
for n in d.nodes.values_mut() {
for anchor in &mut n.port_anchors {
if anchor.port == old {
anchor.port = new;
}
}
if n.element == old {
n.element = new;
}
@@ -275,6 +285,12 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
}
}
for d in p.diagrams.values() {
if d.projection
.state_machine()
.is_some_and(|machine| removed.contains(&machine))
{
removed.insert(d.id);
}
for n in d.nodes.values() {
if removed.contains(&d.id) || removed.contains(&n.element) {
removed.insert(n.id);
@@ -345,6 +361,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
for d in p.diagrams.values_mut() {
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
node.port_anchors
.retain(|anchor| !removed.contains(&anchor.port));
}
}
let clear = |v: &mut Option<Id>| {
if v.is_some_and(|id| removed.contains(&id)) {
+220 -66
View File
@@ -1,6 +1,6 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, PortAnchorSide, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
@@ -264,11 +264,7 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
.iter()
.find(|p| p.element == id && p.owner == node.id)
}) {
let normal = match port.side {
PortAnchorSide::Left => pos(-1.0, 0.0),
PortAnchorSide::Right => pos(1.0, 0.0),
PortAnchorSide::Bottom => pos(0.0, 1.0),
};
let normal = port.side.normal();
return vec![Endpoint {
point: port.position,
stub: pos(
@@ -319,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,11 +338,23 @@ fn route_diagram_impl(
let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect();
let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect();
let authored: BTreeMap<_, _> = diagram.routes.iter().map(|r| (r.edge, r)).collect();
let headers: BTreeMap<_, _> = nodes
.values()
.map(|node| {
(
node.id,
if diagram.projection.state_machine().is_some() && !node.collapsed {
projection::state_header_height(project, node.element)
} else {
HEADER
},
)
})
.collect();
let mut ordered: Vec<_> = view.edges.iter().collect();
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 {
@@ -360,7 +369,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))
@@ -369,10 +378,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;
@@ -387,19 +392,8 @@ 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, &headers);
let cached = preview.and_then(|(baseline, changed)| {
(edge.id == changed)
.then(|| baseline.edges.get(&edge.id))
@@ -410,24 +404,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;
}
}
@@ -462,28 +443,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,
@@ -492,12 +461,68 @@ 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, &headers);
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<Id, &ProjectedNode>,
rectangles: &[Rect],
headers: &BTreeMap<Id, f32>,
) -> Vec<Rect> {
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 + headers[&r.id]);
}
r.inflate(CLEARANCE)
})
.collect()
}
#[derive(Clone, Copy)]
struct Segment {
a: Position,
@@ -554,6 +579,15 @@ fn route_edge(
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-transition must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
{
continue;
}
let estimate = manhattan(a.stub, pins.first().copied().unwrap_or(b.stub))
+ pins.windows(2).map(|p| manhattan(p[0], p[1])).sum::<f32>()
+ pins.last().map_or(0.0, |&p| manhattan(p, b.stub));
@@ -1000,10 +1034,76 @@ fn grid_path(
(None, false)
}
/// Full semantic label shared by state transition routes and internal rows.
/// Expressions remain descriptive; no state-machine execution is implied.
pub fn state_transition_summary(p: &Project, r: &crate::Relation) -> String {
let crate::RelationBody::StateTransition {
transition_kind,
triggers,
trigger,
guard,
effects,
effect,
..
} = &r.body
else {
return r.name.clone();
};
let activation = if !triggers.is_empty() {
triggers
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" OR ")
} else if !trigger.trim().is_empty() {
format!("Unresolved trigger: {trigger}")
} else if p
.elements
.get(&r.source())
.is_some_and(|e| e.kind().is_stable_state())
{
"Completion".into()
} else {
"Transient routing".into()
};
let mut result = format!("{transition_kind:?} \u{b7} {activation}");
if !triggers.is_empty() && !trigger.is_empty() {
result.push_str(&format!(" \u{b7} {trigger}"));
}
if !guard.is_empty() {
result.push_str(&format!(" [{guard}]"));
}
if !effects.is_empty() {
result.push_str(&format!(
" / {}",
effects
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
));
}
if !effect.is_empty() {
result.push_str(&format!(" / {effect}"));
}
result
}
fn label_text(project: &Project, edge: Id) -> String {
let Some(relation) = project.relations.get(&edge) else {
return String::new();
};
if relation.kind() == RelationKind::StateTransition {
let text = state_transition_summary(project, relation);
return if text.chars().count() > 48 {
text.chars()
.take(47)
.chain(std::iter::once('\u{2026}'))
.collect()
} else {
text
};
}
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
@@ -1021,6 +1121,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,
@@ -1028,6 +1144,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 {
@@ -1052,8 +1169,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;
@@ -1097,6 +1214,7 @@ fn make_label(
candidate,
obstacles,
previous_labels,
strokes,
best.as_ref().map(|(score, _, _)| *score),
&mut comparisons,
) else {
@@ -1105,7 +1223,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;
}
}
@@ -1129,9 +1247,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 {
@@ -1142,7 +1261,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
@@ -1164,11 +1283,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)]
@@ -1190,11 +1333,22 @@ impl BoundsBuilder {
}
fn finish(self) -> RoutingBounds {
let (left, top, right, bottom) = self.extents.unwrap_or((0.0, 0.0, 0.0, 0.0));
// Subtraction and subsequent addition can round in opposite directions
// when an authored label extends far left/up. Bounds must still enclose
// every original point, including the far right and bottom edges.
let span = |start: f32, end: f32| {
let extent = end - start;
if start + extent < end {
extent.next_up()
} else {
extent
}
};
RoutingBounds {
position: pos(left, top),
size: Size {
width: right - left,
height: bottom - top,
width: span(left, right),
height: span(top, bottom),
},
}
}
+86 -4
View File
@@ -44,6 +44,16 @@ pub enum Command {
diagram: Id,
edge: Id,
},
SetPortAnchor {
diagram: Id,
node: Id,
anchor: PortAnchor,
},
RemovePortAnchor {
diagram: Id,
node: Id,
port: Id,
},
RelocateNode {
node: Id,
diagram: Id,
@@ -679,6 +689,45 @@ fn apply_command(
.ok_or_else(missing)? = value.clone();
Some(*owner)
}
Command::SetPortAnchor {
diagram,
node,
anchor,
} => {
let n = p
.diagrams
.get_mut(diagram)
.and_then(|d| d.nodes.get_mut(node))
.ok_or_else(missing)?;
if let Some(existing) = n.port_anchors.iter_mut().find(|a| a.port == anchor.port) {
*existing = anchor.clone();
} else {
n.port_anchors.push(anchor.clone());
}
Some(anchor.port)
}
Command::RemovePortAnchor {
diagram,
node,
port,
} => {
let n = p
.diagrams
.get_mut(diagram)
.and_then(|d| d.nodes.get_mut(node))
.ok_or_else(missing)?;
if !p
.elements
.get(port)
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
{
return Err(ModelError(
"Port positioning requires a direct child port of the owner placement.".into(),
));
}
n.port_anchors.retain(|anchor| anchor.port != *port);
Some(*port)
}
Command::SetEdgeRoute { diagram, route } => {
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
@@ -859,9 +908,19 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
if !seen.insert(id) {
return Err(ModelError("Diagram containment cycle.".into()));
}
let own = crate::projection::minimum_node_size(p, d.nodes[&id].element, d.nodes[&id].size);
let node = &d.nodes[&id];
let own = crate::projection::minimum_node_size(p, node.element, node.size);
let own = if d.projection.state_machine().is_some() {
crate::projection::state_node_size(p, &p.elements[&node.element], own, false)
} else {
own
};
let mut width = own.width;
let mut height = 64.;
let mut height = if d.projection.state_machine().is_some() {
crate::projection::state_header_height(p, node.element)
} else {
64.
};
let kids = children.get(&Some(id));
if let Some(kids) = kids {
for child in kids {
@@ -886,9 +945,10 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
children: &BTreeMap<Option<Id>, Vec<Id>>,
sizes: &BTreeMap<Id, Size>,
positions: &mut BTreeMap<Id, Position>,
headers: &BTreeMap<Id, f32>,
) {
positions.insert(id, position);
let mut y = position.y + 64.;
let mut y = position.y + headers[&id];
if let Some(kids) = children.get(&Some(id)) {
for child in kids {
place(
@@ -900,6 +960,7 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
children,
sizes,
positions,
headers,
);
y += sizes[child].height + 16.;
}
@@ -957,6 +1018,20 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
for id in &roots {
measure(p, *id, d, &children, &mut sizes, &mut seen)?;
}
let headers = d
.nodes
.values()
.map(|n| {
(
n.id,
if d.projection.state_machine().is_some() {
crate::projection::state_header_height(p, n.element)
} else {
64.
},
)
})
.collect();
let mut positions = BTreeMap::new();
let mut x = 60.;
let mut y = 60.;
@@ -967,7 +1042,14 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
y += row_height + 60.;
row_height = 0.;
}
place(*id, Position { x, y }, &children, &sizes, &mut positions);
place(
*id,
Position { x, y },
&children,
&sizes,
&mut positions,
&headers,
);
x += sizes[id].width + 240.;
row_height = row_height.max(sizes[id].height);
}
+11 -9
View File
@@ -1457,15 +1457,17 @@ fn item_key(
"literal:{}",
&source[value.span.start..value.span.end]
)),
EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| {
fields.get(key).and_then(|field| {
if let EditKind::Reference(alias) = &field.value.kind {
aliases.get(alias).map(|id| format!("{key}:{id}"))
} else {
None
}
})
}),
EditKind::Record(fields) => ["field", "referenced", "edge", "port"]
.into_iter()
.find_map(|key| {
fields.get(key).and_then(|field| {
if let EditKind::Reference(alias) = &field.value.kind {
aliases.get(alias).map(|id| format!("{key}:{id}"))
} else {
None
}
})
}),
EditKind::List(_) => None,
}
}
+1
View File
@@ -981,6 +981,7 @@ pub fn plan_transition(
height: 94.,
},
collapsed: false,
port_anchors: Vec::new(),
};
working
.diagrams
+79 -1
View File
@@ -643,6 +643,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, r.id, "name", &r.name);
text(&mut out, r.id, "description", &r.description);
validate_relation(p, r, &mut out);
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
validate_relation_context(p, r, &mut out);
}
for (key, d) in &p.diagrams {
@@ -657,6 +658,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
if let Some(machine) = d.projection.state_machine()
&& !p
.elements
.get(&machine)
.is_some_and(|e| e.kind() == Kind::StateMachine && e.layer() == d.layer)
{
error(
&mut out,
d.id,
"state_diagram_context",
"projection",
"State diagram context must reference an existing state machine in the diagram layer.",
);
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
if !routed_edges.insert(route.edge) {
@@ -677,6 +692,21 @@ pub fn validate(p: &Project) -> Vec<Issue> {
"Route edge must reference an existing relation or trace.",
);
}
if let Some(machine) = d.projection.state_machine()
&& !p.relations.get(&route.edge).is_some_and(|r| {
r.kind() == RelationKind::StateTransition
&& crate::projection::state_machine_contains(p, machine, r.source())
&& crate::projection::state_machine_contains(p, machine, r.target())
})
{
error(
&mut out,
d.id,
"state_diagram_route",
"routes",
"State diagram routes belong to transitions in its machine. Remove or move incompatible routes in the same transaction.",
);
}
if route.waypoints.len() > MAX_ROUTE_POINTS {
error(
&mut out,
@@ -731,6 +761,40 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
let mut elements = BTreeSet::new();
for (key, n) in &d.nodes {
let mut ports = BTreeSet::new();
for anchor in &n.port_anchors {
if !ports.insert(anchor.port) {
error(
&mut out,
n.id,
"duplicate_port_anchor",
"port_anchors",
"Each port has at most one manual anchor per owner placement.",
);
}
if !p
.elements
.get(&anchor.port)
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
{
error(
&mut out,
n.id,
"port_anchor_owner",
"port_anchors",
"A manual anchor must reference a direct child port of its owner placement; repair anchors in the same transaction when reparenting.",
);
}
if !anchor.offset.is_finite() || !(0. ..=1.).contains(&anchor.offset) {
error(
&mut out,
n.id,
"invalid_port_offset",
"port_anchors",
"Port offset must be finite and between zero and one.",
);
}
}
if *key != n.id {
error(
&mut out,
@@ -760,6 +824,17 @@ pub fn validate(p: &Project) -> Vec<Issue> {
"Diagram nodes reference existing elements in the selected layer.",
),
};
if let Some(machine) = d.projection.state_machine()
&& !crate::projection::state_machine_contains(p, machine, n.element)
{
error(
&mut out,
n.id,
"state_diagram_member",
"element",
"State diagrams contain only their machine, its regions and vertices. Remove or relocate incompatible placements in the same transaction.",
);
}
for (value, field) in [
(n.position.x, "x"),
(n.position.y, "y"),
@@ -2010,7 +2085,10 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
let (source, target) = r.body.endpoints();
for id in crate::references::relation_references(r) {
if !p.contains(id) {
// Most relation references name elements. Avoid constructing all model
// identities per endpoint while retaining the general lookup semantics
// for other record kinds and malformed map keys.
if !p.elements.get(&id).is_some_and(|element| element.id == id) && !p.contains(id) {
error(
out,
r.id,
+816
View File
@@ -0,0 +1,816 @@
use oa_core::{language, persistence, projection::*, routing::*, source_project::*, *};
use serde_json::json;
use std::collections::BTreeMap;
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Information", a, b);
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&edge].body
else {
unreachable!()
};
let d = p.ensure_diagram(Layer::Sa);
let na = p.place(d, a, pos(100., 100.));
let nb = p.place(d, b, pos(900., 100.));
(p, d, na, nb, source_port, target_port, edge)
}
fn manual(p: &mut Project, d: Id, n: Id, port: Id, side: PortAnchorSide, offset: f32) {
let prepared = prepare_command(
p,
&Command::SetPortAnchor {
diagram: d,
node: n,
anchor: PortAnchor { port, side, offset },
},
)
.unwrap();
assert_eq!(prepared.selected, Some(port));
*p = prepared.project;
}
fn port(view: &ProjectedView, id: Id) -> &ProjectedPort {
view.ports.iter().find(|p| p.element == id).unwrap()
}
#[test]
fn every_side_uses_exact_final_border_fraction_and_outward_routing_normal() {
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
let (mut p, d, na, _, a, _, edge) = fixture();
let before = project_diagram(&p, &p.diagrams[&d]);
manual(&mut p, d, na, a, side, 0.3);
let view = project_diagram(&p, &p.diagrams[&d]);
assert_eq!(
view.nodes, before.nodes,
"A port drag must not resize or move its owner"
);
let node = view.nodes.iter().find(|n| n.id == na).unwrap();
let anchor = port(&view, a);
assert_eq!(anchor.side, side);
assert_eq!(anchor.position, port_anchor_position(node, side, 0.3));
assert!((port_anchor_offset(node, side, anchor.position) - 0.3).abs() < 0.00001);
let route = route_diagram(&p, &p.diagrams[&d], &view);
let points = &route.edges[&edge].points;
assert_eq!(points[0], anchor.position);
let normal = side.normal();
let delta = pos(points[1].x - points[0].x, points[1].y - points[0].y);
assert!(
delta.x * normal.x + delta.y * normal.y >= PORT_STUB - 0.001,
"{side:?}: {points:?}"
);
assert!(
points
.windows(2)
.all(|w| w[0].x == w[1].x || w[0].y == w[1].y)
);
}
}
#[test]
fn neutral_connected_ports_face_peers_and_unconnected_ports_use_four_sides() {
for (peer, expected) in [
(pos(-700., 100.), PortAnchorSide::Left),
(pos(900., 100.), PortAnchorSide::Right),
(pos(100., -700.), PortAnchorSide::Top),
(pos(100., 900.), PortAnchorSide::Bottom),
] {
let mut p = Project::empty();
let a = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let b = p
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
.unwrap();
let mut ports = Vec::new();
for owner in [a, b] {
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Boundary",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(id, port);
ports.push(id);
}
p.relate(
RelationKind::BoundaryExchange,
"Connection",
ports[0],
ports[1],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, a, pos(100., 100.));
p.place(d, b, peer);
let view = project_diagram(&p, &p.diagrams[&d]);
assert_eq!(port(&view, ports[0]).side, expected);
let before = view.ports.clone();
for element in p.elements.values_mut() {
element.alias = format!("renamed_{}", element.id.simple());
}
assert_eq!(project_diagram(&p, &p.diagrams[&d]).ports, before);
p.relations.clear();
for i in 0..3 {
let id = Id::new_v4();
let port = make_port(
&p,
id,
a,
&format!("Spare {i}"),
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(id, port);
}
let view = project_diagram(&p, &p.diagrams[&d]);
let sides: std::collections::BTreeSet<_> = view
.ports
.iter()
.filter(|port| p.elements[&port.element].parent == Some(a))
.map(|port| port.side)
.collect();
assert_eq!(sides.len(), 4);
}
}
#[test]
fn port_upsert_reset_and_undo_preserve_unrelated_node_fields_and_list_order() {
let (mut p, d, n, _, a, _, _) = fixture();
let owner = p.diagrams[&d].nodes[&n].element;
let extra = Id::new_v4();
let spare = make_port(
&p,
extra,
owner,
"Spare",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap();
p.elements.insert(extra, spare);
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.2);
manual(&mut p, d, n, extra, PortAnchorSide::Bottom, 0.7);
let before = p.clone();
let mut session = Session::new(p);
session
.execute(Command::SetPortAnchor {
diagram: d,
node: n,
anchor: PortAnchor {
port: a,
side: PortAnchorSide::Right,
offset: 0.45,
},
})
.unwrap();
let changed = session.project().clone();
let anchors = &changed.diagrams[&d].nodes[&n].port_anchors;
assert_eq!(
anchors.iter().map(|a| a.port).collect::<Vec<_>>(),
[a, extra]
);
assert_eq!(anchors[1], before.diagrams[&d].nodes[&n].port_anchors[1]);
assert_eq!(
changed.diagrams[&d].nodes[&n].position,
before.diagrams[&d].nodes[&n].position
);
session.undo();
assert_eq!(session.project(), &before);
session.redo();
assert_eq!(session.project(), &changed);
session
.execute(Command::RemovePortAnchor {
diagram: d,
node: n,
port: a,
})
.unwrap();
assert_eq!(
session.project().diagrams[&d].nodes[&n].port_anchors.len(),
1
);
let revision = session.revision();
session
.execute(Command::RemovePortAnchor {
diagram: d,
node: n,
port: a,
})
.unwrap();
assert_eq!(session.revision(), revision);
}
#[test]
fn invalid_anchors_and_payloads_fail_atomically_including_duplicate_owner_placements() {
let (p, d, n, _, port, other_port, _) = fixture();
for (port, offset) in [
(port, f32::NAN),
(port, f32::INFINITY),
(port, -0.1),
(port, 1.1),
(other_port, 0.5),
(Id::new_v4(), 0.5),
(p.diagrams[&d].nodes[&n].element, 0.5),
] {
let mut session = Session::new(p.clone());
assert!(
session
.execute(Command::SetPortAnchor {
diagram: d,
node: n,
anchor: PortAnchor {
port,
side: PortAnchorSide::Left,
offset
}
})
.is_err()
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), 0);
}
let anchor = json!({"port": port, "side": "Top", "offset": 0.5});
for bad in [
json!({"port":port,"side":"Top"}),
json!({"port":port,"side":"Diagonal","offset":0.5}),
json!({"port":port,"side":"Top","offset":0.5,"owner":n}),
] {
assert!(serde_json::from_value::<PortAnchor>(bad).is_err());
}
assert!(
serde_json::from_value::<Command>(
json!({"SetPortAnchor":{"diagram":d,"node":n,"anchor":anchor,"unknown":true}})
)
.is_err()
);
let mut duplicate = p.clone();
let value = PortAnchor {
port,
side: PortAnchorSide::Left,
offset: 0.5,
};
duplicate
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&n)
.unwrap()
.port_anchors = vec![value.clone(), value];
assert!(prepare_command(&p, &Command::Replace(duplicate)).is_err());
let mut duplicate = p.diagrams[&d].nodes[&n].clone();
duplicate.id = Id::new_v4();
duplicate.alias = "duplicate_view".into();
assert!(
prepare_command(
&p,
&Command::CreateNode {
diagram: d,
node: duplicate
}
)
.is_err()
);
}
#[test]
fn reidentification_deletion_reuse_and_owner_repair_preserve_correct_identity() {
let (mut p, d, n, target, _, _, _) = fixture();
let owner = p.diagrams[&d].nodes[&n].element;
let id = Id::new_v4();
let value = make_port(
&p,
id,
owner,
"Spare output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap();
p.elements.insert(id, value.clone());
manual(&mut p, d, n, id, PortAnchorSide::Top, 0.25);
let new_id = Id::new_v4();
let renamed = prepare_command(&p, &Command::Reidentify(id, new_id))
.unwrap()
.project;
assert_eq!(renamed.diagrams[&d].nodes[&n].port_anchors[0].port, new_id);
let reused = prepare_command(
&p,
&Command::Batch(vec![Command::Delete(id), Command::CreateElement(value)]),
)
.unwrap()
.project;
assert!(reused.diagrams[&d].nodes[&n].port_anchors.is_empty());
let parent = Some(p.diagrams[&d].nodes[&target].element);
assert!(
prepare_command(
&p,
&Command::Reparent {
element: id,
parent
}
)
.is_err()
);
let mut edited = p.elements[&id].clone();
edited.parent = parent;
assert!(prepare_command(&p, &Command::EditElement(id, edited.clone())).is_err());
let repaired = prepare_command(
&p,
&Command::Batch(vec![
Command::RemovePortAnchor {
diagram: d,
node: n,
port: id,
},
Command::EditElement(id, edited),
]),
)
.unwrap()
.project;
assert!(repaired.diagrams[&d].nodes[&n].port_anchors.is_empty());
assert_eq!(repaired.elements[&id].parent, parent);
}
#[test]
fn separate_views_resize_layout_and_hidden_owners_keep_manual_metadata() {
let (mut p, d, n, _, a, _, _) = fixture();
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.35);
let mut second = p.diagrams[&d].clone();
second.id = Id::new_v4();
second.alias = "review_view".into();
second.nodes.clear();
let other_d = second.id;
p.diagrams.insert(second.id, second);
let owner = p.diagrams[&d].nodes[&n].element;
let other_n = p.place(other_d, owner, pos(50., 50.));
manual(&mut p, other_d, other_n, a, PortAnchorSide::Bottom, 0.8);
let original = p.diagrams[&d].nodes[&n].port_anchors.clone();
let mut edited = p.diagrams[&d].nodes[&n].clone();
edited.size = Size {
width: 600.,
height: 450.,
};
edited.position = pos(200., 250.);
p = prepare_command(
&p,
&Command::EditNode {
diagram: d,
original: n,
node: edited,
},
)
.unwrap()
.project;
let view = project_diagram(&p, &p.diagrams[&d]);
assert_eq!(port(&view, a).position, pos(410., 250.));
p = prepare_command(&p, &Command::Layout(d)).unwrap().project;
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
assert_eq!(
p.diagrams[&other_d].nodes[&other_n].port_anchors[0].side,
PortAnchorSide::Bottom
);
let mut diagram = p.diagrams[&d].clone();
diagram.projection = ProjectionKind::Capabilities;
p = prepare_command(&p, &Command::EditDiagram(d, diagram))
.unwrap()
.project;
assert!(project_diagram(&p, &p.diagrams[&d]).ports.is_empty());
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
}
#[test]
fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometry() {
let (mut p, d, na, nb, a, b, edge) = fixture();
let parent = p
.add_element(Kind::Function, Layer::Sa, "Group", None)
.unwrap();
for node in [na, nb] {
let element = p.diagrams[&d].nodes[&node].element;
p.elements.get_mut(&element).unwrap().parent = Some(parent);
}
let group = p.place(d, parent, pos(50., 0.));
let obstacle = p
.add_element(Kind::Function, Layer::Sa, "Sibling obstacle", Some(parent))
.unwrap();
let obstacle_node = p.place(d, obstacle, pos(440., 160.));
let group_port = Id::new_v4();
let element = make_port(
&p,
group_port,
parent,
"Group input",
PortContract::FunctionInput {
incoming_items: vec![],
},
)
.unwrap();
p.elements.insert(group_port, element);
manual(&mut p, d, group, group_port, PortAnchorSide::Top, 0.25);
manual(&mut p, d, na, a, PortAnchorSide::Bottom, 0.5);
manual(&mut p, d, nb, b, PortAnchorSide::Bottom, 0.5);
p = prepare_command(
&p,
&Command::SetEdgeRoute {
diagram: d,
route: EdgeRoute {
edge,
waypoints: vec![pos(500., 420.)],
label: None,
},
},
)
.unwrap()
.project;
let view = project_diagram(&p, &p.diagrams[&d]);
let parent_body = view.nodes.iter().find(|n| n.id == group).unwrap();
assert!(parent_body.size.width > p.diagrams[&d].nodes[&group].size.width);
assert_eq!(
port(&view, group_port).position,
port_anchor_position(parent_body, PortAnchorSide::Top, 0.25)
);
let routes = route_diagram(&p, &p.diagrams[&d], &view);
assert!(
routes.edges[&edge].warnings.is_empty(),
"{:?}",
routes.edges[&edge]
);
let obstacle = view.nodes.iter().find(|n| n.id == obstacle_node).unwrap();
for pair in routes.edges[&edge].points.windows(2) {
let [a, b] = [pair[0], pair[1]];
let crosses = if a.x == b.x {
a.x > obstacle.position.x
&& a.x < obstacle.position.x + obstacle.size.width
&& a.y.min(b.y) < obstacle.position.y + obstacle.size.height
&& a.y.max(b.y) > obstacle.position.y
} else {
a.y > obstacle.position.y
&& a.y < obstacle.position.y + obstacle.size.height
&& a.x.min(b.x) < obstacle.position.x + obstacle.size.width
&& a.x.max(b.x) > obstacle.position.x
};
assert!(!crosses);
}
let mut session = Session::new(p.clone());
session
.execute(Command::MoveNode {
diagram: d,
node: group,
position: pos(190., 80.),
with_descendants: true,
})
.unwrap();
let moved = session.project();
let after = project_diagram(moved, &moved.diagrams[&d]);
for before in &view.ports {
assert_eq!(
port(&after, before.element).position,
pos(before.position.x + 140., before.position.y + 80.)
);
}
for (id, node) in &p.diagrams[&d].nodes {
assert_eq!(moved.diagrams[&d].nodes[id].port_anchors, node.port_anchors);
}
assert_eq!(moved.diagrams[&d].routes[0].waypoints, [pos(640., 500.)]);
assert!(
route_diagram(moved, &moved.diagrams[&d], &after).edges[&edge]
.warnings
.is_empty()
);
session.undo();
assert_eq!(session.project(), &p);
}
#[test]
fn narrow_peer_gap_uses_clear_side_instead_of_a_blocked_facing_stub() {
let (mut p, d, a, b, output, input, edge) = fixture();
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&b)
.unwrap()
.position = pos(100., 260.);
let view = project_diagram(&p, &p.diagrams[&d]);
let source = view.nodes.iter().find(|n| n.id == a).unwrap();
let target = view.nodes.iter().find(|n| n.id == b).unwrap();
assert!(target.position.y - source.position.y - source.size.height < PORT_STUB + CLEARANCE);
assert_eq!(port(&view, output).side, PortAnchorSide::Right);
assert_eq!(port(&view, input).side, PortAnchorSide::Left);
let routed = route_diagram(&p, &p.diagrams[&d], &view);
assert!(
routed.edges[&edge].warnings.is_empty(),
"{:?}",
routed.edges[&edge]
);
}
#[test]
fn many_neutral_ports_balance_capacity_without_a_giant_bottom_footer() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::System, Layer::Sa, "Many interfaces", None)
.unwrap();
for i in 0..100 {
let id = Id::from_u128(i + 1);
let port = make_port(
&p,
id,
owner,
&format!("Interface {i}"),
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(id, port);
}
let d = p.ensure_diagram(Layer::Sa);
let node = p.place(d, owner, pos(0., 0.));
let view = project_diagram(&p, &p.diagrams[&d]);
assert_eq!(view.ports.len(), 100);
assert!(view.nodes[0].size.width < 1000. && view.nodes[0].size.height < 1000.);
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
let mut offsets: Vec<_> = view
.ports
.iter()
.filter(|p| p.side == side)
.map(|p| port_anchor_offset(&view.nodes[0], side, p.position))
.collect();
assert_eq!(offsets.len(), 25);
offsets.sort_by(f32::total_cmp);
let length = if matches!(side, PortAnchorSide::Left | PortAnchorSide::Right) {
view.nodes[0].size.height
} else {
view.nodes[0].size.width
};
assert!(offsets.windows(2).all(|w| (w[1] - w[0]) * length >= 27.99));
}
let id = view
.ports
.iter()
.find(|p| p.side == PortAnchorSide::Left)
.unwrap()
.element;
manual(&mut p, d, node, id, PortAnchorSide::Top, 0.5);
let after = project_diagram(&p, &p.diagrams[&d]);
assert_eq!(after.nodes, view.nodes);
let placed = port(&after, id);
for other in after.ports.iter().filter(|p| p.element != id) {
assert_eq!(other.side, port(&view, other.element).side);
let distance = (other.position.x - placed.position.x).abs()
+ (other.position.y - placed.position.y).abs();
assert!(
distance >= 14.,
"An automatic port overlaps the authored anchor"
);
}
}
const SOURCE: &str = r#"arcadia 3;
project ports "Ports" {
sa_function owner "Owner" {}
sa_port first "First" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
sa_port second "Second" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
diagram view "View" { layer Sa; }
node owner_view { diagram @view; element @owner; port_anchors [
// first rationale
{ port @first; side Top; offset 0.25; },
// second rationale
{ port @second; side Bottom; offset 0.75; }
]; }
}
"#;
fn find(p: &Project, alias: &str) -> Id {
p.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
#[test]
fn source_json_roundtrip_defaults_and_admission_include_anchor_records() {
let p = language::parse(SOURCE).unwrap();
let formatted = language::format(&p);
assert!(formatted.contains("port @first;") && formatted.contains("side Top;"));
assert_eq!(language::parse(&formatted).unwrap(), p);
assert_eq!(
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
p
);
assert_eq!(
compile_bundle(&format_bundle(&p), ProjectLimits::default())
.unwrap()
.decoded
.project,
p
);
// project + function + two ports + diagram + node + two anchors = 8.
let limits = language::ParseLimits {
max_records: 7,
..language::ParseLimits::UNLIMITED
};
assert!(limits.check_model(&p).is_err());
assert!(persistence::decode_limited(&serde_json::to_vec(&p).unwrap(), limits).is_err());
assert!(language::parse_limited(SOURCE, limits).is_err());
assert!(
compile_bundle(
&format_bundle(&p),
ProjectLimits {
model: limits,
..ProjectLimits::default()
}
)
.is_err()
);
let mut value = serde_json::to_value(&p).unwrap();
let d = find(&p, "view");
let n = find(&p, "owner_view");
value["diagrams"][d.to_string()]["nodes"][n.to_string()]
.as_object_mut()
.unwrap()
.remove("port_anchors");
let read = persistence::decode(&serde_json::to_vec(&value).unwrap()).unwrap();
assert!(read.diagrams[&d].nodes[&n].port_anchors.is_empty());
}
#[test]
fn source_updater_keeps_comment_attachment_through_identity_swap_and_anchor_reordering() {
let current = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), SOURCE.replace('\n', "\r\n"))]),
},
ProjectLimits::default(),
)
.unwrap();
let p = &current.decoded.project;
let d = find(p, "view");
let n = find(p, "owner_view");
let first = find(p, "first");
let second = find(p, "second");
let temp = Id::new_v4();
let mut commands = vec![
Command::Reidentify(first, temp),
Command::Reidentify(second, first),
Command::Reidentify(temp, second),
Command::SetAlias(second, "revised_first".into()),
];
let candidate = prepare_command(p, &Command::Batch(commands.clone()))
.unwrap()
.project;
let mut node = candidate.diagrams[&d].nodes[&n].clone();
node.port_anchors.reverse();
node.port_anchors[1].offset = 0.5;
commands.push(Command::EditNode {
diagram: d,
original: n,
node,
});
let prepared = prepare_command(p, &Command::Batch(commands)).unwrap();
let result = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let text = &result.bundle.files["project.arcadia"];
assert!(
text.contains("// first rationale\r\n { port @revised_first; side Top; offset 0.5; }"),
"{text}"
);
assert!(
text.contains("// second rationale\r\n { port @second; side Bottom; offset 0.75; }"),
"{text}"
);
assert_eq!(result.decoded.project, prepared.project);
assert_eq!(
update_bundle(&result, &prepared.project, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle
);
}
#[test]
fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_record() {
let current = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
},
ProjectLimits::default(),
)
.unwrap();
let p = &current.decoded.project;
let diagram = find(p, "view");
let node = find(p, "owner_view");
let first = find(p, "first");
let second = find(p, "second");
let prepared = prepare_command(
p,
&Command::Batch(vec![
Command::RemovePortAnchor {
diagram,
node,
port: first,
},
Command::RemovePortAnchor {
diagram,
node,
port: second,
},
]),
)
.unwrap();
let cleared = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(cleared.decoded.project, prepared.project);
let text = &cleared.bundle.files["project.arcadia"];
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
assert!(!text.contains("port_anchors"));
let prepared = prepare_command(
&cleared.decoded.project,
&Command::SetPortAnchor {
diagram,
node,
anchor: PortAnchor {
port: second,
side: PortAnchorSide::Right,
offset: 0.5,
},
},
)
.unwrap();
let added = update_bundle_with_identity_map(
&cleared,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(added.decoded.project, prepared.project);
assert_eq!(
added.bundle.files["project.arcadia"]
.matches("// second rationale")
.count(),
1
);
}
#[test]
fn malformed_source_anchor_records_have_located_errors() {
for bad in [
"{ port @first; side Top; }",
"{ port @first; side Top; offset -0.1; }",
"{ port @first; side Corner; offset 0.5; }",
"{ port @owner; side Top; offset 0.5; }",
"{ port @first; side Top; offset 0.5; unexpected true; }",
"{ port @first; side Top; offset 0.1; }, { port @first; side Right; offset 0.2; }",
] {
let start = SOURCE.find("port_anchors [").unwrap() + "port_anchors [".len();
let end = SOURCE[start..].find("]; }").unwrap() + start;
let source = format!("{}\n{}\n{}", &SOURCE[..start], bad, &SOURCE[end..]);
let errors = language::parse(&source).expect_err(bad);
assert!(errors.iter().any(|e| e.line >= 7), "{bad}: {errors:?}");
}
}
+13 -10
View File
@@ -811,16 +811,19 @@ fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_i
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
assert!(!edge.oriented);
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
assert!(
view.ports
.iter()
.filter(|port| [
p.relations[&boundary].source(),
p.relations[&boundary].target()
]
.contains(&port.element))
.all(|port| port.side == PortAnchorSide::Bottom)
);
for (element, side) in [
(p.relations[&boundary].source(), PortAnchorSide::Right),
(p.relations[&boundary].target(), PortAnchorSide::Left),
] {
assert_eq!(
view.ports
.iter()
.find(|port| port.element == element)
.unwrap()
.side,
side
);
}
let (output, input) = endpoints(&p, fe);
assert_eq!(
view.ports
+5 -2
View File
@@ -357,9 +357,12 @@ fn workshop_automatic_labels_are_readable_without_mutating_authored_routes() {
size
}
),
"{} overlaps {}",
"{} overlaps {}: label {:?}, node {:?}, path {:?}",
label.text,
p.named(node.element)
p.named(node.element),
label.bounds,
node,
routed.edges[&edge.id].points
);
}
}
+152
View File
@@ -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<String> {
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);
}
}
}
+744
View File
@@ -0,0 +1,744 @@
use oa_core::{projection::*, routing::*, source_project::*, *};
use std::collections::BTreeMap;
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn add(p: &mut Project, kind: Kind, name: &str, parent: Option<Id>) -> Id {
p.add_element(kind, Layer::Oa, name, parent).unwrap()
}
fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
routes: vec![],
},
);
id
}
fn node(p: &Project, d: Id, e: Id) -> Id {
p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == e)
.unwrap()
.id
}
fn place(p: &mut Project, d: Id, e: Id, x: f32, y: f32, w: f32, h: f32) -> Id {
let id = p.place(d, e, point(x, y));
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&id)
.unwrap()
.size = Size {
width: w,
height: h,
};
id
}
fn transition(p: &mut Project, a: Id, b: Id, kind: TransitionKind) -> Id {
let id = p.relate(RelationKind::StateTransition, "Transition", a, b);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut p.relations.get_mut(&id).unwrap().body
{
*transition_kind = kind;
}
id
}
fn code(p: &Project, expected: &str) {
assert!(
validation::validate(p).iter().any(|i| i.code == expected),
"{:?}",
validation::validate(p)
);
}
fn orthogonal(points: &[Position]) {
assert!(
points
.windows(2)
.all(|p| p[0].x == p[1].x || p[0].y == p[1].y),
"{points:?}"
);
}
struct Fixture {
p: Project,
machine: Id,
region: Id,
a: Id,
b: Id,
inner: Id,
leaf: Id,
entry: Id,
exit: Id,
d: Id,
external: Id,
self_edge: Id,
internal: Id,
local: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let performer = add(&mut p, Kind::Entity, "Crew", None);
let machine = add(
&mut p,
Kind::StateMachine,
"Operating modes",
Some(performer),
);
let region = add(&mut p, Kind::Region, "Main region", Some(machine));
let initial = add(&mut p, Kind::Initial, "Start", Some(region));
let a = add(&mut p, Kind::State, "Operating", Some(region));
let b = add(&mut p, Kind::State, "Standby", Some(region));
let inner = add(&mut p, Kind::Region, "Nested region", Some(a));
let leaf = add(&mut p, Kind::State, "Working", Some(inner));
let entry = add(&mut p, Kind::EntryPoint, "Enter", Some(inner));
let exit = add(&mut p, Kind::ExitPoint, "Exit", Some(inner));
transition(&mut p, initial, a, TransitionKind::External);
let external = transition(&mut p, a, b, TransitionKind::External);
if let RelationBody::StateTransition { guard, .. } =
&mut p.relations.get_mut(&external).unwrap().body
{
*guard = "ready".into();
}
let self_edge = transition(&mut p, b, b, TransitionKind::External);
let internal = transition(&mut p, a, a, TransitionKind::Internal);
let local = transition(&mut p, entry, leaf, TransitionKind::Local);
transition(&mut p, leaf, exit, TransitionKind::External);
let d = view(&mut p, machine, "operating_view");
for (e, x, y, w, h) in [
(machine, 0., 0., 1100., 900.),
(region, 24., 64., 1000., 800.),
(initial, 60., 160., 224., 94.),
(a, 180., 180., 440., 500.),
(b, 760., 180., 224., 94.),
(inner, 204., 320., 380., 300.),
(leaf, 310., 420., 224., 94.),
(entry, -200., 390., 224., 94.),
(exit, 2000., 550., 224., 94.),
] {
place(&mut p, d, e, x, y, w, h);
}
validate_candidate(&p).unwrap();
Fixture {
p,
machine,
region,
a,
b,
inner,
leaf,
entry,
exit,
d,
external,
self_edge,
internal,
local,
}
}
#[test]
fn strict_context_wire_source_and_admission_contract() {
let f = fixture();
let json = serde_json::json!({"StateMachine":{"machine":f.machine}});
assert_eq!(
serde_json::to_value(f.p.diagrams[&f.d].projection).unwrap(),
json
);
assert_eq!(
serde_json::from_value::<ProjectionKind>(serde_json::json!("Overview")).unwrap(),
ProjectionKind::Overview
);
for invalid in [
serde_json::json!("StateMachine"),
serde_json::json!({"StateMachine":{}}),
serde_json::json!({"StateMachine":{"machine":null}}),
serde_json::json!({"StateMachine":{"machine":f.machine,"extra":1}}),
] {
assert!(serde_json::from_value::<ProjectionKind>(invalid).is_err());
}
let source = language::format(&f.p);
assert!(
source.contains(
"projection {\n StateMachine {\n machine @operating_modes;"
),
"{source}"
);
assert_eq!(language::parse(&source).unwrap(), f.p);
assert!(
language::parse(&source.replace(
"machine @operating_modes;",
"machine @operating_modes; extra 1;"
))
.is_err()
);
let mut limits = language::ParseLimits::UNLIMITED;
limits.max_records = f.p.identities().len() - 1;
assert!(language::parse_limited(&source, limits).is_err());
assert_eq!(
serde_json::from_slice::<Project>(&serde_json::to_vec(&f.p).unwrap()).unwrap(),
f.p
);
}
#[test]
fn contexts_restrict_membership_without_changing_legacy_hidden_placements() {
let mut f = fixture();
let other = add(&mut f.p, Kind::StateMachine, "Other machine", None);
let foreign_region = add(&mut f.p, Kind::Region, "Other region", Some(other));
let foreign = add(&mut f.p, Kind::State, "Other state", Some(foreign_region));
assert!(state_machine_contains(&f.p, f.machine, f.leaf));
assert!(!state_machine_contains(&f.p, f.machine, foreign));
let foreign_node = f.p.place(f.d, foreign, point(0., 0.));
code(&f.p, "state_diagram_member");
assert!(
!project_view(&f.p, f.d)
.nodes
.iter()
.any(|n| n.id == foreign_node)
);
f.p.diagrams.get_mut(&f.d).unwrap().projection = ProjectionKind::FunctionalDataflow;
validate_candidate(&f.p).unwrap();
assert!(project_view(&f.p, f.d).nodes.is_empty());
for machine in [Id::nil(), f.region] {
f.p.diagrams.get_mut(&f.d).unwrap().projection = ProjectionKind::StateMachine { machine };
code(&f.p, "state_diagram_context");
}
f.p.diagrams.get_mut(&f.d).unwrap().projection =
ProjectionKind::StateMachine { machine: f.machine };
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.remove(&foreign_node);
f.p.diagrams.get_mut(&f.d).unwrap().layer = Layer::Sa;
code(&f.p, "state_diagram_context");
}
#[test]
fn empty_and_partial_views_are_read_only_and_omit_unrelated_edges() {
let mut f = fixture();
let empty = view(&mut f.p, f.machine, "empty_state_view");
validate_candidate(&f.p).unwrap();
let before = f.p.clone();
assert!(project_view(&f.p, empty).nodes.is_empty());
assert_eq!(before, f.p);
let unrelated =
f.p.relate(RelationKind::Availability, "Availability", f.a, f.b);
let partial = f.p.diagrams.get_mut(&f.d).unwrap();
partial.nodes.retain(|_, n| [f.a, f.b].contains(&n.element));
let before = f.p.clone();
let projected = project_view(&f.p, f.d);
let _ = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected);
assert_eq!(before, f.p);
assert!(
!projected
.edges
.iter()
.any(|e| e.id == unrelated || e.id == f.internal)
);
assert!(projected.edges.iter().any(|e| e.id == f.self_edge));
assert!(projected.nodes.iter().all(|n| n.visual_parent.is_none()));
}
#[test]
fn real_state_symbols_boundaries_internal_rows_and_self_loops_share_geometry() {
let f = fixture();
let projected = project_view(&f.p, f.d);
let bounds = |id| projected.nodes.iter().find(|n| n.element == id).unwrap();
let a = bounds(f.a);
let entry = bounds(f.entry);
let exit = bounds(f.exit);
assert_eq!(entry.size, state_symbol_size(Kind::EntryPoint).unwrap());
assert_eq!(entry.position.x + entry.size.width / 2., a.position.x);
assert_eq!(
exit.position.x + exit.size.width / 2.,
a.position.x + a.size.width
);
assert!(entry.position.y >= a.position.y + state_header_height(&f.p, f.a));
assert_eq!(entry.visual_parent, Some(node(&f.p, f.d, f.inner)));
assert_eq!(state_header_height(&f.p, f.a), 120.);
assert!(!projected.edges.iter().any(|e| e.id == f.internal));
assert!(projected.edges.iter().any(|e| e.id == f.local));
let routed = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected);
let path = &routed.edges[&f.self_edge];
orthogonal(&path.points);
assert_ne!(
path.points.first(),
path.points.last(),
"Self-transition returns at a different boundary anchor"
);
assert!(path.points.len() >= 5, "{:?}", path.points);
assert!(path.warnings.is_empty(), "{:?}", path.warnings);
let b = bounds(f.b);
assert!(path.points.iter().any(|p| p.x < b.position.x
|| p.x > b.position.x + b.size.width
|| p.y < b.position.y
|| p.y > b.position.y + b.size.height));
assert!(routed.edges[&f.external].label.text.contains("[ready]"));
for edge in routed.edges.values() {
orthogonal(&edge.points);
}
}
#[test]
fn folding_retains_authored_routes_without_false_self_transitions() {
let mut f = fixture();
let hidden_self = transition(&mut f.p, f.leaf, f.leaf, TransitionKind::External);
let out = transition(&mut f.p, f.leaf, f.b, TransitionKind::External);
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
waypoints: pins.clone(),
label: None,
});
let a = node(&f.p, f.d, f.a);
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&a)
.unwrap()
.collapsed = true;
let projected = project_view(&f.p, f.d);
assert!(
!projected
.edges
.iter()
.any(|e| e.id == hidden_self || e.id == f.local)
);
let routed = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected);
assert!(routed.edges[&out].custom_suspended);
assert!(routed.edges[&out].pins.is_empty());
assert_eq!(f.p.diagrams[&f.d].routes[0].waypoints, pins);
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&a)
.unwrap()
.collapsed = false;
let restored = route_diagram(&f.p, &f.p.diagrams[&f.d], &project_view(&f.p, f.d));
assert_eq!(restored.edges[&out].pins, pins);
}
#[test]
fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let mut f = fixture();
let copied = view(&mut f.p, f.machine, "second_view");
for n in f.p.diagrams[&f.d]
.nodes
.values()
.cloned()
.collect::<Vec<_>>()
{
place(
&mut f.p,
copied,
n.element,
n.position.x,
n.position.y,
n.size.width,
n.size.height,
);
}
let moved = node(&f.p, f.d, f.a);
let label = EdgeLabel {
fraction: 0.4,
offset: point(3., -9.),
};
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
waypoints: vec![point(690., 230.)],
label: None,
});
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&moved)
.unwrap()
.collapsed = true;
let before = f.p.clone();
let mut session = Session::try_new(f.p).unwrap();
session
.execute(Command::MoveNode {
diagram: f.d,
node: moved,
position: point(220., 240.),
with_descendants: true,
})
.unwrap();
let after = session.project();
assert_eq!(after.diagrams[&copied], before.diagrams[&copied]);
for e in [f.a, f.inner, f.leaf, f.entry, f.exit] {
let n = node(&before, f.d, e);
let old = before.diagrams[&f.d].nodes[&n].position;
assert_eq!(
after.diagrams[&f.d].nodes[&n].position,
point(old.x + 40., old.y + 60.)
);
assert_eq!(after.elements[&e], before.elements[&e]);
}
assert_eq!(
after.diagrams[&f.d].routes[0].waypoints,
vec![point(610., 520.)]
);
assert_eq!(after.diagrams[&f.d].routes[0].label, Some(label));
assert_eq!(
after.diagrams[&f.d].routes[1],
before.diagrams[&f.d].routes[1]
);
let after = after.clone();
session.undo();
assert_eq!(session.project(), &before);
session.redo();
assert_eq!(session.project(), &after);
}
#[test]
fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let mut f = fixture();
let orphan = add(&mut f.p, Kind::State, "Independent", Some(f.region));
let edge = transition(&mut f.p, orphan, orphan, TransitionKind::External);
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
waypoints: vec![],
label: None,
});
let machine = add(&mut f.p, Kind::StateMachine, "Other machine", None);
let region = add(&mut f.p, Kind::Region, "Other region", Some(machine));
let other = view(&mut f.p, machine, "other_view");
let before = f.p.clone();
assert!(
prepare_command(
&f.p,
&Command::Reparent {
element: orphan,
parent: Some(region)
}
)
.is_err()
);
assert_eq!(f.p, before);
let repaired = prepare_command(
&f.p,
&Command::Batch(vec![
Command::Reparent {
element: orphan,
parent: Some(region),
},
Command::RelocateNode {
node: placement,
diagram: other,
},
Command::RemoveEdgeRoute { diagram: f.d, edge },
Command::SetEdgeRoute {
diagram: other,
route: EdgeRoute {
edge,
waypoints: vec![],
label: None,
},
},
]),
)
.unwrap()
.project;
assert_eq!(repaired.diagrams[&other].nodes[&placement].element, orphan);
assert!(!repaired.diagrams[&f.d].nodes.contains_key(&placement));
assert!(repaired.diagrams[&f.d].routes.is_empty());
}
#[test]
fn remapping_and_machine_deletion_include_dedicated_views_and_dependents() {
let mut f = fixture();
let sa_owner =
f.p.add_element(Kind::System, Layer::Sa, "SA system", None)
.unwrap();
let counterpart =
f.p.add_element(Kind::StateMachine, Layer::Sa, "SA modes", Some(sa_owner))
.unwrap();
let trace = Id::new_v4();
f.p.traces.insert(
trace,
Trace {
id: trace,
alias: "machine_trace".into(),
name: "Machine trace".into(),
description: String::new(),
oa: f.machine,
sa: counterpart,
kind: TraceKind::State,
baseline: None,
},
);
let artifact = Id::new_v4();
f.p.artifact_links.insert(
artifact,
ArtifactLink {
id: artifact,
alias: "view_review".into(),
name: "View review".into(),
description: String::new(),
owner: f.d,
relation: ArtifactRelation::References,
uri: "file:///review.txt".into(),
revision: None,
fragment: None,
},
);
validate_candidate(&f.p).unwrap();
let renamed = Id::new_v4();
let remapped = prepare_command(&f.p, &Command::Reidentify(f.machine, renamed))
.unwrap()
.project;
assert_eq!(
remapped.diagrams[&f.d].projection.state_machine(),
Some(renamed)
);
assert_eq!(remapped.elements[&f.region].parent, Some(renamed));
let impact = references::deletion_impact(&remapped, renamed);
for id in [f.d, artifact, trace, node(&remapped, f.d, f.a)] {
assert!(impact.contains(&id));
}
assert!(!impact.contains(&counterpart));
let deleted = prepare_command(&remapped, &Command::Delete(renamed))
.unwrap()
.project;
assert!(!deleted.diagrams.contains_key(&f.d));
assert!(deleted.elements.contains_key(&counterpart));
let hidden = prepare_command(&f.p, &Command::Delete(node(&f.p, f.d, f.machine)))
.unwrap()
.project;
assert!(hidden.diagrams.contains_key(&f.d));
assert!(hidden.elements.contains_key(&f.machine));
}
#[test]
fn source_updates_preserve_context_identity_and_unrelated_authored_bytes() {
let f = fixture();
let mut bundle = format_bundle(&f.p);
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(0, "// state source rationale\n");
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let fresh = Id::new_v4();
let prepared = prepare_command(
&f.p,
&Command::Batch(vec![
Command::Reidentify(f.machine, fresh),
Command::SetAlias(fresh, "reviewed_modes".into()),
]),
)
.unwrap();
let updated = update_bundle_with_identity_map(
&original,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, prepared.project);
assert!(
updated
.bundle
.files
.values()
.any(|s| s.contains("machine @reviewed_modes;"))
);
assert!(updated.bundle.files[&bundle.entry].starts_with("// state source rationale\n"));
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.bundle,
updated.bundle
);
}
#[test]
fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let mut f = fixture();
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
offset: point(11., 7.),
}),
});
let before = f.p.clone();
let routed = route_diagram(&f.p, &f.p.diagrams[&f.d], &project_view(&f.p, f.d));
assert_eq!(routed.edges[&f.self_edge].pins, pins);
let mut previous = 0;
for pin in &pins {
let i = routed.edges[&f.self_edge]
.points
.iter()
.enumerate()
.skip(previous)
.find(|(_, p)| *p == pin)
.unwrap()
.0;
previous = i + 1;
}
assert_eq!(f.p, before);
let laid = prepare_command(&f.p, &Command::Layout(f.d))
.unwrap()
.project;
let nodes = &laid.diagrams[&f.d].nodes;
let a = &nodes[&node(&laid, f.d, f.a)];
let inner = &nodes[&node(&laid, f.d, f.inner)];
assert!(inner.position.y >= a.position.y + state_header_height(&laid, f.a));
let first = language::format(&laid);
assert_eq!(language::parse(&first).unwrap(), laid);
}
#[test]
fn reconnect_keeps_identity_and_validates_replacement_without_double_counting() {
let f = fixture();
let initial =
f.p.elements
.values()
.find(|e| e.kind() == Kind::Initial)
.unwrap()
.id;
let edge =
f.p.relations
.values()
.find(|r| r.source() == initial)
.unwrap();
let mut replacement = edge.clone();
if let RelationBody::StateTransition { target, .. } = &mut replacement.body {
*target = f.b;
}
let after = prepare_command(&f.p, &Command::EditRelation(edge.id, replacement.clone()))
.unwrap()
.project;
assert_eq!(after.relations[&edge.id], replacement);
let mut invalid = replacement.clone();
if let RelationBody::StateTransition { target, .. } = &mut invalid.body {
*target = initial;
}
assert!(prepare_command(&after, &Command::EditRelation(edge.id, invalid)).is_err());
assert_eq!(after.relations[&edge.id], replacement);
}
#[test]
fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_boundary() {
let mut f = fixture();
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
waypoints: pins.clone(),
label: None,
});
let before = project_view(&f.p, f.d);
let entry_before = before
.nodes
.iter()
.find(|n| n.element == f.entry)
.unwrap()
.position;
let region = node(&f.p, f.d, f.inner);
let old = f.p.diagrams[&f.d].nodes[&region].position;
let after = prepare_command(
&f.p,
&Command::MoveNode {
diagram: f.d,
node: region,
position: point(old.x + 10., old.y + 10.),
with_descendants: true,
},
)
.unwrap()
.project;
let projected = project_view(&after, f.d);
let entry_after = projected
.nodes
.iter()
.find(|n| n.element == f.entry)
.unwrap()
.position;
assert_eq!(
entry_after.x, entry_before.x,
"Entry stays on the unmoved composite boundary"
);
assert_eq!(after.diagrams[&f.d].routes[0].waypoints, pins);
}
#[test]
fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_geometry() {
let mut f = fixture();
let initial =
f.p.elements
.values()
.find(|e| e.kind() == Kind::Initial)
.unwrap()
.id;
let edge =
f.p.relations
.values()
.find(|r| r.source() == initial)
.unwrap()
.id;
let projected = project_view(&f.p, f.d);
let n = projected
.nodes
.iter()
.find(|n| n.element == initial)
.unwrap();
assert_eq!(
n.size,
Size {
width: 16.,
height: 16.
}
);
let settled = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected);
let first = settled.edges[&edge].points[0];
assert!(
[
point(n.position.x, n.position.y + 8.),
point(n.position.x + 16., n.position.y + 8.),
point(n.position.x + 8., n.position.y),
point(n.position.x + 8., n.position.y + 16.)
]
.contains(&first)
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,
offset: point(11., 9.),
}),
});
let preview = route_edge_preview(&f.p, &f.p.diagrams[&f.d], &projected, &settled, f.self_edge);
assert_eq!(
preview.edges[&f.self_edge].points,
settled.edges[&f.self_edge].points
);
assert_eq!(preview.edges[&edge], settled.edges[&edge]);
}
+522 -21
View File
@@ -12,6 +12,12 @@ use oa_core::{
use serde_json::{Value, json};
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
#[cfg(test)]
mod behavior_lifecycle_tests;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
#[derive(Debug, Clone)]
pub enum LoadedSnapshot {
Source(DiskProject),
@@ -82,6 +88,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),
@@ -128,6 +141,9 @@ pub enum Message {
Redo,
Diagram(Choice),
Projection(ProjectionKind),
BehaviorOwner(Choice),
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -176,6 +192,10 @@ pub enum Message {
}
pub struct App {
pub connection: Option<crate::connection_edit::Session>,
pub model_menu: Option<crate::model_menu::Opened>,
pub source_tree: crate::source_tree::State,
menu_sequence: u64,
pub canvas_document: Id,
pub canvas_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
@@ -260,6 +280,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,
@@ -336,7 +360,10 @@ impl App {
self.draft.as_ref().is_some_and(Draft::dirty)
}
pub fn dirty(&self) -> bool {
self.document.dirty() || self.draft_dirty() || self.source_dirty()
self.document.dirty()
|| self.draft_dirty()
|| self.source_dirty()
|| self.behavior.has_pending()
}
fn sync_source(&mut self) {
self.source_workspace.accept(self.document.bundle());
@@ -346,6 +373,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);
@@ -360,16 +388,29 @@ impl App {
})
.flatten();
}
fn refresh_draft(&mut self) {
self.selected_node = self
.active_diagram
fn selected_architecture_placement(&self) -> Option<Id> {
self.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.and_then(|d| {
d.nodes
.values()
.find(|n| n.id == self.selected || n.element == self.selected)
})
.map(|n| n.id);
.map(|n| n.id)
}
fn refresh_draft(&mut self) {
self.selected_node = if self.page == Page::Behavior {
None
} else {
self.selected_architecture_placement()
};
if let Some(context) = self.state_context() {
self.behavior.diagram.placement = self.project().diagrams[&context.diagram]
.nodes
.values()
.find(|n| n.id == self.selected || n.element == self.selected)
.map(|n| n.id);
}
self.draft = Draft::from_project(self.project(), self.selected);
if self.exact_strings
&& let Some(d) = &mut self.draft
@@ -419,6 +460,11 @@ impl App {
}
}
pub fn apply_source(&mut self) -> bool {
if self.behavior.has_pending() {
self.status =
"Apply or cancel the behavior form before applying source changes.".into();
return false;
}
if !self.source_dirty() {
return true;
}
@@ -449,6 +495,10 @@ impl App {
}
}
pub fn apply(&mut self) -> bool {
if self.behavior.has_pending() {
self.status = "Apply or cancel the behavior form before changing the model.".into();
return false;
}
if self.source_dirty() {
self.status =
"Apply all source drafts or revert them before changing the model.".into();
@@ -465,7 +515,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())
}) {
@@ -473,6 +526,12 @@ impl App {
if self.active_diagram == Some(original) {
self.active_diagram = Some(id);
}
if self.behavior.active_owner == Some(original) {
self.behavior.active_owner = Some(id);
}
if self.behavior.diagram.diagram == Some(original) {
self.behavior.diagram.diagram = Some(id);
}
if self.selected_node == Some(original) {
self.selected_node = Some(id);
}
@@ -491,8 +550,17 @@ impl App {
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)))
{
self.layer = layer;
self.active_diagram = Some(diagram);
self.selected_node = node;
if self.page == Page::Behavior
&& let Some(machine) =
self.project().diagrams[&diagram].projection.state_machine()
{
self.behavior.active_owner = Some(machine);
self.behavior.diagram.diagram = Some(diagram);
self.behavior.diagram.placement = node;
} else {
self.active_diagram = Some(diagram);
self.selected_node = node;
}
}
self.sync();
self.status = "Properties applied".into();
@@ -505,6 +573,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 {
@@ -520,6 +589,30 @@ impl App {
}
}
}
fn apply_behavior(&mut self) -> bool {
if !self.behavior.has_pending() {
return true;
}
if self.source_dirty() || self.draft_dirty() {
self.status = "Property, source and behavior drafts are retained. Revert the other drafts before applying the behavior form.".into();
return false;
}
match self.behavior.prepare_pending(self.project()) {
Ok(Some(command)) => {
if self.execute(command) {
self.behavior.acknowledge_commit();
true
} else {
false
}
}
Ok(None) => true,
Err(error) => {
self.status = error;
false
}
}
}
fn reset(&mut self, project: Project, path: Option<PathBuf>) {
let window_size = self.window_size;
let canvas_size = self.canvas_size;
@@ -549,6 +642,7 @@ impl App {
if !self.project().contains(id) {
return;
}
let previous_state_view = self.state_view_identity();
self.selected = id;
self.selected_node = None;
if let Some(layer) = self.project().layer_of(id)
@@ -558,6 +652,16 @@ impl App {
self.active_diagram = self.diagram_for(layer, id);
self.fit();
}
if self.page == Page::Behavior
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
{
self.behavior.active_owner = Some(owner);
}
if self.reset_state_view_after_navigation(previous_state_view)
&& self.page == Page::Behavior
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.refresh_draft();
}
fn reveal_explorer_selection(&mut self) {
@@ -577,13 +681,14 @@ impl App {
}
}
pub fn view(&self) -> Element<'_, Message> {
crate::views::view(self)
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
.into()
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
iced::event::listen_with(|event, _, _| {
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers: mods,
@@ -593,6 +698,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,7 +732,33 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
if self.source_dirty()
// 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() || self.behavior.has_pending())
&& matches!(
message,
Message::Field(..)
@@ -637,7 +771,8 @@ impl App {
)
{
self.status =
"Apply all source drafts or revert them before editing properties.".into();
"Apply or cancel pending source and behavior drafts before editing properties."
.into();
return Task::none();
}
if self.busy
@@ -718,11 +853,48 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
if self.behavior.has_pending()
&& matches!(
message,
Message::Page(page) if page != Page::Behavior
)
{
self.status = "Apply or cancel the behavior form before leaving this view.".into();
return Task::none();
}
if self.behavior.has_pending()
&& matches!(
message,
Message::BrowseGroup(_)
| Message::SourceEdit(_)
| Message::SourceCreateFile
| Message::SourceCloseFile(_)
| Message::SourceUndo
| Message::SourceRedo
| Message::SourceRecover
| Message::SourceLoadIncludes
| Message::Menu(_)
| Message::Connection(_)
| Message::PortAnchor(_)
| Message::Route(_)
)
{
self.status = "Apply or cancel the behavior form before changing another view.".into();
return Task::none();
}
let previous = self.selected;
let previous_layer = self.layer;
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 +930,170 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()
&& self.project().elements.get(&choice.id).is_some_and(|e| {
e.layer() == self.layer
&& (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
})
{
self.behavior.active_owner = Some(choice.id);
self.behavior.diagram = Default::default();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(choice.id);
self.page = Page::Behavior;
}
}
Message::BehaviorDiagram(choice) => {
if self.apply()
&& let Some(owner) = crate::behavior::selected_owner(self)
&& self
.project()
.diagrams
.get(&choice.id)
.is_some_and(|d| d.projection.state_machine() == Some(owner))
{
self.behavior.diagram.diagram = Some(choice.id);
self.behavior.diagram.tool = crate::behavior::state_edit::Tool::Select;
self.behavior.diagram.fit = true;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.fit_state_diagram();
}
}
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 = if self.page == Page::Behavior {
self.state_context()
} else {
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;
@@ -865,7 +1200,22 @@ impl App {
}
Message::Page(page) => {
if page != Page::Text || !self.draft_dirty() || self.apply() {
if page == Page::Behavior && self.page != Page::Behavior {
let previous_state_view = self.state_view_identity();
if let Some(owner) =
crate::behavior::selected_behavior(self.project(), self.selected)
{
self.behavior.active_owner = Some(owner);
}
self.behavior.active_owner = crate::behavior::selected_owner(self);
self.reset_state_view_after_navigation(previous_state_view);
}
self.page = page;
self.selected_node = if page == Page::Behavior {
None
} else {
self.selected_architecture_placement()
};
if page == Page::Text {
return self.request_lint();
}
@@ -1012,6 +1362,7 @@ impl App {
}
Message::Layer(layer) => {
if self.apply() {
let previous_state_view = self.state_view_identity();
self.layer = layer;
self.create_kind = if layer == Layer::Oa {
Kind::Activity
@@ -1027,6 +1378,8 @@ impl App {
.map(|d| d.id);
self.selected = self.project().id;
self.selected_node = None;
self.behavior.active_owner = crate::behavior::selected_owner(self);
self.reset_state_view_after_navigation(previous_state_view);
self.refresh_draft();
self.fit();
}
@@ -1111,14 +1464,22 @@ impl App {
}
Message::InspectorTab(tab) => self.inspector_tab = tab,
Message::Behavior(event) => {
if event.is_submit() && !self.apply() {
if event.starts_form() && !self.behavior.has_pending() && !self.apply() {
return Task::none();
}
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior);
let result = state.update(self.project(), event);
self.behavior = state;
match result {
Ok(Some(command)) => return self.handle(Message::Command(command)),
Ok(Some(command)) => {
if self.execute(command) {
self.behavior.acknowledge_commit();
}
}
Ok(None) => {}
Err(error) => self.status = error,
}
@@ -1236,6 +1597,14 @@ impl App {
}
}
Message::Command(command) => {
let semantic_selection = match &command {
Command::EditNode { diagram, node, .. }
if self.state_context().is_some_and(|c| c.diagram == *diagram) =>
{
Some(node.element)
}
_ => None,
};
let original = self.selected;
if self.apply() {
let command = match command {
@@ -1245,7 +1614,12 @@ impl App {
}
command => command,
};
self.execute(command);
if self.execute(command)
&& let Some(element) = semantic_selection
{
self.selected = element;
self.refresh_draft();
}
}
}
Message::Delete => {
@@ -1254,6 +1628,9 @@ impl App {
}
}
Message::ConfirmDelete => {
if !self.delete_pending || self.behavior.has_pending() {
return Task::none();
}
self.delete_pending = false;
self.execute(Command::Delete(self.selected));
}
@@ -1315,6 +1692,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 +1702,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 +1711,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));
}
}
}
}
@@ -1656,7 +2049,7 @@ impl App {
return self.handle(Message::Save(false));
}
Message::Save(save_as) => {
if !self.apply_source() || !self.apply() {
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.continue_after_save = false;
self.pending = None;
return Task::none();
@@ -2356,8 +2749,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 +2898,7 @@ impl App {
route,
}
}
Event::AddBend => {
Event::AddBend | Event::AddBendAt(_) => {
if route.waypoints.len() >= 128 {
return;
}
@@ -2421,7 +2917,12 @@ impl App {
|s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
len(a).total_cmp(&len(b))
});
let point = if let Some(segment) = segment {
let point = if let Event::AddBendAt(point) = event {
if !point.x.is_finite() || !point.y.is_finite() {
return;
}
point
} else if let Some(segment) = segment {
Position {
x: (segment[0].x + segment[1].x) / 2.,
y: (segment[0].y + segment[1].y) / 2.,
@@ -0,0 +1,395 @@
use super::*;
use crate::behavior::{self, states};
use oa_core::source_project::ProjectLimits;
struct Fixture {
app: App,
machine: Id,
source: Id,
target: Id,
work: Id,
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = project
.add_element(
Kind::StateMachine,
Layer::Sa,
"Operating modes",
Some(owner),
)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Sa, "Operation", Some(machine))
.unwrap();
let source = project
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
.unwrap();
let target = project
.add_element(Kind::State, Layer::Sa, "Waiting", Some(region))
.unwrap();
let work = project
.add_element(Kind::Function, Layer::Sa, "Monitor connection", None)
.unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::Select(source));
send(&mut app, Message::Page(Page::Behavior));
Fixture {
app,
machine,
source,
target,
work,
}
}
fn send(app: &mut App, message: Message) {
// Do not poll returned I/O tasks: these tests exercise the App transaction
// boundary without displaying a file dialog or writing to the filesystem.
let _ = app.update(message);
}
fn state_event(app: &mut App, event: states::Event) {
send(app, Message::Behavior(behavior::Event::States(event)));
}
fn choice(app: &App, id: Id) -> Choice {
Choice {
id,
label: app.project().named(id).into(),
}
}
fn start_transition(fixture: &mut Fixture, complete: bool) {
let Fixture {
app,
machine,
source,
target,
work,
} = fixture;
state_event(
app,
states::Event::Start(*machine, states::Tool::Transition, *source),
);
for event in [
states::Event::Name("Wait for recovery".into()),
states::Event::Trigger(choice(app, *work)),
states::Event::Effect(choice(app, *work)),
states::Event::TriggerNotes("A monitor result".into()),
states::Event::Guard("connection unavailable".into()),
states::Event::EffectNotes("Record the timeout".into()),
states::Event::Timed(true),
states::Event::TimeKind("At".into()),
states::Event::Expression("12:30 UTC".into()),
] {
state_event(app, event);
}
if complete {
let target = choice(app, *target);
state_event(app, states::Event::Target(target));
}
assert_eq!(app.behavior.pending_owner(), Some(*machine));
}
fn assert_typed_transition(app: &App, source: Id, target: Id, work: Id) {
assert_eq!(app.project().relations.len(), 1);
let relation = app.project().relations.values().next().unwrap();
assert_eq!(relation.name, "Wait for recovery");
let RelationBody::StateTransition {
source: actual_source,
target: actual_target,
triggers,
effects,
trigger,
guard,
effect,
..
} = &relation.body
else {
panic!("Expected the prepared state transition");
};
assert_eq!((*actual_source, *actual_target), (source, target));
assert_eq!(effects, &[work]);
assert_eq!(trigger, "A monitor result");
assert_eq!(guard, "connection unavailable");
assert_eq!(effect, "Record the timeout");
assert_eq!(triggers.len(), 2);
assert_eq!(triggers[0], work);
assert!(matches!(
app.project().elements[&triggers[1]].shared(),
Some(SharedBody::Event {
body: EventBody::Time { kind: TimeEventKind::At, expression }
}) if expression == "12:30 UTC"
));
assert_eq!(
app.project()
.elements
.values()
.filter(|e| e.kind() == Kind::Event)
.count(),
1,
"Retry must not leave a time event from the rejected transaction"
);
assert_eq!(
source_project::compile_bundle(app.document.bundle(), LIMITS)
.unwrap()
.decoded
.project,
*app.project()
);
}
#[test]
fn source_writeback_failure_keeps_typed_behavior_form_for_repair_and_one_undo() {
let mut fixture = fixture();
let bundle = fixture.app.document.bundle().clone();
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 2000;
fixture.app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
fixture.app.sync();
let before = fixture.app.document.snapshot();
start_transition(&mut fixture, true);
let app = &mut fixture.app;
state_event(app, states::Event::Name("Transition".repeat(1024)));
let prepared = app
.behavior
.prepare_pending(app.project())
.unwrap()
.unwrap();
assert!(execute_batch(app.project(), &[prepared]).is_ok());
state_event(app, states::Event::Submit);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo());
assert!(app.behavior.has_pending());
assert!(app.dirty());
assert!(!app.source_dirty());
state_event(app, states::Event::Name("Wait for recovery".into()));
state_event(app, states::Event::Submit);
assert!(!app.behavior.has_pending());
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
assert_eq!(app.document.revision(), 1);
assert_eq!(app.source_workspace.bundle(), *app.document.bundle());
let generated_files: Vec<_> = app
.document
.bundle()
.files
.iter()
.filter(|(path, _)| !bundle.files.contains_key(*path))
.map(|(path, text)| (path.clone(), text.clone()))
.collect();
assert!(
!generated_files.is_empty(),
"The time event creates a source file"
);
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
bundle
);
assert_eq!(
app.source_workspace.reachable,
bundle.files.keys().cloned().collect::<BTreeSet<_>>()
);
// Undo restores the accepted bundle exactly. Workspace retains clean buffers
// from removed includes so the user may inspect or explicitly close them.
for (path, text) in generated_files {
assert!(!app.source_workspace.reachable.contains(&path));
let detached = &app.source_workspace.files[&path];
assert_eq!(detached.text, text);
assert!(!detached.dirty());
}
assert!(!app.source_workspace.detached_dirty());
assert!(!app.document.can_undo());
assert!(!app.dirty());
}
#[test]
fn incomplete_behavior_form_is_dirty_close_save_retains_it_and_cancel_allows_switching() {
let mut fixture = fixture();
let before = fixture.app.document.snapshot();
start_transition(&mut fixture, false);
let app = &mut fixture.app;
assert!(!app.document.dirty());
assert!(app.dirty());
assert!(app.title().contains(" *"));
let window = window::Id::unique();
send(app, Message::Request(Pending::Close(window)));
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
send(app, Message::SaveAndContinue);
assert!(app.behavior.has_pending());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.pending.is_none());
assert!(!app.continue_after_save);
assert!(!app.busy);
assert!(app.file_prompt.is_none());
assert_eq!(app.page, Page::Behavior);
state_event(app, states::Event::Cancel);
assert!(!app.behavior.has_pending());
assert!(!app.dirty());
send(app, Message::Page(Page::Text));
assert_eq!(app.page, Page::Text);
send(app, Message::Select(fixture.target));
assert_eq!(app.selected, fixture.target);
}
#[test]
fn save_prepares_behavior_before_opening_destination_and_cancel_keeps_one_undo() {
let mut fixture = fixture();
let before = fixture.app.document.snapshot();
start_transition(&mut fixture, true);
let app = &mut fixture.app;
send(app, Message::Save(true));
assert!(!app.behavior.has_pending());
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
assert_eq!(app.document.revision(), 1);
assert!(
app.document.dirty(),
"Choosing a destination is not a successful disk save"
);
assert!(app.file_prompt.is_some() || app.busy);
if app.file_prompt.is_some() {
send(app, Message::CancelFile);
} else {
send(app, Message::SavePath(None));
}
assert!(!app.busy);
assert!(app.document.dirty());
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
assert!(!app.dirty());
}
#[test]
fn pending_behavior_blocks_navigation_property_edits_and_model_history() {
let mut fixture = fixture();
let before = fixture.app.document.snapshot();
start_transition(&mut fixture, true);
let app = &mut fixture.app;
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
for message in [
Message::Page(Page::Diagram),
Message::Page(Page::Catalog),
Message::Page(Page::Text),
Message::Page(Page::Guide),
Message::Layer(Layer::Oa),
Message::Select(fixture.target),
Message::Explore(Some(fixture.target)),
Message::BrowseGroup(crate::explorer::Group::Work),
Message::Field("/name".into(), "Overwrite properties".into()),
Message::Command(Command::Rename(fixture.source, "Overwrite model".into())),
Message::ConfirmDelete,
Message::Undo,
Message::Redo,
Message::ShortcutUndo(false),
Message::SourceForSelection,
Message::SourceNavigation(crate::source_navigation::Event::Open(false)),
] {
send(app, message);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, fixture.source);
assert_eq!(app.layer, Layer::Sa);
assert_eq!(app.behavior.pending_owner(), Some(fixture.machine));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
properties
);
assert!(!app.source_dirty());
}
}
#[test]
fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
let mut fixture = fixture();
let app = &mut fixture.app;
let before = app.document.snapshot();
send(
app,
Message::Field("/name".into(), "Unapplied state name".into()),
);
assert!(app.draft_dirty());
for event in [
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
behavior::Event::Topology(behavior::topology::Event::Fit),
] {
send(app, Message::Behavior(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
assert_eq!(app.source_workspace.bundle(), before.bundle);
assert!(!app.document.can_undo());
}
}
#[test]
fn pending_behavior_blocks_source_creation_and_source_history_replay() {
for redo in [false, true] {
let mut fixture = fixture();
let app = &mut fixture.app;
let baseline = app.source_workspace.bundle();
let edited = format!(
"{}\n// Source draft that must stay in history.\n",
app.source_workspace.text()
);
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
send(
app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(edited),
))),
);
send(
app,
if redo {
Message::SourceUndo
} else {
Message::RevertSource
},
);
assert!(!app.source_dirty());
start_transition(&mut fixture, true);
let app = &mut fixture.app;
send(
app,
if redo {
Message::SourceRedo
} else {
Message::SourceUndo
},
);
assert_eq!(
app.source_workspace.bundle(),
baseline,
"Source history must not create a competing draft"
);
send(app, Message::SourceNewPath("queued.arcadia".into()));
send(app, Message::SourceCreateFile);
assert_eq!(
app.source_workspace.bundle(),
baseline,
"A queued create-file action must not create a competing draft"
);
assert!(!app.source_dirty());
assert_eq!(app.behavior.pending_owner(), Some(fixture.machine));
state_event(app, states::Event::Submit);
assert!(!app.behavior.has_pending());
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Adapter from captured state-canvas events to the shared document transaction.
use super::*;
use crate::behavior::{
self,
state_edit::{self, Context, Event, Tool},
};
impl App {
/// The behavior view resolved independently of the current page. Capture
/// this before selection/layer changes, while it still identifies the view
/// whose camera and tool are retained during navigation.
pub(super) fn state_view_identity(&self) -> (Option<Id>, Option<Id>) {
let owner = behavior::selected_owner(self);
let diagram = owner.and_then(|owner| behavior::state_view::diagram(self, owner));
(owner, diagram)
}
pub(super) fn reset_state_view_after_navigation(
&mut self,
previous: (Option<Id>, Option<Id>),
) -> bool {
let current = self.state_view_identity();
if current == previous {
return false;
}
self.behavior.diagram = behavior::state_view::State {
diagram: current.1,
..Default::default()
};
true
}
pub fn state_context(&self) -> Option<Context> {
if self.page != Page::Behavior {
return None;
}
let diagram = self.state_view_identity().1?;
Some(Context {
document: self.canvas_document,
diagram,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn fit_state_diagram(&mut self) {
let Some(context) = self.state_context() else {
return;
};
let diagram = &self.project().diagrams[&context.diagram];
let view = projection::project_diagram(self.project(), diagram);
let bounds = routing::route_diagram(self.project(), diagram, &view).bounds;
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
));
let width = (viewport.width - 48.).max(1.);
let height = (viewport.height - 80.).max(1.);
let zoom = (width / bounds.size.width.max(1.))
.min(height / bounds.size.height.max(1.))
.clamp(0.15, 1.25);
self.behavior.diagram.zoom = zoom;
self.behavior.diagram.pan = iced::Vector::new(
24. + (width - bounds.size.width * zoom) / 2. - bounds.position.x * zoom,
56. + (height - bounds.size.height * zoom) / 2. - bounds.position.y * zoom,
);
}
pub(super) fn edit_state_diagram(&mut self, event: Event) -> Task<Message> {
if self.page != Page::Behavior {
return Task::none();
}
if let Some(captured) = event.context() {
let Some(current) = self.state_context() else {
return Task::none();
};
if state_edit::validate_context(self.project(), captured, current).is_err() {
return Task::none();
}
}
match event {
Event::Viewport(_, size) => {
if size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.
{
let resized = self.behavior.diagram.viewport != Some(size);
self.behavior.diagram.viewport = Some(size);
if resized && self.behavior.diagram.fit {
self.fit_state_diagram();
}
}
}
Event::Fit(_) => {
self.behavior.diagram.fit = true;
self.fit_state_diagram();
}
Event::Zoom(_, zoom) if zoom.is_finite() => {
let state = &mut self.behavior.diagram;
let new = zoom.clamp(0.15, 3.);
let center = state.viewport.map_or(iced::Vector::new(300., 220.), |s| {
iced::Vector::new(s.width / 2., s.height / 2.)
});
state.pan = center - (center - state.pan) * (new / state.zoom);
state.zoom = new;
state.fit = false;
}
Event::Pan(_, delta) if delta.x.is_finite() && delta.y.is_finite() => {
self.behavior.diagram.pan += delta;
self.behavior.diagram.fit = false;
}
Event::Select(_, id) => {
if !self.behavior.has_pending() {
self.select(id);
self.selected_node = None;
}
}
Event::ContextMenu {
context,
object,
placement,
anchor,
} => {
let mut request = crate::model_menu::Request::object(object, anchor);
request.canvas = Some(context);
request.placement = placement;
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
}
Event::EditTransition(_, id) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
return self.handle(Message::Behavior(behavior::Event::States(
behavior::states::Event::Edit(id),
)));
}
Event::Choose(tool) => {
if !self.apply() {
return Task::none();
}
self.behavior.diagram.tool = tool;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::CreateView(machine) => {
if behavior::selected_owner(self) != Some(machine) || !self.apply() {
return Task::none();
}
match state_edit::create_view(self.project(), machine) {
Ok(plan) => {
if self.execute(plan.command) {
self.behavior.active_owner = Some(machine);
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
self.behavior.diagram.tool = Tool::Select;
self.behavior.diagram.fit = true;
self.selected = plan.selected;
self.selected_node = None;
self.refresh_draft();
self.fit_state_diagram();
self.status = "State diagram created · existing model elements are placed in this view".into();
}
}
Err(error) => self.status = error.to_string(),
}
}
Event::Zoom(..) | Event::Pan(..) => {}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the state diagram.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if self.execute(plan.command) {
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
self.selected = plan.selected;
self.selected_node = None;
self.refresh_draft();
if !matches!(mutation, Event::Move { .. }) {
self.behavior.diagram.tool = Tool::Select;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.status =
"State diagram updated · Undo restores the complete operation"
.into();
}
}
Err(error) => self.status = error.to_string(),
}
}
}
Task::none()
}
}
@@ -0,0 +1,979 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
states,
};
struct Fixture {
app: App,
machine: Id,
region: Id,
a: Id,
b: Id,
leaf: Id,
members: BTreeSet<Id>,
}
fn send(app: &mut App, message: Message) {
// Exercise the real App boundary without polling asynchronous I/O tasks.
let _ = app.update(message);
}
fn event(app: &mut App, event: Event) {
send(app, Message::StateDiagram(event));
}
fn form(app: &mut App, event: states::Event) {
send(app, Message::Behavior(behavior::Event::States(event)));
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let mut add = |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap();
let owner = add(Kind::System, "System", None);
let machine = add(Kind::StateMachine, "Operating modes", Some(owner));
let region = add(Kind::Region, "Main", Some(machine));
let initial = add(Kind::Initial, "Start", Some(region));
let a = add(Kind::State, "Operating", Some(region));
let b = add(Kind::State, "Standby", Some(region));
let nested = add(Kind::Region, "Work", Some(a));
let leaf = add(Kind::State, "Working", Some(nested));
let other_machine = add(Kind::StateMachine, "Other machine", None);
let other_region = add(Kind::Region, "Other region", Some(other_machine));
add(Kind::State, "Foreign state", Some(other_region));
let mut app = App::with_project(project);
send(&mut app, Message::Select(a));
send(&mut app, Message::Page(Page::Behavior));
assert_eq!(behavior::selected_owner(&app), Some(machine));
Fixture {
app,
machine,
region,
a,
b,
leaf,
members: BTreeSet::from([machine, region, initial, a, b, nested, leaf]),
}
}
fn create_view(f: &mut Fixture) -> Context {
event(&mut f.app, Event::CreateView(f.machine));
f.app
.state_context()
.expect("CreateView must activate the new persisted view")
}
fn node(app: &App, diagram: Id, element: Id) -> Id {
app.project().diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == element)
.unwrap()
.id
}
fn assert_source_model_agree(app: &App) {
assert_eq!(
source_project::compile_bundle(app.document.bundle(), LIMITS)
.unwrap()
.decoded
.project,
*app.project()
);
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
*app.document.bundle()
);
assert!(!app.source_dirty());
}
struct NavigationFixture {
app: App,
machine_a: Id,
diagram_a: Id,
state_a: Id,
transition_a: Id,
root_event: Id,
machine_b: Id,
diagram_b: Id,
state_b: Id,
}
fn navigation_fixture(layer_b: Layer) -> NavigationFixture {
let mut project = Project::empty();
let mut machine = |layer, name| {
let machine = project
.add_element(Kind::StateMachine, layer, name, None)
.unwrap();
let region = project
.add_element(Kind::Region, layer, "Main", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, layer, "Ready", Some(region))
.unwrap();
(machine, state)
};
let (machine_a, state_a) = machine(Layer::Sa, "Machine A");
let (machine_b, state_b) = machine(layer_b, "Machine B");
let root_event = project
.add_element(Kind::Event, Layer::Sa, "External event", None)
.unwrap();
let transition_a = project.relate(RelationKind::StateTransition, "Repeat", state_a, state_a);
let plan_a = state_edit::create_view(&project, machine_a).unwrap();
project = prepare_command(&project, &plan_a.command).unwrap().project;
let plan_b = state_edit::create_view(&project, machine_b).unwrap();
project = prepare_command(&project, &plan_b.command).unwrap().project;
let mut app = App::with_project(project);
send(&mut app, Message::Select(state_a));
send(&mut app, Message::Page(Page::Behavior));
send(
&mut app,
Message::BehaviorDiagram(Choice {
id: plan_a.diagram,
label: "A view".into(),
}),
);
let context = app.state_context().unwrap();
event(
&mut app,
Event::Viewport(context, iced::Size::new(820., 540.)),
);
event(&mut app, Event::Zoom(context, 0.65));
event(
&mut app,
Event::Pan(context, iced::Vector::new(-1_400., -800.)),
);
event(
&mut app,
Event::Choose(Tool::Reconnect {
relation: transition_a,
endpoint: Endpoint::Target,
}),
);
assert_eq!(app.state_context().unwrap().diagram, plan_a.diagram);
assert!(!app.behavior.diagram.fit);
NavigationFixture {
app,
machine_a,
diagram_a: plan_a.diagram,
state_a,
transition_a,
root_event,
machine_b,
diagram_b: plan_b.diagram,
state_b,
}
}
fn assert_switched_state_view(f: &mut NavigationFixture) {
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_b));
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_b);
let defaults = behavior::state_view::State::default();
let state = &f.app.behavior.diagram;
assert_eq!(state.diagram, Some(f.diagram_b));
assert_eq!(
state.tool,
Tool::Select,
"Reconnect from A must not be armed in B"
);
assert_eq!(state.pan, defaults.pan);
assert_eq!(state.zoom, defaults.zoom);
assert!(state.fit);
assert!(
state.viewport.is_none(),
"B needs its own first viewport measurement"
);
assert!(state.placement.is_none());
let context = f.app.state_context().unwrap();
event(
&mut f.app,
Event::Viewport(context, iced::Size::new(820., 540.)),
);
assert!(
f.app.behavior.diagram.pan != defaults.pan || f.app.behavior.diagram.zoom != defaults.zoom,
"The unchanged widget size still fits a newly selected view"
);
}
fn catalog_selects_another_machine(layer_b: Layer) {
let mut f = navigation_fixture(layer_b);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Page(Page::Catalog));
send(&mut f.app, Message::Select(f.state_b));
assert_eq!(f.app.layer, layer_b);
send(&mut f.app, Message::Page(Page::Behavior));
assert_switched_state_view(&mut f);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_source_model_agree(&f.app);
}
#[test]
fn state_navigation_catalog_selection_of_another_machine_resets_camera_and_tool() {
catalog_selects_another_machine(Layer::Sa);
}
#[test]
fn state_navigation_catalog_selection_across_layers_resets_camera_and_tool() {
catalog_selects_another_machine(Layer::Oa);
}
#[test]
fn state_navigation_layer_change_resets_the_new_machine_at_the_same_viewport_size() {
let mut f = navigation_fixture(Layer::Oa);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Layer(Layer::Oa));
assert_switched_state_view(&mut f);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_source_model_agree(&f.app);
}
#[test]
fn state_navigation_return_to_the_same_view_preserves_camera_tool_and_fit_mode() {
for fit in [false, true] {
let mut f = navigation_fixture(Layer::Sa);
if fit {
let context = f.app.state_context().unwrap();
event(&mut f.app, Event::Fit(context));
}
let state = &f.app.behavior.diagram;
let retained = (
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport,
);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Page(Page::Catalog));
send(&mut f.app, Message::Select(f.state_a));
send(&mut f.app, Message::Page(Page::Behavior));
let context = f.app.state_context().unwrap();
event(
&mut f.app,
Event::Viewport(context, iced::Size::new(820., 540.)),
);
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
assert_eq!(context.diagram, f.diagram_a);
let state = &f.app.behavior.diagram;
assert_eq!(
(
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport
),
retained
);
assert_eq!(
state.tool,
Tool::Reconnect {
relation: f.transition_a,
endpoint: Endpoint::Target
}
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
}
#[test]
fn state_navigation_root_event_and_same_layer_keep_the_current_camera_and_tool() {
let mut f = navigation_fixture(Layer::Sa);
let state = &f.app.behavior.diagram;
let retained = (
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport,
);
let before = f.app.document.snapshot();
send(&mut f.app, Message::Select(f.root_event));
assert_eq!(f.app.selected, f.root_event);
send(&mut f.app, Message::Page(Page::Catalog));
send(&mut f.app, Message::Page(Page::Behavior));
send(&mut f.app, Message::Layer(Layer::Sa));
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_a);
let state = &f.app.behavior.diagram;
assert_eq!(
(
state.diagram,
state.pan,
state.zoom,
state.tool,
state.fit,
state.viewport
),
retained
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
#[test]
fn create_view_places_only_its_machine_and_restores_exact_source_in_one_undo() {
let mut f = fixture();
let before = f.app.document.snapshot();
let context = create_view(&mut f);
let app = &mut f.app;
let diagram = &app.project().diagrams[&context.diagram];
assert_eq!(
diagram.projection,
ProjectionKind::StateMachine { machine: f.machine }
);
assert_eq!(
diagram
.nodes
.values()
.map(|n| n.element)
.collect::<BTreeSet<_>>(),
f.members
);
assert_eq!(app.project().elements, before.decoded.project.elements);
assert_eq!(app.project().relations, before.decoded.project.relations);
assert_eq!(app.selected, f.machine);
assert!(app.selected_node.is_none());
assert_eq!(app.document.revision(), 1);
assert_source_model_agree(app);
let accepted = app.document.snapshot();
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.dirty());
assert_source_model_agree(app);
send(app, Message::Redo);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_source_model_agree(app);
}
#[test]
fn direct_create_connect_select_and_move_keep_semantic_identity_and_atomic_history() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before_create = app.document.snapshot();
event(
app,
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 900., y: 700. },
},
);
let created = app.selected;
assert_eq!(app.project().elements[&created].parent, Some(f.region));
assert_eq!(app.project().elements[&created].kind(), Kind::State);
assert_eq!(
app.project().elements.len(),
before_create.decoded.project.elements.len() + 1
);
let created_node = node(app, context.diagram, created);
assert_ne!(created_node, created);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&created_node].position,
Position { x: 900., y: 700. }
);
assert_source_model_agree(app);
let before_connect = app.document.snapshot();
let context = app.state_context().unwrap();
event(
app,
Event::Connect {
context,
source: f.a,
target: created,
},
);
let relation = app.selected;
assert!(matches!(app.project().relations[&relation].body,
RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. }
if source == f.a && target == created));
assert!(app.selected_node.is_none());
assert_eq!(behavior::selected_owner(app), Some(f.machine));
assert_source_model_agree(app);
let context = app.state_context().unwrap();
let before_select = app.document.snapshot();
event(app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert_eq!(
app.state_context(),
Some(context),
"The press selection must not invalidate its following drag release"
);
assert!(Arc::ptr_eq(&before_select, &app.document.snapshot()));
let placement = node(app, context.diagram, f.a);
let leaf = node(app, context.diagram, f.leaf);
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
let leaf_original = app.project().diagrams[&context.diagram].nodes[&leaf].position;
event(
app,
Event::Move {
context,
node: placement,
position: Position {
x: original.x + 31.,
y: original.y + 43.,
},
},
);
assert_eq!(app.selected, f.a);
assert!(app.selected_node.is_none());
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&leaf].position,
Position {
x: leaf_original.x + 31.,
y: leaf_original.y + 43.
}
);
assert_eq!(
app.document.revision(),
4,
"View, vertex, edge, and drag each commit once"
);
assert_source_model_agree(app);
for expected in [before_select, before_connect, before_create] {
send(app, Message::Undo);
assert_eq!(app.project(), &expected.decoded.project);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_source_model_agree(app);
}
assert!(app.project().diagrams.contains_key(&context.diagram));
assert!(!app.project().contains(created));
assert!(!app.project().contains(relation));
}
#[test]
fn all_context_dimensions_reject_stale_selection_camera_and_model_messages() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before = app.document.snapshot();
let selected = app.selected;
let pan = app.behavior.diagram.pan;
let zoom = app.behavior.diagram.zoom;
let viewport = app.behavior.diagram.viewport;
let placement = node(app, context.diagram, f.a);
for changed in 0..4 {
let mut captured = context;
match changed {
0 => captured.document = Id::new_v4(),
1 => captured.diagram = Id::new_v4(),
2 => captured.revision += 1,
_ => captured.epoch += 1,
}
for stale in [
Event::Select(captured, f.b),
Event::Viewport(captured, iced::Size::new(999., 777.)),
Event::Pan(captured, iced::Vector::new(55., 66.)),
Event::Zoom(captured, 2.7),
Event::Move {
context: captured,
node: placement,
position: Position { x: 88., y: 99. },
},
Event::Connect {
context: captured,
source: f.a,
target: f.b,
},
Event::CreateAt {
context: captured,
owner: f.region,
kind: Kind::State,
position: Position { x: 800., y: 800. },
},
] {
event(app, stale);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, selected);
assert_eq!(app.behavior.diagram.pan, pan);
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.viewport, viewport);
assert_eq!(app.state_context(), Some(context));
assert!(!app.behavior.has_pending());
}
}
assert_source_model_agree(app);
}
#[test]
fn real_view_tool_and_history_changes_invalidate_old_release_events() {
let mut f = fixture();
let first = create_view(&mut f);
let second = create_view(&mut f);
let app = &mut f.app;
assert_ne!(first.diagram, second.diagram);
let first_choice = Choice {
id: first.diagram,
label: app.project().named(first.diagram).into(),
};
send(app, Message::BehaviorDiagram(first_choice));
let restored_view = app.state_context().unwrap();
assert_eq!(restored_view.diagram, first.diagram);
let before = app.document.snapshot();
for captured in [first, second] {
event(
app,
Event::Connect {
context: captured,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
event(app, Event::Choose(Tool::Transition));
let armed = app.state_context().unwrap();
assert_ne!(restored_view.epoch, armed.epoch);
event(
app,
Event::Connect {
context: restored_view,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
event(
app,
Event::Connect {
context: armed,
source: f.a,
target: f.b,
},
);
assert_eq!(
app.project().relations.len(),
before.decoded.project.relations.len() + 1
);
let committed = app.state_context().unwrap();
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
let undone = app.document.snapshot();
event(
app,
Event::Connect {
context: armed,
source: f.a,
target: f.b,
},
);
event(
app,
Event::Connect {
context: committed,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
send(app, Message::Page(Page::Diagram));
assert!(app.state_context().is_none());
let diagram_count = app.project().diagrams.len();
event(app, Event::CreateView(f.machine));
assert_eq!(app.project().diagrams.len(), diagram_count);
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
}
#[test]
fn rejected_creation_keeps_source_and_selection_then_valid_owner_can_retry() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before = app.document.snapshot();
let selected = app.selected;
event(
app,
Event::CreateAt {
context,
owner: f.a,
kind: Kind::State,
position: Position { x: 600., y: 700. },
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, selected);
assert_eq!(app.state_context(), Some(context));
event(
app,
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 600., y: 700. },
},
);
assert_eq!(app.project().elements[&app.selected].parent, Some(f.region));
assert_eq!(app.document.revision(), context.revision + 1);
assert_source_model_agree(app);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn pending_drafts_block_every_direct_mutation_and_passive_camera_preserves_inputs() {
for pending in ["properties", "source", "behavior"] {
let mut f = fixture();
let context = create_view(&mut f);
event(
&mut f.app,
Event::Connect {
context,
source: f.a,
target: f.b,
},
);
let relation = f.app.selected;
// A valid Place request must have a genuinely absent placement.
let missing = f
.app
.project()
.make_element(Kind::State, Layer::Sa, "Not yet displayed", Some(f.region))
.unwrap();
let missing_id = missing.id;
send(
&mut f.app,
Message::Command(Command::CreateElement(missing)),
);
send(&mut f.app, Message::Select(f.a));
let app = &mut f.app;
let context = app.state_context().unwrap();
let placement = node(app, context.diagram, f.a);
let at = app.project().diagrams[&context.diagram].nodes[&placement].position;
let mutations = [
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 700., y: 800. },
},
Event::Place {
context,
element: missing_id,
position: Position { x: 800., y: 900. },
},
Event::Move {
context,
node: placement,
position: Position {
x: at.x + 50.,
y: at.y + 70.,
},
},
Event::Connect {
context,
source: f.b,
target: f.a,
},
Event::Reconnect {
context,
relation,
endpoint: Endpoint::Target,
vertex: f.leaf,
},
];
for mutation in &mutations {
let plan = state_edit::plan(app.project(), context, mutation).unwrap();
assert_ne!(
&prepare_command(app.project(), &plan.command)
.unwrap()
.project,
app.project(),
"Each blocked probe must otherwise be a valid, substantive model change"
);
}
match pending {
"properties" => send(app, Message::Field("/name".into(), "Unapplied name".into())),
"source" => {
let draft = format!(
"{}\n// Unapplied source note.\n",
app.source_workspace.text()
);
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
send(
app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(draft),
))),
);
}
_ => {
form(app, states::Event::Edit(relation));
form(app, states::Event::Name("Retained form name".into()));
form(app, states::Event::Guard("temperature nominal".into()));
form(app, states::Event::TriggerNotes("Retained trigger".into()));
}
}
assert_eq!(app.draft_dirty(), pending == "properties");
assert_eq!(app.source_dirty(), pending == "source");
assert_eq!(app.behavior.has_pending(), pending == "behavior");
let before = app.document.snapshot();
let source = app.source_workspace.bundle();
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
let pending_candidate = app
.behavior
.prepare_pending(app.project())
.unwrap()
.map(|command| prepare_command(app.project(), &command).unwrap().project);
let selected = app.selected;
for mutation in mutations {
event(app, mutation);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
assert_eq!(app.selected, selected);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
properties
);
}
for passive in [
Event::Viewport(context, iced::Size::new(960., 640.)),
Event::Pan(context, iced::Vector::new(12., 18.)),
Event::Zoom(context, 0.7),
Event::Fit(context),
] {
event(app, passive);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
properties
);
}
assert_eq!(
app.behavior
.prepare_pending(app.project())
.unwrap()
.map(|command| prepare_command(app.project(), &command).unwrap().project),
pending_candidate
);
match pending {
"properties" => send(app, Message::RevertProperties),
"source" => send(app, Message::RevertSource),
_ => form(app, states::Event::Cancel),
}
assert!(!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending());
let context = app.state_context().unwrap();
event(
app,
Event::Move {
context,
node: placement,
position: Position {
x: at.x + 50.,
y: at.y + 70.,
},
},
);
assert_eq!(app.selected, f.a);
assert_ne!(app.document.bundle(), &before.bundle);
assert_source_model_agree(app);
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
}
}
#[test]
fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move() {
let f = fixture();
let mut project = f.app.project().clone();
// One eligible machine makes the fallback deterministic. Start from a loaded
// project with no selected behavior element and no explicitly chosen owner.
project
.elements
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
let plan = state_edit::create_view(&project, f.machine).unwrap();
let project = prepare_command(&project, &plan.command).unwrap().project;
let mut app = App::with_project(project);
assert_eq!(app.selected, app.project().id);
assert!(app.behavior.active_owner.is_none());
send(&mut app, Message::Page(Page::Behavior));
let context = app.state_context().unwrap();
assert_eq!(context.diagram, plan.diagram);
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
let before = app.document.snapshot();
let placement = node(&app, context.diagram, f.a);
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
event(&mut app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert_eq!(
app.state_context(),
Some(context),
"Pinning the already-visible fallback owner must not advance the gesture epoch"
);
assert_eq!(app.behavior.diagram.placement, Some(placement));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let destination = Position {
x: original.x + 37.,
y: original.y + 29.,
};
event(
&mut app,
Event::Move {
context,
node: placement,
position: destination,
},
);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&placement].position,
destination
);
assert_eq!(app.selected, f.a);
assert_eq!(
app.document.revision(),
1,
"The first release must commit without another press"
);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn same_size_viewport_after_revision_or_tool_epoch_does_not_refit_the_camera() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let size = iced::Size::new(960., 640.);
event(app, Event::Viewport(context, size));
assert!(app.behavior.diagram.fit);
let camera = (app.behavior.diagram.pan, app.behavior.diagram.zoom);
let placement = node(app, context.diagram, f.a);
// Move far enough to change projected bounds. Refitting now would visibly
// jump the camera even though the canvas has not changed size.
event(
app,
Event::Move {
context,
node: placement,
position: Position { x: 4000., y: 3000. },
},
);
let changed = app.state_context().unwrap();
assert_ne!(changed.revision, context.revision);
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(app.behavior.diagram.fit);
let after_move = app.document.snapshot();
event(app, Event::Viewport(changed, size));
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera,
"Context initialization is not a canvas resize"
);
event(app, Event::Choose(Tool::Transition));
let changed_tool = app.state_context().unwrap();
assert_ne!(changed_tool.epoch, changed.epoch);
event(app, Event::Viewport(changed_tool, size));
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
// A real resize still uses automatic fitting while fit mode is active.
event(
app,
Event::Viewport(changed_tool, iced::Size::new(1200., 720.)),
);
assert_ne!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
assert_source_model_agree(app);
}
#[test]
fn shared_semantic_states_keep_architecture_and_behavior_placements_independent() {
let f = fixture();
let mut project = f.app.project().clone();
let architecture = project.ensure_diagram(Layer::Sa);
let arch_a = project.place(architecture, f.a, Position { x: 110., y: 90. });
let arch_b = project.place(architecture, f.b, Position { x: 780., y: 240. });
project.place(architecture, f.leaf, Position { x: 190., y: 310. });
let state_view = state_edit::create_view(&project, f.machine).unwrap();
let project = prepare_command(&project, &state_view.command)
.unwrap()
.project;
let architecture_before = project.diagrams[&architecture].clone();
let mut app = App::with_project(project);
let architecture_choice = Choice {
id: architecture,
label: app.project().named(architecture).into(),
};
send(&mut app, Message::Diagram(architecture_choice));
send(&mut app, Message::Select(f.b));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected_node, Some(arch_b));
send(&mut app, Message::Page(Page::Behavior));
let context = app.state_context().unwrap();
assert_eq!(context.diagram, state_view.diagram);
let state_a = node(&app, context.diagram, f.a);
assert_ne!(state_a, arch_a);
event(&mut app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert!(
app.selected_node.is_none(),
"Behavior must not borrow the architecture placement"
);
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert_eq!(app.active_diagram, Some(architecture));
let before_move = app.document.snapshot();
let original = app.project().diagrams[&context.diagram].nodes[&state_a].position;
let destination = Position {
x: original.x + 41.,
y: original.y + 63.,
};
event(
&mut app,
Event::Move {
context,
node: state_a,
position: destination,
},
);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&state_a].position,
destination
);
assert_eq!(app.project().diagrams[&architecture], architecture_before);
assert_eq!(app.selected, f.a);
assert!(app.selected_node.is_none());
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert_source_model_agree(&app);
let moved = app.document.snapshot();
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected, f.a);
assert_eq!(
app.selected_node,
Some(arch_a),
"Returning to architecture resolves that view's placement of the same state"
);
assert!(Arc::ptr_eq(&moved, &app.document.snapshot()));
send(&mut app, Message::Page(Page::Behavior));
assert!(app.selected_node.is_none());
assert_eq!(app.behavior.diagram.placement, Some(state_a));
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before_move.decoded.project);
assert_eq!(app.document.bundle(), &before_move.bundle);
assert_eq!(app.project().diagrams[&architecture], architecture_before);
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert!(!app.document.can_undo());
}
+303 -57
View File
@@ -14,10 +14,13 @@ use iced::{
};
use oa_core::*;
use std::collections::BTreeMap;
mod scenario;
mod state_scene;
mod states;
mod topology;
pub(crate) mod scenario;
pub(crate) mod state_canvas;
pub(crate) mod state_edit;
pub(crate) mod state_menu;
pub(crate) mod state_view;
pub(crate) mod states;
pub(crate) mod topology;
#[derive(Debug, Clone)]
pub enum Event {
Topology(topology::Event),
@@ -26,6 +29,14 @@ pub enum Event {
Submit,
}
impl Event {
pub fn starts_form(&self) -> bool {
matches!(
self,
Self::Topology(topology::Event::Start(..))
| Self::Scenario(scenario::Event::Start(..))
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
)
}
pub fn is_submit(&self) -> bool {
matches!(
self,
@@ -38,17 +49,76 @@ impl Event {
}
#[derive(Default)]
pub struct State {
pub active_owner: Option<Id>,
pub diagram: state_view::State,
topology: topology::State,
scenario: scenario::State,
states: states::State,
}
const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another.";
impl State {
pub fn has_pending(&self) -> bool {
self.pending_owner().is_some()
}
pub fn pending_owner(&self) -> Option<Id> {
self.topology
.pending_owner()
.or_else(|| self.scenario.pending_owner())
.or_else(|| self.states.pending_owner())
}
/// Prepare the active form without discarding inputs. The caller must execute
/// this exact command and acknowledge only a successful document transaction.
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
if self.topology.pending_owner().is_some() {
self.topology.prepare_pending(p).map(Some)
} else if self.scenario.pending_owner().is_some() {
self.scenario.prepare_pending(p).map(Some)
} else if self.states.pending_owner().is_some() {
self.states.prepare_pending(p).map(Some)
} else {
Ok(None)
}
}
/// Clear the active form after its prepared command commits successfully.
/// Rejected commands must leave the form available for repair and retry.
pub fn acknowledge_commit(&mut self) {
self.topology.acknowledge_commit();
self.scenario.acknowledge_commit();
self.states.acknowledge_commit();
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if self.has_pending()
&& matches!(
&event,
Event::Topology(topology::Event::Start(..))
| Event::Scenario(scenario::Event::Start(..))
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
)
{
return Err(PENDING_DRAFT.into());
}
let cancels_pending = match &event {
Event::Topology(topology::Event::Cancel) => self.topology.pending_owner().is_some(),
Event::Scenario(scenario::Event::Cancel) => self.scenario.pending_owner().is_some(),
Event::States(states::Event::Cancel) => self.states.pending_owner().is_some(),
_ => false,
};
if cancels_pending {
self.acknowledge_commit();
return Ok(None);
}
match event {
Event::Topology(event) => self.topology.update(p, event),
Event::Scenario(event) => self.scenario.update(p, event),
Event::States(event) => self.states.update(p, event),
Event::Submit => self.topology.update(p, topology::Event::Submit),
Event::Submit => self
.prepare_pending(p)?
.map(Some)
.ok_or_else(|| "Choose a behavior modeling tool.".into()),
}
}
}
@@ -66,7 +136,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
label: format!("{} · {}", e.kind(), e.name),
})
.collect::<Vec<_>>();
let owner = selected_behavior(p, app.selected);
let owner = selected_owner(app);
let selected = owner
.and_then(|id| choices.iter().find(|c| c.id == id))
.or(choices.first())
@@ -76,8 +146,11 @@ pub fn view(app: &App) -> Element<'_, Message> {
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
};
let e = &p.elements[&selected.id];
if e.kind() == Kind::StateMachine {
out = column![text("States & modes").size(20)].spacing(8);
}
out = out.push(
pick_list(choices, Some(selected), |c| Message::Select(c.id))
pick_list(choices, Some(selected), Message::BehaviorOwner)
.width(Fill)
.text_size(13)
.padding(10),
@@ -88,7 +161,20 @@ pub fn view(app: &App) -> Element<'_, Message> {
if e.kind() == Kind::Scenario {
return scenario::view(app, e.id, out);
}
states::view(app, e.id, out)
state_view::view(app, e.id, out)
}
pub fn selected_owner(app: &App) -> Option<Id> {
let valid = |id: &Id| {
app.project().elements.get(id).is_some_and(|e| {
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
})
};
app.behavior
.pending_owner()
.or(app.behavior.active_owner.filter(valid))
.or_else(|| selected_behavior(app.project(), app.selected).filter(valid))
.or_else(|| app.project().elements.keys().find(|id| valid(id)).copied())
}
/// Mirror the core's legacy-versus-explicit process interpretation. Existing
/// exchange-only lists still describe ordered paths until explicitly migrated.
@@ -115,7 +201,7 @@ fn uses_explicit_topology(p: &Project, owner: Id) -> bool {
)
})
}
fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
pub fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
let mut current = Some(
p.relations
.get(&selected)
@@ -506,12 +592,9 @@ impl canvas::Program<Message> for Scene {
) && let Some(p) = cursor.position_in(bounds)
{
let p = Point::new(p.x / self.scale, p.y / self.scale);
if let Some(b) = self
.boxes
.iter()
.rev()
.find(|b| b.rect.contains(p) && (!self.is_container(b) || p.y < b.rect.y + 38.))
{
if let Some(b) = self.boxes.iter().rev().find(|b| {
b.rect.contains(p) && (b.marker != Some(Kind::Occurrence) || p.y < b.rect.y + 38.)
}) {
return Some(Action::publish(Message::Select(b.id)).and_capture());
}
if let Some(l) = self.lines.iter().find(|l| {
@@ -556,10 +639,7 @@ impl canvas::Program<Message> for Scene {
&Path::rectangle(b.rect.position(), b.rect.size()),
Stroke::default().with_color(color),
);
} else if !b
.marker
.is_some_and(|k| k.is_state() && !k.is_stable_state())
{
} else {
let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into());
f.fill(&path, Color::from_rgb8(243, 248, 248));
f.stroke(
@@ -622,45 +702,9 @@ impl canvas::Program<Message> for Scene {
}
}
for b in &self.boxes {
let pseudo = b
.marker
.is_some_and(|k| k.is_state() && !k.is_stable_state());
if pseudo {
state_scene::draw_symbol(
&mut f,
b,
if b.id == self.selected {
style::TEAL
} else {
style::INK
},
);
}
let prefix = match b.marker {
Some(Kind::Initial) => "INITIAL · ",
Some(Kind::Final) => "FINAL · ",
Some(Kind::Mode) => "MODE · ",
Some(Kind::State) => "STATE · ",
Some(Kind::Choice) => "CHOICE · ",
Some(Kind::Junction) => "JUNCTION · ",
Some(Kind::Fork) => "FORK · ",
Some(Kind::Join) => "JOIN · ",
Some(Kind::ShallowHistory) => "H · ",
Some(Kind::DeepHistory) => "H* · ",
Some(Kind::EntryPoint) => "ENTRY · ",
Some(Kind::ExitPoint) => "EXIT · ",
Some(Kind::Terminate) => "TERMINATE · ",
_ => "",
};
f.fill_text(Text {
content: format!(
"{prefix}{}",
short(&b.title, ((b.rect.width - 25.) / 7.) as usize)
),
position: Point::new(
b.rect.x + if pseudo { 38. } else { 12. },
b.rect.y + if pseudo { 18. } else { 12. },
),
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(),
color: if b.id == self.selected {
style::TEAL
@@ -698,6 +742,208 @@ impl canvas::Program<Message> for Scene {
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Document, DocumentError};
use oa_core::source_project::{self, ProjectLimits};
#[test]
fn one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit() {
let p = Project::demo();
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
let process = owner(Kind::Process);
let scenario = owner(Kind::Scenario);
let machine = owner(Kind::StateMachine);
let transition = p
.relations
.values()
.find(|r| r.kind() == RelationKind::StateTransition)
.unwrap()
.id;
let starts = [
Event::Topology(topology::Event::Start(
process,
topology::Tool::Control,
None,
)),
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Fragment)),
Event::States(states::Event::Start(machine, states::Tool::Region, machine)),
];
let names = [
Event::Topology(topology::Event::Name("Keep this draft".into())),
Event::Scenario(scenario::Event::Name("Keep this draft".into())),
Event::States(states::Event::Name("Keep this draft".into())),
];
let cancels = [
Event::Topology(topology::Event::Cancel),
Event::Scenario(scenario::Event::Cancel),
Event::States(states::Event::Cancel),
];
for (index, expected_owner) in [process, scenario, machine].into_iter().enumerate() {
let mut state = State::default();
assert!(!state.has_pending());
assert!(state.prepare_pending(&p).unwrap().is_none());
state.update(&p, starts[index].clone()).unwrap();
state.update(&p, names[index].clone()).unwrap();
if index == 1 {
state
.update(
&p,
Event::Scenario(scenario::Event::Expression("approved".into())),
)
.unwrap();
}
assert_eq!(state.pending_owner(), Some(expected_owner));
for replacement in starts
.iter()
.cloned()
.chain([Event::States(states::Event::Edit(transition))])
{
assert_eq!(state.update(&p, replacement).unwrap_err(), PENDING_DRAFT);
assert_eq!(state.pending_owner(), Some(expected_owner));
}
state.update(&p, cancels[(index + 1) % 3].clone()).unwrap();
assert!(state.has_pending(), "Another editor's cancel is stale");
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.has_pending(), "Preparation must retain the form");
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
assert!(expected.elements.values().any(|e| {
!p.elements.contains_key(&e.id)
&& e.name == "Keep this draft"
&& e.parent == Some(expected_owner)
}));
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
document.execute(command).unwrap();
assert_eq!(document.project(), &expected);
state.acknowledge_commit();
assert!(!state.has_pending());
assert!(state.prepare_pending(document.project()).unwrap().is_none());
assert_eq!(document.revision(), 1);
assert!(document.undo());
assert_eq!(document.project(), &p);
assert!(!document.can_undo());
state.update(&p, starts[index].clone()).unwrap();
state.update(&p, cancels[index].clone()).unwrap();
assert!(!state.has_pending(), "Explicit cancel discards this form");
state.update(&p, starts[(index + 1) % 3].clone()).unwrap();
}
}
#[test]
fn failed_document_commit_retains_typed_transition_and_time_event_for_repair() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Sa, "Region", Some(machine))
.unwrap();
let source = p
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
.unwrap();
let target = p
.add_element(Kind::State, Layer::Sa, "Waiting", Some(region))
.unwrap();
let work = p
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
.unwrap();
let choice = |id| Choice {
id,
label: p.named(id).into(),
};
let mut state = State::default();
for event in [
states::Event::Start(machine, states::Tool::Transition, source),
states::Event::Name("Transition".repeat(1024)),
states::Event::Trigger(choice(work)),
states::Event::Effect(choice(work)),
states::Event::TriggerNotes("On a monitor result".into()),
states::Event::Guard("connection unavailable".into()),
states::Event::EffectNotes("Record the timeout".into()),
states::Event::Timed(true),
states::Event::TimeKind("At".into()),
states::Event::Expression("12:30 UTC".into()),
] {
state.update(&p, Event::States(event)).unwrap();
}
assert!(
state.prepare_pending(&p).is_err(),
"A target is still missing"
);
assert_eq!(state.pending_owner(), Some(machine));
state
.update(&p, Event::States(states::Event::Target(choice(target))))
.unwrap();
let command = state.prepare_pending(&p).unwrap().unwrap();
let bundle = source_project::format_bundle(&p);
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 2000;
let mut document = Document::from_bundle(bundle.clone(), limits).unwrap();
assert!(matches!(
document.execute(command),
Err(DocumentError::Source(_))
));
assert_eq!(document.project(), &p);
assert_eq!(document.bundle(), &bundle);
assert_eq!(document.revision(), 0);
assert!(!document.can_undo());
assert!(state.has_pending());
state
.update(
&p,
Event::States(states::Event::Name("Wait for recovery".into())),
)
.unwrap();
let command = state.prepare_pending(&p).unwrap().unwrap();
let Command::Batch(commands) = &command else {
panic!("Expected one atomic transition batch")
};
assert_eq!(commands.len(), 2);
let Command::CreateElement(event) = &commands[0] else {
panic!("Expected a typed time event")
};
assert!(
matches!(event.shared(), Some(SharedBody::Event { body: EventBody::Time { kind: TimeEventKind::At, expression } }) if expression == "12:30 UTC")
);
let Command::CreateRelation(relation) = &commands[1] else {
panic!("Expected the transition")
};
let RelationBody::StateTransition {
triggers,
effects,
trigger,
guard,
effect,
..
} = &relation.body
else {
panic!()
};
assert_eq!(triggers, &[work, event.id]);
assert_eq!(effects, &[work]);
assert_eq!(trigger, "On a monitor result");
assert_eq!(guard, "connection unavailable");
assert_eq!(effect, "Record the timeout");
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
document.execute(command).unwrap();
assert_eq!(
document.project(),
&expected,
"Commit the exact prepared identities"
);
state.acknowledge_commit();
assert!(!state.has_pending());
assert_eq!(document.revision(), 1);
assert!(document.undo());
assert_eq!(document.bundle(), &bundle);
assert_eq!(document.project(), &p);
assert!(!document.can_undo());
}
#[test]
fn legacy_process_and_chain_keep_their_ordered_exchange_scene() {
let p = Project::demo();
+23 -8
View File
@@ -103,7 +103,28 @@ struct Draft {
values: BTreeMap<Id, String>,
}
impl State {
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.owner)
}
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
let command = self
.draft
.as_ref()
.ok_or("Choose a scenario tool first.")?
.command(p)?;
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(super) fn acknowledge_commit(&mut self) {
self.draft = None;
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Start(owner, tool) => {
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
@@ -127,14 +148,8 @@ impl State {
values: BTreeMap::new(),
});
}
Event::Cancel => self.draft = None,
Event::Submit => {
let d = self.draft.as_ref().ok_or("Choose a scenario tool first.")?;
let command = d.command(p)?;
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
self.draft = None;
return Ok(Some(command));
}
Event::Cancel => self.acknowledge_commit(),
Event::Submit => return self.prepare_pending(p).map(Some),
other => {
if let Some(d) = &mut self.draft {
match other {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
//! State actions capture their own diagram; architecture selection stays separate.
use super::{
state_edit::{self, Endpoint, Event, Tool},
states,
};
use crate::{
app::{App, Message},
context_menu::{Entry, Menu},
model_menu::{self, Opened},
};
use oa_core::*;
pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
let project = app.project();
let machine = super::selected_owner(app)
.filter(|id| project.elements[id].kind() == Kind::StateMachine)?;
let context = app.state_context();
let request = &opened.request;
let id = request.object;
let item = |caption: &str, message: Message, enabled: bool| {
if clean && enabled {
Entry::action(
caption,
Message::Menu(model_menu::Event::Invoke(opened.token, Box::new(message))),
)
} else {
Entry::disabled(caption)
}
};
let diagram_event = Message::StateDiagram;
let form = |tool| {
Message::Behavior(super::Event::States(states::Event::Start(
machine, tool, id,
)))
};
let mut entries = vec![Entry::title(crate::views::short(project.named(id), 38))];
if !clean {
entries.push(Entry::title("Apply or cancel pending edits first"));
}
if let Some(context) = context.filter(|context| context.diagram == id) {
entries.extend([
item("View properties", Message::EditProperties(id), true),
item("Fit diagram", diagram_event(Event::Fit(context)), true),
item(
"Create another state view",
diagram_event(Event::CreateView(machine)),
true,
),
Entry::separator(),
item(
"Create region…",
diagram_event(Event::Choose(Tool::Region)),
true,
),
item(
"New transition with details…",
form(states::Tool::Transition),
true,
),
]);
} else {
entries.extend([
item("Properties", Message::EditProperties(id), true),
item("Open source", Message::SourceForSelection, true),
item(
"Connections & references",
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
true,
),
]);
if let Some(element) = project.elements.get(&id)
&& projection::state_machine_contains(project, machine, id)
{
entries.push(Entry::separator());
let creates = if element.kind() == Kind::Region {
Some((
"Create state…",
Kind::State,
states::Tool::Vertex,
Tool::Vertex(Kind::State),
))
} else if element.kind() == Kind::StateMachine || element.kind().is_stable_state() {
Some((
"Create region…",
Kind::Region,
states::Tool::Region,
Tool::Region,
))
} else {
None
};
if let Some((caption, kind, typed, tool)) = creates {
entries.push(if let Some(context) = context {
item(
caption,
diagram_event(Event::Choose(tool)),
state_edit::eligible_owner(project, context.diagram, id, kind),
)
} else {
item(caption, form(typed), true)
});
}
entries.push(item(
"New transition with details…",
form(states::Tool::Transition),
true,
));
if let Some(context) = context
&& let Some(node) = request
.placement
.and_then(|placement| project.diagrams[&context.diagram].nodes.get(&placement))
.filter(|node| node.element == id)
{
entries.push(Entry::separator());
entries.push(item(
"Placement properties",
Message::EditProperties(node.id),
true,
));
if element.kind() == Kind::StateMachine
|| element.kind() == Kind::Region
|| element.kind().is_stable_state()
{
let mut changed = node.clone();
changed.collapsed = !changed.collapsed;
entries.push(item(
if node.collapsed {
"Unfold contents"
} else {
"Fold contents"
},
Message::Command(Command::EditNode {
diagram: context.diagram,
original: node.id,
node: changed,
}),
true,
));
}
entries.push(item(
"Hide from this diagram",
Message::Command(Command::Delete(node.id)),
true,
));
}
}
if let Some(relation) = project.relations.get(&id)
&& relation.kind() == RelationKind::StateTransition
&& projection::state_machine_contains(project, machine, relation.source())
&& projection::state_machine_contains(project, machine, relation.target())
{
entries.push(Entry::separator());
let edit = context.map_or_else(
|| Message::Behavior(super::Event::States(states::Event::Edit(id))),
|context| diagram_event(Event::EditTransition(context, id)),
);
entries.push(item("Edit transition", edit, true));
if let Some(context) = context {
let visible = projection::project_view(project, context.diagram)
.edges
.iter()
.any(|edge| edge.id == id);
for (caption, endpoint) in [
("Reconnect source", Endpoint::Source),
("Reconnect target", Endpoint::Target),
] {
entries.push(item(
caption,
diagram_event(Event::Choose(Tool::Reconnect {
relation: id,
endpoint,
})),
visible,
));
}
if !visible {
entries.push(Entry::title(
"Use transition details for hidden or internal endpoints",
));
}
}
}
if id != project.id {
entries.push(Entry::separator());
entries.push(item("Delete from model…", Message::Delete, true));
}
}
Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(model_menu::Event::Close),
})
}
@@ -1,287 +0,0 @@
//! State geometry follows semantic containment; presentation never reparents records.
use super::*;
fn internal_transitions(p: &Project, id: Id) -> impl Iterator<Item = &Relation> {
p.relations.values().filter(move |r| {
matches!(&r.body, RelationBody::StateTransition {
source, transition_kind: TransitionKind::Internal, ..
} if *source == id)
})
}
fn content_width(p: &Project, owner: Id) -> f32 {
p.children(owner)
.map(|e| match e.kind() {
Kind::Region => content_width(p, e.id) + 120.,
k if k.is_stable_state() => content_width(p, e.id) + 32.,
_ => 300.,
})
.fold(300., f32::max)
}
impl Scene {
pub(super) fn states(p: &Project, owner: Id, selected: Id) -> Self {
let mut scene = Self {
scale: 1.,
boxes: vec![],
lines: vec![],
size: Size::new(400., 180.),
selected,
};
if !p.children(owner).any(|e| e.kind() == Kind::Region) {
return scene;
}
let width = content_width(p, owner) + 40.;
scene.boxes.push(BoxItem {
id: owner,
rect: Rectangle::new(Point::new(24., 20.), Size::new(width, 100.)),
title: p.named(owner).into(),
detail: String::new(),
marker: Some(Kind::StateMachine),
});
let mut y = 64.;
for region in p.children(owner).filter(|e| e.kind() == Kind::Region) {
y = scene.state_region(p, region.id, 44., y, width - 40.) + 14.;
}
scene.boxes[0].rect.height = y - 20.;
// Entry/exit symbols are region-owned but attach to the containing boundary.
let rectangles: BTreeMap<_, _> = scene.boxes.iter().map(|b| (b.id, b.rect)).collect();
for b in &mut scene.boxes {
if !matches!(b.marker, Some(Kind::EntryPoint | Kind::ExitPoint)) {
continue;
}
let boundary = p
.elements
.get(&b.id)
.and_then(|e| e.parent)
.and_then(|id| p.elements.get(&id))
.and_then(|e| e.parent);
if let Some(rect) = boundary.and_then(|id| rectangles.get(&id)) {
// The symbol is centered 16 px into its 32 px hit target.
b.rect.x = if b.marker == Some(Kind::EntryPoint) {
rect.x - 16.
} else {
rect.x + rect.width - 16.
};
}
}
let vertices: BTreeMap<_, _> = scene
.boxes
.iter()
.filter(|b| b.marker.is_some_and(|k| k.is_state()))
.map(|b| (b.id, (b.rect, b.marker.unwrap())))
.collect();
for (index, r) in p
.relations
.values()
.filter(|r| {
r.kind() == RelationKind::StateTransition
&& vertices.contains_key(&r.source())
&& vertices.contains_key(&r.target())
})
.enumerate()
{
if matches!(
&r.body,
RelationBody::StateTransition {
transition_kind: TransitionKind::Internal,
..
}
) {
continue;
}
let (a, ak) = vertices[&r.source()];
let (b, bk) = vertices[&r.target()];
let ay = a.y + if ak.is_stable_state() { 25. } else { 28. };
let by = if r.source() == r.target() {
ay + 28.
} else {
b.y + if bk.is_stable_state() { 25. } else { 28. }
};
let ax = if ak.is_stable_state() {
a.x + a.width
} else {
a.x + 32.
};
let bx = if bk.is_stable_state() {
b.x + b.width
} else {
b.x + 32.
};
let lane = (ax.max(bx) + 28. + (index % 4) as f32 * 20.).max(360.);
let activation = states::transition_summary(p, r);
scene.lines.push(Line {
id: r.id,
points: vec![
Point::new(ax, ay),
Point::new(lane, ay),
Point::new(lane, by),
Point::new(bx, by),
],
label: format!("{}. {}", index + 1, short(&activation, 24)),
label_at: Point::new(lane + 5., (ay + by) / 2.),
dashed: matches!(
&r.body,
RelationBody::StateTransition {
transition_kind: TransitionKind::Local,
..
}
),
});
scene.size.width = scene.size.width.max(lane + 220.);
}
scene.size.width = scene.size.width.max(width + 180.);
scene.size.height = (y + 24.).max(180.);
scene
}
fn state_region(&mut self, p: &Project, id: Id, x: f32, top: f32, width: f32) -> f32 {
let index = self.boxes.len();
self.boxes.push(BoxItem {
id,
rect: Rectangle::new(Point::new(x, top), Size::new(width, 90.)),
title: format!("REGION · {}", p.named(id)),
detail: String::new(),
marker: Some(Kind::Region),
});
let mut y = top + 42.;
for state in p.children(id).filter(|e| e.kind().is_state()) {
let stable = state.kind().is_stable_state();
let inner_width = if stable { width - 100. } else { 300. };
let index = self.boxes.len();
let detail = match state.shared() {
Some(SharedBody::State {
entry,
exit,
do_activity,
..
}) => [("entry", entry), ("do", do_activity), ("exit", exit)]
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(label, value)| format!("{label} / {value}"))
.collect::<Vec<_>>()
.join(" · "),
_ => String::new(),
};
self.boxes.push(BoxItem {
id: state.id,
rect: Rectangle::new(
Point::new(x + 18., y),
Size::new(inner_width, if stable { 76. } else { 56. }),
),
title: state.name.clone(),
detail,
marker: Some(state.kind()),
});
let start = y;
y += if stable { 76. } else { 56. };
for r in internal_transitions(p, state.id) {
self.boxes.push(BoxItem {
id: r.id,
rect: Rectangle::new(Point::new(x + 28., y), Size::new(inner_width - 20., 55.)),
title: format!("INTERNAL · {}", r.name),
detail: states::transition_summary(p, r),
marker: None,
});
y += 61.;
}
for region in p.children(state.id).filter(|e| e.kind() == Kind::Region) {
y = self.state_region(p, region.id, x + 34., y, inner_width - 32.) + 12.;
}
self.boxes[index].rect.height = y - start;
y += 18.;
}
self.boxes[index].rect.height = (y - top).max(90.);
top + self.boxes[index].rect.height
}
pub(super) fn is_container(&self, item: &BoxItem) -> bool {
matches!(
item.marker,
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
) || (item.marker.is_some_and(|kind| kind.is_stable_state())
&& self.boxes.iter().any(|other| {
other.id != item.id
&& item.rect.contains(other.rect.position())
&& item.rect.contains(Point::new(
other.rect.x + other.rect.width,
other.rect.y + other.rect.height,
))
}))
}
}
/// Pseudostates use actual vector shapes, with a full row as their hit target.
pub(super) fn draw_symbol(f: &mut Frame, b: &BoxItem, color: Color) {
let center = Point::new(b.rect.x + 16., b.rect.y + 28.);
let stroke = Stroke::default().with_color(color).with_width(1.8);
let circle = Path::circle(center, 11.);
match b.marker.unwrap() {
Kind::Initial | Kind::Junction => f.fill(
&Path::circle(
center,
if b.marker == Some(Kind::Initial) {
8.
} else {
5.
},
),
color,
),
Kind::Final => {
f.stroke(&circle, stroke);
f.fill(&Path::circle(center, 7.), color);
}
Kind::Choice => f.stroke(
&Path::new(|p| {
p.move_to(Point::new(center.x, center.y - 12.));
p.line_to(Point::new(center.x + 12., center.y));
p.line_to(Point::new(center.x, center.y + 12.));
p.line_to(Point::new(center.x - 12., center.y));
p.close();
}),
stroke,
),
Kind::Fork | Kind::Join => f.fill_rectangle(
Point::new(center.x - 3., center.y - 18.),
Size::new(6., 36.),
color,
),
Kind::ShallowHistory | Kind::DeepHistory => {
f.stroke(&circle, stroke);
f.fill_text(Text {
content: if b.marker == Some(Kind::DeepHistory) {
"H*"
} else {
"H"
}
.into(),
position: Point::new(center.x - 8., center.y - 9.),
color,
size: 13.into(),
..Default::default()
});
}
Kind::EntryPoint => {
f.fill(&circle, Color::WHITE);
f.stroke(&circle, stroke);
}
Kind::ExitPoint | Kind::Terminate => {
if b.marker == Some(Kind::ExitPoint) {
f.fill(&circle, Color::WHITE);
f.stroke(&circle, stroke);
}
f.stroke(
&Path::new(|p| {
p.move_to(Point::new(center.x - 7., center.y - 7.));
p.line_to(Point::new(center.x + 7., center.y + 7.));
p.move_to(Point::new(center.x - 7., center.y + 7.));
p.line_to(Point::new(center.x + 7., center.y - 7.));
}),
stroke,
);
}
_ => {}
}
}
@@ -0,0 +1,221 @@
//! State workspace controls. Persistent semantics and layout live in the model.
use super::state_edit::{Event, Tool};
use super::*;
use iced::widget::{Column, column, opaque, stack};
pub struct State {
pub diagram: Option<Id>,
pub placement: Option<Id>,
pub tool: Tool,
pub viewport: Option<Size>,
pub zoom: f32,
pub pan: iced::Vector,
pub fit: bool,
}
impl Default for State {
fn default() -> Self {
Self {
diagram: None,
placement: None,
tool: Tool::Select,
viewport: None,
zoom: 0.8,
pan: iced::Vector::new(24., 48.),
fit: true,
}
}
}
pub fn diagram(app: &App, machine: Id) -> Option<Id> {
let valid = |id: &Id| {
app.project()
.diagrams
.get(id)
.is_some_and(|d| d.projection.state_machine() == Some(machine))
};
app.behavior
.diagram
.diagram
.filter(valid)
.or_else(|| app.project().diagrams.keys().find(|id| valid(id)).copied())
}
fn event(e: Event) -> Message {
Message::StateDiagram(e)
}
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
let Some(id) = diagram(app, machine) else {
let base = out.push(container(column![
text("Create a state diagram").size(20),
label("Place the machine’s existing regions and states in an editable view. Creating another view leaves the existing views unchanged."),
button("Create diagram").padding(12).on_press(event(Event::CreateView(machine))),
row![
action("Add region…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Region, app.selected)))),
action("Add state…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Vertex, app.selected)))),
transition_details(machine, app.selected),
].spacing(5),
].spacing(14)).padding(24).style(style::card)).padding(18).into();
return with_form(app, machine, base);
};
let context = app
.state_context()
.expect("a visible state diagram has a context");
let p = app.project();
let state = &app.behavior.diagram;
let views = p
.diagrams
.values()
.filter(|d| d.projection.state_machine() == Some(machine))
.map(|d| Choice {
id: d.id,
label: d.name.clone(),
})
.collect::<Vec<_>>();
let current = views.iter().find(|v| v.id == id).cloned();
out = out.push(
row![
pick_list(views, current, Message::BehaviorDiagram)
.padding(8)
.text_size(12)
.width(Fill),
action("+ View", event(Event::CreateView(machine))),
action("Fit", event(Event::Fit(context))),
action("−", event(Event::Zoom(context, state.zoom / 1.2))),
label(format!("{:.0}%", state.zoom * 100.)),
action("+", event(Event::Zoom(context, state.zoom * 1.2))),
]
.spacing(5)
.align_y(iced::Center),
);
let tool = |name: &'static str, value: Tool| {
button(text(name).size(12))
.padding([8, 10])
.style(if state.tool == value {
button::primary
} else {
style::nav
})
.on_press(event(Event::Choose(value)))
};
let kinds = [
Kind::State,
Kind::Mode,
Kind::Initial,
Kind::Final,
Kind::Choice,
Kind::Junction,
Kind::Fork,
Kind::Join,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
];
let selected_kind = if let Tool::Vertex(kind) = state.tool {
Some(kind)
} else {
None
};
out = out.push(
row![
tool("Select / move", Tool::Select),
pick_list(kinds.to_vec(), selected_kind, |k| event(Event::Choose(
Tool::Vertex(k)
)))
.placeholder("+ State / symbol")
.padding(8)
.text_size(12),
tool("+ Region", Tool::Region),
tool("Transition", Tool::Transition),
]
.spacing(4),
);
let missing = state_edit::unplaced(p, id)
.into_iter()
.map(|element| Choice {
id: element,
label: format!("{} · {}", p.named(element), p.elements[&element].kind()),
})
.collect::<Vec<_>>();
if !missing.is_empty() {
let position = Position {
x: (48. - state.pan.x) / state.zoom,
y: (90. - state.pan.y) / state.zoom,
};
out = out.push(
pick_list(missing, None::<Choice>, move |c| {
event(Event::Place {
context,
element: c.id,
position,
})
})
.placeholder("Show an element not yet in this view…")
.padding(8)
.text_size(12)
.width(Fill),
);
}
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
project: p,
diagram: id,
context,
selected: app.selected,
tool: state.tool,
zoom: state.zoom,
pan: state.pan,
editable: !app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending(),
})
.width(Fill)
.height(Fill)
.into();
out = out.push(canvas.map(event));
let base: Element<'_, Message> = container(out.spacing(8))
.padding(14)
.width(Fill)
.height(Fill)
.into();
with_form(app, machine, base)
}
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
action(
"New transition with details…",
Message::Behavior(super::Event::States(states::Event::Start(
machine,
states::Tool::Transition,
selected,
))),
)
.into()
}
fn with_form<'a>(app: &'a App, machine: Id, base: Element<'a, Message>) -> Element<'a, Message> {
if let Some(form) = states::form(app, machine) {
stack![
base,
opaque(
container(
container(
scrollable(form).height((app.window_size.height - 250.).clamp(280., 610.))
)
.padding(18)
.width(600)
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
} else {
base
}
}
+232 -397
View File
@@ -1,6 +1,6 @@
//! State authoring through the same validated commands as source and REST.
use super::*;
use iced::widget::{Column, checkbox, column, text_input};
use iced::widget::{checkbox, column, text_input};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
@@ -41,15 +41,10 @@ pub enum Event {
Timed(bool),
TimeKind(String),
Expression(String),
Viewport(Size),
Zoom(f32),
Fit,
}
#[derive(Default)]
pub struct State {
draft: Option<Draft>,
viewport: Option<Size>,
zoom: Option<f32>,
}
struct Draft {
machine: Id,
@@ -189,7 +184,28 @@ impl Draft {
}
}
impl State {
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.machine)
}
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
let command = self
.draft
.as_ref()
.ok_or("Choose a state modeling tool.")?
.command(p)?;
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(super) fn acknowledge_commit(&mut self) {
self.draft = None;
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if self.draft.is_some() && matches!(&event, Event::Start(..) | Event::Edit(..)) {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Start(machine, tool, selected) => {
let mut d = Draft::new(machine, tool);
@@ -249,20 +265,8 @@ impl State {
d.effect = effect.clone();
self.draft = Some(d);
}
Event::Cancel => self.draft = None,
Event::Submit => {
let command = self
.draft
.as_ref()
.ok_or("Choose a state modeling tool.")?
.command(p)?;
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
self.draft = None;
return Ok(Some(command));
}
Event::Viewport(size) => self.viewport = Some(size),
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
Event::Fit => self.zoom = None,
Event::Cancel => self.acknowledge_commit(),
Event::Submit => return self.prepare_pending(p).map(Some),
event => {
if let Some(d) = &mut self.draft {
match event {
@@ -347,359 +351,218 @@ fn picker<'a>(
.width(Fill)
.into()
}
pub(super) fn transition_summary(p: &Project, r: &Relation) -> String {
let RelationBody::StateTransition {
transition_kind,
triggers,
trigger,
guard,
effects,
effect,
..
} = &r.body
else {
return r.name.clone();
};
let activation = if !triggers.is_empty() {
triggers
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" OR ")
} else if !trigger.trim().is_empty() {
format!("Unresolved trigger: {trigger}")
} else if p
.elements
.get(&r.source())
.is_some_and(|e| e.kind().is_stable_state())
{
"Completion".into()
} else {
"Transient routing".into()
};
let mut result = format!("{transition_kind:?} · {activation}");
if !triggers.is_empty() && !trigger.is_empty() {
result.push_str(&format!(" · {trigger}"));
}
if !guard.is_empty() {
result.push_str(&format!(" [{guard}]"));
}
if !effects.is_empty() {
result.push_str(&format!(
" / {}",
effects
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
));
}
if !effect.is_empty() {
result.push_str(&format!(" / {effect}"));
}
result
}
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
pub(super) fn form(app: &App, machine: Id) -> Option<Element<'_, Message>> {
let p = app.project();
let state = &app.behavior.states;
out = out.push(
row![
action(
"+ Vertex",
event(Event::Start(machine, Tool::Vertex, app.selected))
),
action(
"+ Region",
event(Event::Start(machine, Tool::Region, app.selected))
),
action(
"+ Transition",
event(Event::Start(machine, Tool::Transition, app.selected))
),
action("Properties", Message::EditProperties(machine)),
]
.spacing(5),
);
if let Some(d) = state.draft.as_ref().filter(|d| d.machine == machine) {
let mut form = column![
text(if d.original.is_some() {
"Edit transition"
} else {
match d.tool {
Tool::Vertex => "Add state or pseudostate",
Tool::Region => "Add region",
Tool::Transition => "Connect states",
}
})
.size(17),
text_input("Engineering name (optional)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12),
]
.spacing(9);
if d.tool != Tool::Transition {
if d.tool == Tool::Vertex {
form = form.push(
pick_list(
Kind::ALL
.into_iter()
.filter(|k| k.is_state())
.collect::<Vec<_>>(),
Some(d.vertex),
|k| event(Event::Vertex(k)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
}
let parents = p
.elements
.values()
.filter(|e| {
in_machine(p, e.id, machine)
&& if d.tool == Tool::Vertex {
e.kind() == Kind::Region
} else {
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
}
})
.map(|e| choice(p, e.id))
.collect();
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
let d = app
.behavior
.states
.draft
.as_ref()
.filter(|d| d.machine == machine)?;
let mut form = column![
text(if d.original.is_some() {
"Edit transition"
} else {
let mut context = p.clone();
if let Some(id) = d.original {
context.relations.remove(&id);
match d.tool {
Tool::Vertex => "Add state or pseudostate",
Tool::Region => "Add region",
Tool::Transition => "Connect states",
}
let vertices: Vec<_> = p
.elements
.values()
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
.map(|e| choice(p, e.id))
.collect();
let targets = vertices
.iter()
.filter(|c| {
d.source.is_some_and(|source| {
if d.kind == TransitionKind::Internal {
return c.id == source && p.elements[&source].kind().is_stable_state();
}
if d.kind == TransitionKind::Local
&& p.elements[&source].kind().is_stable_state()
{
return c.id != source && in_machine(p, c.id, source);
}
validation::can_connect(
&context,
RelationKind::StateTransition,
source,
c.id,
)
})
})
.cloned()
.collect();
form = form
.push(picker(vertices, d.source, "From state…", Event::Source))
.push(picker(targets, d.target, "To state…", Event::Target))
.push(
pick_list(
[
TransitionChoice(TransitionKind::External),
TransitionChoice(TransitionKind::Local),
TransitionChoice(TransitionKind::Internal),
],
Some(TransitionChoice(d.kind)),
|k| event(Event::Kind(k)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
let layer = p.elements[&machine].layer();
let triggers = p
.elements
.values()
.filter(|e| {
e.layer() == layer
&& (e.kind() == Kind::Event || e.kind().is_work())
&& !d.triggers.contains(&e.id)
})
.map(|e| choice(p, e.id))
.collect();
form = form.push(label("Trigger events / work (alternatives)"));
for id in &d.triggers {
form = form.push(
row![
text(p.named(*id)).size(12).width(Fill),
action("Remove", event(Event::RemoveTrigger(*id)))
]
.spacing(5),
);
}
form = form
.push(picker(
triggers,
None,
"Add existing trigger…",
Event::Trigger,
))
.push(
checkbox(d.timed)
.label("Add a time event with this transition")
.on_toggle(|b| event(Event::Timed(b))),
);
if d.timed {
form = form.push(
row![
pick_list(
["After".to_string(), "At".to_string()],
Some(format!("{:?}", d.time_kind)),
|s| event(Event::TimeKind(s))
)
.padding(9)
.text_size(12),
text_input("Time expression, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
]
.spacing(6),
);
}
form = form
.push(
text_input("Trigger notes (optional)", &d.trigger)
.on_input(|s| event(Event::TriggerNotes(s)))
.padding(9)
.size(12),
})
.size(17),
text_input("Engineering name (optional)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12),
]
.spacing(9);
if d.tool != Tool::Transition {
if d.tool == Tool::Vertex {
form = form.push(
pick_list(
Kind::ALL
.into_iter()
.filter(|k| k.is_state())
.collect::<Vec<_>>(),
Some(d.vertex),
|k| event(Event::Vertex(k)),
)
.push(
text_input("Guard condition (optional)", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
);
let effects = p
.elements
.values()
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
.map(|e| choice(p, e.id))
.collect();
for id in &d.effects {
form = form.push(
row![
text(p.named(*id)).size(12).width(Fill),
action("Remove", event(Event::RemoveEffect(*id)))
]
.spacing(5),
);
}
form = form
.push(picker(effects, None, "Add effect work…", Event::Effect))
.push(
text_input("Effect notes (optional)", &d.effect)
.on_input(|s| event(Event::EffectNotes(s)))
.padding(9)
.size(12),
);
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
form=form.push(label("No event trigger: completion from a state, transient routing from a pseudostate."));
}
}
let candidate = d.command(p).and_then(|command| {
execute_batch(p, &[command])
.map(|_| ())
.map_err(|e| e.to_string())
});
if let Err(error) = &candidate {
form = form.push(text(error.clone()).size(12).color(style::MUTED));
}
form = form.push(
row![
button("Apply")
.padding(9)
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7),
);
out = out.push(container(form).padding(14).style(style::card));
}
let mut scene = Scene::states(p, machine, app.selected);
if scene.boxes.is_empty() {
out=out.push(container(column![text("Start with a region").size(18),label("Add a region, then an Initial vertex and a State or Mode. Connect them to describe the default entry.")].spacing(12)).padding(24).style(style::card));
} else {
let width = state
.viewport
.map_or((app.window_size.width - 600.).max(200.), |s| s.width);
let scale = state.zoom.unwrap_or_else(|| {
((width - 24.).max(1.) / scene.size.width)
.min(440. / scene.size.height)
.clamp(0.05, 1.)
});
scene.scale = scale;
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
out = out
.push(
row![
action("Fit", event(Event::Fit)),
action("−", event(Event::Zoom(scale / 1.25))),
label(format!("{:.0}%", scale * 100.)),
action("+", event(Event::Zoom(scale * 1.25))),
action("100%", event(Event::Zoom(1.)))
]
.spacing(8)
.align_y(iced::Center),
)
.push(
iced::widget::sensor(
container(
scrollable(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(size.height.clamp(180., 460.)),
)
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(|s| event(Event::Viewport(s)))
.on_resize(|s| event(Event::Viewport(s))),
.padding(9)
.text_size(12)
.width(Fill),
);
}
for r in p
.relations
.values()
.filter(|r| r.kind() == RelationKind::StateTransition && in_machine(p, r.source(), machine))
{
out = out.push(
container(
column![
action(
&format!("{} → {}", p.named(r.source()), p.named(r.target())),
event(Event::Edit(r.id))
)
.width(Fill),
text(transition_summary(p, r)).size(12),
action("Properties", Message::EditProperties(r.id))
}
let parents = p
.elements
.values()
.filter(|e| {
in_machine(p, e.id, machine)
&& if d.tool == Tool::Vertex {
e.kind() == Kind::Region
} else {
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
}
})
.map(|e| choice(p, e.id))
.collect();
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
} else {
let mut context = p.clone();
if let Some(id) = d.original {
context.relations.remove(&id);
}
let vertices: Vec<_> = p
.elements
.values()
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
.map(|e| choice(p, e.id))
.collect();
let targets = vertices
.iter()
.filter(|c| {
d.source.is_some_and(|source| {
if d.kind == TransitionKind::Internal {
return c.id == source && p.elements[&source].kind().is_stable_state();
}
if d.kind == TransitionKind::Local
&& p.elements[&source].kind().is_stable_state()
{
return c.id != source && in_machine(p, c.id, source);
}
validation::can_connect(&context, RelationKind::StateTransition, source, c.id)
})
})
.cloned()
.collect();
form = form
.push(picker(vertices, d.source, "From state…", Event::Source))
.push(picker(targets, d.target, "To state…", Event::Target))
.push(
pick_list(
[
TransitionChoice(TransitionKind::External),
TransitionChoice(TransitionKind::Local),
TransitionChoice(TransitionKind::Internal),
],
Some(TransitionChoice(d.kind)),
|k| event(Event::Kind(k)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
let layer = p.elements[&machine].layer();
let triggers = p
.elements
.values()
.filter(|e| {
e.layer() == layer
&& (e.kind() == Kind::Event || e.kind().is_work())
&& !d.triggers.contains(&e.id)
})
.map(|e| choice(p, e.id))
.collect();
form = form.push(label("Trigger events / work (alternatives)"));
for id in &d.triggers {
form = form.push(
row![
text(p.named(*id)).size(12).width(Fill),
action("Remove", event(Event::RemoveTrigger(*id)))
]
.spacing(5),
);
}
form = form
.push(picker(
triggers,
None,
"Add existing trigger…",
Event::Trigger,
))
.push(
checkbox(d.timed)
.label("Add a time event with this transition")
.on_toggle(|b| event(Event::Timed(b))),
);
if d.timed {
form = form.push(
row![
pick_list(
["After".to_string(), "At".to_string()],
Some(format!("{:?}", d.time_kind)),
|s| event(Event::TimeKind(s))
)
.padding(9)
.text_size(12),
text_input("Time expression, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
]
.spacing(6),
);
}
form = form
.push(
text_input("Trigger notes (optional)", &d.trigger)
.on_input(|s| event(Event::TriggerNotes(s)))
.padding(9)
.size(12),
)
.padding(12)
.style(style::card),
);
.push(
text_input("Guard condition (optional)", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
);
let effects = p
.elements
.values()
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
.map(|e| choice(p, e.id))
.collect();
for id in &d.effects {
form = form.push(
row![
text(p.named(*id)).size(12).width(Fill),
action("Remove", event(Event::RemoveEffect(*id)))
]
.spacing(5),
);
}
form = form
.push(picker(effects, None, "Add effect work…", Event::Effect))
.push(
text_input("Effect notes (optional)", &d.effect)
.on_input(|s| event(Event::EffectNotes(s)))
.padding(9)
.size(12),
);
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
form = form.push(label(
"No event trigger: completion from a state, transient routing from a pseudostate.",
));
}
}
scrollable(out.padding(24)).into()
let candidate = d.command(p).and_then(|command| {
execute_batch(p, &[command])
.map(|_| ())
.map_err(|e| e.to_string())
});
if let Err(error) = &candidate {
form = form.push(text(error.clone()).size(12).color(style::MUTED));
}
form = form.push(
row![
button("Apply")
.padding(9)
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7),
);
Some(container(form).padding(4).into())
}
#[cfg(test)]
@@ -771,7 +634,7 @@ mod tests {
assert_eq!(guard, "link unavailable");
assert!(triggers.iter().any(|id| matches!(result.elements[id].shared(), Some(SharedBody::Event { body: EventBody::Time { kind:TimeEventKind::After, expression }}) if expression == "30 s")));
assert_eq!(&language::parse(&language::format(result)).unwrap(), result);
assert!(transition_summary(result, r).contains(" OR "));
assert!(routing::state_transition_summary(result, r).contains(" OR "));
session.undo();
assert_eq!(session.project(), &p);
}
@@ -810,6 +673,7 @@ mod tests {
}
let mut result =
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
state.acknowledge_commit();
let r = result.relations.values_mut().next().unwrap();
r.description = "Retain configuration".into();
let (id, alias) = (r.id, r.alias.clone());
@@ -828,14 +692,7 @@ mod tests {
result.relations[&id].source(),
result.relations[&id].target()
);
let scene = Scene::states(&result, machine, id);
assert!(
scene
.boxes
.iter()
.any(|b| b.id == id && b.title.starts_with("INTERNAL"))
);
assert!(!scene.lines.iter().any(|l| l.id == id));
assert!(!routing::state_transition_summary(&result, &result.relations[&id]).is_empty());
}
#[test]
fn selected_composite_owns_new_region_and_history_uses_selected_region() {
@@ -846,6 +703,7 @@ mod tests {
.unwrap();
let result =
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
state.acknowledge_commit();
let region = result
.children(ready)
.find(|e| e.kind() == Kind::Region)
@@ -867,29 +725,6 @@ mod tests {
.children(region)
.any(|e| e.kind() == Kind::DeepHistory)
);
let mut scene = Scene::states(&result, machine, ready);
let container = scene.boxes.iter().find(|b| b.id == ready).unwrap().rect;
let child = scene.boxes.iter().find(|b| b.id == region).unwrap().rect;
assert!(container.contains(child.position()));
assert!(container.contains(Point::new(child.x + child.width, child.y + child.height)));
let history = scene
.boxes
.iter()
.find(|b| b.marker == Some(Kind::DeepHistory))
.unwrap();
let id = history.id;
let point = Point::new((history.rect.x + 16.) * 0.5, (history.rect.y + 28.) * 0.5);
scene.scale = 0.5;
let action = canvas::Program::update(
&scene,
&mut (),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, scene.size),
mouse::Cursor::Available(point),
)
.unwrap()
.into_inner()
.0;
assert!(matches!(action, Some(Message::Select(selected)) if selected == id));
assert_eq!(language::parse(&language::format(&result)).unwrap(), result);
}
}
+28 -14
View File
@@ -76,7 +76,28 @@ struct Draft {
control: ControlKind,
}
impl State {
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.owner)
}
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
let command = self
.draft
.as_ref()
.ok_or("Choose a contribution tool first.")?
.command(p)?;
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(super) fn acknowledge_commit(&mut self) {
self.draft = None;
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Viewport(size) => self.viewport = Some(size),
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
@@ -118,19 +139,8 @@ impl State {
control: ControlKind::Choice,
});
}
Event::Cancel => self.draft = None,
Event::Submit => {
let d = self
.draft
.as_ref()
.ok_or("Choose a contribution tool first.")?;
let command = d.command(p)?;
// Validate before clearing the user's inputs. The ordinary command
// handler remains responsible for the single undoable transaction.
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
self.draft = None;
return Ok(Some(command));
}
Event::Cancel => self.acknowledge_commit(),
Event::Submit => return self.prepare_pending(p).map(Some),
other => {
if let Some(d) = &mut self.draft {
match other {
@@ -1772,9 +1782,13 @@ mod tests {
assert!(state.draft.is_some());
state.update(&p, Event::Guard("ready".into())).unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.draft.is_none());
assert!(state.draft.is_some());
assert!(matches!(command, Command::Batch(_)));
assert_eq!(p.elements[&owner].steps().unwrap().len(), 2);
let result = execute_batch(&p, &[command]).unwrap();
state.acknowledge_commit();
assert!(state.draft.is_none());
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
}
#[test]
fn cycle_projection_terminates_and_retains_actual_links() {
+142
View File
@@ -0,0 +1,142 @@
//! A palette operation is transient; only a completed connection enters history.
use crate::{connection_tool::Tool, route_edit::Context};
use oa_core::{Id, Project, projection::ProjectedView};
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub enum Event {
Choose(Option<Tool>),
Begin(Tool, Id),
Endpoint(Context, Id),
Restart(Context),
}
#[derive(Debug, Clone)]
pub struct Session {
pub context: Context,
pub tool: Tool,
pub source: Option<Id>,
pub candidates: BTreeSet<Id>,
}
impl Session {
pub fn new(project: &Project, view: &ProjectedView, context: Context, tool: Tool) -> Self {
let candidates = visible(view)
.filter(|id| tool.can_start(project, *id))
.collect();
Self {
context,
tool,
source: None,
candidates,
}
}
pub fn start(&mut self, project: &Project, view: &ProjectedView, source: Id) {
self.source = Some(source);
let compatible = self.tool.targets(project, source);
self.candidates = visible(view).filter(|id| compatible.contains(id)).collect();
}
}
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
view.nodes
.iter()
.map(|n| n.element)
.chain(view.ports.iter().map(|p| p.element))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{App, Message, Page};
use oa_core::*;
fn fixture() -> (App, Id, Id, Id) {
let mut p = Project::empty();
let a = p.add(Kind::Function, "Send command", 0., 0.);
let b = p.add(Kind::Function, "Receive command", 400., 0.);
let hidden = p
.add_element(Kind::Function, Layer::Sa, "Hidden work", None)
.unwrap();
(App::with_project(p), a, b, hidden)
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Connection(event));
}
#[test]
fn visible_endpoint_drawing_commits_ports_and_exchange_once_with_exact_history() {
let (mut app, a, b, hidden) = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
assert!(
!app.connection
.as_ref()
.unwrap()
.candidates
.contains(&hidden)
);
let context = app.route_context().unwrap();
send(&mut app, Event::Endpoint(context, a));
send(&mut app, Event::Endpoint(context, hidden));
assert_eq!(app.document.revision(), revision);
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
send(&mut app, Event::Endpoint(context, b));
assert!(app.connection.is_none());
assert_eq!(app.document.revision(), revision + 1);
let relation = &app.project().relations[&app.selected];
assert_eq!(relation.kind(), RelationKind::FunctionalExchange);
assert_eq!((relation.source(), relation.target()), (a, b));
assert_eq!(
app.project()
.elements
.values()
.filter(|e| e.kind() == Kind::Port)
.count(),
2
);
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(*app.document.bundle(), before);
let _ = app.update(Message::Redo);
assert_eq!(*app.document.bundle(), after);
}
#[test]
fn cancel_navigation_revision_and_dirty_drafts_cannot_commit_a_stale_connection() {
let (mut app, a, b, _) = fixture();
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
let context = app.route_context().unwrap();
send(&mut app, Event::Endpoint(context, a));
let _ = app.update(Message::Escape);
send(&mut app, Event::Endpoint(context, b));
assert!(app.project().relations.is_empty());
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
let current = app.route_context().unwrap();
send(&mut app, Event::Endpoint(current, a));
send(&mut app, Event::Endpoint(context, b));
assert_eq!(
app.connection.as_ref().unwrap().source,
Some(a),
"A stale click must leave the newer drawing session intact"
);
let revision = app.document.revision();
let _ = app.update(Message::Route(crate::route_edit::Event::AddBend));
assert_eq!(app.document.revision(), revision);
assert!(app.connection.is_some());
let _ = app.update(Message::Page(Page::Catalog));
assert!(app.connection.is_none());
let _ = app.update(Message::Page(Page::Diagram));
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
send(&mut app, Event::Endpoint(context, b));
assert!(app.project().relations.is_empty());
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Field("/name".into(), "Pending rename".into()));
assert!(app.draft_dirty());
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
assert!(app.connection.is_none());
assert_eq!(app.project().named(a), "Send command");
}
}
+609
View File
@@ -0,0 +1,609 @@
//! Semantic endpoints for direct canvas connections. Gesture state and visible
//! hit targets belong to the canvas; ordinary core commands own model changes.
use oa_core::*;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Interaction,
FunctionalExchange,
BoundaryExchange,
}
impl Tool {
pub fn for_layer(layer: Layer) -> &'static [Self] {
match layer {
Layer::Oa => &[Self::Interaction],
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
}
}
pub fn label(self) -> &'static str {
match self {
Self::Interaction => "Interaction",
Self::FunctionalExchange => "Functional exchange",
Self::BoundaryExchange => "Boundary exchange",
}
}
pub fn hint(self) -> &'static str {
match self {
Self::Interaction => "Click a source activity, then a target activity.",
Self::FunctionalExchange => {
"Click an output port or source function, then an input port or target function. Function cards use automatic ports."
}
Self::BoundaryExchange => {
"Click a source component port, then a port on another participant. Flow goes from Out to In; Standard is unoriented."
}
}
}
pub fn can_start(self, project: &Project, source: Id) -> bool {
match self {
Self::Interaction => project
.elements
.get(&source)
.is_some_and(|e| e.layer() == Layer::Oa && e.kind() == Kind::Activity),
Self::FunctionalExchange => function_endpoint(project, source, true).is_some(),
Self::BoundaryExchange => component_endpoint(project, source)
.is_some_and(|contract| component_allows(contract, true)),
}
}
/// Cheap feasibility for highlighting candidates. The caller intersects this
/// with the actual projected nodes and ports; hidden objects are not hits.
pub fn can_connect(self, project: &Project, source: Id, target: Id) -> bool {
if !self.can_start(project, source) {
return false;
}
match self {
Self::Interaction => {
validation::can_connect(project, RelationKind::Interaction, source, target)
}
Self::FunctionalExchange => {
let (Some((source, _)), Some((target, _))) = (
function_endpoint(project, source, true),
function_endpoint(project, target, false),
) else {
return false;
};
validation::can_connect(project, RelationKind::FunctionalExchange, source, target)
}
Self::BoundaryExchange => {
component_endpoint(project, target).is_some()
&& validation::can_connect_boundary(
project,
source,
target,
boundary_oriented(project, source),
)
}
}
}
pub fn targets(self, project: &Project, source: Id) -> BTreeSet<Id> {
if !self.can_start(project, source) {
return BTreeSet::new();
}
project
.elements
.keys()
.copied()
.filter(|target| self.can_connect(project, source, *target))
.collect()
}
/// Return one validated history operation without modifying the input.
/// FE endpoint generation uses the same atomic planner as the form workflow.
/// No carried items are inferred from a port's allowed item declarations.
pub fn command(self, project: &Project, source: Id, target: Id) -> Result<Command, ModelError> {
if !self.can_connect(project, source, target) {
return Err(ModelError(format!(
"Choose compatible endpoints for {}. {}",
self.label(),
self.hint()
)));
}
let id = Id::new_v4();
if self == Self::FunctionalExchange {
let (source, source_port) = function_endpoint(project, source, true)
.expect("Functional source was checked above");
let (target, target_port) = function_endpoint(project, target, false)
.expect("Functional target was checked above");
let name = engineering_name(project, self, source, target);
return plan_functional_exchange(
project,
&FunctionalExchangeRequest {
id,
alias: project.fresh_alias(&name, "exchange", id),
name,
description: String::new(),
source,
target,
source_port,
target_port,
items: Vec::new(),
},
)
.map(Command::Batch);
}
let name = engineering_name(project, self, source, target);
let body = match self {
Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
Self::BoundaryExchange => RelationBody::BoundaryExchange {
source,
target,
oriented: boundary_oriented(project, source),
items: Vec::new(),
},
Self::FunctionalExchange => unreachable!("Functional planner returned above"),
};
let command = Command::CreateRelation(Relation {
id,
alias: project.fresh_alias(&name, "relation", id),
name,
description: String::new(),
body,
});
prepare_command(project, &command)?;
Ok(command)
}
}
/// A clicked port determines its own function owner. It cannot be paired with
/// a different remembered owner or silently substituted by automatic selection.
fn function_endpoint(project: &Project, id: Id, outgoing: bool) -> Option<(Id, PortChoice)> {
let element = project.elements.get(&id)?;
if element.layer() != Layer::Sa {
return None;
}
if element.kind() == Kind::Function {
return Some((id, PortChoice::Auto));
}
match (port_contract(element)?, outgoing) {
(PortContract::FunctionOutput { .. }, true)
| (PortContract::FunctionInput { .. }, false) => {}
_ => return None,
}
let owner = project.elements.get(&element.parent?)?;
(owner.layer() == Layer::Sa && owner.kind() == Kind::Function)
.then_some((owner.id, PortChoice::Reuse(id)))
}
fn component_endpoint(project: &Project, id: Id) -> Option<&PortContract> {
let element = project.elements.get(&id)?;
let owner = project.elements.get(&element.parent?)?;
if element.layer() != Layer::Sa || owner.layer() != Layer::Sa || !owner.kind().is_performer() {
return None;
}
let contract = port_contract(element)?;
match contract {
PortContract::Component {
kind: ComponentPortKind::Flow,
..
}
| PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
} => Some(contract),
_ => None,
}
}
fn boundary_oriented(project: &Project, source: Id) -> bool {
matches!(
component_endpoint(project, source),
Some(PortContract::Component {
kind: ComponentPortKind::Flow,
..
})
)
}
fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> String {
let endpoint = |id| {
if tool == Tool::BoundaryExchange
&& let Some(owner) = project.elements.get(&id).and_then(|e| e.parent)
{
format!("{} / {}", project.named(owner), project.named(id))
} else {
project.named(id).to_owned()
}
};
let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
"—"
} else {
"→"
};
format!(
"{}: {} {arrow} {}",
tool.label(),
endpoint(source),
endpoint(target)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::Document;
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
fn element(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
let id = Id::new_v4();
let port = make_port(p, id, owner, name, contract).unwrap();
p.elements.insert(id, port);
id
}
fn flow(orientation: ComponentPortOrientation) -> PortContract {
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
}
}
fn input() -> PortContract {
PortContract::FunctionInput {
incoming_items: Vec::new(),
}
}
fn output() -> PortContract {
PortContract::FunctionOutput {
outgoing_items: Vec::new(),
}
}
fn created(project: &Project, command: &Command) -> (Project, Relation) {
let prepared = prepare_command(project, command).unwrap();
let relation = prepared.project.relations[&prepared.selected.unwrap()].clone();
(prepared.project, relation)
}
#[test]
fn operational_interaction_targets_only_distinct_oa_activities() {
let mut p = Project::empty();
let source = element(&mut p, Kind::Activity, Layer::Oa, "Send report");
let target = element(&mut p, Kind::Activity, Layer::Oa, "Assess report");
let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
let before = p.clone();
assert_eq!(Tool::for_layer(Layer::Oa), &[Tool::Interaction]);
assert_eq!(
Tool::Interaction.targets(&p, source),
BTreeSet::from([target])
);
for invalid in [source, actor, function, Id::new_v4()] {
assert!(Tool::Interaction.command(&p, source, invalid).is_err());
}
let command = Tool::Interaction.command(&p, source, target).unwrap();
assert!(matches!(command, Command::CreateRelation(_)));
let (_, relation) = created(&p, &command);
assert_eq!(relation.body.endpoints(), (source, target));
assert_eq!(relation.name, "Interaction: Send report → Assess report");
assert_eq!(p, before);
}
#[test]
fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
let mut p = Project::empty();
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
let source = port(
&mut p,
a,
"Reports out",
PortContract::FunctionOutput {
outgoing_items: vec![report],
},
);
let target = port(
&mut p,
b,
"Results in",
PortContract::FunctionInput {
incoming_items: vec![result],
},
);
let other = port(&mut p, a, "Other output", output());
let before = p.clone();
let command = Tool::FunctionalExchange
.command(&p, source, target)
.unwrap();
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 1));
let (accepted, relation) = created(&p, &command);
assert!(matches!(relation.body, RelationBody::FunctionalExchange {
source: s, target: t, source_port: sp, target_port: tp, ref items,
} if (s,t,sp,tp) == (a,b,source,target) && items.is_empty()));
assert_eq!(accepted.elements, p.elements);
assert_eq!(relation.name, "Functional exchange: Detect → Assess");
for (s, t) in [
(target, source),
(source, other),
(source, a),
(source, source),
] {
assert!(!Tool::FunctionalExchange.can_connect(&p, s, t));
assert!(Tool::FunctionalExchange.command(&p, s, t).is_err());
}
assert_eq!(p, before);
}
#[test]
fn function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous() {
let mut p = Project::empty();
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
let out = port(&mut p, a, "Report out", output());
let input = port(&mut p, b, "Report in", input());
let (_, reused) = created(&p, &Tool::FunctionalExchange.command(&p, a, b).unwrap());
assert!(
matches!(reused.body, RelationBody::FunctionalExchange { source_port, target_port, .. }
if (source_port,target_port) == (out,input))
);
let second = port(&mut p, a, "Other report out", output());
let command = Tool::FunctionalExchange.command(&p, a, input).unwrap();
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 2));
let (accepted, generated) = created(&p, &command);
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = generated.body
else {
panic!("Expected functional exchange")
};
assert_eq!(target_port, input);
assert!(![out, second].contains(&source_port));
assert_eq!(accepted.elements[&source_port].parent, Some(a));
assert_eq!(accepted.elements.len(), p.elements.len() + 1);
}
#[test]
fn boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind() {
let mut p = Project::empty();
let a = element(&mut p, Kind::System, Layer::Sa, "Response system");
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Dispatcher");
let mut endpoints = Vec::new();
for owner in [a, b] {
for orientation in [
ComponentPortOrientation::Out,
ComponentPortOrientation::In,
ComponentPortOrientation::InOut,
ComponentPortOrientation::Unset,
] {
endpoints.push(port(
&mut p,
owner,
&format!("Flow {orientation:?}"),
flow(orientation),
));
}
endpoints.push(port(
&mut p,
owner,
"Control",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
));
}
for source in &endpoints {
let oriented = boundary_oriented(&p, *source);
let targets = Tool::BoundaryExchange.targets(&p, *source);
for target in &endpoints {
let expected = validation::can_connect_boundary(&p, *source, *target, oriented);
assert_eq!(targets.contains(target), expected, "{source} → {target}");
let command = Tool::BoundaryExchange.command(&p, *source, *target);
assert_eq!(command.is_ok(), expected);
if let Ok(command) = command {
let (_, relation) = created(&p, &command);
assert!(
matches!(relation.body, RelationBody::BoundaryExchange { oriented: value, .. } if value == oriented)
);
assert!(relation.name.contains(" / "));
assert_eq!(relation.name.contains('→'), oriented);
}
}
}
}
#[test]
fn boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks() {
let mut p = Project::empty();
let a = element(&mut p, Kind::System, Layer::Sa, "System");
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
let standard = PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
};
let source = port(&mut p, a, "Service", standard.clone());
let target = port(&mut p, b, "Required service", standard);
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
for (interface, item) in [(provided, report), (required, result)] {
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
}
p.relate(
RelationKind::InterfaceProvision,
"Provides reporting",
source,
provided,
);
let requirement = p.relate(
RelationKind::InterfaceRequirement,
"Requires results",
target,
required,
);
validate_candidate(&p).unwrap();
let before = p.clone();
assert!(Tool::BoundaryExchange.targets(&p, source).contains(&target));
assert!(validation::can_connect_boundary(&p, source, target, false));
let (accepted, relation) = created(
&p,
&Tool::BoundaryExchange.command(&p, source, target).unwrap(),
);
let warnings: Vec<_> = validation::validate(&accepted)
.into_iter()
.filter(|issue| issue.code == "unsatisfied_interface_requirement")
.collect();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].severity, validation::Severity::Warning);
assert_eq!(warnings[0].element, Some(requirement));
assert_eq!(warnings[0].field.as_deref(), Some("target"));
let mut carrying = relation.clone();
let RelationBody::BoundaryExchange { items, .. } = &mut carrying.body else {
unreachable!()
};
items.push(result);
assert!(prepare_command(&p, &Command::CreateRelation(carrying.clone())).is_err());
assert_eq!(p, before, "Rejected carried items never mutate the input");
p.relate(
RelationKind::InterfaceProvision,
"Provides results",
source,
required,
);
let prepared = prepare_command(&p, &Command::CreateRelation(carrying)).unwrap();
assert!(
validation::validate(&prepared.project)
.iter()
.all(|issue| issue.code != "unsatisfied_interface_requirement")
);
}
#[test]
fn mismatched_port_owners_layers_and_contract_kinds_are_not_candidates() {
let mut p = Project::empty();
let function = element(&mut p, Kind::Function, Layer::Sa, "Function");
let system = element(&mut p, Kind::System, Layer::Sa, "System");
let activity = element(&mut p, Kind::Activity, Layer::Oa, "Activity");
let function_on_system = port(&mut p, system, "Wrong function owner", output());
let component_on_function = port(
&mut p,
function,
"Wrong component owner",
flow(ComponentPortOrientation::Out),
);
let oa_function_port = port(&mut p, activity, "Wrong layer", output());
let malformed_standard = port(
&mut p,
system,
"Wrong Standard orientation",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Out,
},
);
for source in [
function_on_system,
component_on_function,
oa_function_port,
malformed_standard,
] {
for tool in Tool::for_layer(Layer::Sa) {
assert!(!tool.can_start(&p, source));
assert!(tool.targets(&p, source).is_empty());
assert!(tool.command(&p, source, function).is_err());
}
}
}
#[test]
fn direct_connections_share_exact_source_history_and_model_parity() {
let mut p = Project::empty();
let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident");
let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident");
let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report");
let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
let system = element(&mut p, Kind::System, Layer::Sa, "System");
let actor = element(&mut p, Kind::SystemActor, Layer::Sa, "Actor");
let boundary_a = port(&mut p, system, "Send", flow(ComponentPortOrientation::Out));
let boundary_b = port(&mut p, actor, "Receive", flow(ComponentPortOrientation::In));
let mut bundle = format_bundle(&p);
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(0, "// Preserve engineering rationale exactly.\n");
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
for (tool, source, target) in [
(Tool::Interaction, oa_a, oa_b),
(Tool::FunctionalExchange, sa_a, sa_b),
(Tool::BoundaryExchange, boundary_a, boundary_b),
] {
let before = document.bundle().clone();
let original = document.project().clone();
let revision = document.revision();
let command = tool.command(document.project(), source, target).unwrap();
let expected = prepare_command(document.project(), &command).unwrap();
let selected = document.execute(command).unwrap();
assert_eq!(selected, expected.selected);
assert_eq!(document.project(), &expected.project);
assert_eq!(document.revision(), revision + 1);
assert_eq!(
compile_bundle(document.bundle(), ProjectLimits::default())
.unwrap()
.decoded
.project,
expected.project
);
let changed = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.bundle(), &before);
assert_eq!(document.project(), &original);
assert!(document.redo());
assert_eq!(document.bundle(), &changed);
}
}
#[test]
fn boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map() {
let mut p = Project::empty();
let a = element(&mut p, Kind::System, Layer::Sa, "System");
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
let source = port(&mut p, a, "Send", flow(ComponentPortOrientation::Out));
let target = port(&mut p, b, "Receive", flow(ComponentPortOrientation::In));
assert!(Tool::BoundaryExchange.can_connect(&p, source, target));
// An indexed key alone is not proof of a live model identity. Preserve
// the reference check even when inspecting an inconsistent input model.
p.elements.get_mut(&target).unwrap().id = Id::new_v4();
let before = p.clone();
assert!(!validation::can_connect_boundary(&p, source, target, true));
assert!(!Tool::BoundaryExchange.targets(&p, source).contains(&target));
assert!(Tool::BoundaryExchange.command(&p, source, target).is_err());
assert_eq!(p, before);
}
#[test]
fn final_command_validation_rejects_invalid_models_without_mutation() {
let mut p = Project::empty();
let a = element(&mut p, Kind::Activity, Layer::Oa, "Send");
let b = element(&mut p, Kind::Activity, Layer::Oa, "Receive");
p.elements.get_mut(&a).unwrap().alias = p.elements[&b].alias.clone();
let before = p.clone();
assert!(
Tool::Interaction.can_connect(&p, a, b),
"Candidate highlighting checks the proposed link; commit checks the whole model"
);
assert!(Tool::Interaction.command(&p, a, b).is_err());
assert_eq!(p, before);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,283 @@
use crate::{
app::{App, Message},
connection_edit,
connection_tool::Tool,
context_menu::Entry,
diagram::{DiagramCanvas, State},
model_menu, port_edit,
};
use iced::{
Point, Rectangle, Size, Vector, keyboard, mouse,
widget::canvas::{self, Program},
};
use oa_core::*;
fn fixture() -> (App, Id, Id) {
let mut p = Project::empty();
let a = p.add(Kind::Function, "Transmit", 100., 200.);
let b = p.add(Kind::Function, "Receive", 500., 200.);
let output = Id::new_v4();
let input = Id::new_v4();
p.elements.insert(
output,
make_port(
&p,
output,
a,
"Send",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap(),
);
p.elements.insert(
input,
make_port(
&p,
input,
b,
"Receive",
PortContract::FunctionInput {
incoming_items: vec![],
},
)
.unwrap(),
);
let mut app = App::with_project(p);
app.pan = Vector::new(20., 30.);
(app, output, input)
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
project: app.project(),
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom: 1.,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
connection: app.connection.as_ref(),
}
}
fn screen(app: &App, id: Id) -> Point {
let v = projection::project_view(app.project(), app.active_diagram.unwrap());
let p = v.ports.iter().find(|p| p.element == id).unwrap().position;
Point::new(bounds().x + app.pan.x + p.x, bounds().y + app.pan.y + p.y)
}
fn event(c: &DiagramCanvas<'_>, s: &mut State, e: mouse::Event, p: Point) -> Option<Message> {
c.update(
s,
&canvas::Event::Mouse(e),
bounds(),
mouse::Cursor::Available(p),
)
.and_then(|a| a.into_inner().0)
}
fn choose(app: &App, caption: &str) -> Message {
model_menu::popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|e| match e {
Entry::Action { label, message, .. } if label == caption => message,
_ => None,
})
.unwrap_or_else(|| panic!("No action {caption}"))
}
fn send(app: &mut App, m: Message) {
let _ = app.update(m);
}
#[test]
fn middle_and_arrow_panning_preserve_source_and_history() {
for armed in [false, true] {
let (mut app, a, _) = fixture();
send(
&mut app,
Message::Connection(connection_edit::Event::Choose(Some(
Tool::FunctionalExchange,
))),
);
let context = app.route_context().unwrap();
if armed {
send(
&mut app,
Message::Connection(connection_edit::Event::Endpoint(context, a)),
);
}
let before = app.document.bundle().clone();
let pan = app.pan;
let mut state = State::default();
let start = screen(&app, a);
let end = start + Vector::new(140., 70.);
let c = canvas(&app);
let messages: Vec<_> = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Middle),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Middle),
]
.into_iter()
.filter_map(|e| event(&c, &mut state, e, end))
.collect();
for m in messages {
send(&mut app, m);
}
assert_eq!(app.pan, pan + Vector::new(140., 70.));
assert_eq!(app.connection.as_ref().unwrap().source, armed.then_some(a));
let e = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowRight),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let message = canvas(&app)
.update(&mut state, &e, bounds(), mouse::Cursor::Available(end))
.unwrap()
.into_inner()
.0
.unwrap();
send(&mut app, message);
assert_eq!(app.pan, pan + Vector::new(60., 70.));
assert_eq!(app.connection.as_ref().unwrap().context, context);
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn menu_reset_and_draw_use_fresh_open_context() {
let (mut app, a, b) = fixture();
let c = app.route_context().unwrap();
let v = projection::project_view(app.project(), c.diagram);
let owner = v.ports.iter().find(|p| p.element == a).unwrap().owner;
send(
&mut app,
Message::PortAnchor(port_edit::Event::Commit(
c,
owner,
PortAnchor {
port: a,
side: PortAnchorSide::Top,
offset: 0.4,
},
)),
);
let before = app.document.bundle().clone();
let revision = app.document.revision();
let mut request = model_menu::Request::object(a, screen(&app, a));
request.canvas = app.route_context();
let old = request.canvas.unwrap();
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
assert_ne!(app.route_context().unwrap().epoch, old.epoch);
let action = choose(&app, "Reset automatic position");
send(&mut app, action);
assert!(
app.project().diagrams[&c.diagram].nodes[&owner]
.port_anchors
.is_empty()
);
assert_eq!(app.document.revision(), revision + 1);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &before);
let mut request = model_menu::Request::object(a, screen(&app, a));
request.canvas = app.route_context();
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
let caption = model_menu::popup(&app)
.unwrap()
.entries
.iter()
.find_map(|e| match e {
Entry::Action { label, .. } if label.starts_with("Draw functional exchange") => {
Some(label.clone())
}
_ => None,
})
.unwrap();
let action = choose(&app, &caption);
send(&mut app, action);
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
let context = app.route_context().unwrap();
assert_eq!(app.connection.as_ref().unwrap().context, context);
send(
&mut app,
Message::Connection(connection_edit::Event::Endpoint(context, b)),
);
assert_eq!(app.project().relations.len(), 1);
let accepted = app.document.bundle().clone();
assert_eq!(
source_project::compile_bundle(&accepted, Default::default())
.unwrap()
.decoded
.project,
*app.project()
);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn motion_after_release_must_not_move_clicked_port() {
let (mut app, a, _) = fixture();
let before = app.document.bundle().clone();
let start = screen(&app, a);
let end = start + Vector::new(-120., -150.);
let mut state = State::default();
let c = canvas(&app);
// Iced batches all window events until AboutToWait and passes the final
// window cursor to every event, even when the final motion follows release.
let messages: Vec<_> = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
]
.into_iter()
.filter_map(|e| event(&c, &mut state, e, end))
.collect();
for m in messages {
send(&mut app, m);
}
assert!(
app.document.bundle() == &before,
"Moving the cursor after release must not author a port anchor"
);
}
#[test]
fn motion_after_source_click_release_must_not_create_connection() {
let (mut app, a, b) = fixture();
send(
&mut app,
Message::Connection(connection_edit::Event::Choose(Some(
Tool::FunctionalExchange,
))),
);
let start = screen(&app, a);
let end = screen(&app, b);
let mut state = State::default();
let c = canvas(&app);
let messages: Vec<_> = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
]
.into_iter()
.filter_map(|e| event(&c, &mut state, e, end))
.collect();
for m in messages {
send(&mut app, m);
}
assert!(
app.project().relations.is_empty(),
"Hover after releasing the source is not a connection commit"
);
}
@@ -0,0 +1,359 @@
use crate::{
app::{App, Message},
connection_edit,
connection_tool::Tool,
diagram::{DiagramCanvas, State},
model_menu, port_edit,
};
use iced::{
Point, Rectangle, Size, Vector, mouse,
widget::canvas::{self, Program},
};
use oa_core::*;
fn fixture() -> (App, Id, Id) {
let mut p = Project::empty();
let a = p.add(Kind::Function, "Transmit", 100., 200.);
let b = p.add(Kind::Function, "Receive", 500., 200.);
let output = Id::new_v4();
let input = Id::new_v4();
p.elements.insert(
output,
make_port(
&p,
output,
a,
"Send",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap(),
);
p.elements.insert(
input,
make_port(
&p,
input,
b,
"Receive",
PortContract::FunctionInput {
incoming_items: vec![],
},
)
.unwrap(),
);
(
{
let mut app = App::with_project(p);
app.pan = Vector::new(20., 30.);
app
},
output,
input,
)
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
}
fn screen(p: Position, zoom: f32) -> Point {
Point::new(bounds().x + p.x * zoom + 20., bounds().y + p.y * zoom + 30.)
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
project: app.project(),
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
connection: app.connection.as_ref(),
}
}
fn event(
c: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
final_cursor: Point,
) -> Option<Message> {
c.update(
state,
&canvas::Event::Mouse(event),
bounds(),
mouse::Cursor::Available(final_cursor),
)
.and_then(|a| a.into_inner().0)
}
fn batch(app: &App, state: &mut State, start: Point, end: Point, zoom: f32) -> Vec<Message> {
let c = canvas(app, zoom);
[
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| event(&c, state, e, end))
.collect()
}
fn assert_history(app: &mut App, before: &oa_core::source_project::SourceBundle, revision: u64) {
assert_eq!(app.document.revision(), revision + 1);
let after = app.document.bundle().clone();
assert_eq!(
oa_core::source_project::compile_bundle(&after, Default::default())
.unwrap()
.decoded
.project,
*app.project()
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
#[test]
fn coalesced_fe_ports_commit_once_through_app_and_source_history() {
for zoom in [0.4, 1., 2.] {
let (mut app, output, input) = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
Tool::FunctionalExchange,
))));
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
let at = |id| {
screen(
view.ports
.iter()
.find(|p| p.element == id)
.unwrap()
.position,
zoom,
)
};
let messages = batch(&app, &mut State::default(), at(output), at(input), zoom);
assert_eq!(messages.len(), 2);
assert!(
matches!(&messages[0],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==output)
);
assert!(
matches!(&messages[1],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==input)
);
for message in messages {
let _ = app.update(message);
}
assert_eq!(app.project().relations.len(), 1);
assert_eq!(app.project().elements.len(), 4);
assert!(
matches!(app.project().relations[&app.selected].body,RelationBody::FunctionalExchange{source_port,target_port,..}if source_port==output&&target_port==input)
);
assert!(app.connection.is_none());
assert_history(&mut app, &before, revision);
}
}
#[test]
fn coalesced_port_drag_commits_once_through_app_and_source_history() {
for zoom in [0.4, 1., 2.] {
let (mut app, output, _) = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
let port = view.ports.iter().find(|p| p.element == output).unwrap();
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
let dest = projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35);
let messages = batch(
&app,
&mut State::default(),
screen(port.position, zoom),
screen(dest, zoom),
zoom,
);
assert_eq!(messages.len(), 2);
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
assert!(
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
);
let node = owner.id;
for message in messages {
let _ = app.update(message);
}
assert_eq!(app.project().elements.len(), 4);
assert!(app.project().relations.is_empty());
assert_eq!(
app.project().diagrams[&app.active_diagram.unwrap()].nodes[&node].port_anchors[0].port,
output
);
assert_history(&mut app, &before, revision);
}
}
#[test]
fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
let (mut app, output, input) = fixture();
let before = app.document.bundle().clone();
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
let a = screen(
view.ports
.iter()
.find(|p| p.element == output)
.unwrap()
.position,
1.,
);
let b = screen(
view.ports
.iter()
.find(|p| p.element == input)
.unwrap()
.position,
1.,
);
let mut state = State::default();
let _ = event(
&canvas(&app, 1.),
&mut state,
mouse::Event::CursorMoved { position: a },
a,
);
let right = event(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
a,
)
.unwrap();
let _ = app.update(right);
assert!(app.model_menu.is_some());
// The overlay consumes the right release, movement to B, outside press and
// its release. None of those events reach the underlying canvas.
let _ = app.update(Message::Menu(model_menu::Event::Close));
let press = event(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
)
.unwrap();
let release = event(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
);
let picked = match &press {
Message::Select(id) => Some(*id),
_ => None,
};
let release_debug = format!("{release:?}");
let _ = app.update(press);
if let Some(message) = release {
let _ = app.update(message);
}
assert_eq!(
picked,
Some(input),
"Click at B picked another port; following release: {release_debug}"
);
assert_eq!(
app.document.bundle(),
&before,
"A stationary click after closing a menu must not edit port geometry"
);
}
#[test]
fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
let (mut app, output, _) = fixture();
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
let port = view.ports.iter().find(|p| p.element == output).unwrap();
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
let a = screen(port.position, 1.);
let b = screen(
projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35),
1.,
);
let mut state = State::default();
let selected = batch(&app, &mut state, a, a, 1.);
for message in selected {
let _ = app.update(message);
}
assert_eq!(app.selected, output);
// No new motion to A occurs: the previous selection ended there. Winit
// batches press(A), motion(B), release(B) with cursor B on every event.
let c = canvas(&app, 1.);
let messages: Vec<_> = [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: b },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| event(&c, &mut state, e, b))
.collect();
assert!(
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
"Stationary source was lost between batches: {messages:?}"
);
assert!(
matches!(messages.last(),Some(Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))) if anchor.port==output)
);
}
#[test]
fn modal_and_stale_epoch_block_queued_connection_and_port_commits() {
for is_connection in [false, true] {
for barrier in ["menu", "modal", "epoch"] {
let (mut app, output, input) = fixture();
let before = app.document.bundle().clone();
if is_connection {
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
Tool::FunctionalExchange,
))));
}
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
let out = view.ports.iter().find(|p| p.element == output).unwrap();
let target = if is_connection {
view.ports
.iter()
.find(|p| p.element == input)
.unwrap()
.position
} else {
projection::port_anchor_position(
view.nodes.iter().find(|n| n.id == out.owner).unwrap(),
PortAnchorSide::Top,
0.35,
)
};
let messages = batch(
&app,
&mut State::default(),
screen(out.position, 1.),
screen(target, 1.),
1.,
);
match barrier {
"menu" => {
let _ = app.update(model_menu::open(output, Point::ORIGIN));
}
"modal" => {
let _ = app.update(Message::AddRelation);
}
_ => {
let _ = app.update(Message::Escape);
}
}
for message in messages {
let _ = app.update(message);
}
assert_eq!(
app.document.bundle(),
&before,
"{barrier}; connection={is_connection}"
);
}
}
}
@@ -0,0 +1,167 @@
use super::*;
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
project,
diagram,
selected: Id::nil(),
zoom,
pan: Vector::new(17., 23.),
context: Context {
document: project.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
editable: true,
connection: None,
}
}
fn local(canvas: &DiagramCanvas<'_>, position: Position) -> Point {
Point::new(
canvas.pan.x + position.x * canvas.zoom,
canvas.pan.y + position.y * canvas.zoom,
)
}
#[test]
fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
let project = Project::empty();
let diagram = Id::new_v4();
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
let mut view = ProjectedView {
ports: (0..2)
.map(|i| projection::ProjectedPort {
element: Id::from_u128(i + 10),
owner: diagram,
position: Position {
x: 100. + if horizontal { i as f32 * 28. } else { 0. },
y: 100. + if horizontal { 0. } else { i as f32 * 28. },
},
side,
})
.collect(),
..Default::default()
};
for zoom in [0.0001, 0.35, 1., 3.] {
let canvas = canvas(&project, diagram, zoom);
for _ in 0..2 {
for port in &view.ports {
assert_eq!(
canvas.endpoint_at(&view, local(&canvas, port.position)),
Some(port.element),
"Exact port center must win at zoom {zoom} on {side:?}"
);
}
view.ports.reverse();
}
}
}
}
#[test]
fn coincident_ports_keep_selection_then_use_stable_identity() {
let project = Project::empty();
let diagram = Id::new_v4();
let a = Id::from_u128(10);
let b = Id::from_u128(20);
let mut view = ProjectedView {
ports: [b, a]
.into_iter()
.map(|element| projection::ProjectedPort {
element,
owner: diagram,
position: Position { x: 100., y: 100. },
side: PortAnchorSide::Right,
})
.collect(),
..Default::default()
};
let mut canvas = canvas(&project, diagram, 0.35);
let at = local(&canvas, view.ports[0].position);
for _ in 0..2 {
canvas.selected = Id::nil();
assert_eq!(canvas.endpoint_at(&view, at), Some(a));
canvas.selected = b;
assert_eq!(canvas.endpoint_at(&view, at), Some(b));
view.ports.reverse();
}
assert!(canvas.hit_port(&view, Point::new(-100., -100.)).is_none());
}
#[test]
fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
let mut project = Project::empty();
let group = project
.add_element(Kind::Function, Layer::Sa, "Response", None)
.unwrap();
let owner = project
.add_element(Kind::Function, Layer::Sa, "Send report", Some(group))
.unwrap();
let ports = [Id::from_u128(10), Id::from_u128(20)];
for (i, id) in ports.into_iter().enumerate() {
let port = make_port(
&project,
id,
owner,
&format!("Output {i}"),
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap();
project.elements.insert(id, port);
}
let diagram = project.ensure_diagram(Layer::Sa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
project.place(diagram, group, Position { x: 60., y: 60. });
let node = project.place(diagram, owner, Position { x: 100., y: 140. });
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.port_anchors = ports
.into_iter()
.enumerate()
.map(|(i, port)| PortAnchor {
port,
side: PortAnchorSide::Right,
offset: 0.4 + i as f32 * 0.1,
})
.collect();
validate_candidate(&project).unwrap();
let canvas = canvas(&project, diagram, 0.35);
let view = project_view(&project, diagram);
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
for port in &view.ports {
let at = local(&canvas, port.position);
assert_eq!(canvas.endpoint_at(&view, at), Some(port.element));
let mut state = State::default();
let cursor = mouse::Cursor::Available(Point::new(bounds.x + at.x, bounds.y + at.y));
let press = canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
cursor,
)
.unwrap()
.into_inner()
.0;
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
let menu = canvas.context_request(&state, at, bounds);
assert_eq!(menu.object, port.element);
}
}
@@ -0,0 +1,373 @@
//! Passive renderer layer for port identity feedback. Iced batches every text
//! primitive after meshes within one GPU layer, even across Canvas frames.
//! A second stacked canvas places the complete tooltip above diagram text.
use super::*;
pub struct PortLabels<'a>(pub DiagramCanvas<'a>);
#[derive(Default)]
pub struct State {
view: RefCell<Option<(Context, ProjectedView)>>,
context: Option<Context>,
held_buttons: u8,
}
impl canvas::Program<Message> for PortLabels<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if state.context != Some(self.0.context) {
state.context = Some(self.0.context);
state.held_buttons = 0;
}
let button_mask = |button| match button {
mouse::Button::Left => 1,
mouse::Button::Middle => 2,
_ => 0,
};
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) if cursor.is_over(bounds) => {
state.held_buttons |= button_mask(*button)
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
state.held_buttons &= !button_mask(*button)
}
canvas::Event::Window(iced::window::Event::Unfocused) => state.held_buttons = 0,
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) =>
{
state.held_buttons = 0
}
_ => {}
}
// Never capture input: every event still reaches the interactive canvas.
matches!(event, canvas::Event::Mouse(_)).then(Action::request_redraw)
}
fn draw(
&self,
state: &State,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
if state.context == Some(self.0.context) && state.held_buttons != 0 {
return vec![];
}
let canvas = &self.0;
let mut cached = state.view.borrow_mut();
if cached
.as_ref()
.is_none_or(|(context, _)| *context != canvas.context)
{
*cached = Some((canvas.context, project_view(canvas.project, canvas.diagram)));
}
let view = &cached.as_ref().unwrap().1;
let Some(port) = cursor
.position_in(bounds)
.and_then(|p| canvas.hit_port(view, p))
.or_else(|| view.ports.iter().find(|p| p.element == canvas.selected))
else {
return vec![];
};
let center = point(port.position);
let mut port_label = Frame::new(renderer, bounds.size());
let name = short(canvas.project.named(port.element), 40);
let owner = canvas.project.elements[&port.element]
.parent
.map(|id| short(canvas.project.named(id), 40))
.unwrap_or_default();
let alias = &canvas.project.elements[&port.element].alias;
let alias = if alias.chars().count() > 48 {
format!(
"@{}…{}",
alias.chars().take(22).collect::<String>(),
alias
.chars()
.skip(alias.chars().count() - 24)
.collect::<String>()
)
} 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 * canvas.zoom + canvas.pan.x + 12.,
center.y * canvas.zoom + canvas.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()
});
vec![port_label.into_geometry()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::App, model_menu};
use canvas::Program;
fn fixture() -> (App, Id) {
let mut project = Project::empty();
let owner = project.add(Kind::Function, "Send report", 100., 150.);
let port = Id::new_v4();
project.elements.insert(
port,
make_port(
&project,
port,
owner,
"Report output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap(),
);
(App::with_project(project), port)
}
fn program(app: &App) -> PortLabels<'_> {
PortLabels(DiagramCanvas {
project: app.project(),
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom: 1.,
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
connection: None,
})
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(900., 600.))
}
// Stack visits this canvas first. Every event must remain available to the
// interactive child, and this layer must never levitate its cursor.
fn passive(labels: &PortLabels<'_>, state: &mut State, event: canvas::Event, at: Point) {
let cursor = mouse::Cursor::Available(at);
if let Some(action) = labels.update(state, &event, bounds(), cursor) {
let (message, _, status) = action.into_inner();
assert!(message.is_none());
assert_eq!(status, iced::event::Status::Ignored);
}
assert_eq!(
labels.mouse_interaction(state, bounds(), cursor),
mouse::Interaction::None
);
}
fn mouse(labels: &PortLabels<'_>, state: &mut State, event: mouse::Event) {
passive(
labels,
state,
canvas::Event::Mouse(event),
bounds().center(),
);
}
#[test]
fn leaving_and_reentering_keeps_suppression_until_each_held_button_releases() {
let (app, _) = fixture();
let labels = program(&app);
let mut state = State::default();
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Middle),
);
mouse(&labels, &mut state, mouse::Event::CursorLeft);
mouse(&labels, &mut state, mouse::Event::CursorEntered);
mouse(
&labels,
&mut state,
mouse::Event::CursorMoved {
position: bounds().center(),
},
);
assert_ne!(
state.held_buttons, 0,
"Preview geometry remains active across reentry"
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Right),
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
);
assert_ne!(state.held_buttons, 0, "Middle-button pan is still held");
mouse(
&labels,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
);
assert_eq!(state.held_buttons, 0);
}
#[test]
fn menu_epoch_clears_suppression_when_the_overlay_consumes_release() {
let (mut app, port) = fixture();
let before = app.document.bundle().clone();
let mut state = State::default();
mouse(
&program(&app),
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
);
let old_context = state.context;
let _ = app.update(model_menu::open(port, bounds().center()));
assert!(app.model_menu.is_some());
assert_ne!(old_context, app.route_context());
// Left release and dismissal input are captured by the root popup.
let _ = app.update(Message::Menu(model_menu::Event::Close));
passive(
&program(&app),
&mut state,
canvas::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
bounds().center(),
);
assert_eq!(state.held_buttons, 0);
assert_eq!(state.context, app.route_context());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn outside_presses_do_not_suppress_and_cancel_or_focus_loss_clear_held_buttons() {
let (app, _) = fixture();
let labels = program(&app);
let mut state = State::default();
passive(
&labels,
&mut state,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Point::ORIGIN,
);
assert_eq!(state.held_buttons, 0);
for event in [
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
canvas::Event::Window(iced::window::Event::Unfocused),
] {
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
);
mouse(
&labels,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Middle),
);
passive(&labels, &mut state, event, bounds().center());
assert_eq!(state.held_buttons, 0);
}
}
#[test]
fn passive_layer_leaves_port_drag_selection_and_commit_with_the_interactive_canvas() {
let (app, port) = fixture();
let labels = program(&app);
let view = project_view(app.project(), labels.0.diagram);
let projected = view.ports.iter().find(|p| p.element == port).unwrap();
let owner = view.nodes.iter().find(|n| n.id == projected.owner).unwrap();
let screen = |p: Position| point(p) + Vector::new(bounds().x, bounds().y);
let start = screen(projected.position);
let end = screen(projection::port_anchor_position(
owner,
PortAnchorSide::Top,
0.3,
));
let mut state = State::default();
let mut interactive = super::super::State::default();
let mut messages = Vec::new();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
let event = canvas::Event::Mouse(event);
passive(&labels, &mut state, event.clone(), end);
if let Some(action) = labels.0.update(
&mut interactive,
&event,
bounds(),
mouse::Cursor::Available(end),
) {
let (message, _, _) = action.into_inner();
messages.extend(message);
}
}
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
assert!(
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
);
assert_eq!(messages.len(), 2);
assert_eq!(state.held_buttons, 0);
}
}
+27 -21
View File
@@ -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()
}
+218 -12
View File
@@ -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())
}),
@@ -334,6 +337,8 @@ fn friendly(name: &str) -> String {
"Named" => "Data type".into(),
"body" => "Occurrence type".into(),
"diagram" => "Diagram".into(),
"StateMachine" => "State machine".into(),
"machine" => "State machine context".into(),
"uri" => "Artifact URI".into(),
"definition" => "Data type definition".into(),
"steps" | "occurrences" => "Ordered occurrences".into(),
@@ -387,6 +392,7 @@ fn reference(path: &str) -> bool {
| "represented"
| "exchange"
| "edge"
| "port"
| "sender"
| "receiver"
| "action"
@@ -404,6 +410,7 @@ fn reference(path: &str) -> bool {
| "sa"
| "Named"
| "diagram"
| "machine"
) || matches!(
parent,
"items"
@@ -516,6 +523,17 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
let owner = value
.get("element")
.and_then(|id| serde_json::from_value::<Id>(id.clone()).ok());
return project
.elements
.values()
.filter(|e| e.kind() == Kind::Port && e.parent == owner)
.map(|e| e.id)
.collect();
}
let element = (draft.kind == RecordKind::Element)
.then(|| serde_json::from_value::<Element>(value.clone()).ok())
.flatten()
@@ -527,7 +545,12 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
let layer = element
.as_ref()
.map(Element::layer)
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())));
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())))
.or_else(|| {
(draft.kind == RecordKind::Diagram)
.then(|| serde_json::from_value::<Layer>(value["layer"].clone()).ok())
.flatten()
});
let field = path.rsplit('/').next().unwrap_or_default();
if field == "edge" {
return project
@@ -552,6 +575,8 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
};
if field == "diagram" {
// Keep destinations selectable before retargeting the element. The
// element picker below uses the draft destination's membership rules.
return project.diagrams.keys().copied().collect();
}
if field == "owner" || list == "constrained" {
@@ -673,18 +698,16 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
.and_then(Value::as_str)
.and_then(|id| id.parse().ok())
.unwrap_or(diagram);
return project.diagrams.get(&diagram).is_some_and(|d| {
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
&& !d
.nodes
.values()
.any(|n| n.id != draft.original && n.element == candidate.id)
});
return project
.diagrams
.get(&diagram)
.is_some_and(|d| placement_allowed(project, d, candidate, draft.original));
}
if !same_layer(candidate) {
return false;
}
match field {
"machine" => candidate.kind() == Kind::StateMachine,
"parent" => element.as_ref().is_some_and(|child| {
if candidate.id == draft.original
|| candidate.id == child.id
@@ -987,6 +1010,18 @@ fn group_label(path: &str) -> Option<&'static str> {
}
}
fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool {
(diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
&& diagram
.projection
.state_machine()
.is_none_or(|machine| projection::state_machine_contains(project, machine, element.id))
&& !diagram
.nodes
.values()
.any(|node| node.id != current && node.element == element.id)
}
fn unit_body(path: &str, value: &Value) -> bool {
value.is_string()
&& matches!(
@@ -998,16 +1033,31 @@ fn unit_body(path: &str, value: &Value) -> bool {
/// Closed choices for the concrete model enums. No runtime schema/plugin layer.
fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> {
let field = path.rsplit('/').next().unwrap_or_default();
let words: &[&str] = match field {
"layer" => &["Oa", "Sa"],
"projection" => &[
if path == "/projection" && kind == RecordKind::Diagram {
let mut choices = [
"Overview",
"Decomposition",
"Allocation",
"Traceability",
"FunctionalDataflow",
"Capabilities",
],
]
.into_iter()
.map(|name| (friendly(name), json!(name)))
.collect::<Vec<_>>();
choices.push((
"State machine".into(),
if value.get("StateMachine").is_some() {
value.clone()
} else {
json!({"StateMachine": {"machine": Id::nil()}})
},
));
return choices;
}
let words: &[&str] = match field {
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
"layer" => &["Oa", "Sa"],
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
"viewpoint" if path.contains("/Oa/") => &[
"OperationalActivity",
@@ -1499,6 +1549,162 @@ mod tests {
reference_ids(draft, project, path)
}
fn state_diagram_form_fixture() -> Project {
language::parse(
r#"arcadia 3; project state_forms "State forms" {
oa_entity crew "Crew" {}
oa_state_machine modes "Modes" { parent @crew; }
oa_region main "Main" { parent @modes; }
oa_state ready "Ready" { parent @main; }
oa_region nested "Nested" { parent @ready; }
oa_state leaf "Leaf" { parent @nested; }
oa_state_machine other "Other modes" { parent @crew; }
oa_region other_region "Other region" { parent @other; }
oa_state foreign "Foreign" { parent @other_region; }
sa_system system "System" {}
sa_state_machine sa_modes "System modes" { parent @system; }
diagram overview "Overview" {}
diagram states "States" {
projection { StateMachine { machine @modes; }; };
}
diagram other_states "Other states" {
projection { StateMachine { machine @other; }; };
}
diagram duplicate "Duplicate" {
projection { StateMachine { machine @modes; }; };
}
node ready_view { diagram @states; element @ready; }
node region_view { diagram @states; element @main; }
node duplicate_view { diagram @duplicate; element @ready; }
}"#,
)
.unwrap()
}
fn alias_id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, candidate)| *candidate == alias)
.unwrap()
.0
}
#[test]
fn state_projection_form_keeps_string_variants_and_requires_a_typed_context() {
let project = state_diagram_form_fixture();
let diagram = alias_id(&project, "overview");
let machine = alias_id(&project, "modes");
let mut draft = Draft::from_project(&project, diagram).unwrap();
let choices = variants("/projection", &draft.value["projection"], draft.kind);
assert_eq!(choices.len(), 7);
for (_, value) in &choices[..6] {
let decoded = serde_json::from_value::<ProjectionKind>(value.clone()).unwrap();
assert!(value.is_string());
assert_eq!(json!(decoded), *value);
}
draft.shape("/projection", choices[6].1.clone()).unwrap();
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
let path = "/projection/StateMachine/machine";
assert!(reference(path));
assert!(!optional_reference(path));
assert_eq!(
ids(&draft, &project, path)
.into_iter()
.collect::<BTreeSet<_>>(),
BTreeSet::from([machine, alias_id(&project, "other")])
);
draft.shape(path, json!(machine)).unwrap();
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
assert_eq!(
accepted.diagrams[&diagram].projection.state_machine(),
Some(machine)
);
assert_eq!(accepted.elements, project.elements);
assert!(accepted.diagrams[&diagram].nodes.is_empty());
assert_eq!(
variants("/projection", &draft.value["projection"], draft.kind)[6].1,
json!({"StateMachine": {"machine": machine}})
);
draft.shape("/layer", json!("Sa")).unwrap();
assert_eq!(
ids(&draft, &project, path),
vec![alias_id(&project, "sa_modes")]
);
assert!(
reference_choices(&draft, &project, path, machine)
.iter()
.any(|choice| {
choice.id == machine && choice.label.starts_with("Current choice needs review")
})
);
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
assert_eq!(
draft.materialize().unwrap().pointer(path),
Some(&json!(machine))
);
}
#[test]
fn state_placement_form_uses_draft_destination_membership_without_changing_other_views() {
let project = state_diagram_form_fixture();
let node = alias_id(&project, "ready_view");
let mut draft = Draft::from_project(&project, node).unwrap();
let offered = ids(&draft, &project, "/element");
for alias in ["modes", "ready", "nested", "leaf"] {
assert!(offered.contains(&alias_id(&project, alias)), "{alias}");
}
for alias in [
"main",
"other",
"other_region",
"foreign",
"crew",
"sa_modes",
] {
assert!(!offered.contains(&alias_id(&project, alias)), "{alias}");
}
assert_eq!(
ids(&draft, &project, "/diagram")
.into_iter()
.collect::<BTreeSet<_>>(),
project.diagrams.keys().copied().collect::<BTreeSet<_>>()
);
// A destination must remain selectable before its compatible element;
// the current element is retained visibly until the draft is complete.
draft
.shape("/diagram", json!(alias_id(&project, "other_states")))
.unwrap();
let ready = alias_id(&project, "ready");
assert!(!ids(&draft, &project, "/element").contains(&ready));
assert!(
reference_choices(&draft, &project, "/element", ready)
.iter()
.any(|choice| {
choice.id == ready && choice.label.starts_with("Current choice needs review")
})
);
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
draft
.shape("/element", json!(alias_id(&project, "foreign")))
.unwrap();
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
assert!(
accepted.diagrams[&alias_id(&project, "other_states")]
.nodes
.contains_key(&node)
);
draft
.shape("/diagram", json!(alias_id(&project, "overview")))
.unwrap();
assert!(ids(&draft, &project, "/element").contains(&alias_id(&project, "crew")));
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok());
}
#[test]
fn editing_allocation_offers_other_performers_without_counting_itself_twice() {
let mut p = Project::empty();
+22 -14
View File
@@ -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(
@@ -467,18 +467,26 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "Describe the conditions in which the operation or system behaves differently. States and modes belong to regions within a state machine.",
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal",
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal diagram draw reconnect local internal timed fold unfold placement pan zoom undo",
sections: &[
walkthrough(
"Build a state machine",
&[
"In Behavior & scenarios, create a State machine and select it. Use + Region to give its state structure an owner.",
"Use + State for conditions such as Nominal and Degraded, and + Initial for the starting pseudostate. Select a region and use Create element for other supported state kinds.",
"Select a source state and choose + Relationship → State transition. Pick a compatible target in the same state machine.",
"Edit the transition's Trigger, Guard and Effect in Properties. These distinguish what initiates the transition, what must be true, and what follows.",
"Review the rendered state view and numbered transition list. Model checks calls out unfinished structures so they can be corrected during authoring.",
"In Behavior & scenarios, create or select a State machine, then choose Create diagram. This places its existing regions and vertices in a saved view. + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
],
),
explain(
"Navigate and recover a view",
"Middle-button drag pans; scroll or use −/+ to zoom, and Fit frames the diagram. Right-click a container for Fold contents or Unfold contents, or choose Placement properties for exact coordinates and size. Hide from this diagram removes only its placement; Show an element not yet in this view restores missing elements. Escape cancels a drawing gesture. Undo restores each completed gesture or menu edit, and Model checks identifies unfinished structures.",
),
explain(
"One saved diagram across editors",
"Each state view stores a reference to its state machine and its own placements. Text, JSON and REST edit those same diagram records, including the persisted machine context; saving and reopening retains them. Merely opening the behavior page does not create a diagram. State-view edits preserve other diagram layouts.",
),
explain(
"Availability and state actions",
"Availability links a participant or behavior to a State/Mode in which it is available. State action links a State/Mode to an Activity/Function and records its entry, do or exit phase. Use these typed relationships when work depends on operating conditions.",
@@ -518,7 +526,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 +621,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.",
+6
View File
@@ -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)]
+866
View File
@@ -0,0 +1,866 @@
//! Contextual actions reuse the ordinary guarded application commands.
use crate::{
app::{App, Message, Page},
context_menu::{Entry, Menu},
port_workflow, route_edit,
};
use iced::Point;
use oa_core::*;
#[derive(Debug, Clone)]
pub struct Request {
pub anchor: Point,
pub object: Id,
pub placement: Option<Id>,
pub bend: Option<usize>,
pub route_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub source_file: Option<String>,
}
impl Request {
pub fn object(object: Id, anchor: Point) -> Self {
Self {
anchor,
object,
placement: None,
bend: None,
route_point: None,
canvas: None,
source_file: None,
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Open(Request),
Invoke(u64, Box<Message>),
Close,
}
pub struct Opened {
pub token: u64,
pub request: Request,
document: Id,
revision: u64,
epoch: u64,
page: Page,
layer: Layer,
diagram: Option<Id>,
}
impl Opened {
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
if !app.project().contains(request.object)
|| request
.canvas
.is_some_and(|c| Some(c) != current_canvas(app))
{
return None;
}
Some(Self {
token,
request,
document: app.canvas_document,
revision: app.document.revision(),
epoch: app.canvas_epoch,
page: app.page,
layer: app.layer,
diagram: current_diagram(app),
})
}
pub fn valid(&self, app: &App) -> bool {
self.document == app.canvas_document
&& self.revision == app.document.revision()
&& self.epoch == app.canvas_epoch
&& self.page == app.page
&& self.layer == app.layer
&& self.diagram == current_diagram(app)
&& app.project().contains(self.request.object)
&& self
.request
.canvas
.is_none_or(|c| Some(c) == current_canvas(app))
&& self
.request
.source_file
.as_ref()
.is_none_or(|path| app.source_workspace.files.contains_key(path))
}
}
fn current_canvas(app: &App) -> Option<route_edit::Context> {
if app.page == Page::Behavior {
app.state_context()
} else {
app.route_context()
}
}
fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.state_context().map(|context| context.diagram)
} else {
app.active_diagram
}
}
pub fn open(object: Id, anchor: Point) -> Message {
Message::Menu(Event::Open(Request::object(object, anchor)))
}
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
crate::context_menu::Area::left(
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
move |anchor| {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
Message::Menu(Event::Open(request))
},
)
.into()
}
pub fn popup(app: &App) -> Option<Menu<Message>> {
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
let request = &opened.request;
let p = app.project();
let id = request.object;
let clean = !app.busy
&& (request.source_file.is_some()
|| (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()));
let item = |caption: &str, message: Message, enabled: bool| {
if clean && enabled {
Entry::action(
caption,
Message::Menu(Event::Invoke(opened.token, Box::new(message))),
)
} else {
Entry::disabled(caption)
}
};
if let Some(path) = &request.source_file {
let buffer = app.source_workspace.files.get(path)?;
let entries = vec![
Entry::title(path),
item("Open file", Message::SourceFile(path.clone()), true),
item("Reveal active file", Message::SourceTreeLocate, true),
Entry::separator(),
item(
"Close detached buffer",
Message::SourceCloseFile(path.clone()),
!app.source_workspace.reachable.contains(path) && !buffer.dirty(),
),
Entry::title(if app.source_workspace.reachable.contains(path) {
"Included files remain part of the project"
} else if buffer.dirty() {
"Apply or revert this file before closing"
} else {
"Closing leaves the file on disk"
}),
];
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
if app.page == Page::Behavior
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
{
return Some(menu);
}
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 state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() {
use crate::behavior::state_edit;
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let architecture = project.ensure_diagram(Layer::Oa);
project.place(architecture, state, Position { x: 80., y: 90. });
let first = state_edit::create_view(&project, machine).unwrap();
let first_id = first.diagram;
project = execute_batch(&project, &[first.command]).unwrap();
let second = state_edit::create_view(&project, machine).unwrap();
let second_id = second.diagram;
project = execute_batch(&project, &[second.command]).unwrap();
let placement = project.diagrams[&first_id]
.nodes
.values()
.find(|node| node.element == state)
.unwrap()
.id;
let mut app = App::with_project(project.clone());
send(&mut app, Message::Select(machine));
send(&mut app, Message::Page(Page::Behavior));
app.behavior.diagram.diagram = Some(first_id);
app.active_diagram = Some(architecture);
let open_state = |app: &mut App| {
let mut request = Request::object(state, Point::ORIGIN);
request.canvas = app.state_context();
request.placement = Some(placement);
send(app, Message::Menu(Event::Open(request)));
};
// A canvas menu from Architecture is stale on the state page even when
// the architecture diagram itself remains the active architecture view.
let mut wrong = Request::object(state, Point::ORIGIN);
wrong.canvas = app.route_context();
assert!(Opened::new(&app, wrong, 1).is_none());
open_state(&mut app);
let fold = choose(&app, "Fold contents");
send(&mut app, fold);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
assert_eq!(app.selected, state, "Folding retains semantic selection");
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
open_state(&mut app);
let unfold = choose(&app, "Unfold contents");
send(&mut app, unfold);
assert_eq!(app.selected, state, "Unfolding retains semantic selection");
assert_eq!(app.project(), &project);
assert_eq!(app.active_diagram, Some(architecture));
send(&mut app, Message::Undo);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
open_state(&mut app);
let stale = choose(&app, "Fold contents");
app.behavior.diagram.diagram = Some(second_id);
send(&mut app, stale);
assert_eq!(app.project(), &project);
app.behavior.diagram.diagram = Some(first_id);
open_state(&mut app);
let hide = choose(&app, "Hide from this diagram");
send(&mut app, hide);
assert!(
!app.project().diagrams[&first_id]
.nodes
.contains_key(&placement)
);
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
// Placement properties deliberately select a placement record, but an
// accepted numeric edit must keep the Behavior view's own context.
let source_before = app.document.bundle().clone();
open_state(&mut app);
let properties = choose(&app, "Placement properties");
send(&mut app, properties);
assert_eq!(app.selected, placement);
assert_eq!(
app.draft.as_ref().unwrap().kind,
crate::forms::RecordKind::Node(first_id)
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(architecture));
let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25;
send(
&mut app,
Message::Field("/position/x".into(), x.to_string()),
);
assert!(app.draft_dirty());
send(&mut app, Message::Apply);
assert!(!app.draft_dirty());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(machine));
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(
app.active_diagram,
Some(architecture),
"Placement Apply must not replace the Architecture view"
);
let mut expected = project.clone();
expected
.diagrams
.get_mut(&first_id)
.unwrap()
.nodes
.get_mut(&placement)
.unwrap()
.position
.x = x;
assert_eq!(app.project(), &expected);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source_before);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
}
#[test]
fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() {
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let entry = project
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region))
.unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::Select(entry));
send(&mut app, Message::Page(Page::Behavior));
send(&mut app, open(entry, Point::ORIGIN));
let start = choose(&app, "New transition with details…");
send(&mut app, start);
assert_eq!(app.behavior.pending_owner(), Some(machine));
send(
&mut app,
Message::Behavior(crate::behavior::Event::States(
crate::behavior::states::Event::Cancel,
)),
);
send(
&mut app,
Message::Field("/name".into(), "Unapplied entry".into()),
);
let before = app.draft.as_ref().unwrap().materialize().unwrap();
send(&mut app, open(entry, Point::ORIGIN));
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
entry,
Entry::Action {
message: Some(_),
..
}
)));
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before);
}
#[test]
fn right_click_targets_actor_without_changing_selection_until_action() {
let mut app = App::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"
);
}
}
+1 -1
View File
@@ -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()
}
+226
View File
@@ -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);
}
}
+1
View File
@@ -15,6 +15,7 @@ pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
AddBend,
AddBendAt(oa_core::Position),
RemoveBend,
Reset,
}
+669
View File
@@ -0,0 +1,669 @@
//! Source paths are navigation groups, not model containment or include ownership.
//! The workspace retains every buffer; expanding folders never changes its files.
use crate::{context_menu, source_workspace::Workspace, style};
use iced::{
Element, Fill, Point,
widget::{Space, button, column, container, row, scrollable, text, tooltip},
};
use oa_core::source_project::SourceDiagnostic;
use std::collections::{BTreeMap, BTreeSet};
const ROW_HEIGHT: f32 = 32.;
const INDENT: f32 = 16.;
#[derive(Debug, Clone)]
pub enum Event {
ToggleFolder(String),
OpenFile(String),
FileMenu(String, Point),
}
/// Presentation only. Folder keys are exact logical prefixes without a final `/`.
/// Start compact; explicit file navigation reveals that file's ancestors.
#[derive(Default)]
pub struct State {
expanded: BTreeSet<String>,
}
impl State {
pub fn toggle(&mut self, path: &str) {
if !self.expanded.remove(path) {
self.expanded.insert(path.into());
}
}
pub fn reveal(&mut self, file: &str) {
for (offset, _) in file.match_indices('/') {
self.expanded.insert(file[..offset].into());
}
}
pub fn collapse_all(&mut self) {
self.expanded.clear();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RowKind {
Folder {
expanded: bool,
file_count: usize,
},
File {
entry: bool,
detached: bool,
closable: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// Original logical file path or exact folder prefix. Never normalized here.
pub path: String,
pub name: String,
pub depth: usize,
pub kind: RowKind,
pub selected: bool,
pub contains_active: bool,
pub dirty_files: usize,
pub diagnostics: usize,
}
impl Row {
pub fn tooltip(&self) -> String {
let mut lines = vec![match &self.kind {
RowKind::Folder { .. } => format!("{}/", self.path),
RowKind::File { .. } => self.path.clone(),
}];
match self.kind {
RowKind::Folder { file_count, .. } => {
lines.push(format!(
"{file_count} open source file{}",
if file_count == 1 { "" } else { "s" }
));
if self.contains_active {
lines.push("Contains the active source file".into());
}
}
RowKind::File {
entry, detached, ..
} => {
lines.push(if detached {
"Detached buffer · closing never deletes its file".into()
} else if entry {
"Project entry".into()
} else {
"Included source file".into()
});
if self.selected {
lines.push("Active source file".into());
}
}
}
if self.dirty_files > 0 {
lines.push(format!(
"{} modified source draft{}",
self.dirty_files,
if self.dirty_files == 1 { "" } else { "s" }
));
}
if self.diagnostics > 0 {
lines.push(format!(
"{} source diagnostic{}",
self.diagnostics,
if self.diagnostics == 1 { "" } else { "s" }
));
}
lines.join("\n")
}
}
#[derive(Default)]
struct Folder {
name: String,
folders: BTreeMap<String, usize>,
files: Vec<usize>,
file_count: usize,
dirty_files: usize,
diagnostics: usize,
contains_active: bool,
}
enum Pending {
Folder(usize, String, usize),
File(usize, usize),
}
/// Build a flat arena and traverse only expanded folders. The arena avoids
/// recursive construction, traversal and drop for deeply nested logical paths.
pub fn rows(workspace: &Workspace, diagnostics: &[SourceDiagnostic], state: &State) -> Vec<Row> {
let mut issue_counts = BTreeMap::<&str, usize>::new();
for issue in diagnostics {
*issue_counts.entry(&issue.path).or_default() += 1;
}
let mut folders = vec![Folder::default()];
let mut files = Vec::with_capacity(workspace.files.len());
for (path, buffer) in &workspace.files {
let mut parts = path.split('/').peekable();
let mut current = 0;
let mut ancestors = vec![0];
let mut name = "";
while let Some(part) = parts.next() {
if parts.peek().is_none() {
name = part;
break;
}
current = match folders[current].folders.get(part).copied() {
Some(index) => index,
None => {
let index = folders.len();
folders.push(Folder {
name: part.into(),
..Default::default()
});
folders[current].folders.insert(part.into(), index);
index
}
};
ancestors.push(current);
}
let detached = !workspace.reachable.contains(path);
let dirty_files = usize::from(buffer.dirty());
let diagnostics = issue_counts.get(path.as_str()).copied().unwrap_or(0);
let selected = workspace.active == *path;
for index in ancestors {
let folder = &mut folders[index];
folder.file_count += 1;
folder.dirty_files += dirty_files;
folder.diagnostics += diagnostics;
folder.contains_active |= selected;
}
folders[current].files.push(files.len());
files.push(Row {
path: path.clone(),
name: name.into(),
depth: 0,
kind: RowKind::File {
entry: workspace.entry == *path,
detached,
closable: detached && !buffer.dirty(),
},
selected,
contains_active: selected,
dirty_files,
diagnostics,
});
}
let mut pending = Vec::new();
append_children(&mut pending, &folders, &files, 0, "", 0);
let mut result = Vec::new();
while let Some(next) = pending.pop() {
match next {
Pending::File(index, depth) => {
let mut row = files[index].clone();
row.depth = depth;
result.push(row);
}
Pending::Folder(index, path, depth) => {
let folder = &folders[index];
let expanded = state.expanded.contains(&path);
result.push(Row {
path: path.clone(),
name: folder.name.clone(),
depth,
kind: RowKind::Folder {
expanded,
file_count: folder.file_count,
},
selected: false,
contains_active: folder.contains_active,
dirty_files: folder.dirty_files,
diagnostics: folder.diagnostics,
});
if expanded {
append_children(&mut pending, &folders, &files, index, &path, depth + 1);
}
}
}
}
result
}
/// Locate the active file after `State::reveal`, retaining one ancestor row of
/// context. If it is intentionally collapsed, locate its visible folder instead.
pub fn active_offset(
workspace: &Workspace,
diagnostics: &[SourceDiagnostic],
state: &State,
) -> iced::widget::operation::AbsoluteOffset {
let rows = rows(workspace, diagnostics, state);
let active = rows
.iter()
.position(|row| row.selected)
.or_else(|| rows.iter().rposition(|row| row.contains_active));
active.map_or_else(iced::widget::operation::AbsoluteOffset::default, |index| {
iced::widget::operation::AbsoluteOffset {
x: rows[index].depth.saturating_sub(1) as f32 * INDENT,
y: (index as f32 * ROW_HEIGHT - ROW_HEIGHT).max(0.),
}
})
}
fn append_children(
pending: &mut Vec<Pending>,
folders: &[Folder],
files: &[Row],
parent: usize,
prefix: &str,
depth: usize,
) {
let folder = &folders[parent];
let mut children = folder.files.clone();
children.sort_by_cached_key(|index| {
(
files[*index].name.to_lowercase(),
files[*index].path.clone(),
)
});
pending.extend(
children
.into_iter()
.rev()
.map(|index| Pending::File(index, depth)),
);
let mut children: Vec<_> = folder.folders.values().copied().collect();
children.sort_by_cached_key(|index| {
(
folders[*index].name.to_lowercase(),
folders[*index].name.clone(),
)
});
pending.extend(children.into_iter().rev().map(|index| {
let name = &folders[index].name;
let path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
Pending::Folder(index, path, depth)
}));
}
pub fn view<'a>(
workspace: &Workspace,
diagnostics: &[SourceDiagnostic],
state: &State,
) -> Element<'a, Event> {
let mut content = column![].spacing(0);
for entry in rows(workspace, diagnostics, state) {
let hint = entry.tooltip();
let (symbol, action, muted) = match entry.kind {
RowKind::Folder { expanded, .. } => (
if expanded { "⌄" } else { "›" },
Event::ToggleFolder(entry.path.clone()),
false,
),
RowKind::File {
entry: is_entry,
detached,
..
} => (
if is_entry {
"◆"
} else if detached {
"○"
} else {
"·"
},
Event::OpenFile(entry.path.clone()),
detached,
),
};
let caption = row![
text(symbol).size(15).width(18),
text(entry.name)
.size(12)
.width(Fill)
.wrapping(text::Wrapping::None)
.color(if muted { style::MUTED } else { style::INK }),
text(if entry.dirty_files > 0 { "*" } else { "" })
.size(12)
.color(style::TEAL),
text(if entry.diagnostics > 0 { "!" } else { "" })
.size(12)
.color(style::MUTED),
text(match entry.kind {
RowKind::Folder { file_count, .. } => file_count.to_string(),
RowKind::File { .. } => String::new(),
})
.size(11)
.color(style::MUTED),
]
.spacing(4)
.align_y(iced::Center);
let selected = entry.selected
|| (entry.contains_active
&& matches!(
entry.kind,
RowKind::Folder {
expanded: false,
..
}
));
let control = button(caption)
.width(210. - entry.depth.min(2) as f32 * INDENT)
.height(ROW_HEIGHT)
.padding([7, 6])
.clip(true)
.on_press(action)
.style(if selected {
style::selected
} else {
style::nav
});
let control: Element<'a, Event> = if matches!(entry.kind, RowKind::File { .. }) {
let path = entry.path;
context_menu::Area::new(control, move |point| Event::FileMenu(path.clone(), point))
.into()
} else {
control.into()
};
content = content.push(
row![
Space::new().width(entry.depth as f32 * INDENT),
Space::new().width(8),
container(Space::new())
.width(1)
.height(ROW_HEIGHT)
.style(|_| container::Style {
background: Some(style::LINE.into()),
..Default::default()
}),
Space::new().width(7),
tooltip(
control,
container(text(hint).size(12)).padding(8).style(style::card),
tooltip::Position::Right,
),
]
.height(ROW_HEIGHT)
.align_y(iced::Center),
);
}
if workspace.files.is_empty() {
content = content.push(
text("No source files are open.")
.size(12)
.color(style::MUTED),
);
}
scrollable(content)
.id("source-files")
.height(Fill)
.width(Fill)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use oa_core::{language, source_project::SourceBundle, validation::Severity};
fn workspace(paths: &[&str]) -> Workspace {
Workspace::new(
&SourceBundle {
entry: "project.arcadia".into(),
files: paths
.iter()
.map(|p| ((*p).into(), "// source\n".into()))
.collect(),
},
true,
)
}
fn diagnostic(path: &str) -> SourceDiagnostic {
SourceDiagnostic {
path: path.into(),
diagnostic: language::Diagnostic {
severity: Severity::Error,
code: "test".into(),
object_id: None,
field: None,
line: 1,
column: 1,
message: "Source needs repair".into(),
span: language::Span::default(),
},
related: Vec::new(),
}
}
#[test]
fn folders_are_compact_and_sorted_before_files_without_losing_duplicate_basenames() {
let workspace = workspace(&[
"project.arcadia",
"z.arcadia",
"A.arcadia",
"sa/parts/shared.arcadia",
"oa/shared.arcadia",
"sa/other.arcadia",
"sa/Parts/another.arcadia",
]);
let mut state = State::default();
let compact = rows(&workspace, &[], &state);
assert_eq!(
compact
.iter()
.map(|row| row.path.as_str())
.collect::<Vec<_>>(),
["oa", "sa", "A.arcadia", "project.arcadia", "z.arcadia"]
);
state.reveal("sa/parts/shared.arcadia");
state.reveal("oa/shared.arcadia");
let expanded = rows(&workspace, &[], &state);
assert_eq!(
expanded
.iter()
.map(|row| row.path.as_str())
.collect::<Vec<_>>(),
[
"oa",
"oa/shared.arcadia",
"sa",
"sa/Parts",
"sa/parts",
"sa/parts/shared.arcadia",
"sa/other.arcadia",
"A.arcadia",
"project.arcadia",
"z.arcadia"
]
);
assert_eq!(
expanded
.iter()
.filter(|row| row.name == "shared.arcadia")
.count(),
2
);
assert_eq!(
expanded
.iter()
.find(|row| row.path == "sa/parts/shared.arcadia")
.unwrap()
.depth,
2
);
}
#[test]
fn collapsed_folders_retain_active_dirty_and_diagnostic_markers() {
let mut workspace = workspace(&[
"project.arcadia",
"sa/parts/first.arcadia",
"sa/second.arcadia",
]);
workspace.active = "sa/parts/first.arcadia".into();
workspace
.files
.get_mut(&workspace.active)
.unwrap()
.text
.push_str("// draft\n");
let issues = [
diagnostic(&workspace.active),
diagnostic("sa/second.arcadia"),
diagnostic("missing.arcadia"),
];
let mut state = State::default();
let compact = rows(&workspace, &issues, &state);
let folder = compact.iter().find(|row| row.path == "sa").unwrap();
assert!(folder.contains_active);
assert!(!folder.selected);
assert_eq!((folder.dirty_files, folder.diagnostics), (1, 2));
assert!(folder.tooltip().contains("Contains the active source file"));
state.reveal(&workspace.active);
let expanded = rows(&workspace, &issues, &state);
let active = expanded.iter().find(|row| row.selected).unwrap();
assert_eq!(active.path, workspace.active);
assert_eq!((active.dirty_files, active.diagnostics), (1, 1));
assert!(active.tooltip().contains("1 modified source draft"));
state.collapse_all();
assert_eq!(rows(&workspace, &issues, &state), compact);
}
#[test]
fn detached_buffers_keep_paths_and_only_clean_buffers_are_closable() {
let mut workspace = workspace(&[
"project.arcadia",
"archive/clean.arcadia",
"archive/draft.arcadia",
]);
workspace.reachable.retain(|path| path == "project.arcadia");
workspace
.files
.get_mut("archive/draft.arcadia")
.unwrap()
.text
.push_str("// retain\n");
let mut state = State::default();
state.toggle("archive");
let all = rows(&workspace, &[], &state);
assert_eq!(
all.iter()
.find(|row| row.path == "archive/clean.arcadia")
.unwrap()
.kind,
RowKind::File {
entry: false,
detached: true,
closable: true
}
);
assert_eq!(
all.iter()
.find(|row| row.path == "archive/draft.arcadia")
.unwrap()
.kind,
RowKind::File {
entry: false,
detached: true,
closable: false
}
);
assert_eq!(
all.iter()
.find(|row| row.path == "project.arcadia")
.unwrap()
.kind,
RowKind::File {
entry: true,
detached: false,
closable: false
}
);
state.toggle("archive");
assert_eq!(rows(&workspace, &[], &state).len(), 2);
assert_eq!(workspace.files.len(), 3);
}
#[test]
fn exact_unicode_spaces_and_file_folder_name_collisions_are_not_normalized() {
let paths = [
"project.arcadia",
"Réponse équipe/États du système.arcadia",
"same.arcadia",
"same.arcadia/nested.arcadia",
];
let workspace = workspace(&paths);
let mut state = State::default();
for path in paths {
state.reveal(path);
}
let all = rows(&workspace, &[], &state);
let file_paths: BTreeSet<_> = all
.iter()
.filter(|row| matches!(row.kind, RowKind::File { .. }))
.map(|row| row.path.as_str())
.collect();
assert_eq!(file_paths, paths.into_iter().collect());
assert_eq!(
all.iter().filter(|row| row.path == "same.arcadia").count(),
2
);
assert!(
all.iter()
.find(|row| row.name == "États du système.arcadia")
.unwrap()
.tooltip()
.starts_with("Réponse équipe/États du système.arcadia\n")
);
}
#[test]
fn deep_paths_build_expand_and_drop_on_a_small_stack() {
std::thread::Builder::new()
.stack_size(128 * 1024)
.spawn(|| {
let file = format!("{}leaf.arcadia", "folder/".repeat(600));
let workspace = workspace(&["project.arcadia", &file]);
let mut state = State::default();
assert_eq!(rows(&workspace, &[], &state).len(), 2);
state.reveal(&file);
let all = rows(&workspace, &[], &state);
let leaf = all.iter().find(|row| row.path == file).unwrap();
assert_eq!(leaf.depth, 600);
assert_eq!(all.len(), 602);
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn active_scroll_offset_uses_the_visible_order_and_retains_ancestor_context() {
let mut workspace = workspace(&[
"project.arcadia",
"a/first.arcadia",
"b/inner/deeper/active.arcadia",
]);
workspace.active = "b/inner/deeper/active.arcadia".into();
let mut state = State::default();
state.reveal("a/first.arcadia");
state.reveal(&workspace.active);
let offset = active_offset(&workspace, &[], &state);
assert_eq!((offset.x, offset.y), (32., 128.));
state.toggle("b/inner");
let offset = active_offset(&workspace, &[], &state);
assert_eq!((offset.x, offset.y), (0., 64.));
workspace.files.clear();
let offset = active_offset(&workspace, &[], &state);
assert_eq!((offset.x, offset.y), (0., 0.));
}
}
+10 -1
View File
@@ -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
}]
);
}
+107 -316
View File
@@ -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(
@@ -398,23 +382,26 @@ fn diagram(app: &App) -> View<'_, Message> {
.spacing(10)
.align_y(iced::Center);
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
widget::sensor(
widget::canvas(DiagramCanvas {
project: app.project(),
diagram: id,
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, i)| i),
editable: !app.draft_dirty() && !app.source_dirty(),
})
.width(Fill)
.height(Fill),
)
let program = DiagramCanvas {
project: app.project(),
diagram: id,
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, i)| i),
editable: !app.draft_dirty() && !app.source_dirty(),
connection: app.connection.as_ref(),
};
widget::sensor(widget::stack![
widget::canvas(program).width(Fill).height(Fill),
widget::canvas(crate::diagram::PortLabels(program))
.width(Fill)
.height(Fill),
])
.on_show(Message::CanvasResized)
.on_resize(Message::CanvasResized)
.into()
@@ -439,7 +426,20 @@ fn diagram(app: &App) -> View<'_, Message> {
.center(Fill)
.into()
};
let mut tools = column![toolbar, modes].spacing(8);
let choose = |tool: Option<crate::connection_tool::Tool>| {
let active = app.connection.as_ref().map(|s| s.tool) == tool;
button(text(tool.map_or("Select / move", |t| t.label())).size(12))
.padding([8, 10])
.on_press(Message::Connection(crate::connection_edit::Event::Choose(
tool,
)))
.style(if active { button::primary } else { style::nav })
};
let mut palette = row![choose(None)].spacing(4);
for tool in crate::connection_tool::Tool::for_layer(app.layer) {
palette = palette.push(choose(Some(*tool)));
}
let mut tools = column![toolbar, modes, palette].spacing(8);
if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram);
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
@@ -451,39 +451,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 +539,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 +607,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 +658,8 @@ fn inspector(app: &App) -> View<'_, Message> {
})
)
}),
)),
);
body = body.push(label(
"Choose the other participant's port in the next step.",
));
}
let contextual: &[(&str, RelationKind, bool)] = match kind {
Some(Kind::OperationalMission | Kind::Mission) => &[
(
"Exploit capability…",
RelationKind::MissionCapability,
false,
),
(
"Involve participant…",
RelationKind::MissionInvolvement,
true,
),
],
Some(Kind::Capability | Kind::SystemCapability) => &[
(
"Include capability…",
RelationKind::CapabilityInclude,
false,
),
("Extend capability…", RelationKind::CapabilityExtend, false),
(
"Specialize capability…",
RelationKind::CapabilityGeneralization,
false,
),
(
"Involve participant or work…",
RelationKind::CapabilityInvolvement,
true,
),
],
_ => &[],
};
if !contextual.is_empty() {
let choices = contextual
.iter()
.map(|(label, kind, incoming)| crate::workflow::RelationAction {
label,
kind: *kind,
incoming: *incoming,
})
.collect::<Vec<_>>();
body = body.push(
pick_list(choices, None::<crate::workflow::RelationAction>, |choice| {
Message::AddRelationKind(choice.kind, choice.incoming)
})
.placeholder("Modeling actions…")
.padding(9)
.text_size(12)
.width(Fill),
)
}),
);
}
if let Some(r) = p.relations.get(&app.selected) {
@@ -788,82 +683,6 @@ fn inspector(app: &App) -> View<'_, Message> {
if let Some(d) = &app.draft {
body = body.push(forms::view(d, p));
}
let links = p
.relations
.values()
.filter(|r| r.source() == app.selected || r.target() == app.selected)
.collect::<Vec<_>>();
if !links.is_empty() {
body = body
.push(Space::new().height(6))
.push(label("RELATED ELEMENTS"));
for r in links {
let other = if r.source() == app.selected {
r.target()
} else {
r.source()
};
body = body.push(
container(
column![
action(
&format!("{} · {}", r.kind(), short(&r.name, 24)),
Message::Select(r.id)
)
.width(Fill),
action(
&format!("→ {}", short(p.named(other), 34)),
Message::Select(other)
)
.width(Fill)
]
.spacing(2),
)
.style(style::subtle),
);
}
}
for t in p
.traces
.values()
.filter(|t| t.oa == app.selected || t.sa == app.selected)
{
let other = if t.oa == app.selected { t.sa } else { t.oa };
body = body.push(
action(
&format!(
"{}: {}",
if t.oa == app.selected {
"Realized by"
} else {
"Realizes"
},
short(p.named(other), 29)
),
Message::Select(other),
)
.width(Fill),
);
}
if p.layer_of(app.selected).is_some() {
body = body
.push(action("+ Relationship", Message::AddRelation).width(Fill))
.push(
button(text("Create / update counterpart…").size(12))
.padding(10)
.on_press(Message::StartTransition)
.width(Fill),
);
}
body = body.push(action("+ Artifact reference", Message::AddArtifact).width(Fill));
if app.selected != p.id {
body = body.push(
button(text("Delete from model…").size(11))
.padding(9)
.style(button::danger)
.on_press(Message::Delete),
);
}
container(
column![
tabs,
@@ -912,7 +731,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 +761,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 +856,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)
-12
View File
@@ -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),
+2
View File
@@ -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;
+447
View File
@@ -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(&current.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::<Vec<Command>>(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::<Project>(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(&current);
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, &current, commands.clone(), false).await;
assert_eq!(no_op.headers()["etag"], current.etag());
assert_eq!(no_op.json::<Value>().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(&current);
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(&current).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::<Value>().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,
&current,
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, &current, json!([reparent]), false)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
transact(
&server,
&current,
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(&current).unwrap()
);
let remove = json!({"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}});
assert_eq!(
transact(&server, &current, 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::<Command>(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;
}
@@ -0,0 +1,339 @@
//! The public state-view representation is shared by the language, REST and UI.
use oa_core::{Command, Id, Kind, Layer, Project, ProjectionKind, language, validate_candidate};
use oa_server::{
app,
store::{Store, StoredProject},
};
use reqwest::{Client, StatusCode};
use serde_json::{Value, json};
use std::{path::Path, sync::Arc};
use tokio::{sync::oneshot, task::JoinHandle};
struct Server {
base: String,
client: Client,
stop: oneshot::Sender<()>,
task: JoinHandle<()>,
}
impl Server {
async fn start(path: &Path) -> Self {
let path = path.to_owned();
let store = tokio::task::spawn_blocking(move || Store::open(path))
.await
.unwrap()
.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/api/v1", listener.local_addr().unwrap());
let (stop, done) = oneshot::channel();
let task = tokio::spawn(async move {
axum::serve(listener, app(Arc::new(store), None))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
Self {
base,
client: Client::new(),
stop,
task,
}
}
async fn get(&self, id: Id) -> StoredProject {
self.client
.get(format!("{}/projects/{id}", self.base))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap()
}
async fn edit(&self, current: &StoredProject, projection: Value) -> reqwest::Response {
let diagram = current.model.diagrams.values().next().unwrap();
let mut record = json!(diagram);
record["projection"] = projection;
self.client
.post(format!(
"{}/projects/{}/transactions",
self.base, current.resource_id
))
.header("if-match", current.etag())
.json(&json!({"commands":[{"EditDiagram":[diagram.id, record]}],"dry_run":false}))
.send()
.await
.unwrap()
}
async fn stop(self) {
let _ = self.stop.send(());
drop(self.client);
tokio::time::timeout(std::time::Duration::from_secs(5), self.task)
.await
.unwrap()
.unwrap();
}
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn documented_model() -> Project {
// Parse the actual complete example, so an invalid published constructor or
// reference cannot drift away from the server's accepted representation.
let contract = include_str!("../../../docs/language.rst");
let example = contract
.split("This complete source example places one state from an existing machine:")
.nth(1)
.unwrap()
.split(".. code-block:: text")
.nth(1)
.unwrap();
let source = example
.lines()
.skip_while(|line| line.trim().is_empty())
.take_while(|line| line.starts_with(" ") || line.is_empty())
.map(|line| line.strip_prefix(" ").unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
let model = language::parse(&source).unwrap();
validate_candidate(&model).unwrap();
model
}
fn assert_pair(current: &StoredProject) {
use oa_core::source_project::{ProjectLimits, compile_bundle};
let compiled = compile_bundle(&current.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, current.model);
assert_eq!(compiled.bundle, current.source_project);
}
#[tokio::test]
async fn state_view_source_json_transactions_and_restart_share_the_published_contract() {
let mut model = documented_model();
let machine = id(&model, "modes");
let crew = id(&model, "crew");
let other = model
.add_element(Kind::StateMachine, Layer::Oa, "Other modes", Some(crew))
.unwrap();
let system = model
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let wrong_layer = model
.add_element(Kind::StateMachine, Layer::Sa, "System modes", Some(system))
.unwrap();
let diagram = id(&model, "mode_view");
let directory = tempfile::tempdir().unwrap();
let server = Server::start(directory.path()).await;
let response = server
.client
.post(format!("{}/projects/import", server.base))
.json(&model)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let imported: Value = response.json().await.unwrap();
let resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
let original = server.get(resource).await;
assert_eq!(original.model, model);
assert_pair(&original);
let source = server
.client
.get(format!("{}/projects/{resource}/source", server.base))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.text()
.await
.unwrap();
assert!(source.contains("machine @modes;"));
assert_eq!(language::parse(&source).unwrap(), model);
let response = server
.client
.post(format!("{}/projects/import", server.base))
.header("content-type", "text/plain")
.body(source)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let imported: Value = response.json().await.unwrap();
let source_resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
assert_eq!(server.get(source_resource).await.model, model);
for malformed in [
json!("StateMachine"),
json!({"StateMachine":{}}),
json!({"StateMachine":{"machine":null}}),
json!({"StateMachine":{"machine":"not-a-uuid"}}),
json!({"StateMachine":{"machine":machine,"extra":1}}),
json!({"StateMachine":{"machine":machine},"Overview":null}),
] {
assert_eq!(
server.edit(&original, malformed.clone()).await.status(),
StatusCode::BAD_REQUEST,
"{malformed}"
);
}
for (context, code) in [
(Id::nil(), "state_diagram_context"),
(crew, "state_diagram_context"),
(wrong_layer, "state_diagram_context"),
(other, "state_diagram_member"),
] {
let response = server
.edit(&original, json!({"StateMachine":{"machine":context}}))
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let problem: Value = response.json().await.unwrap();
assert_eq!(problem["code"], "command_rejected");
// Commands report the shared validator's first failure as detail.
// Candidate validation provides the structured diagnostic collection.
let mut candidate = original.model.clone();
candidate.diagrams.get_mut(&diagram).unwrap().projection =
ProjectionKind::StateMachine { machine: context };
let checked = server
.client
.post(format!("{}/projects/{resource}/validation", server.base))
.json(&json!({"format":"json","model":candidate}))
.send()
.await
.unwrap();
assert_eq!(checked.status(), StatusCode::OK);
let checked: Value = checked.json().await.unwrap();
assert_eq!(checked["valid"], false);
assert!(
checked["issues"]
.as_array()
.unwrap()
.iter()
.any(|issue| issue["code"] == code),
"{checked}"
);
}
let unchanged = server.get(resource).await;
assert_eq!(json!(unchanged), json!(original));
// A context change and removal of incompatible placements is one accepted
// diagram edit; no semantic record or implied replacement placement appears.
let mut changed = model.diagrams[&diagram].clone();
changed.projection = ProjectionKind::StateMachine { machine: other };
changed.nodes.clear();
let response = server
.client
.post(format!("{}/projects/{resource}/transactions", server.base))
.header("if-match", original.etag())
.json(&json!({"commands":[Command::EditDiagram(diagram, changed.clone())],"dry_run":false}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let current = server.get(resource).await;
assert_eq!(current.revision, original.revision + 1);
assert_eq!(current.model.diagrams[&diagram], changed);
assert_eq!(current.model.elements, model.elements);
assert_pair(&current);
// Source writes use the identical context reference and conditional commit.
let source = language::format(&current.model);
let invalid = source.replace("machine @other_modes;", "machine @crew;");
assert_ne!(invalid, source);
let response = server
.client
.put(format!("{}/projects/{resource}/source", server.base))
.header("if-match", current.etag())
.header("content-type", "text/plain")
.body(invalid)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(json!(server.get(resource).await), json!(current));
let response = server
.client
.put(format!("{}/projects/{resource}/source", server.base))
.header("if-match", current.etag())
.header("content-type", "text/plain")
.body(source.replace("machine @other_modes;", "machine @modes;"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let final_state = server.get(resource).await;
assert_eq!(final_state.revision, current.revision + 1);
assert_eq!(
final_state.model.diagrams[&diagram]
.projection
.state_machine(),
Some(machine)
);
assert!(final_state.model.diagrams[&diagram].nodes.is_empty());
assert_pair(&final_state);
server.stop().await;
let server = Server::start(directory.path()).await;
assert_eq!(json!(server.get(resource).await), json!(final_state));
server.stop().await;
}
#[tokio::test]
async fn served_state_projection_schema_preserves_closed_string_and_payload_variants() {
let directory = tempfile::tempdir().unwrap();
let server = Server::start(directory.path()).await;
let spec: Value = server
.client
.get(format!("{}/openapi.json", server.base))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
let schemas = &spec["components"]["schemas"];
assert_eq!(
schemas["Diagram"]["properties"]["projection"]["$ref"],
"#/components/schemas/ProjectionKind"
);
let variants = schemas["ProjectionKind"]["oneOf"].as_array().unwrap();
assert_eq!(variants.len(), 2);
let strings = variants[0]["enum"].as_array().unwrap();
assert_eq!(strings.len(), 6);
for value in strings {
assert_eq!(
json!(serde_json::from_value::<ProjectionKind>(value.clone()).unwrap()),
*value
);
}
assert!(!strings.contains(&json!("StateMachine")));
assert_eq!(variants[1]["required"], json!(["StateMachine"]));
assert_eq!(variants[1]["additionalProperties"], false);
let payload = &variants[1]["properties"]["StateMachine"];
assert_eq!(payload["required"], json!(["machine"]));
assert_eq!(payload["additionalProperties"], false);
assert_eq!(payload["properties"]["machine"]["type"], "string");
assert_eq!(payload["properties"]["machine"]["format"], "uuid");
for example in schemas["ProjectionKind"]["examples"].as_array().unwrap() {
assert_eq!(
json!(serde_json::from_value::<ProjectionKind>(example.clone()).unwrap()),
*example
);
}
server.stop().await;
}
+58 -4
View File
@@ -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.</p>
<p>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 <span class="docutils literal">422 model_limit</span>;
@@ -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
<span class="docutils literal">changed: false</span> and the original ETag.</p>
</section>
<section id="diagram-port-positions">
<h2>Diagram port positions</h2>
<p><span class="docutils literal">NodePlacement.port_anchors</span> is an optional list of
<span class="docutils literal"><span class="pre">{&quot;port&quot;:&quot;&lt;port</span> <span class="pre">UUID&gt;&quot;,&quot;side&quot;:&quot;Top&quot;,&quot;offset&quot;:0.25}</span></span> 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.</p>
<p>The existing transaction endpoint accepts:</p>
<pre class="literal-block">{&quot;commands&quot;:[{&quot;SetPortAnchor&quot;:{
&quot;diagram&quot;:&quot;&lt;diagram UUID&gt;&quot;,&quot;node&quot;:&quot;&lt;placement UUID&gt;&quot;,
&quot;anchor&quot;:{&quot;port&quot;:&quot;&lt;port UUID&gt;&quot;,&quot;side&quot;:&quot;Top&quot;,&quot;offset&quot;:0.25}
}}]}</pre>
<p><span class="docutils literal">SetPortAnchor</span> upserts by port UUID. <span class="docutils literal">RemovePortAnchor</span> takes
<span class="docutils literal">{diagram,node,port}</span> 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 <span class="docutils literal">port_anchors</span>;
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
</section>
<section id="contextual-process-and-chain-topology">
<h2>Contextual process and chain topology</h2>
<p>Operational processes and system functional chains can combine work
@@ -963,8 +987,9 @@ reinterpret old mixed lifelines as a strict new viewpoint.</p>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 3; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
if its candidate is invalid. A malformed request envelope or JSON model record
is a 400. Source diagnostics include one-based line and column according to the
if its candidate is invalid, including a model record that fails typed decoding.
Malformed request JSON or an invalid format/payload envelope is a 400.
Source diagnostics include one-based line and column according to the
shared language parser. Candidate validation does not replace current state.
Source-project validation locations refer to the submitted bytes before UUID
insertion. Successful source-project PUT diagnostics refer to the returned
@@ -994,6 +1019,35 @@ introduce a completion transition. Invalid typed references reject the whole
candidate and preserve revision/ETag. All writes retain the same conditional
commit and disk persistence contract as other semantic records.</p>
</section>
<section id="state-diagram-representation">
<h2>State diagram representation</h2>
<p><span class="docutils literal">Diagram.projection</span> keeps the existing string values <span class="docutils literal">Overview</span>,
<span class="docutils literal">Decomposition</span>, <span class="docutils literal">Allocation</span>, <span class="docutils literal">Traceability</span>, <span class="docutils literal">FunctionalDataflow</span> and
<span class="docutils literal">Capabilities</span>. A dedicated state view instead uses the required payload:</p>
<pre class="literal-block">{&quot;StateMachine&quot;:{&quot;machine&quot;:&quot;11111111-1111-4111-8111-111111111111&quot;}}</pre>
<p>The UUID MUST identify an existing StateMachine in the diagram's layer.
Only that machine, its Regions and its state vertices may be placed, including
nested members. Empty and partial views are valid. No machine, Region, vertex
or placement is synthesized by accepting or reading a diagram. Source uses
<span class="docutils literal">projection { StateMachine { machine &#64;modes; }; };</span> with the same reference.</p>
<p>Use the ordinary <span class="docutils literal">CreateDiagram</span> or <span class="docutils literal">EditDiagram</span> transaction commands,
JSON model import/replacement, or source/source-project writes. A bare
<span class="docutils literal">&quot;StateMachine&quot;</span>, missing or null <span class="docutils literal">machine</span>, malformed UUID, or additional
payload field is malformed JSON model data (400). A missing, wrong-kind or
cross-layer machine produces the semantic issue <span class="docutils literal">state_diagram_context</span>;
an incompatible placement produces <span class="docutils literal">state_diagram_member</span>. These reject
writes with 422. Transaction rejections use the <span class="docutils literal">command_rejected</span> problem
code and include the first core failure in <span class="docutils literal">detail</span>; candidate validation
provides the structured <span class="docutils literal">issues</span> collection.
Source syntax errors use the source rejection response (422) with locations.
The validation endpoint returns 200 with <span class="docutils literal">valid:false</span> for a well-formed
invalid candidate, as described above.</p>
<p>When changing a diagram's machine or selecting StateMachine for an existing
diagram, remove or relocate incompatible placements in the same transaction.
Other projections continue to preserve hidden placements. Rejected writes
retain the accepted model, source bundle, revision and ETag. Reading a state
view has no semantic or persistence side effects.</p>
</section>
<section id="cross-layer-transitions">
<h2>Cross-layer transitions</h2>
<p>Preview accepts the shared <span class="docutils literal">TransitionRequest</span> record, including direction
+67 -4
View File
@@ -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":"<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
-------------------------------------
@@ -358,8 +386,9 @@ or::
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
if its candidate is invalid. A malformed request envelope or JSON model record
is a 400. Source diagnostics include one-based line and column according to the
if its candidate is invalid, including a model record that fails typed decoding.
Malformed request JSON or an invalid format/payload envelope is a 400.
Source diagnostics include one-based line and column according to the
shared language parser. Candidate validation does not replace current state.
Source-project validation locations refer to the submitted bytes before UUID
insertion. Successful source-project PUT diagnostics refer to the returned
@@ -392,6 +421,40 @@ introduce a completion transition. Invalid typed references reject the whole
candidate and preserve revision/ETag. All writes retain the same conditional
commit and disk persistence contract as other semantic records.
State diagram representation
----------------------------
``Diagram.projection`` keeps the existing string values ``Overview``,
``Decomposition``, ``Allocation``, ``Traceability``, ``FunctionalDataflow`` and
``Capabilities``. A dedicated state view instead uses the required payload::
{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}
The UUID MUST identify an existing StateMachine in the diagram's layer.
Only that machine, its Regions and its state vertices may be placed, including
nested members. Empty and partial views are valid. No machine, Region, vertex
or placement is synthesized by accepting or reading a diagram. Source uses
``projection { StateMachine { machine @modes; }; };`` with the same reference.
Use the ordinary ``CreateDiagram`` or ``EditDiagram`` transaction commands,
JSON model import/replacement, or source/source-project writes. A bare
``"StateMachine"``, missing or null ``machine``, malformed UUID, or additional
payload field is malformed JSON model data (400). A missing, wrong-kind or
cross-layer machine produces the semantic issue ``state_diagram_context``;
an incompatible placement produces ``state_diagram_member``. These reject
writes with 422. Transaction rejections use the ``command_rejected`` problem
code and include the first core failure in ``detail``; candidate validation
provides the structured ``issues`` collection.
Source syntax errors use the source rejection response (422) with locations.
The validation endpoint returns 200 with ``valid:false`` for a well-formed
invalid candidate, as described above.
When changing a diagram's machine or selecting StateMachine for an existing
diagram, remove or relocate incompatible placements in the same transaction.
Other projections continue to preserve hidden placements. Rejected writes
retain the accepted model, source bundle, revision and ETag. Reading a state
view has no semantic or persistence side effects.
Cross-layer transitions
-----------------------
+72 -9
View File
@@ -1285,18 +1285,48 @@ Studio profile; no running history state is persisted or synthesized.</p>
<h2>5. Diagrams, artifacts, and cross-layer traceability</h2>
<p><span class="docutils literal">diagram</span> has name, identity, alias, description, <span class="docutils literal">layer</span> (Oa by default),
<span class="docutils literal">projection</span> (Overview by default), and <span class="docutils literal">routes</span> (empty list by default).
Other projections are Decomposition,
Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
other diagrams show their chosen layer.</p>
The string projections are Overview, Decomposition, Allocation,
FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
constructor with one required <span class="docutils literal">machine</span> reference. Traceability diagrams
can show both layers; other diagrams show their chosen layer.</p>
<p>Allocation is an architecture view: it shows participants and work, nests
allocated work under its performer, and displays interactions, functional
exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
and its exchanges. Capabilities shows participants, roles, missions and
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
Overview also exposes all relationships. These string projections hide
inapplicable placements without deleting them or changing semantic records.</p>
<p>Ports are calculated boundary anchors of visible owners, with their existing
<p>A StateMachine projection MUST reference an existing StateMachine in the
diagram's layer. Its placements MUST refer to that machine, its Regions, or
its state vertices, including nested Regions and vertices. Events, other
machines and their members, and unrelated semantic elements are not valid
placements in this view. Empty and partial state views are valid; neither
creating nor reading a view synthesizes semantic elements or placements.
Changing an existing diagram to StateMachine, or changing its machine, MUST
remove or relocate incompatible placements in the same transaction. Context
errors use <span class="docutils literal">state_diagram_context</span>; incompatible placements use
<span class="docutils literal">state_diagram_member</span>. Neither case silently hides invalid members.</p>
<p>This complete source example places one state from an existing machine:</p>
<pre class="code text literal-block"><code>arcadia 3;
project operating &quot;Operating modes&quot; {
oa_entity crew &quot;Crew&quot; {}
oa_state_machine modes &quot;Modes&quot; { parent &#64;crew; }
oa_region main &quot;Main region&quot; { parent &#64;modes; }
oa_state ready &quot;Ready&quot; { parent &#64;main; }
diagram mode_view &quot;Operating modes&quot; {
layer Oa;
projection { StateMachine { machine &#64;modes; }; };
}
node ready_view { diagram &#64;mode_view; element &#64;ready; }
}</code></pre>
<p>The JSON projection is
<span class="docutils literal"><span class="pre">{&quot;StateMachine&quot;:{&quot;machine&quot;:&quot;11111111-1111-4111-8111-111111111111&quot;}}</span></span>,
where the UUID is the referenced machine's identity. A bare <span class="docutils literal">&quot;StateMachine&quot;</span>,
a missing or null <span class="docutils literal">machine</span>, an invalid UUID, or additional payload fields
is malformed. Existing projections retain their string representation, for
example <span class="docutils literal">&quot;Overview&quot;</span> in JSON and <span class="docutils literal">projection Overview;</span> in source.</p>
<p>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 +1335,45 @@ may terminate at a visible ancestor without creating a new port or exchange.</p>
<p><span class="docutils literal">node</span> has an alias and identity but no display-name string. Required fields
are <span class="docutils literal">diagram</span> and <span class="docutils literal">element</span> references. Optional fields are <span class="docutils literal">position</span>
(record with x/y, default 0/0), <span class="docutils literal">size</span> (width/height, default 224/94), and
<span class="docutils literal">collapsed</span> (false). Node identity is independent of its semantic element.
<span class="docutils literal">collapsed</span> (false), and <span class="docutils literal">port_anchors</span> (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.</p>
size, collapse state, manual port anchors, and attached artifacts. The destination
must accept the element's layer, satisfy StateMachine membership when applicable,
and must not already contain another placement of that element.</p>
<p>Each <span class="docutils literal">port_anchors</span> entry is a PortAnchor record with required <span class="docutils literal">port</span>
reference, <span class="docutils literal">side</span> (Left, Right, Top, or Bottom), and <span class="docutils literal">offset</span> (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 <span class="docutils literal">element</span>. A port MAY
have one manual anchor per placement; different diagrams MAY position it
independently. List order is preserved but has no positional meaning.</p>
<p><span class="docutils literal">offset</span> 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 <span class="docutils literal">[]</span>.</p>
<pre class="code text literal-block"><code>node actor_view {
diagram &#64;architecture; element &#64;vessel;
port_anchors [{ port &#64;communication; side Top; offset 0.25; }];
}</code></pre>
<p><span class="docutils literal">SetPortAnchor { diagram, node, anchor }</span> upserts by port identity and retains
unrelated entries. <span class="docutils literal">RemovePortAnchor { diagram, node, port }</span> 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 <span class="docutils literal">ParseLimits.max_records</span>.</p>
<p>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
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
@@ -1615,7 +1677,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.</p>
model fields. Layout, collapse state, and manual port anchors inside
NodePlacement are model data.</p>
<p>The pure <span class="docutils literal">format_bundle</span> exporter uses the same typed declaration writer as
<span class="docutils literal">format</span>. It creates a small entry file, layer/category modules, separate
activity/function roots and behavior contexts, and separate diagrams with their
+85 -9
View File
@@ -625,9 +625,10 @@ Studio profile; no running history state is persisted or synthesized.
``diagram`` has name, identity, alias, description, ``layer`` (Oa by default),
``projection`` (Overview by default), and ``routes`` (empty list by default).
Other projections are Decomposition,
Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
other diagrams show their chosen layer.
The string projections are Overview, Decomposition, Allocation,
FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
constructor with one required ``machine`` reference. Traceability diagrams
can show both layers; other diagrams show their chosen layer.
Allocation is an architecture view: it shows participants and work, nests
allocated work under its performer, and displays interactions, functional
@@ -635,10 +636,45 @@ exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
and its exchanges. Capabilities shows participants, roles, missions and
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
Overview also exposes all relationships. These string projections hide
inapplicable placements without deleting them or changing semantic records.
Ports are calculated boundary anchors of visible owners, with their existing
A StateMachine projection MUST reference an existing StateMachine in the
diagram's layer. Its placements MUST refer to that machine, its Regions, or
its state vertices, including nested Regions and vertices. Events, other
machines and their members, and unrelated semantic elements are not valid
placements in this view. Empty and partial state views are valid; neither
creating nor reading a view synthesizes semantic elements or placements.
Changing an existing diagram to StateMachine, or changing its machine, MUST
remove or relocate incompatible placements in the same transaction. Context
errors use ``state_diagram_context``; incompatible placements use
``state_diagram_member``. Neither case silently hides invalid members.
This complete source example places one state from an existing machine:
.. code-block:: text
arcadia 3;
project operating "Operating modes" {
oa_entity crew "Crew" {}
oa_state_machine modes "Modes" { parent @crew; }
oa_region main "Main region" { parent @modes; }
oa_state ready "Ready" { parent @main; }
diagram mode_view "Operating modes" {
layer Oa;
projection { StateMachine { machine @modes; }; };
}
node ready_view { diagram @mode_view; element @ready; }
}
The JSON projection is
``{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}``,
where the UUID is the referenced machine's identity. A bare ``"StateMachine"``,
a missing or null ``machine``, an invalid UUID, or additional payload fields
is malformed. Existing projections retain their string representation, for
example ``"Overview"`` in JSON and ``projection Overview;`` in source.
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 +684,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, satisfy StateMachine membership when applicable,
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 +1061,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
+118 -9
View File
@@ -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": [
@@ -3651,6 +3685,53 @@
],
"additionalProperties": false
},
"ProjectionKind": {
"description": "Existing projections remain strings. StateMachine carries one required UUID reference to an existing StateMachine in the diagram layer (state_diagram_context). Its placements may contain only that machine and its regions/state vertices, including nested members (state_diagram_member). Empty and partial views are valid. Incompatible placements must be removed or relocated in the same transaction when changing context; accepting or reading a view does not synthesize semantic records or placements.",
"oneOf": [
{
"type": "string",
"enum": [
"FunctionalDataflow",
"Capabilities",
"Overview",
"Decomposition",
"Allocation",
"Traceability"
]
},
{
"type": "object",
"properties": {
"StateMachine": {
"type": "object",
"properties": {
"machine": {
"type": "string",
"format": "uuid",
"description": "Required identity of the same-layer StateMachine that owns this view's members."
}
},
"required": [
"machine"
],
"additionalProperties": false
}
},
"required": [
"StateMachine"
],
"additionalProperties": false
}
],
"examples": [
"Overview",
{
"StateMachine": {
"machine": "11111111-1111-4111-8111-111111111111"
}
}
]
},
"Diagram": {
"type": "object",
"properties": {
@@ -3673,15 +3754,7 @@
"$ref": "#/components/schemas/Layer"
},
"projection": {
"type": "string",
"enum": [
"FunctionalDataflow",
"Capabilities",
"Overview",
"Decomposition",
"Allocation",
"Traceability"
]
"$ref": "#/components/schemas/ProjectionKind"
},
"nodes": {
"type": "object",
@@ -4734,6 +4807,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": {
+25 -19
View File
@@ -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.
+23
View File
@@ -35,3 +35,26 @@
- 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.
- Port identity feedback is a passive stacked Canvas: Iced Frame separation alone
does not create GPU layering. The independent native overprint reproduction
established the need; input stays with the existing interactive child.
- Each checkout uses a distinct Cargo target directory. A shared-target Linux run
reused a different worktree's test executable; only explicitly rebuilt main
results count as evidence.
## Persisted state workspace and input coordinates (2026-09-06)
State-machine views use the core projection/routing and ordinary Document commands,
with a retained behavior owner/view separate from Architecture. Palette gestures
create elements plus placements atomically; typed forms remain reachable for Local,
Internal and timed transitions. Context menus capture the state diagram so fold,
hide and placement properties cannot modify an unrelated Architecture view.
Gesture displacement is measured in ordered window coordinates, not coordinates
relative to a canvas whose origin may move when selection changes the toolbar.
Viewport notifications fit only on actual size changes. Native testing also showed
Iced WGPU places parent meshes after clipped child meshes; backgrounds must share
their content clip to avoid hiding vector geometry while leaving text visible.
+3 -1
View File
@@ -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.
The user-directed contextual-menu/source-tree, movable-port and direct-connection milestone has scoped Windows/Linux native acceptance. See release-port-drawing.md for exact builds, saved trials and minimum-size boundaries. Continue direct state-machine editing in the isolated state-integration checkout: persist typed machine context, use shared state/route geometry, retain drafts until accepted document commit, then verify creation, movement, transition drawing/reconnection, undo and reopen. This first behavior slice precedes scenario/process gestures; 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.
+161 -40
View File
@@ -2,66 +2,187 @@
## Objective and current phase
The first-release goal remains active and incomplete: a Rust/Iced OA and SA/System Architecture tool for system engineers with equivalent graphical, textual and REST semantics, hierarchical diagrams and typed ports, modular source projects, artifact traceability, direct behavior editing, offline help, and Windows/Linux release evidence. The authoritative objective is attachment `07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md`, fully read on 2026-09-05. PLAN.md, TASK_LEDGER.md and release-semantics-audit.md retain the broader scope.
The first-release goal remains active and incomplete. Deliver Rust/Iced Operational
Analysis and System Analysis/System Architecture for system engineers, equivalent
graphical/textual/REST semantics, hierarchical diagrams and typed ports, modular
source projects, artifact traceability, editable behavior, useful offline help,
and Windows/Linux release evidence. Later Arcadia layers remain future releases.
Authoritative objective: attachment
`07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md`, reread 2026-09-06.
PLAN.md, TASK_LEDGER.md and release-semantics-audit.md retain full release scope.
The editable connection-routing milestone is accepted at `23535b4` with viewport follow-up `366ce44`. Per-diagram authoring, obstacle-aware geometry, typed/nested workshop movement, save/reopen, SVG and minimum-size gestures are verified. Commit `65fa9ab` resolved all six reproduced default SA label-overlap pairs. Fresh correctness and native UI review found no unresolved material routing finding; root also verified the actual Linux application and the viewport follow-up. The broader first release remains incomplete; production text authoring is active. See release-routing-verification.md and release-routing-linux.md for exact evidence and limitations. Root alone operates native applications.
The latest user-directed drawing/port/menu/source-tree milestone is accepted at
`fef2e0d` plus tooltip-layer follow-up `ad7feb5`. It does not complete the release.
Root now integrates direct state-machine editing in an isolated worktree. There
is meaningful implementation, independent review and real runtime progress; no
external blocker or disk blocker exists.
## Completed foundations
## Accepted foundations and evidence
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.
- Typed OA/SA model, schema 3 ports, validated atomic commands and ordinary history
are shared across three Rust crates. Typed contracts and migration are documented
in release-port-contract.md, release-port-core.md and release-port-migration.md.
Desktop typed authoring checkpoint `fa096ce` passed native creation, generation,
undo/redo and persistence. Original Capella was operated in a disposable workspace;
exact observed semantics/workflows: release-capella-workflow-benchmark.md.
- Explorer/port discovery `d364d3c`: visible Add port, grouped roots, focused children,
breadcrumbs, contextual search/paging and full-depth outline. Windows port/Standard
exchange, 251-root paging, 12-level navigation and 1100x700 trials passed; rebuilt
Linux save/reopen/font correction passed. See release-explorer-usability.md.
- Editable routes `23535b4`, viewport fix `366ce44`, automatic labels `65fa9ab`:
authored bends/labels, nested group movement, folding/restoration, undo/redo,
save/reopen, SVG and minimum-size gestures passed native and independent review.
See release-routing-verification.md and release-routing-linux.md. Dense optimized
full solve measured119ms; arbitrary dense crossings remain possible.
- Nested source and coupled hosted source/model checkpoint `4e8575b`: source3
element nesting/includes, token/provenance-aware structural writes, exact model
source history, retained drafts and safe Save All/recovery. Full422 Windows and
425 Linux tests, strict checks and builds passed at that checkpoint. Actual REST
import/comment-only revision/cross-file reparent/stale ETag/restart passed.
Windows/Linux source and nested graphical edits, save/reopen and recovery were
exercised. See release-source-integration.md. Experiments remain evidence only;
do not adopt their independent parser/writer.
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.
## Drawing acceptance — current main checkout
- 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.
`93fd32c` fixes candidate scaling without weakening validation; `35133de` supplies
persisted four-side normalized port anchors. `fef2e0d` adds direct palette drawing,
shared automatic labels, contextual menus and the source folder tree. OA Interaction,
SA Functional Exchange and Boundary Exchange accept click-click or drag. Function
cards create/reuse typed ports atomically. Compatible targets highlight; Standard
and Flow contracts remain distinct. Pan retains source; Escape cancels.
- 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.
- 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.
Independent review reproduced batched-pointer click corruption and overview port
mispicking. Ordered CursorMoved coordinates, explicit context epochs and nearest
port glyph picking fix these, with production regressions. Native review then
reproduced port-tooltip overprint: separate Frames still share an Iced GPU layer.
The fix uses a passive second stacked Canvas (a real renderer layer). It preserves
interactive child state/input and resets held-button suppression on context change,
matching releases, Escape and focus loss. CursorLeft alone does not end suppression.
## Routing implementation and verification
Evidence is in release-port-drawing.md and release-context-menus.md. Exact native
review report: `D:/CodexBuilds/Capella2-release1-01a06e2e/drawing-independent-native-review.md`.
Windows independent trials passed Standard rejection/completion, automatic function
ports, undo/redo, save/reopen and1100x700. Overprint was reproduced on9A7FF074 and
closed on3F534C67 at1440x900 and1100x700. Root tested final1EEBDC3C: first port drag,
menu outside dismissal, first repeated drag, direct FE, readable identity and Save.
Saved trial: `ui-port-tooltip-final/project.arcadia`,41objects/26links/0checks.
- `4e5a88d`: per-diagram `EdgeRoute` pins and label placement, atomic set/remove commands, validation, delete/reidentify cleanup, compatible empty defaults, group movement and layout translation. Routes refer to existing relation/trace IDs and do not duplicate semantic identity.
- `a7c6f96`: equivalent source, source-project update, JSON and REST behavior, located validation, OpenAPI and reST contracts.
- `68b1eff`: shared deterministic orthogonal routing and geometry for typed ports, nested obstacles, labels, bounds and collapse suspension. Desktop draw, hit testing, gesture previews, Fit and SVG use this geometry.
- `30c7db1`: label-only edits preserve the settled polyline and warnings; single-edge previews retain settled per-edge routing budgets. `65fa9ab` adds stable bounded automatic label placement, reducing the workshop's six reproduced label-overlap pairs to zero. All 18 geometry tests, strict core Clippy and fresh independent correctness review passed.
- `23535b4`: accepted desktop route authoring, shared geometry integration, route/label controls, guarded atomic gestures and release-cursor correction. Final independent native review captured clear minimum-size controls with the authored bend/label and the readable lower SA label band. Its full SA view was partly occluded; root's earlier full SA captures provide the complete-view evidence.
- Locked workspace tests passed with 196 core, 103 desktop and 22 server tests before the last geometry regressions were added. Final follow-up passed 104 desktop tests, strict full-workspace Clippy and desktop/server builds (`routing-final-*` logs). Independent review also passed four focused desktop tests for the final release-cursor correction, which commits fast drags despite coalesced motion events. Earlier follow-up passed the 1500-edge reproducer and six external preview/commit fixtures. Exact scopes are in release-routing-verification.md; a final full-workspace test rerun after all later edits is not claimed.
- Independent stress measurements on 100 nodes/150 edges with eight pins per edge: maximum single-edge preview 16.4 ms debug and 2.4 ms release; settled full solve 863 ms debug and 119 ms release. The optimized settled result slightly exceeds the initial 100 ms target; this remains a measured performance limitation, not a claim that the target passed.
- Routing workshop `4b8545d` adds deterministic, validated `.arcadia` and JSON examples plus a reproducible Rust generator. OA and SA architecture views contain three participants and nested work groups; SA includes real functional/component ports, Standard and Flow exchanges, and an internal System exchange for group-movement testing. Generation, source/JSON equality and zero semantic/routing issues were verified.
Root operated actual Linux7AC5EEE: first FE drag, Right-to-Bottom port drag, Reset/
Undo, repeated Bottom-to-Left drag, source-offscreen arrow pan and completion,
Save All/reopen, persisted geometry and source-tree expansion/open. Saved trial:
`ui-port-tooltip-linux/project.arcadia`,41objects/27links/0checks. Runtime log empty;
Linux window closed normally, exec13766 exit0. Linux was inspected at1440x900;
minimum-size acceptance is Windows, not a claim of Linux minimum-size testing.
## Native checkpoint and evidence boundaries
Verification scopes: full485 Windows /488 Linux workspace tests preceded the last
pointer/tooltip refinements. Latest main Windows193 desktop tests, strict desktop
all-target Clippy and debug build passed. Latest Linux189 desktop tests, strict
desktop Clippy and release build passed; four new tooltip lifecycle tests then
passed separately. A142-test Linux result from a shared-worktree Cargo cache was
identified and EXCLUDED; scoped package cleaning and explicit main recompilation
established the valid189-test result. Use a distinct CARGO_TARGET_DIR per worktree.
The reviewed executable at `ui-routing-reviewed/arcadia-studio.exe` has SHA256 `6DAD490D75F2A67A79A0804F33192EF62A3DCBEA92F5EEDA8EA47788D12273B8`. Root performed default-size OA edge selection without canvas movement, segment drag without a retracing spur, separate label drag with unchanged polyline, displaced-label selection, existing-bend drag, Undo/Redo, Remove bend, Reset routing, and undo restoration. Folding an endpoint container used an automatic promoted edge and disabled route editing; expansion restored the authored pin and label. Model checks remained zero.
## Active isolated state integration
Native Save As and SVG export produced `ui-routing-reviewed/routing-authoring-ui.arcadia` (33,548 bytes) and `.svg` (2,564 bytes). The source contains the authored waypoint and label offset. Root then launched `ui-routing-final/arcadia-studio.exe`, SHA256 `5558CC203E9DAFED3D0BCA1C40B5E4BDE426F00F8BC37DCD9B33A7338ADD5615`, containing `30c7db1`, duplicate fold-notice cleanup and export-dialog copy changes. Native Open reopened the saved source with “All changes saved”, 41 objects, 25 links and zero checks; the bend remained visible and the detached label selected the same edge. Root rendered the actual exported SVG to PNG with Sharp and visually verified the authored detour, label, bounds and nesting.
Worktree: `D:/CodexBuilds/Capella2-state-integration`, branch`codex/state-integration`.
Target: `D:/CodexBuilds/Capella2-state-integration-target`. Main core is not yet changed.
The workshop loaded with 12 cards/seven links in OA and 12 cards/12 links in SA; the full model reported 75 objects, 61 links and zero checks. Root dragged the internal Incident decision line to create a pin. Moving one function left that pin fixed; moving the whole System by snapped delta (-20, +40) translated the pin by the same amount. A single Undo/Redo restored both placement and pin state. The typed trial was saved to `ui-routing-final/typed-routing-authoring-ui.arcadia` (66,598 bytes); folding and expanding the System restored the route and the UI reported all changes saved.
- `e7a8163` integrates reviewed core state-context commit a63654f, preserving latest
anchors and routing labels. State views skip semantic port layout after boundary
snapping; both routing and label passes reserve actual internal-row headers.
- `2459581` retains behavior drafts until Document commit succeeds.
- `53cd096` adds typed state-context forms, placement membership choices and normative
source/JSON/REST/OpenAPI contracts. Strict HTML generation passed. Two actual HTTP
tests passed, including malformed/invalid rejection, revision/source atomicity and
restart. Destination pickers permit intermediate invalid drafts so coordinated
element/diagram retargeting is reachable; Apply remains strict.
- `e5660b8` integrates App dirty/Close/Save/navigation/mutation guards. Exact prepared
commands acknowledge drafts only after accepted source/model writes. Review found
passive viewport events applying properties, queued source controls creating
competing drafts and stale delete confirmations; all three fixes have App tests.
Six App lifecycle tests passed, including typed source-writeback rejection/retry
and one Undo. Clean detached buffers after Undo are intentional; reachable bundle
and accepted Document are restored exactly.
- Integrated baseline:514 Windows workspace tests (277core,199desktop,38server),
strict workspace all-target Clippy passed. This baseline precedes direct gestures;
there is no native state-gesture acceptance yet.
On `ui-routing-labels/arcadia-studio.exe`, SHA256 `4D4C8163EA2A81F2D9A241921571BE3118AE57ECD2987E7088878CBCA9F50536`, native reopen of that typed trial retained the SA custom pin and separated labels. Dragging an automatically displaced label was continuous and Undo worked. This closes the label finding together with the geometry tests and independent review.
Direct-state UI integration is now implemented (uncommitted): persisted canvas,
creation within explicit regions, movement, direct transitions and endpoint
reconnection, pan/zoom/Fit, typed detail forms, and contextual fold/unfold/hide.
The old computed static state scene is removed. Source/model commands remain atomic.
Independent review found removed typed-form access, unreachable folded contents,
selection relayout altering drag deltas, fallback owner resetting the first drag,
and same-size viewport events refitting the camera. Fixes preserve typed creation,
use state-specific menus, window-space ordered gesture deltas, retained owner and
actual-size resize checks. App tests also exposed Architecture placement leaking
into Behavior selection; the two view selections are now separate.
The accepted executable at `ui-routing-accepted/arcadia-studio.exe` has SHA256 `E9B662788CC3979F8E883299CAF9B16917498D6F0DEAB62E02D9A6CE5C18AFB2`. At 1100×700 (captured frame 1102×732), a short 29-pixel bend drag and approximately 18-pixel label drag succeeded on the first attempt. The canvas and controls remained stable; SVG dialog copy fitted. Native export produced `minimum-routing-ui.svg`; its PNG rendering was visually inspected for correct route, label and nesting. This later successful trial supersedes the earlier width-only resize attempt. The accepted minimum-size window retains an unsaved demo with those two gestures; preserve it before switching models. The saved typed workshop remains the durable SA fixture.
First direct-state Windows verification:231 desktop tests and strict desktop
all-target Clippy passed, logs `D:/CodexBuilds/Capella2-state-direct-{tests,clippy}.log`.
Native trial2A4B5C11 created a new SA StateMachine and dedicated view; it reproduced
missing box fills/borders while text remained. Iced WGPU appends parent meshes after
clipped children: the opaque parent background covered every node. The fix moves
the content background into the clip and restricts the outer fill to the guidance
strip. Native re-verification remains required. Saved reproduction is
`ui-state-direct-01/project.arcadia`,42objects/25links/2 expected incomplete-machine
checks. Trial window1442250 received normal close after verified Save; re-inventory.
Unexpected concurrent arrangement/view changes were separately preserved to `ui-routing-final/workshop-arrangement-preserved.arcadia` (66,434 bytes). A prior icon-only capture did not establish an application failure; subsequent full native captures succeeded. Obtain fresh inventory before further input instead of relying on historical window IDs.
Latest verification:551 Windows workspace tests (277core,236desktop,38server)
and strict workspace all-target Clippy passed. A single let-and-return lint in the
new label hit helper was fixed without changing behavior. Subsequent compact-toolbar
changes passed desktop Clippy and build. Exact logs use `state-direct-workspace-*`
and `state-compact-*` under D:/CodexBuilds.
Original Capella was operated in a disposable workspace. Native creation demonstrated real FE/function ports and explicit component-port allocation. `Format > Line Style > Oblique` followed by a segment drag created an actual bend without changing its Audio Signal/FOP1/FIP1 endpoints. A rectilinear-corner attempt did not move. See release-capella-workflow-benchmark.md; the reference sample was not saved.
Native82AE5A2C closed the missing-shapes bug; root directly created Region, Initial,
State, first-drag Initial→State transition, selected without motion, moved/undid the
state, created Internal Monitor communications with generated After30s Event,
Undo/Redo of both, and Fold/Unfold via right-click. Saved trial46objects/27links/1
remaining model check uses ui-state-direct-01/project.arcadia; its original empty
machine fixture is preserved in baseline-before-editing. Minimum1100x700 exposed
excessive toolbar height; latest build removes selected-action row and shortens
state heading. Native recheck is pending, along with reconnection, nested composite,
Linux state runtime and independent final runtime review.
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.
Additional review/native fixes: Fold retains semantic selection; placement property
Apply retains Behavior view without overwriting Architecture view. Initial names
choose among12 deterministic route-aware positions, and label hit testing uses the
same cached rectangle. This closes the observed rightward-line/name collision in
code; native verification of this last refinement remains pending.
Root owns integration/native input; all workers have returned files. Every worktree
uses its own Cargo target. Main remains ad7feb5; state work is isolated and not yet
accepted into main. No release-completion claim.
## 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. Rebuild and verify the state rendering fix on the saved native reproduction;
complete real state creation, connections, folding, form edit, Save/reopen and
minimum-size trials. Commit only after reviewed behavior is demonstrated.
2. Finish state-edit acceptance with explicit retained
behavior view, creation in a region, movement, transition drawing/reconnection,
guarded forms, discoverable missing placements, pan/zoom and contextual actions.
Build/run/test native at default/minimum sizes; review/reproduce/fix findings.
3. Continue direct scenario/process gestures, R3 semantic drift/reconciliation,
R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag
workflows, offline guidance and normative contracts. All remain release work.
4. Fresh final requirements/domain/correctness/native UX/architecture reviews;
Windows/Linux/MSRV checks, actual runtime, docs and packages/checksums before
completing the broad goal. V2 or a subsystem checkpoint is insufficient.
## Environment and reproducibility
## Native safety and environment
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.
Root owns native input. Final Windows1EEBDC3C window2100032/exec55548 is saved on
its isolated41/26 trial. Earlier windows524634 and328292 are saved on the Standard
and auto-port review fixtures. Re-inventory and inspect current dirty state before
operating any window. Protected user7210312 and earlier6228978 were absent; root did
not close them and makes no recovery claim. If either returns, do not save/reset/close.
Prior WSL Ubuntu 24.04 evidence is in verification-v2-linux.md; current routing automated/build evidence is in release-routing-linux.md. The Linux routing application was launched with the documented Mesa Vulkan software adapter and operated through Computer Use. Full first-release Linux runtime/package acceptance is still required after remaining implementation. There is no current disk blocker. Do not retry the earlier rejected recursive target deletion. No usage reset or remote push is authorized.
Main Windows target `D:/CodexBuilds/Capella2-01a06e2e`; Rust1.98.1. Use `cargo test
--locked --workspace`, `cargo clippy --locked --workspace --all-targets -- -D warnings`
and `cargo build --locked -p oa-desktop -p oa-server`. WSL Ubuntu24.04 uses the
existing target/linux-cargo.toml vendor config and its own linux-target; launch
with WGPU_BACKEND=vulkan and the Mesa lvp ICD. Release evidence lives under
`D:/CodexBuilds/Capella2-release1-01a06e2e`. No remote push or usage reset is authorized.
Do not retry the previously rejected broad recursive target deletion.
+11 -1
View File
@@ -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,13 @@ 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.
2026-09-06 follow-up: direct drawing, movable ports, menus and source folders now
have scoped native Windows/Linux acceptance. Independent QA falsified the initial
separate-Frame tooltip fix; a real stacked GPU layer passed native retest. Current
main Windows193 desktop tests and Linux189+4 focused tests passed. Independent
state/representation/lifecycle integration passed514 Windows workspace tests and
strict Clippy in its isolated checkout. Direct state-canvas authoring is underway;
all broader release requirements remain active. This turn made concrete implementation,
review and runtime progress, not a blocked or waiting-only turn. See STATE.md.
+23
View File
@@ -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.
+112
View File
@@ -0,0 +1,112 @@
# 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.
## Earlier verification (superseded by final acceptance below)
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.
## Final tooltip and native acceptance
The earlier statement that separate Frames eliminated overprint was falsified by
independent native QA. Iced batches text after meshes within one GPU layer. A
passive second Canvas in Stack now creates a real layer above diagram text, with
no input capture or cursor override. Context-scoped held-button tracking prevents
stale suppression after menu dismissal and premature tooltips during leave/reentry.
Four production lifecycle tests passed on Windows and Linux.
Final Windows193 desktop tests, strict desktop Clippy and build passed. Root tested
binary1EEBDC3C (full SHA256 in ui-port-tooltip-final executable): first Bottom drag,
menu outside dismissal, first repeated Left drag, FE creation and Save,41/26/0.
Independent9A7/3F534C review passed Standard rejection/completion, function-card
endpoint generation, one Undo/Redo, Save/reopen and minimum1100x700; it reproduced
then verified the tooltip fix. Exact report: drawing-independent-native-review.md
in the external evidence directory.
Root operated Linux binary7AC5EEEFFFD5514C771CF0E3B75471CA771DF75BE5DEBE9B9928B1527E1591F1:
first FE drag; Right→Bottom and Bottom→Left port movement; Reset/Undo; arrow pan
with source offscreen and target completion; Save/reopen; source folders and file
opening. Saved ui-port-tooltip-linux/project.arcadia,41/27/0, contains both exchanges
and Left anchor. Runtime log empty; normal close exit0. Inspected1440x900; minimum
layout evidence is Windows. Linux189 desktop tests/strict Clippy/release passed,
then four new lifecycle tests passed separately. A stale142-test shared-worktree
cache result was excluded and main explicitly rebuilt; targets must be checkout-specific.
Drawing milestone accepted with these evidence boundaries. The broader first
release and direct behavior editing remain incomplete.
+3
View File
@@ -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.
+38 -1
View File
@@ -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.