Establish OA/SA prototype baseline and first-release work

This commit is contained in:
2026-09-05 17:04:16 +03:00
commit 93ba83d321
109 changed files with 56512 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "oa-desktop"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "arcadia-studio"
path = "src/main.rs"
[dependencies]
oa-core = { path = "../oa-core" }
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
rfd = "0.15"
serde_json.workspace = true
tokio = { version = "1", features = ["rt", "time"] }
File diff suppressed because it is too large Load Diff
+761
View File
@@ -0,0 +1,761 @@
//! Readable behavioral projections. Selection edits the same typed records as text.
use crate::{
app::{App, Choice, Message},
style,
views::{action, heading, label, short},
};
use iced::{
Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse,
widget::{
Action, button,
canvas::{self, Frame, Path, Stroke, Text},
column, container, pick_list, row, scrollable, text,
},
};
use oa_core::*;
use std::collections::BTreeMap;
mod scenario;
mod state_scene;
mod states;
mod topology;
#[derive(Debug, Clone)]
pub enum Event {
Topology(topology::Event),
Scenario(scenario::Event),
States(states::Event),
Submit,
}
impl Event {
pub fn is_submit(&self) -> bool {
matches!(
self,
Self::Submit
| Self::Topology(topology::Event::Submit)
| Self::Scenario(scenario::Event::Submit)
| Self::States(states::Event::Submit)
)
}
}
#[derive(Default)]
pub struct State {
topology: topology::State,
scenario: scenario::State,
states: states::State,
}
impl State {
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
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),
}
}
}
pub fn view(app: &App) -> Element<'_, Message> {
let p = app.project();
let choices = p
.elements
.values()
.filter(|e| {
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.kind(), e.name),
})
.collect::<Vec<_>>();
let owner = selected_behavior(p, app.selected);
let selected = owner
.and_then(|id| choices.iter().find(|c| c.id == id))
.or(choices.first())
.cloned();
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
let Some(selected) = selected else {
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];
out = out.push(
pick_list(choices, Some(selected), |c| Message::Select(c.id))
.width(Fill)
.text_size(13)
.padding(10),
);
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return topology::view(app, e.id, out);
}
if e.kind() == Kind::Scenario {
return scenario::view(app, e.id, out);
}
states::view(app, e.id, out)
}
/// Mirror the core's legacy-versus-explicit process interpretation. Existing
/// exchange-only lists still describe ordered paths until explicitly migrated.
fn uses_explicit_topology(p: &Project, owner: Id) -> bool {
let Some(e) = p.elements.get(&owner) else {
return false;
};
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return false;
}
p.relations.values().any(|r| {
r.kind() == RelationKind::SequenceLink
&& p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner)
&& p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner)
}) || e.steps().into_iter().flatten().any(|id| {
matches!(
p.elements.get(id).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Action { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. },
..
})
)
})
}
fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
let mut current = Some(
p.relations
.get(&selected)
.filter(|r| {
matches!(
r.kind(),
RelationKind::StateTransition | RelationKind::SequenceLink
)
})
.map(|r| r.source())
.unwrap_or(selected),
);
while let Some(id) = current {
let e = p.elements.get(&id)?;
if e.kind().is_sequence() || e.kind() == Kind::StateMachine {
return Some(id);
}
current = e.parent;
}
None
}
fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
let mut current = Some(id);
while let Some(id) = current {
if id == machine {
return true;
}
current = p.elements.get(&id).and_then(|e| e.parent);
}
false
}
struct BoxItem {
id: Id,
rect: Rectangle,
title: String,
detail: String,
marker: Option<Kind>,
}
struct Line {
id: Id,
points: Vec<Point>,
label: String,
label_at: Point,
dashed: bool,
}
struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
size: Size,
selected: Id,
scale: f32,
}
impl Scene {
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
let mut scene = Self {
scale: 1.,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
selected,
};
let mut lanes = p
.children(owner)
.filter(|e| e.kind() == Kind::Lifeline)
.map(|e| {
(
e.id,
e.name.clone(),
match e.shared() {
Some(SharedBody::Lifeline {
represented: Some(id),
}) => p.named(*id).to_owned(),
_ => "Unassigned participant".into(),
},
)
})
.collect::<Vec<_>>();
let scenario = p.elements[&owner].kind() == Kind::Scenario;
if !scenario {
fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec<Id>) {
for id in steps {
if let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get(id).and_then(|e| e.shared())
{
match body {
OccurrenceBody::Exchange {
exchange: Some(id), ..
} => {
if let Some(r) = p.relations.get(id) {
for endpoint in [r.source(), r.target()] {
if !ids.contains(&endpoint) {
ids.push(endpoint);
}
}
}
}
OccurrenceBody::Fragment { branches, .. } => {
endpoints(p, branches, ids)
}
OccurrenceBody::Branch { occurrences, .. } => {
endpoints(p, occurrences, ids)
}
_ => {}
}
}
}
}
let mut ids = vec![];
endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids);
lanes = ids
.into_iter()
.map(|id| {
(
id,
p.named(id).to_owned(),
p.elements
.get(&id)
.map(|e| e.kind().label())
.unwrap_or("Endpoint")
.into(),
)
})
.collect();
}
let lane_x = lanes
.iter()
.enumerate()
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
.collect::<BTreeMap<_, _>>();
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
let mut y = 120.;
scene.occurrences(
p,
p.elements[&owner].steps().map(Vec::as_slice).unwrap_or(&[]),
scenario,
&lane_x,
&mut y,
0,
);
scene.size.height = (y + 45.).max(210.);
for (id, name, detail) in lanes {
let x = lane_x[&id];
scene.lines.insert(
0,
Line {
id,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
dashed: false,
},
);
scene.boxes.push(BoxItem {
id,
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: name,
detail,
marker: None,
});
}
scene
}
fn occurrences(
&mut self,
p: &Project,
steps: &[Id],
scenario: bool,
lanes: &BTreeMap<Id, f32>,
y: &mut f32,
depth: usize,
) {
for (index, id) in steps.iter().enumerate() {
let Some(e) = p.elements.get(id) else {
continue;
};
let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
continue;
};
match body {
OccurrenceBody::Fragment { operator, branches } => {
let top = *y;
*y += 55.;
self.occurrences(p, branches, scenario, lanes, y, depth + 1);
let rect = Rectangle::new(
Point::new(18. + depth as f32 * 10., top),
Size::new(
self.size.width - 36. - depth as f32 * 20.,
(*y - top).max(95.),
),
);
self.boxes.insert(
0,
BoxItem {
id: *id,
rect,
title: format!("{operator:?} · {}", e.name),
detail: String::new(),
marker: Some(Kind::Occurrence),
},
);
*y += 20.;
}
OccurrenceBody::Branch { guard, occurrences } => {
self.boxes.push(BoxItem {
id: *id,
rect: Rectangle::new(
Point::new(36. + depth as f32 * 10., *y),
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
),
title: format!(
"[{}]",
if guard.is_empty() {
"guard not specified"
} else {
guard
}
),
detail: String::new(),
marker: Some(Kind::Occurrence),
});
*y += 62.;
self.occurrences(p, occurrences, scenario, lanes, y, depth + 1);
}
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
message_kind,
..
} => {
let r = exchange.and_then(|id| p.relations.get(&id));
let source = if scenario {
*sender
} else {
r.map(|r| r.source())
};
let target = if scenario {
if matches!(message_kind, MessageKind::Timer { .. }) {
*sender
} else {
*receiver
}
} else {
r.map(|r| r.target())
};
if let (Some(a), Some(b)) = (
source.and_then(|id| lanes.get(&id)),
target.and_then(|id| lanes.get(&id)),
) {
let points = if a == b {
vec![
Point::new(*a, *y),
Point::new(a + 75., *y),
Point::new(a + 75., *y + 24.),
Point::new(*a, *y + 24.),
]
} else {
vec![Point::new(*a, *y), Point::new(*b, *y)]
};
let kind = if !scenario {
""
} else {
match message_kind {
MessageKind::Asynchronous => "signal · ",
MessageKind::Call => "call · ",
MessageKind::Reply => "reply · ",
MessageKind::Create => "create · ",
MessageKind::Delete => "delete · ",
MessageKind::Timer { .. } => "timer · ",
}
};
let message_label = if let MessageKind::Timer { expression } = message_kind
{
expression.as_str()
} else {
r.map(|r| r.name.as_str()).unwrap_or(&e.name)
};
self.lines.push(Line {
id: *id,
points,
label: format!("{}. {kind}{}", index + 1, message_label),
label_at: Point::new(a.min(*b) + 12., *y - 26.),
dashed: matches!(message_kind, MessageKind::Reply),
});
if scenario && matches!(message_kind, MessageKind::Call) {
self.boxes.push(BoxItem {
id: *id,
rect: Rectangle::new(Point::new(*b - 4., *y), Size::new(8., 80.)),
title: String::new(),
detail: String::new(),
marker: None,
});
}
} else {
self.boxes.push(BoxItem {
id: *id,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 65.),
),
title: e.name.clone(),
detail: "Choose an exchange and its lifelines in Properties".into(),
marker: None,
});
}
*y += 92.;
}
OccurrenceBody::Action { action, lifeline }
| OccurrenceBody::State {
state: action,
lifeline,
} => {
let x = lifeline
.and_then(|id| lanes.get(&id))
.copied()
.unwrap_or(180.);
self.boxes.push(BoxItem {
id: *id,
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
title: e.name.clone(),
detail: action
.map(|id| p.named(id).to_owned())
.unwrap_or("Choose observed behavior".into()),
marker: None,
});
*y += 95.;
}
OccurrenceBody::Control { kind } => {
self.boxes.push(BoxItem {
id: *id,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
title: format!("{kind:?} · {}", e.name),
detail: "Contextual process control".into(),
marker: None,
});
*y += 95.;
}
OccurrenceBody::SequenceReference { sequence } => {
self.boxes.push(BoxItem {
id: *id,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
title: e.name.clone(),
detail: sequence
.map(|id| format!("Use {}", p.named(id)))
.unwrap_or("Choose a reusable process or chain".into()),
marker: None,
});
*y += 95.;
}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
self.boxes.push(BoxItem {
id: *id,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 70.),
),
title: format!("ref · {}", e.name),
detail: scenario
.map(|id| {
format!(
"{} · {} explicit lifeline bindings",
p.named(id),
bindings.len()
)
})
.unwrap_or("Choose a scenario".into()),
marker: Some(Kind::Occurrence),
});
*y += 95.;
}
}
}
}
}
impl canvas::Program<Message> for Scene {
type State = ();
fn update(
&self,
_: &mut (),
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && 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.))
{
return Some(Action::publish(Message::Select(b.id)).and_capture());
}
if let Some(l) = self.lines.iter().find(|l| {
!l.label.is_empty()
&& (Rectangle::new(
l.label_at,
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25.),
)
.contains(p)
|| l.points
.windows(2)
.any(|v| crate::diagram::distance_to_segment(p, v[0], v[1]) < 8.))
}) {
return Some(Action::publish(Message::Select(l.id)).and_capture());
}
}
None
}
fn draw(
&self,
_: &(),
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.scale(self.scale);
for b in &self.boxes {
let outline = matches!(
b.marker,
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
);
let color = if b.id == self.selected {
style::TEAL
} else {
style::LINE
};
if outline {
f.stroke(
&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())
{
let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into());
f.fill(&path, Color::from_rgb8(243, 248, 248));
f.stroke(
&path,
Stroke::default()
.with_color(color)
.with_width(if b.id == self.selected { 2.5 } else { 1. }),
);
}
}
for l in &self.lines {
let color = if l.id == self.selected {
style::TEAL
} else {
style::MUTED
};
f.stroke(
&Path::new(|p| {
p.move_to(l.points[0]);
for point in &l.points[1..] {
p.line_to(*point);
}
}),
Stroke {
line_dash: canvas::LineDash {
segments: if l.dashed { &[6., 4.] } else { &[] },
offset: 0,
},
..Stroke::default()
.with_color(color)
.with_width(if l.id == self.selected { 2.5 } else { 1.2 })
},
);
if !l.label.is_empty() {
let end = l.points[l.points.len() - 1];
let prev = l.points[l.points.len() - 2];
let delta = end - prev;
let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.);
let d = delta / len;
f.stroke(
&Path::new(|p| {
p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.);
p.line_to(end);
p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.);
}),
Stroke::default().with_color(color),
);
f.fill_rectangle(
l.label_at,
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.),
Color::WHITE,
);
f.fill_text(Text {
content: short(&l.label, 32),
position: l.label_at,
color,
size: 12.into(),
..Default::default()
});
}
}
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. },
),
size: 13.into(),
color: if b.id == self.selected {
style::TEAL
} else {
style::INK
},
..Default::default()
});
if !b.detail.is_empty() {
f.fill_text(Text {
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
size: 11.into(),
color: style::MUTED,
..Default::default()
});
}
}
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
_: &(),
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if cursor.is_over(bounds) {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_process_and_chain_keep_their_ordered_exchange_scene() {
let p = Project::demo();
let owners: Vec<_> = p
.elements
.values()
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.collect();
assert!(owners.iter().any(|e| e.kind() == Kind::Process));
assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain));
for owner in owners {
assert!(!uses_explicit_topology(&p, owner.id));
let expected: Vec<_> = owner
.steps()
.unwrap()
.iter()
.enumerate()
.map(|(index, id)| {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
..
},
..
}) = p.elements[id].shared()
else {
panic!("Demo legacy sequence should contain exchange occurrences");
};
format!("{}. {}", index + 1, p.relations[exchange].name)
})
.collect();
assert!(!expected.is_empty());
let scene = Scene::sequence(&p, owner.id, owner.id);
let actual: Vec<_> = scene
.lines
.iter()
.filter(|line| !line.label.is_empty())
.map(|line| line.label.clone())
.collect();
assert_eq!(
actual, expected,
"Legacy rendering must preserve the model's ordered exchange path"
);
}
}
#[test]
fn selecting_state_transition_keeps_its_behavior_context() {
let p = Project::demo();
for r in p
.relations
.values()
.filter(|r| r.kind() == RelationKind::StateTransition)
{
let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine");
assert_eq!(p.elements[&machine].kind(), Kind::StateMachine);
assert!(in_machine(&p, r.source(), machine));
assert!(in_machine(&p, r.target(), machine));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
//! 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,
);
}
_ => {}
}
}
+895
View File
@@ -0,0 +1,895 @@
//! State authoring through the same validated commands as source and REST.
use super::*;
use iced::widget::{Column, checkbox, column, text_input};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Vertex,
Region,
Transition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransitionChoice(pub TransitionKind);
impl std::fmt::Display for TransitionChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self.0 {
TransitionKind::External => "External",
TransitionKind::Local => "Local",
TransitionKind::Internal => "Internal",
})
}
}
#[derive(Debug, Clone)]
pub enum Event {
Start(Id, Tool, Id),
Edit(Id),
Cancel,
Submit,
Name(String),
Parent(Choice),
Vertex(Kind),
Source(Choice),
Target(Choice),
Kind(TransitionChoice),
Trigger(Choice),
Effect(Choice),
RemoveTrigger(Id),
RemoveEffect(Id),
TriggerNotes(String),
Guard(String),
EffectNotes(String),
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,
tool: Tool,
original: Option<Id>,
name: String,
parent: Option<Id>,
vertex: Kind,
source: Option<Id>,
target: Option<Id>,
kind: TransitionKind,
triggers: Vec<Id>,
effects: Vec<Id>,
trigger: String,
guard: String,
effect: String,
timed: bool,
time_kind: TimeEventKind,
expression: String,
}
impl Draft {
fn new(machine: Id, tool: Tool) -> Self {
Self {
machine,
tool,
original: None,
name: String::new(),
parent: None,
vertex: Kind::State,
source: None,
target: None,
kind: TransitionKind::External,
triggers: vec![],
effects: vec![],
trigger: String::new(),
guard: String::new(),
effect: String::new(),
timed: false,
time_kind: TimeEventKind::After,
expression: String::new(),
}
}
fn command(&self, p: &Project) -> Result<Command, String> {
let layer = p
.elements
.get(&self.machine)
.filter(|e| e.kind() == Kind::StateMachine)
.ok_or("Choose a state machine.")?
.layer();
if self.tool != Tool::Transition {
let kind = if self.tool == Tool::Region {
Kind::Region
} else {
self.vertex
};
let parent = self
.parent
.ok_or("Choose the owning region or composite state.")?;
if parent != self.machine && !in_machine(p, parent, self.machine) {
return Err("Choose an owner in this machine.".into());
}
let name = if self.name.trim().is_empty() {
kind.label()
} else {
&self.name
};
let element = p
.make_element(kind, layer, name, Some(parent))
.ok_or("This owner cannot contain the selected kind.")?;
return Ok(Command::CreateElement(element));
}
let source = self.source.ok_or("Choose the source state.")?;
let target = self.target.ok_or("Choose the destination state.")?;
if !in_machine(p, source, self.machine) || !in_machine(p, target, self.machine) {
return Err("Both endpoints belong to this machine.".into());
}
let mut commands = vec![];
let mut triggers = self.triggers.clone();
if self.timed {
if self.expression.trim().is_empty() {
return Err("Enter a time expression, such as 30 s.".into());
}
let mut event = p
.make_element(
Kind::Event,
layer,
&format!("{:?} {}", self.time_kind, self.expression),
None,
)
.ok_or("Cannot create event.")?;
*event.shared_mut().unwrap() = SharedBody::Event {
body: EventBody::Time {
kind: self.time_kind,
expression: self.expression.clone(),
},
};
triggers.push(event.id);
commands.push(Command::CreateElement(event));
}
let name = if self.name.trim().is_empty() {
format!("{} → {}", p.named(source), p.named(target))
} else {
self.name.clone()
};
let mut relation = if let Some(id) = self.original {
p.relations
.get(&id)
.cloned()
.ok_or("The transition no longer exists.")?
} else {
let id = Id::new_v4();
Relation {
id,
alias: p.fresh_alias(&name, "transition", id),
name: name.clone(),
description: String::new(),
body: RelationBody::new(RelationKind::StateTransition, source, target),
}
};
relation.name = name;
relation.body = RelationBody::StateTransition {
source,
target,
transition_kind: self.kind,
trigger: self.trigger.clone(),
guard: self.guard.clone(),
effect: self.effect.clone(),
triggers,
effects: self.effects.clone(),
};
commands.push(if let Some(id) = self.original {
Command::EditRelation(id, relation)
} else {
Command::CreateRelation(relation)
});
Ok(Command::Batch(commands))
}
}
impl State {
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
match event {
Event::Start(machine, tool, selected) => {
let mut d = Draft::new(machine, tool);
let selected = p
.elements
.get(&selected)
.filter(|e| in_machine(p, e.id, machine));
d.parent = if tool == Tool::Region {
selected
.filter(|e| e.kind().is_stable_state())
.map(|e| e.id)
.or(Some(machine))
} else {
selected
.and_then(|e| validation::ancestor_of_kind(p, e.id, Kind::Region))
.or_else(|| {
p.children(machine)
.find(|e| e.kind() == Kind::Region)
.map(|e| e.id)
})
};
d.source = selected.filter(|e| e.kind().is_state()).map(|e| e.id);
if d.source
.is_some_and(|id| p.elements[&id].kind() == Kind::EntryPoint)
{
d.kind = TransitionKind::Local;
}
self.draft = Some(d);
}
Event::Edit(id) => {
let r = p.relations.get(&id).ok_or("Select a transition.")?;
let machine = validation::ancestor_of_kind(p, r.source(), Kind::StateMachine)
.ok_or("Choose a state-machine transition.")?;
let RelationBody::StateTransition {
source,
target,
transition_kind,
triggers,
effects,
trigger,
guard,
effect,
} = &r.body
else {
return Err("Select a state transition.".into());
};
let mut d = Draft::new(machine, Tool::Transition);
d.original = Some(id);
d.name = r.name.clone();
d.source = Some(*source);
d.target = Some(*target);
d.kind = *transition_kind;
d.triggers = triggers.clone();
d.effects = effects.clone();
d.trigger = trigger.clone();
d.guard = guard.clone();
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 => {
if let Some(d) = &mut self.draft {
match event {
Event::Name(s) => d.name = s,
Event::Parent(c) => d.parent = Some(c.id),
Event::Vertex(k) => d.vertex = k,
Event::Source(c) => {
d.source = Some(c.id);
d.target = None;
d.kind = if p.elements[&c.id].kind() == Kind::EntryPoint {
TransitionKind::Local
} else {
TransitionKind::External
};
}
Event::Target(c) => d.target = Some(c.id),
Event::Kind(k) => {
d.kind = k.0;
if k.0 == TransitionKind::Internal {
d.target = d.source;
}
}
Event::Trigger(c) => {
if !d.triggers.contains(&c.id) {
d.triggers.push(c.id);
}
}
Event::Effect(c) => {
if !d.effects.contains(&c.id) {
d.effects.push(c.id);
}
}
Event::RemoveTrigger(id) => d.triggers.retain(|v| *v != id),
Event::RemoveEffect(id) => d.effects.retain(|v| *v != id),
Event::TriggerNotes(s) => d.trigger = s,
Event::Guard(s) => d.guard = s,
Event::EffectNotes(s) => d.effect = s,
Event::Timed(b) => d.timed = b,
Event::Expression(s) => d.expression = s,
Event::TimeKind(s) => {
d.time_kind = if s == "At" {
TimeEventKind::At
} else {
TimeEventKind::After
}
}
_ => {}
}
}
}
}
Ok(None)
}
}
fn event(e: Event) -> Message {
Message::Behavior(super::Event::States(e))
}
fn choice(p: &Project, id: Id) -> Choice {
Choice {
id,
label: format!(
"{} · {}",
p.named(id),
p.elements
.get(&id)
.map(|e| e.kind().to_string())
.unwrap_or_default()
),
}
}
fn picker<'a>(
choices: Vec<Choice>,
selected: Option<Id>,
placeholder: &str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let current = choices.iter().find(|c| Some(c.id) == selected).cloned();
pick_list(choices, current, move |c| event(change(c)))
.placeholder(placeholder)
.padding(9)
.text_size(12)
.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> {
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." }));
} 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),
)
.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))),
);
}
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))
]
.spacing(5),
)
.padding(12)
.style(style::card),
);
}
scrollable(out.padding(24)).into()
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (Project, Id, Id, Id, Id) {
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,
"Operating modes",
Some(owner),
)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Sa, "Operation", Some(machine))
.unwrap();
let ready = p
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
.unwrap();
let degraded = p
.add_element(Kind::State, Layer::Sa, "Degraded", Some(region))
.unwrap();
(p, machine, region, ready, degraded)
}
#[test]
fn timeout_and_work_trigger_commit_together_roundtrip_and_undo() {
let (mut p, machine, _, ready, degraded) = fixture();
let work = p
.add_element(Kind::Function, Layer::Sa, "Monitor link", None)
.unwrap();
let mut state = State::default();
for e in [
Event::Start(machine, Tool::Transition, ready),
Event::Target(choice(&p, degraded)),
Event::Trigger(choice(&p, work)),
Event::Timed(true),
Event::Expression("30 s".into()),
Event::Guard("link unavailable".into()),
Event::Effect(choice(&p, work)),
] {
state.update(&p, e).unwrap();
}
let command = state.update(&p, Event::Submit).unwrap().unwrap();
let mut session = Session::new(p.clone());
session.execute(command).unwrap();
let result = session.project();
let r = result
.relations
.values()
.find(|r| r.kind() == RelationKind::StateTransition)
.unwrap();
let RelationBody::StateTransition {
triggers,
effects,
guard,
..
} = &r.body
else {
panic!()
};
assert_eq!(triggers.len(), 2);
assert!(triggers.contains(&work));
assert_eq!(effects, &[work]);
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 "));
session.undo();
assert_eq!(session.project(), &p);
}
#[test]
fn invalid_pseudo_trigger_keeps_draft_and_does_not_create_orphan_event() {
let (mut p, machine, region, ready, _) = fixture();
let initial = p
.add_element(Kind::Initial, Layer::Sa, "Start", Some(region))
.unwrap();
let mut state = State::default();
for e in [
Event::Start(machine, Tool::Transition, initial),
Event::Target(choice(&p, ready)),
Event::Timed(true),
Event::Expression("30 s".into()),
] {
state.update(&p, e).unwrap();
}
assert!(state.update(&p, Event::Submit).is_err());
assert!(state.draft.is_some());
assert!(!p.elements.values().any(|e| e.kind() == Kind::Event));
state.update(&p, Event::Timed(false)).unwrap();
let result =
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
assert_eq!(result.relations.len(), 1);
}
#[test]
fn internal_choice_targets_self_and_edit_preserves_identity_and_description() {
let (p, machine, _, ready, _) = fixture();
let mut state = State::default();
for e in [
Event::Start(machine, Tool::Transition, ready),
Event::Kind(TransitionChoice(TransitionKind::Internal)),
] {
state.update(&p, e).unwrap();
}
let mut result =
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
let r = result.relations.values_mut().next().unwrap();
r.description = "Retain configuration".into();
let (id, alias) = (r.id, r.alias.clone());
state.update(&result, Event::Edit(id)).unwrap();
state
.update(&result, Event::Guard("healthy".into()))
.unwrap();
let result = execute_batch(
&result,
&[state.update(&result, Event::Submit).unwrap().unwrap()],
)
.unwrap();
assert_eq!(result.relations[&id].alias, alias);
assert_eq!(result.relations[&id].description, "Retain configuration");
assert_eq!(
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));
}
#[test]
fn selected_composite_owns_new_region_and_history_uses_selected_region() {
let (p, machine, _, ready, _) = fixture();
let mut state = State::default();
state
.update(&p, Event::Start(machine, Tool::Region, ready))
.unwrap();
let result =
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
let region = result
.children(ready)
.find(|e| e.kind() == Kind::Region)
.unwrap()
.id;
state
.update(&result, Event::Start(machine, Tool::Vertex, region))
.unwrap();
state
.update(&result, Event::Vertex(Kind::DeepHistory))
.unwrap();
let result = execute_batch(
&result,
&[state.update(&result, Event::Submit).unwrap().unwrap()],
)
.unwrap();
assert!(
result
.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));
}
}
File diff suppressed because it is too large Load Diff
+702
View File
@@ -0,0 +1,702 @@
use crate::{app::Message, style, views::short};
use iced::{
Color, Point, Rectangle, Renderer, Size, Theme, Vector, mouse,
widget::{
Action,
canvas::{self, Frame, Path, Stroke, Text},
},
};
use oa_core::{
projection::{ProjectedEdge, ProjectedNode, ProjectedView, project_view},
*,
};
use std::collections::BTreeSet;
pub struct DiagramCanvas<'a> {
pub project: &'a Project,
pub diagram: Id,
pub selected: Id,
pub zoom: f32,
pub pan: Vector,
}
#[derive(Default)]
pub struct State {
drag: Option<(Id, Point, Position)>,
preview: Option<Position>,
pan_start: Option<Point>,
}
impl DiagramCanvas<'_> {
fn world(&self, p: Point) -> Point {
Point::new(
(p.x - self.pan.x) / self.zoom,
(p.y - self.pan.y) / self.zoom,
)
}
fn hit(&self, view: &ProjectedView, p: Point) -> Option<Id> {
let p = self.world(p);
view.nodes
.iter()
.rev()
.find(|n| {
Rectangle::new(
Point::new(n.position.x, n.position.y),
Size::new(n.size.width, n.size.height),
)
.contains(p)
&& (!view
.nodes
.iter()
.any(|child| child.visual_parent == Some(n.id))
|| p.y < n.position.y + 62.)
})
.map(|n| n.id)
}
fn hit_edge(&self, view: &ProjectedView, p: Point) -> Option<Id> {
let p = self.world(p);
view.edges.iter().rev().find_map(|edge| {
let (start, end) = edge_endpoints(view, edge)?;
let tolerance = 7. / self.zoom;
let mut previous = start;
for i in 1..=24 {
let next = curve_point(start, end, i as f32 / 24.);
if distance_to_segment(p, previous, next) <= tolerance {
return Some(edge.id);
}
previous = next;
}
None
})
}
fn preview_view(&self, state: &State) -> ProjectedView {
let mut view = project_view(self.project, self.diagram);
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
let dx = preview.x - original.x;
let dy = preview.y - original.y;
let mut moved = BTreeSet::from([id]);
for n in &view.nodes {
if n.visual_parent.is_some_and(|p| moved.contains(&p)) {
moved.insert(n.id);
}
}
for n in &mut view.nodes {
if moved.contains(&n.id) {
n.position.x += dx;
n.position.y += dy;
}
}
for port in &mut view.ports {
if moved.contains(&port.owner) {
port.position.x += dx;
port.position.y += dy;
}
}
}
view
}
}
impl canvas::Program<Message> for DiagramCanvas<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let local = cursor
.position()
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
if cursor.is_over(bounds) =>
{
let p = local?;
let view = project_view(self.project, self.diagram);
let world = self.world(p);
if let Some(port) = view.ports.iter().rev().find(|port| {
let radius = (10. / self.zoom).max(10.);
(world.x - port.position.x).abs() <= radius
&& (world.y - port.position.y).abs() <= radius
}) {
return Some(Action::publish(Message::Select(port.element)).and_capture());
}
if let Some(id) = self.hit(&view, p) {
let position = self
.project
.diagrams
.get(&self.diagram)?
.nodes
.get(&id)?
.position;
state.drag = Some((id, p, position));
state.preview = Some(position);
Some(Action::publish(Message::SelectNode(id)).and_capture())
} else if let Some(id) = self.hit_edge(&view, p) {
Some(Action::publish(Message::Select(id)).and_capture())
} else {
state.pan_start = Some(p);
Some(Action::capture())
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
let p = local?;
if let Some((_, start, original)) = state.drag {
let delta = p - start;
state.preview = Some(if delta.x * delta.x + delta.y * delta.y > 9. {
Position {
x: original.x + delta.x / self.zoom,
y: original.y + delta.y / self.zoom,
}
} else {
original
});
return Some(Action::request_redraw());
}
if let Some(start) = state.pan_start {
state.pan_start = Some(p);
return Some(Action::publish(Message::Pan(p - start)).and_capture());
}
None
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.pan_start = None;
if let Some((id, _, original)) = state.drag.take() {
let pos = state.preview.take()?;
if pos == original {
return Some(Action::capture());
}
Some(
Action::publish(Message::MoveNode(
id,
Position {
x: (pos.x / 10.).round() * 10.,
y: (pos.y / 10.).round() * 10.,
},
))
.and_capture(),
)
} else {
None
}
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
if cursor.is_over(bounds) =>
{
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
*y
}
};
if y == 0. {
None
} else {
Some(
Action::publish(Message::Zoom(if y > 0. { 1.1 } else { 1. / 1.1 }))
.and_capture(),
)
}
}
_ => None,
}
}
fn draw(
&self,
state: &State,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(
Point::ORIGIN,
bounds.size(),
Color::from_rgb8(246, 248, 251),
);
let step = (24. * self.zoom).max(14.);
let ox = self.pan.x.rem_euclid(step);
let oy = self.pan.y.rem_euclid(step);
for x in 0..(bounds.width / step) as i32 + 1 {
for y in 0..(bounds.height / step) as i32 + 1 {
f.fill(
&Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65),
style::LINE,
);
}
}
f.translate(self.pan);
f.scale(self.zoom);
let view = self.preview_view(state);
let containers = view
.nodes
.iter()
.filter_map(|n| n.visual_parent)
.collect::<BTreeSet<_>>();
for n in &view.nodes {
if containers.contains(&n.id) {
paint_node(&mut f, self.project, n, self.selected, true);
}
}
let highlighted = highlighted_exchanges(self.project, self.selected);
for edge in &view.edges {
let Some((start, end)) = edge_endpoints(&view, edge) else {
continue;
};
let active = edge.id == self.selected || highlighted.contains(&edge.id);
let color = if active {
style::TEAL
} else if edge.trace {
Color::from_rgb8(134, 107, 168)
} else {
Color::from_rgb8(144, 158, 173)
};
let capability_label =
self.project
.relations
.get(&edge.id)
.and_then(|r| match r.kind() {
RelationKind::CapabilityInclude => Some("«include»"),
RelationKind::CapabilityExtend => Some("«extend»"),
RelationKind::CapabilityGeneralization => Some("specializes"),
RelationKind::MissionCapability => Some("exploits"),
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
Some("participates")
}
_ => None,
});
let mut stroke =
Stroke::default()
.with_color(color)
.with_width(if active { 3. } else { 1.4 });
if matches!(capability_label, Some("«include»" | "«extend»")) {
stroke.line_dash = canvas::LineDash {
segments: &[6., 4.],
offset: 0,
};
}
f.stroke(&curve(start, end), stroke);
let previous = curve_point(start, end, 0.96);
let direction = end - previous;
let length = (direction.x * direction.x + direction.y * direction.y)
.sqrt()
.max(0.001);
let direction = direction / length;
let tip1 = end - direction * 9. + Vector::new(-direction.y, direction.x) * 4.;
let tip2 = end - direction * 9. - Vector::new(-direction.y, direction.x) * 4.;
f.stroke(
&Path::new(|p| {
p.move_to(tip1);
p.line_to(end);
p.line_to(tip2);
}),
Stroke::default().with_color(color).with_width(1.7),
);
if edge.id == self.selected
|| capability_label.is_some()
|| matches!(
self.project.relations.get(&edge.id).map(|r| r.kind()),
Some(
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
)
)
{
let center = curve_point(start, end, 0.5);
f.fill_rectangle(
Point::new(center.x - 112., center.y - 30.),
Size::new(224., 24.),
Color::WHITE,
);
f.fill_text(Text {
content: if edge.id == self.selected || capability_label.is_none() {
short(self.project.named(edge.id), 32)
} else {
capability_label.unwrap_or_default().into()
},
position: Point::new(center.x, center.y - 8.),
color: if active { style::TEAL } else { style::MUTED },
size: 13.into(),
align_x: iced::widget::text::Alignment::Center,
align_y: iced::alignment::Vertical::Bottom,
max_width: f32::INFINITY,
..Default::default()
});
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
paint_node(&mut f, self.project, n, self.selected, false);
} else {
// Container headers stay readable when relationships traverse
// the allocation enclosure. Children remain on top.
let mut header = n.clone();
header.size.height = 62.;
paint_node(&mut f, self.project, &header, self.selected, true);
}
}
for port in &view.ports {
let color = if self.selected == port.element {
style::TEAL
} else {
style::kind_color(
self.project.elements[&port.element]
.parent
.and_then(|id| self.project.elements.get(&id))
.map(|e| e.kind())
.unwrap_or(Kind::Port),
)
};
let origin = Point::new(port.position.x - 7., port.position.y - 7.);
let path = Path::rectangle(origin, Size::new(14., 14.));
f.fill(&path, Color::WHITE);
f.stroke(
&path,
Stroke::default()
.with_color(color)
.with_width(if self.selected == port.element {
2.5
} else {
1.5
}),
);
let center = Point::new(port.position.x, port.position.y);
f.stroke(
&Path::new(|p| {
p.move_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(4., 0.));
p.move_to(center + Vector::new(1., -3.));
p.line_to(center + Vector::new(4., 0.));
p.line_to(center + Vector::new(1., 3.));
if port.direction == PortDirection::InOut {
p.move_to(center + Vector::new(-1., -3.));
p.line_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(-1., 3.));
}
}),
Stroke::default().with_color(color),
);
if self.selected == port.element {
f.fill_text(Text {
content: short(self.project.named(port.element), 40),
position: center + Vector::new(12., 9.),
color,
size: 12.into(),
..Default::default()
});
}
}
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
state: &State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.drag.is_some() || state.pan_start.is_some() {
mouse::Interaction::Grabbing
} else if cursor.is_over(bounds) {
mouse::Interaction::Grab
} else {
mouse::Interaction::default()
}
}
}
fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, container: bool) {
let Some(e) = p.elements.get(&n.element) else {
return;
};
let color = style::kind_color(e.kind());
let origin = Point::new(n.position.x, n.position.y);
let size = Size::new(n.size.width, n.size.height);
let path = Path::rounded_rectangle(origin, size, 10.into());
let tint = Color {
r: 0.94 + color.r * 0.06,
g: 0.94 + color.g * 0.06,
b: 0.94 + color.b * 0.06,
a: 1.,
};
f.fill(&path, if container { tint } else { Color::WHITE });
f.stroke(
&path,
Stroke::default()
.with_color(if e.id == selected { color } else { style::LINE })
.with_width(if e.id == selected { 2.6 } else { 1.2 }),
);
f.fill_rectangle(
Point::new(origin.x + 14., origin.y + 15.),
Size::new(4., 12.),
color,
);
f.fill_text(Text {
content: format!(
"{}{}",
e.kind().label().to_uppercase(),
if n.collapsed { " +" } else { "" }
),
position: Point::new(origin.x + 25., origin.y + 14.),
color,
size: 11.into(),
max_width: f32::INFINITY,
..Default::default()
});
let max_chars = ((size.width - 28.) / 8.0).max(8.) as usize;
f.fill_text(Text {
content: short(&e.name, max_chars),
position: Point::new(origin.x + 14., origin.y + 35.),
color: style::INK,
size: 16.into(),
max_width: f32::INFINITY,
..Default::default()
});
if !container && size.height >= 86. {
f.fill_text(Text {
content: format!("@{}", short(&e.alias, max_chars + 6)),
position: Point::new(origin.x + 14., origin.y + 66.),
color: style::MUTED,
size: 10.5.into(),
max_width: f32::INFINITY,
..Default::default()
});
}
}
fn highlighted_exchanges(p: &Project, selected: Id) -> BTreeSet<Id> {
p.elements
.get(&selected)
.and_then(|e| e.steps())
.into_iter()
.flatten()
.filter_map(|id| match p.elements.get(id)?.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => *exchange,
_ => None,
})
.collect()
}
fn edge_endpoints(view: &ProjectedView, edge: &ProjectedEdge) -> Option<(Point, Point)> {
let a = view.nodes.iter().find(|n| n.id == edge.source)?;
let b = view.nodes.iter().find(|n| n.id == edge.target)?;
let (start, end) = endpoints(a, b);
let port_point = |id: Option<Id>| {
id.and_then(|id| view.ports.iter().find(|p| p.element == id))
.map(|p| Point::new(p.position.x, p.position.y))
};
Some((
port_point(edge.source_port).unwrap_or(start),
port_point(edge.target_port).unwrap_or(end),
))
}
fn endpoints(a: &ProjectedNode, b: &ProjectedNode) -> (Point, Point) {
let ax = a.position.x + a.size.width / 2.;
let ay = a.position.y + a.size.height / 2.;
let bx = b.position.x + b.size.width / 2.;
let by = b.position.y + b.size.height / 2.;
if (bx - ax).abs() > (by - ay).abs() {
if bx >= ax {
(
Point::new(a.position.x + a.size.width, ay),
Point::new(b.position.x, by),
)
} else {
(
Point::new(a.position.x, ay),
Point::new(b.position.x + b.size.width, by),
)
}
} else if by >= ay {
(
Point::new(ax, a.position.y + a.size.height),
Point::new(bx, b.position.y),
)
} else {
(
Point::new(ax, a.position.y),
Point::new(bx, b.position.y + b.size.height),
)
}
}
fn curve(a: Point, b: Point) -> Path {
let mid = (a.x + b.x) / 2.;
Path::new(|p| {
p.move_to(a);
p.bezier_curve_to(Point::new(mid, a.y), Point::new(mid, b.y), b);
})
}
fn curve_point(a: Point, b: Point, t: f32) -> Point {
let mid = (a.x + b.x) / 2.;
let u = 1. - t;
Point::new(
u * u * u * a.x + 3. * u * u * t * mid + 3. * u * t * t * mid + t * t * t * b.x,
u * u * u * a.y + 3. * u * u * t * a.y + 3. * u * t * t * b.y + t * t * t * b.y,
)
}
pub fn distance_to_segment(p: Point, a: Point, b: Point) -> f32 {
let ab = b - a;
let ap = p - a;
let len = ab.x * ab.x + ab.y * ab.y;
let t = if len > 0. {
((ap.x * ab.x + ap.y * ab.y) / len).clamp(0., 1.)
} else {
0.
};
let delta = p - (a + ab * t);
(delta.x * delta.x + delta.y * delta.y).sqrt()
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn hex(c: Color) -> String {
format!(
"#{:02x}{:02x}{:02x}",
(c.r * 255.) as u8,
(c.g * 255.) as u8,
(c.b * 255.) as u8
)
}
pub fn export_svg(p: &Project, diagram: Id) -> String {
let view = project_view(p, diagram);
let left = view.nodes.iter().map(|n| n.position.x).fold(0., f32::min) - 30.;
let top = view.nodes.iter().map(|n| n.position.y).fold(0., f32::min) - 30.;
let right = view
.nodes
.iter()
.map(|n| n.position.x + n.size.width)
.fold(800., f32::max)
+ 30.;
let bottom = view
.nodes
.iter()
.map(|n| n.position.y + n.size.height)
.fold(500., f32::max)
+ 30.;
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {} {}\"><title>{}</title><defs><marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10\" fill=\"#8999a9\"/></marker></defs><rect x=\"{left}\" y=\"{top}\" width=\"{}\" height=\"{}\" fill=\"#f6f8fb\"/>",
right - left,
bottom - top,
escape(p.named(diagram)),
right - left,
bottom - top
);
let containers = view
.nodes
.iter()
.filter_map(|n| n.visual_parent)
.collect::<BTreeSet<_>>();
let node_svg = |n: &ProjectedNode| {
let e = &p.elements[&n.element];
let color = hex(style::kind_color(e.kind()));
let x = n.position.x;
let y = n.position.y;
format!(
"<g><rect x=\"{x}\" y=\"{y}\" width=\"{}\" height=\"{}\" rx=\"10\" fill=\"{}\" stroke=\"{color}\"/><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"11\" fill=\"{color}\">{}</text><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"16\" fill=\"#223044\">{}</text></g>",
n.size.width,
n.size.height,
if containers.contains(&n.id) {
"#edf3f7"
} else {
"white"
},
x + 14.,
y + 25.,
escape(e.kind().label()),
x + 14.,
y + 51.,
escape(&short(
&e.name,
((n.size.width - 28.) / 8.).max(8.) as usize
))
)
};
for n in &view.nodes {
if containers.contains(&n.id) {
svg.push_str(&node_svg(n));
}
}
for edge in &view.edges {
if let Some((a, b)) = edge_endpoints(&view, edge) {
let mid = (a.x + b.x) / 2.;
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\" marker-end=\"url(#arrow)\"><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
svg.push_str(&node_svg(n));
}
}
for port in &view.ports {
svg.push_str(&format!("<g><title>{}</title><rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"/><path d=\"M {} {} h 8 l -3 -3 m 3 3 l -3 3\" fill=\"none\" stroke=\"#147d7b\"/></g>",escape(p.named(port.element)),port.position.x-7.,port.position.y-7.,port.position.x-4.,port.position.y));
}
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn implicit_port_click_selects_semantics_and_drag_preview_svg_share_anchors() {
let p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let canvas = DiagramCanvas {
project: &p,
diagram,
selected: p.id,
zoom: 0.6,
pan: Vector::new(10., 20.),
};
let view = project_view(&p, diagram);
let port = &view.ports[0];
let click = Point::new(port.position.x * 0.6 + 10., port.position.y * 0.6 + 20.);
let mut state = State::default();
let selected = canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(1600., 1000.)),
mouse::Cursor::Available(click),
)
.unwrap()
.into_inner()
.0;
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
assert!(
state.drag.is_none(),
"Port selection must not drag its parent"
);
let original = p.diagrams[&diagram].nodes[&port.owner].position;
state.drag = Some((port.owner, Point::ORIGIN, original));
state.preview = Some(Position {
x: original.x + 100.,
y: original.y + 70.,
});
let preview = canvas.preview_view(&state);
let moved = preview
.ports
.iter()
.find(|p| p.element == port.element)
.unwrap();
assert_eq!(moved.position.x, port.position.x + 100.);
assert_eq!(moved.position.y, port.position.y + 70.);
let svg = export_svg(&p, diagram);
for port in &view.ports {
assert!(svg.contains(&format!("<title>{}</title>", escape(p.named(port.element)))));
}
for edge in &view.edges {
let (start, end) = edge_endpoints(&view, edge).unwrap();
assert!(svg.contains(&format!("M {} {} C", start.x, start.y)));
assert!(svg.contains(&format!("{}, {} {}", end.y, end.x, end.y)));
}
}
}
File diff suppressed because it is too large Load Diff
+956
View File
@@ -0,0 +1,956 @@
//! Offline engineering help. Topics describe the shared model and real UI controls.
//!
//! This module owns only reading/navigation state. Application navigation and demo
//! loading use normal App messages, so help never bypasses the model's draft guards.
use crate::{
app::{Message, Page, Pending},
style,
};
use iced::{
Element, Fill, Font,
widget::{button, column, container, keyed_column, row, scrollable, text, text_input},
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Topic {
#[default]
FirstProject,
Perspectives,
Participants,
Nesting,
Capabilities,
Exchanges,
Chains,
Scenarios,
States,
Interfaces,
Transitions,
Artifacts,
Source,
Recovery,
Files,
Server,
}
#[derive(Debug, Clone, Default)]
pub struct State {
pub query: String,
pub topic: Topic,
}
#[derive(Debug, Clone)]
pub enum Event {
Search(String),
Topic(Topic),
}
impl State {
pub fn update(&mut self, event: Event) {
match event {
Event::Search(query) => {
self.query = query;
let matches = search(&self.query);
if !matches.contains(&self.topic)
&& let Some(first) = matches.first()
{
self.topic = *first;
}
}
Event::Topic(topic) => {
self.topic = topic;
// Related/contextual links always reveal the requested topic.
self.query.clear();
}
}
}
}
impl Topic {
const ALL: [Self; 16] = [
Self::FirstProject,
Self::Perspectives,
Self::Participants,
Self::Nesting,
Self::Capabilities,
Self::Exchanges,
Self::Chains,
Self::Scenarios,
Self::States,
Self::Interfaces,
Self::Transitions,
Self::Artifacts,
Self::Source,
Self::Recovery,
Self::Files,
Self::Server,
];
pub fn title(self) -> &'static str {
match self {
Self::FirstProject => "Build your first model",
Self::Perspectives => "Operational & System Analysis",
Self::Participants => "Participants, roles & work",
Self::Nesting => "Ownership, allocation & views",
Self::Capabilities => "Missions & capabilities",
Self::Exchanges => "Exchanges & dataflow",
Self::Chains => "Processes & functional chains",
Self::Scenarios => "Scenarios & alternatives",
Self::States => "States, modes & transitions",
Self::Interfaces => "Data types, ports & interfaces",
Self::Transitions => "Move between OA & SA",
Self::Artifacts => "Connect engineering evidence",
Self::Source => "Textual editing & Git",
Self::Recovery => "Checks & error recovery",
Self::Files => "Files & keyboard shortcuts",
Self::Server => "REST API & automation",
}
}
fn category(self) -> &'static str {
match self {
Self::FirstProject | Self::Perspectives => "START HERE",
Self::Participants | Self::Nesting | Self::Capabilities | Self::Exchanges => {
"MODEL THE SYSTEM"
}
Self::Chains | Self::Scenarios | Self::States | Self::Interfaces => "DESCRIBE BEHAVIOR",
Self::Transitions | Self::Artifacts | Self::Source => "CONNECT YOUR WORK",
Self::Recovery | Self::Files | Self::Server => "REFERENCE",
}
}
}
struct Article {
summary: &'static str,
keywords: &'static str,
sections: &'static [Section],
related: &'static [Topic],
destination: Option<(Page, &'static str)>,
}
struct Section {
title: &'static str,
body: &'static str,
steps: &'static [&'static str],
code: Option<&'static str>,
}
const fn explain(title: &'static str, body: &'static str) -> Section {
Section {
title,
body,
steps: &[],
code: None,
}
}
const fn walkthrough(title: &'static str, steps: &'static [&'static str]) -> Section {
Section {
title,
body: "",
steps,
code: None,
}
}
const fn example(title: &'static str, body: &'static str, code: &'static str) -> Section {
Section {
title,
body,
steps: &[],
code: Some(code),
}
}
fn article(topic: Topic) -> Article {
match topic {
Topic::FirstProject => {
const {
Article {
summary: "Create a small operational model, then derive the system contribution. Use concrete names for work and keep your first diagram focused.",
keywords: "getting started tutorial beginner quick start new example rescue coordinator allocation",
sections: &[
walkthrough(
"Create an operational context",
&[
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
"On Architecture, choose Create architecture view. The initial Allocation projection shows work inside its performer.",
"Choose Entity under Create element, then + At root. Name it Rescue coordinator and Apply changes. Use Actor for an external participant such as a Vessel in distress.",
"Select Rescue coordinator, choose Activity, then + Allocated. Name the new activity Assess incident. It appears inside its performer; an Allocation relationship records that responsibility.",
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
"Visit Model checks to review unfinished work. Save project writes the accepted model and applied drafts to your chosen file.",
],
),
explain(
"Continue into system design",
"Use Create / update counterpart on an operational element to review an OA → SA mapping. In System Analysis, identify the System under study, its external Actors and the Functions for which each is responsible. The transition tutorial explains scope, reuse and review decisions.",
),
explain(
"Explore a worked model",
"Open the coastal rescue example to inspect allocation, decomposition, repeated scenario messages, operating modes, typed interfaces and artifact references. Loading the example uses the normal unsaved-work prompt. It replaces the current project only after that workflow completes.",
),
],
related: &[
Topic::Perspectives,
Topic::Participants,
Topic::Nesting,
Topic::Transitions,
],
destination: Some((Page::Diagram, "Open Architecture")),
}
}
}
Topic::Perspectives => {
const {
Article {
summary: "Operational Analysis asks what people and organizations need to accomplish. System Analysis asks what the system must do within that operation.",
keywords: "oa sa arcadia capella system architecture operational need boundary abstraction actor system of interest",
sections: &[
explain(
"Operational Analysis: understand the need",
"Describe entities and actors, their activities, roles, interactions and operational capabilities without assuming a particular technical solution. An activity such as Assess incident states work in the operation; it does not imply which software module implements it.",
),
explain(
"System Analysis: define the contribution",
"Describe one System under study, external System actors, Functions, functional exchanges, missions and system capabilities. A participant's operational activity may be supported by several system functions or may remain outside the system boundary. Review that engineering decision explicitly.",
),
explain(
"Switching perspective does not transition the model",
"The 01 Operational and 02 System controls change the visible perspective and available creation kinds. They do not move or delete records. Create / update counterpart makes a reviewed, traceable correspondence while preserving both perspectives.",
),
explain(
"Keep the boundary explicit",
"Separate work performed by the System from work performed by external actors. Allocate leaf functions to the responsible participant. Logical and Physical Architecture belong to later design layers; a system-level function is not a software class or a physical component.",
),
],
related: &[Topic::FirstProject, Topic::Capabilities, Topic::Transitions],
destination: Some((Page::Catalog, "Open Model explorer")),
}
}
}
Topic::Participants => {
const {
Article {
summary: "Represent who participates separately from the work they perform. Decomposition and responsibility answer different engineering questions.",
keywords: "entity actor participant role realization assignment leaf activity function performer decomposition responsibility",
sections: &[
walkthrough(
"Create and allocate work",
&[
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
"Create Activity in OA or Function in SA. Select existing work and use + In selected to decompose it into smaller work units.",
"To create work for a participant, select the participant, choose Activity or Function, then + Allocated. To allocate existing work, use + Relationship with Allocation from the participant to the leaf work.",
"Open the Allocation projection to review responsibilities. Open Decomposition to review the activity/function hierarchy independently.",
],
),
explain(
"Operational roles",
"A Role describes a responsibility independently of a particular entity. Role realization links an Entity or Actor to a Role. Role assignment links a Role to an Activity. These relations express role responsibilities; an Allocation identifies the concrete performer of leaf work.",
),
explain(
"Name work as an action",
"Prefer Assess incident or Deliver electrical power for work, and Rescue coordinator or Power controller for participants. A meaningful description records assumptions, input/output expectations and the decision behind an allocation.",
),
explain(
"Creation choices are constrained",
"+ In selected is enabled only for a legal semantic parent. + Allocated appears when the selected participant can perform the chosen work. Pickers show compatible endpoints. If a control is unavailable, check the active perspective, selected owner and element kind.",
),
],
related: &[Topic::Nesting, Topic::Exchanges, Topic::Capabilities],
destination: Some((Page::Catalog, "Open Model explorer")),
}
}
}
Topic::Nesting => {
const {
Article {
summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.",
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize",
sections: &[
explain(
"Three things to distinguish",
"Semantic parent records ownership: an activity contains subactivities, or a scenario owns occurrences. Allocation records who performs leaf work. A placement records where one element appears in a diagram. One element can have placements in several views without becoming several semantic elements.",
),
walkthrough(
"Choose and edit the relationship you mean",
&[
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective and projection.",
"Choose Decomposition to nest semantic children, or Allocation to nest work under its performer. Overview keeps a broader view; OA → SA displays counterpart traces.",
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
"Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.",
"Select a diagram node to access Placement, Fold / unfold and Hide. Hide removes its view placement; Delete from model removes model records and requires review of the affected references.",
],
),
explain(
"Keep diagrams readable",
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
),
explain(
"What nesting does not imply",
"Containment in an allocation projection expresses a performer relationship. It is not a physical deployment statement. Physical deployment is a different architectural concern; do not infer hardware installation from an OA/SA box layout.",
),
],
related: &[Topic::Participants, Topic::Perspectives, Topic::Artifacts],
destination: Some((Page::Diagram, "Open Architecture")),
}
}
}
Topic::Capabilities => {
const {
Article {
summary: "Capabilities describe an ability to achieve an outcome. Missions explain the purpose served by those abilities. Both Operational and System Analysis have missions, capabilities and participants; activities and functions describe supporting work.",
keywords: "capability mission outcome operational capability system capability involvement contribution trace coverage use case",
sections: &[
walkthrough(
"Connect outcomes to the work",
&[
"Create an OA Capability such as Protect life at sea. Describe the operational outcome and the conditions in which it matters.",
"Select a relevant participant, role or activity. Use + Relationship → Capability involvement and choose the capability as the target.",
"Create an Operational mission in OA, or a Mission in SA. Select it and choose Exploit capability to connect it to a capability in the same perspective. Involve participant associates an entity or actor with the mission.",
"Connect System participants/functions to the capability using Capability involvement. Connect a Process or Functional chain to a capability using Chain involvement.",
"Review OA/SA counterpart links in Traceability and read Model checks for incomplete coverage. A named capability alone does not demonstrate that the necessary behavior exists.",
],
),
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.",
"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.",
],
),
explain(
"Use scenarios as concrete evidence",
"A scenario illustrates one path through the behavior: normal response, degraded operation or an exception. Keep its intended capability and assumptions clear in its engineering description and available model relationships. Scenarios complement the capability definition; they do not replace the outcome.",
),
explain(
"Avoid implementation names at this stage",
"Protect life at sea is a capability. Coordinate rescue is work. Send a JSON request is usually a design choice. Start with the engineering obligation and add technical detail when the perspective calls for it.",
),
],
related: &[
Topic::Perspectives,
Topic::Chains,
Topic::Scenarios,
Topic::Transitions,
],
destination: Some((Page::Catalog, "Open Model explorer")),
}
}
}
Topic::Exchanges => {
const {
Article {
summary: "An exchange says what passes between work or across a participant boundary. Its payload, endpoints and allocation make that statement precise.",
keywords: "interaction functional exchange boundary exchange flow information material energy event source target port exchange allocation",
sections: &[
walkthrough(
"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.",
"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.",
],
),
explain(
"Flow and sequence are different",
"A dataflow graph shows possible dependencies between activities/functions. It does not by itself impose an execution order. Use a Process, Functional chain or Scenario when you need to describe an ordered path, repeated exchanges or alternatives.",
),
explain(
"Use engineering payloads",
"Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.",
),
],
related: &[
Topic::Interfaces,
Topic::Chains,
Topic::Scenarios,
Topic::Artifacts,
],
destination: Some((Page::Catalog, "Open Model explorer")),
}
}
}
Topic::Chains => {
const {
Article {
summary: "An operational Process or system Functional chain selects an ordered path through existing exchanges. It explains how work contributes to an outcome.",
keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps",
sections: &[
walkthrough(
"Describe a path through exchanges",
&[
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
"Select that behavior in the page's selector. Use + Work or + Exchange to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
"Select a contribution, then add another after it or use Connect to wire existing contributions. Branch / parallel creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.",
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
],
),
explain(
"Choose the right behavioral description",
"A chain concentrates on a route through existing exchanges. A scenario can also carry actions, state observations, lifelines and nested fragments. The architecture diagram remains a view of the possible relationships rather than an execution trace.",
),
explain(
"Review the path as an engineer",
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse to involve an existing process or chain as a nested reference. Imported ordered exchange paths retain their meaning; the first topology edit explains and preserves their implied links in the same transaction.",
),
],
related: &[Topic::Exchanges, Topic::Capabilities, Topic::Scenarios],
destination: Some((Page::Behavior, "Open Behavior & scenarios")),
}
}
}
Topic::Scenarios => {
const {
Article {
summary: "Use a scenario to tell a concrete behavioral story, including repeated messages, actions, state observations and guarded alternatives.",
keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state",
sections: &[
walkthrough(
"Create a scenario",
&[
"Open Behavior & scenarios, choose Scenario under Create element and add it at a legal root/owner. Select the scenario in the behavior selector.",
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
"Use Action, Observe state or Fragment for other behavior. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
"Use the ordered list's up/down controls to change sequence order. Click a message or list entry to inspect that occurrence rather than the reusable exchange definition.",
],
),
walkthrough(
"Describe alternatives",
&[
"Choose Fragment and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
"Use the Destination selector in subsequent message or action forms to choose the intended operand. New occurrences append to that operand's ordered behavior.",
"Use Reuse scenario to refer to existing behavior. Bind every referenced lifeline to a compatible local lifeline explicitly. Open source follows the original scenario without copying its contents.",
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
],
),
explain(
"Conditions are engineering statements",
"Guards, actions and effects document behavior; a drawn scenario does not execute code or prove timing. Add evidence references when a simulation, test or analysis establishes a stronger claim.",
),
],
related: &[
Topic::Chains,
Topic::States,
Topic::Exchanges,
Topic::Artifacts,
],
destination: Some((Page::Behavior, "Open Behavior & scenarios")),
}
}
}
Topic::States => {
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",
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.",
],
),
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.",
),
explain(
"Keep conditions testable",
"Prefer a guard such as battery_level < reserve_threshold over a vague phrase such as low battery, and define the quantities elsewhere in the model or linked evidence. A state diagram documents the intended behavior; it is not a running state-machine simulator.",
),
],
related: &[
Topic::Scenarios,
Topic::Participants,
Topic::Recovery,
Topic::Artifacts,
],
destination: Some((Page::Behavior, "Open Behavior & scenarios")),
}
}
}
Topic::Interfaces => {
const {
Article {
summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.",
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
sections: &[
walkthrough(
"Define a reusable payload",
&[
"Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.",
"Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.",
"For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.",
"Select the exchange and reference the payload items. Apply changes and check the result in Model checks.",
],
),
walkthrough(
"Connect participant boundaries",
&[
"Create an Interface that names the contract, then ports under the participants or work that owns them. Set each port's direction and compatible contract references.",
"Use Interface provision and Interface requirement relationships to state who provides or needs the contract.",
"Create a Boundary exchange from an output-capable port to an input-capable port. Connect carried functional exchanges using Exchange allocation.",
"Link the detailed interface-control document, CAD connector or schematic through an Artifact reference when an external artifact supplies additional evidence.",
],
),
explain(
"Preserve meaning across perspectives",
"Shared kinds such as Data type and Interface still belong to an engineering perspective. Their Engineering perspective property is explicit. Use counterpart traces for separate OA and SA definitions that correspond; do not assume that similar names create a trace.",
),
],
related: &[Topic::Exchanges, Topic::Artifacts, Topic::Transitions],
destination: Some((Page::Data, "Open Data & interfaces")),
}
}
}
Topic::Transitions => {
const {
Article {
summary: "Create or reuse counterpart records across OA and SA through a reviewable preview. Both perspectives keep their identities and remain linked.",
keywords: "transition counterpart bidirectional mapping realization drift conflict merge keep target take source baseline scope dependency reverse update trace reuse",
sections: &[
walkthrough(
"Review a counterpart transition",
&[
"Select an operational element and choose Create / update counterpart. Selecting a system element starts the reverse System → Operational workflow.",
"Choose Include: Selected only, Required references or Whole subtree. Required dependencies must have compatible counterparts before the plan can apply.",
"For each source, choose Create new counterpart or reuse an existing target. Review the target kind and any parent, performer or System-boundary decisions offered by the preview.",
"Read the proposed changes and conflicts. For a descriptive conflict, choose the source value, keep the target value, or enter a merged value. Changing the target/kind requires reviewing that target's decisions again.",
"Apply only after every blocking decision is resolved. The accepted plan is one undoable model change; Cancel preserves the current model.",
],
),
explain(
"Review drift deliberately",
"Traceability marks descriptive links Reviewed or Review needed by comparing both endpoints with their last review baseline. Independent edits may be intentional. Keeping a target value records that reviewed decision; it does not delete the operational need. A manual trace records correspondence without propagating properties.",
),
explain(
"Know what reconciliation changes",
"The current counterpart workflow reviews names and descriptions of existing targets. It can create structural dependencies for new counterparts. Reparenting, reallocating, removing or restructuring an existing counterpart remains an explicit engineering edit; do not assume Refresh preview automatically synchronizes every structural change.",
),
explain(
"Avoid duplicate counterparts",
"Check an existing trace before requesting a new counterpart. Several engineering records can correspond, but each mapping should have a reason. Reverse transitions also use the trace to reuse the intended record rather than silently replacing the original.",
),
],
related: &[
Topic::Perspectives,
Topic::Participants,
Topic::Capabilities,
Topic::Recovery,
],
destination: Some((Page::Traces, "Open Traceability")),
}
}
}
Topic::Artifacts => {
const {
Article {
summary: "Connect model decisions to external engineering evidence using stable references to code, CAD, schematics, documents and other artifacts.",
keywords: "artifact traceability evidence code cad schematic electrical docx requirements revision fragment uri git commit owner placement document",
sections: &[
walkthrough(
"Link an artifact",
&[
"Select the model record the evidence concerns. This may be an element, relationship, diagram, placement or other supported persistent record.",
"Choose + Artifact reference in Properties, or + Artifact in Traceability. Name the reference so another engineer understands its purpose.",
"Enter the Artifact URI, optional external Revision and Fragment. Use a commit or document revision when a reproducible reference matters; the fragment identifies a particular part of the artifact.",
"Add a description explaining whether the artifact specifies, implements or verifies the engineering decision. Apply changes, then inspect the owner and reference in Traceability.",
],
),
explain(
"A reference is not an attachment",
"The model preserves the author-provided URI, revision and fragment. Loading or saving a model does not fetch, execute or verify an external artifact. A path on one workstation may not exist on another; choose team-resolvable locations and identifiers.",
),
explain(
"Choose the right owner",
"Link semantic evidence to the element or relationship it supports. Link view-specific evidence to a diagram or placement. A diagram node's placement identity remains distinct from its represented element, so the reference can express the intended scope.",
),
explain(
"Automation uses the same records",
"A CAD or version-control plugin can query and edit artifact links through the REST API. Its update should preserve stable model IDs and use the server's conditional write contract to avoid overwriting another engineer's work.",
),
],
related: &[Topic::Source, Topic::Server, Topic::Nesting],
destination: Some((Page::Traces, "Open Traceability")),
}
}
}
Topic::Source => {
const {
Article {
summary: "The text editor and graphical forms edit the same model. Canonical Arcadia source is intended for human review, automation and version control.",
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity",
sections: &[
walkthrough(
"Edit and review source",
&[
"Open Textual model for the full canonical source, or choose Source in Properties to locate the selected record.",
"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 to model to parse and validate the complete candidate. A successful application is one model change; graphical views and Properties then reflect it.",
"If parsing or validation fails, the accepted model remains unchanged and the source draft is preserved. Correct the reported location or use Revert draft to restore the accepted source.",
"Save as .arcadia for canonical UTF-8 text. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
],
),
explain(
"Names, aliases and IDs serve different purposes",
"The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.",
),
example(
"Readable references",
"For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.",
"source @receive_distress;\ntarget @assess_incident;",
),
explain(
"Use one contract",
"The normative specification is docs/language.rst in the distribution. It defines the source grammar and supported graphical semantics together. Text can preserve semantic records, diagram placements, ordered occurrences, traces and artifact references; it is not merely an export of visible boxes. Applying source normalizes formatting and removes comments; put durable engineering rationale in model descriptions.",
),
explain(
"Resolve source-control conflicts before applying",
"Ordinary Git conflict markers are not Arcadia syntax. Resolve the text merge, preserve intended UUIDs/references, then apply and inspect Model checks. The editor does not decide which engineer's model change is correct.",
),
],
related: &[
Topic::Recovery,
Topic::Files,
Topic::Artifacts,
Topic::Server,
],
destination: Some((Page::Text, "Open Textual model")),
}
}
}
Topic::Recovery => {
const {
Article {
summary: "Structural errors protect the accepted model. Warnings keep incomplete engineering work visible without preventing you from developing it incrementally.",
keywords: "validation checks error warning invalid alias missing reference undo redo revert recover draft unsaved structural external conflict lint",
sections: &[
walkthrough(
"Resolve a model issue",
&[
"Open Model checks and read the issue's message and severity. Use its Open action, where available, to inspect the affected record.",
"Correct its properties or missing relationships. Check the active perspective and compatible parent/endpoints before changing record kinds.",
"Apply changes and revisit Model checks. A warning about missing allocation or coverage may describe unfinished work; a rejected structural error must be corrected before that candidate can become the model.",
],
),
explain(
"A property draft is not yet the model",
"Apply changes validates the candidate. When it is rejected, correct the retained draft or use Revert. The canvas continues to show accepted values. Navigation that needs to apply an invalid draft may remain on the current record so your work is not lost. You can read this guide without applying or discarding that draft.",
),
explain(
"Aliases and references",
"Stable source aliases must follow the language's identifier rules and avoid reserved words. Use a simple descriptive identifier such as assess_incident. Spaces belong in the displayed Name, not the alias. Reference pickers offer compatible model records; creating the required target first is often the missing step.",
),
explain(
"Undo and recover deliberately",
"Ctrl+Z and redo restore accepted model changes. While the source editor is active, they operate on the source draft instead. Revert restores the accepted source or properties. If a file changed externally, save refuses a blind overwrite: preserve your work with Save as, then compare the files.",
),
explain(
"Save work before leaving",
"New, Open, loading the example and Close use the unsaved-work prompt. Save and continue, discard or cancel is your decision. There is no autosave recovery journal in the current desktop; save meaningful milestones and use version control or backups.",
),
],
related: &[Topic::Source, Topic::Files, Topic::Transitions],
destination: Some((Page::Validation, "Open Model checks")),
}
}
}
Topic::Files => {
const {
Article {
summary: "Keep editable model files and exported pictures distinct. Save the model to retain its semantics, identities and traceability.",
keywords: "file open save save as keyboard shortcuts ctrl control shift export svg json arcadia backup external changes migration",
sections: &[
explain(
"Editable formats",
".arcadia is canonical text suitable for Git. .json is the typed model exchange format. Both preserve the supported persistent model, diagrams and traceability. Export SVG creates a diagram image for reports; it is not an editable model backup.",
),
example(
"Keyboard reference",
"Shortcuts act on the current project. In Textual model, undo and redo affect the source draft.",
"Ctrl+N New project\nCtrl+O Open model\nCtrl+S Save project\nCtrl+Shift+S Save as\nCtrl+Z Undo\nCtrl+Shift+Z Redo\nCtrl+Y Redo\nEscape Cancel the active dialog",
),
walkthrough(
"Save and share a model",
&[
"Use Save project to apply valid drafts and save to the current path. For a new project, select a destination and format in the file dialog.",
"Use Save as for a new path or explicit format conversion. Check the filename extension when selecting text or JSON.",
"After saving, check All changes saved. Share the model file with its necessary external evidence locations, or commit it to version control.",
"If another tool changed or deleted the open file, preserve your candidate using a new Save as destination. Compare and reconcile the versions before replacing either one.",
],
),
explain(
"Open and migration",
"Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.",
),
],
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
destination: None,
}
}
}
Topic::Server => {
const {
Article {
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
sections: &[
example(
"Start a local service",
"Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.",
"arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
),
walkthrough(
"Build a safe editing client",
&[
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
"Submit typed commands to /transactions, or a complete candidate to /source or /model, using If-Match. Use transaction dry-run or validation before committing substantial generated changes.",
"A stale write returns 412. Keep the user's draft, reload the current head and reconcile deliberately. After an uncertain write outcome, read the server state before retrying.",
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
],
),
explain(
"Desktop files and hosted models",
"The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.",
),
explain(
"Deployment and persistence",
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
),
explain(
"Use the precise contract",
"docs/api.rst and docs/openapi.json define routes, command shapes, limits and errors. The running server also serves OpenAPI. No network request or service mutation is triggered by reading this guide.",
),
],
related: &[
Topic::Artifacts,
Topic::Source,
Topic::Transitions,
Topic::Recovery,
],
destination: None,
}
}
}
}
}
/// Help search is independent of model search. All query words must occur in
/// the article, title or retrieval keywords; order and case are insignificant.
fn search(query: &str) -> Vec<Topic> {
let query = query.to_lowercase();
let words = query.split_whitespace().collect::<Vec<_>>();
Topic::ALL
.into_iter()
.filter(|topic| {
let article = article(*topic);
let mut haystack =
format!("{} {} {}", topic.title(), article.summary, article.keywords);
for section in article.sections {
haystack.push_str(section.title);
haystack.push(' ');
haystack.push_str(section.body);
for step in section.steps {
haystack.push(' ');
haystack.push_str(step);
}
if let Some(code) = section.code {
haystack.push(' ');
haystack.push_str(code);
}
}
let haystack = haystack.to_lowercase();
words.iter().all(|word| haystack.contains(word))
})
.collect()
}
fn topic_button(topic: Topic, selected: bool) -> iced::widget::Button<'static, Message> {
button(text(topic.title()).size(13))
.padding([10, 11])
.width(Fill)
.style(if selected {
style::selected
} else {
style::nav
})
.on_press(Message::Guide(Event::Topic(topic)))
}
fn article_view(topic: Topic) -> Element<'static, Message> {
let article = article(topic);
let mut content = column![
text(topic.category()).size(11).color(style::MUTED),
text(topic.title()).size(25),
text(article.summary).size(15).color(style::MUTED),
]
.spacing(14)
.width(Fill);
for section in article.sections {
let mut body = column![text(section.title).size(18)]
.spacing(12)
.width(Fill);
if !section.body.is_empty() {
body = body.push(text(section.body).size(14));
}
for (index, step) in section.steps.iter().enumerate() {
body = body.push(
row![
text(format!("{:02}", index + 1))
.size(13)
.color(style::TEAL)
.width(28),
text(*step).size(14).width(Fill),
]
.spacing(9),
);
}
if let Some(code) = section.code {
body = body.push(
container(text(code).font(Font::MONOSPACE).size(12).width(Fill))
.padding(14)
.width(Fill)
.style(style::subtle),
);
}
content = content.push(container(body).padding(20).width(Fill).style(style::card));
}
if let Some((page, label)) = article.destination {
content = content.push(
button(text(label).size(13))
.padding([11, 15])
.on_press(Message::Page(page)),
);
}
if topic == Topic::FirstProject {
content = content.push(
button(text("Open coastal rescue example").size(13))
.padding([11, 15])
.style(style::nav)
.on_press(Message::Request(Pending::Demo)),
);
}
content = content.push(text("CONTINUE EXPLORING").size(11).color(style::MUTED));
for related in article.related {
content = content.push(topic_button(*related, false));
}
scrollable(content.padding(iced::Padding {
right: 12.,
bottom: 24.,
..Default::default()
}))
.width(Fill)
.height(Fill)
.into()
}
pub fn view(state: &State) -> Element<'_, Message> {
let matches = search(&state.query);
let mut navigation = column![].spacing(5).width(Fill);
let mut category = "";
for topic in &matches {
if category != topic.category() {
category = topic.category();
navigation = navigation.push(
container(text(category).size(10).color(style::MUTED)).padding(iced::Padding {
top: 12.,
bottom: 4.,
left: 8.,
right: 4.,
}),
);
}
navigation = navigation.push(topic_button(*topic, state.topic == *topic));
}
let topic_list = container(scrollable(navigation.padding(iced::Padding {
right: 8.,
..Default::default()
})))
.width(200)
.height(Fill);
let article: Element<'_, Message> = if matches.is_empty() {
container(column![
text("No help topics found").size(23),
text("Try a modeling concept such as allocation, scenario, data type, Git or API. Help search does not search your model.").size(14).color(style::MUTED),
button(text("Clear search").size(13)).padding([10, 14]).on_press(Message::Guide(Event::Search(String::new()))),
].spacing(16).max_width(520)).padding(24).width(Fill).into()
} else {
// A different article gets a fresh scroll state and starts at its title.
keyed_column([(state.topic, article_view(state.topic))])
.width(Fill)
.height(Fill)
.into()
};
column![
column![
text("Engineering guide").size(25),
text("Practical workflows, modeling concepts and reference · available offline")
.size(12)
.color(style::MUTED),
]
.spacing(5),
row![
text_input("Search help: allocation, scenario, Git…", &state.query)
.on_input(|query| Message::Guide(Event::Search(query)))
.padding(11)
.size(14)
.width(Fill),
button(text("Clear").size(12))
.padding(11)
.style(style::nav)
.on_press_maybe(
(!state.query.is_empty()).then(|| Message::Guide(Event::Search(String::new())))
),
]
.spacing(8),
text(format!("{} topics", matches.len()))
.size(11)
.color(style::MUTED),
row![topic_list, article].spacing(20).height(Fill),
]
.spacing(14)
.padding(22)
.width(Fill)
.height(Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_finds_concepts_in_walkthroughs_and_api_reference() {
assert!(search(" ALLOCATED rescue ").contains(&Topic::FirstProject));
assert!(search("sender lifeline").contains(&Topic::Scenarios));
assert!(search("If-Match 412").contains(&Topic::Server));
assert!(search("Ctrl+Shift+S").contains(&Topic::Files));
assert!(search("no-such-modeling-topic-987").is_empty());
assert_eq!(search(" \n\t "), Topic::ALL);
}
#[test]
fn filtering_and_related_navigation_keep_selection_visible() {
let mut state = State::default();
state.update(Event::Search("If-Match 412".into()));
assert_eq!(state.topic, Topic::Server);
state.update(Event::Topic(Topic::Nesting));
assert!(state.query.is_empty());
assert_eq!(state.topic, Topic::Nesting);
state.update(Event::Search("nonexistent-topic-987".into()));
assert_eq!(state.topic, Topic::Nesting);
state.update(Event::Search(String::new()));
assert!(search(&state.query).contains(&state.topic));
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Iced adapter for the core language lexer. Tokens are computed once per draft,
//! then split into line-local UTF-8 ranges (including multiline comments).
use iced::{
Color, Font, Theme,
advanced::text::{Highlighter, highlighter::Format},
};
use oa_core::language::{TokenClass, tokenize};
use std::{ops::Range, sync::Arc};
pub struct ArcadiaHighlighter {
lines: Vec<Vec<(Range<usize>, TokenClass)>>,
current: usize,
}
impl Highlighter for ArcadiaHighlighter {
type Settings = Arc<str>;
type Highlight = TokenClass;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, TokenClass)>;
fn new(source: &Self::Settings) -> Self {
let mut offsets = vec![0];
offsets.extend(source.match_indices('\n').map(|(offset, _)| offset + 1));
let mut lines = vec![Vec::new(); offsets.len()];
for token in tokenize(source) {
if matches!(token.class, TokenClass::Whitespace) {
continue;
}
let first = offsets
.partition_point(|offset| *offset <= token.span.start)
.saturating_sub(1);
for line in first..offsets.len() {
let start = offsets[line];
if start >= token.span.end {
break;
}
let end = offsets.get(line + 1).copied().unwrap_or(source.len());
let local = token.span.start.max(start) - start..token.span.end.min(end) - start;
if !local.is_empty() {
lines[line].push((local, token.class));
}
}
}
Self { lines, current: 0 }
}
fn update(&mut self, source: &Self::Settings) {
*self = Self::new(source);
}
fn change_line(&mut self, line: usize) {
self.current = line;
}
fn highlight_line(&mut self, _line: &str) -> Self::Iterator<'_> {
let spans = self.lines.get(self.current).cloned().unwrap_or_default();
self.current += 1;
spans.into_iter()
}
fn current_line(&self) -> usize {
self.current
}
}
pub fn format(class: &TokenClass, _theme: &Theme) -> Format<Font> {
let color = match class {
TokenClass::Keyword => Color::from_rgb8(118, 62, 160),
TokenClass::Identifier => Color::from_rgb8(20, 93, 139),
TokenClass::String => Color::from_rgb8(24, 113, 79),
TokenClass::Number => Color::from_rgb8(163, 78, 30),
TokenClass::Comment => Color::from_rgb8(111, 121, 132),
TokenClass::Error => Color::from_rgb8(186, 38, 62),
TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(91, 101, 117),
};
Format {
color: Some(color),
font: None,
}
}
+38
View File
@@ -0,0 +1,38 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;
mod behavior;
mod diagram;
mod forms;
mod guide;
mod highlight;
mod navigation;
mod source_navigation;
mod style;
#[cfg(test)]
mod tests;
mod views;
mod workflow;
fn main() -> iced::Result {
// Small headless entry points support reproducible examples and CI validation.
let args: Vec<_> = std::env::args().collect();
if args.get(1).is_some_and(|s| s == "--write-example") {
let path = args
.get(2)
.expect("usage: arcadia-studio --write-example PATH");
oa_core::persistence::save(std::path::Path::new(path), &oa_core::Project::demo())
.expect("write example");
return Ok(());
}
iced::application(app::App::new, app::App::update, app::App::view)
.title(app::App::title)
.theme(|_: &app::App| style::theme())
.subscription(app::App::subscription)
.window(iced::window::Settings {
size: iced::Size::new(1440., 900.),
min_size: Some(iced::Size::new(1100., 700.)),
exit_on_close_request: false,
..Default::default()
})
.run()
}
+293
View File
@@ -0,0 +1,293 @@
//! Read-only semantic navigation derived from the core's reference visitors.
use crate::{
app::Message,
style,
views::{action, label, short},
};
use iced::{
Element, Fill,
widget::{column, container, text},
};
use oa_core::{
references::{element_references, relation_references},
*,
};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum InspectorTab {
#[default]
Properties,
Connections,
}
struct Group {
title: &'static str,
ids: BTreeSet<Id>,
}
fn groups(p: &Project, selected: Id) -> Vec<Group> {
let parent = p.elements.get(&selected).and_then(|e| e.parent);
let children = p.children(selected).map(|e| e.id).collect::<BTreeSet<_>>();
let mut direct = p
.elements
.get(&selected)
.map(element_references)
.or_else(|| p.relations.get(&selected).map(relation_references))
.unwrap_or_default()
.into_iter()
.collect::<BTreeSet<_>>();
direct.retain(|id| Some(*id) != parent && !children.contains(id));
let outgoing = p
.relations
.values()
.filter(|r| r.source() == selected)
.map(|r| r.id)
.collect();
let incoming = p
.relations
.values()
.filter(|r| r.target() == selected)
.map(|r| r.id)
.collect();
let users = p
.elements
.values()
.filter(|e| {
e.id != selected
&& Some(e.id) != parent
&& e.parent != Some(selected)
&& element_references(e).contains(&selected)
})
.map(|e| e.id)
.chain(
p.relations
.values()
.filter(|r| {
r.source() != selected
&& r.target() != selected
&& relation_references(r).contains(&selected)
})
.map(|r| r.id),
)
.collect();
let counterparts = p
.traces
.values()
.filter_map(|t| {
if t.oa == selected {
Some(t.sa)
} else if t.sa == selected {
Some(t.oa)
} else {
None
}
})
.collect();
let artifacts = p
.artifact_links
.values()
.filter(|a| a.owner == selected)
.map(|a| a.id)
.collect();
vec![
Group {
title: "OWNED BY",
ids: parent.into_iter().collect(),
},
Group {
title: "CONTAINS",
ids: children,
},
Group {
title: "REFERENCES",
ids: direct,
},
Group {
title: "OUTGOING RELATIONSHIPS",
ids: outgoing,
},
Group {
title: "INCOMING RELATIONSHIPS",
ids: incoming,
},
Group {
title: "USED BY BEHAVIOR & CONTRACTS",
ids: users,
},
Group {
title: "COUNTERPARTS",
ids: counterparts,
},
Group {
title: "ENGINEERING ARTIFACTS",
ids: artifacts,
},
]
}
pub fn diagrams_containing(p: &Project, selected: Id) -> Vec<Id> {
let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]);
p.diagrams
.values()
.filter(|d| {
let placed = |id| {
let e = p.elements.get(&id);
let owner = e
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id);
let kind = p.elements.get(&owner).map(|e| e.kind());
kind.is_none_or(|kind| d.projection.includes(kind))
&& d.nodes.values().any(|n| n.element == owner || n.id == id)
};
placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed))
})
.map(|d| d.id)
.collect()
}
pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
let mut content = column![
text(short(p.named(selected), 50)).size(18),
text("Follow ownership, usage and engineering relationships.")
.size(12)
.color(style::MUTED)
]
.spacing(12);
let mut count = 0;
for group in groups(p, selected) {
if group.ids.is_empty() {
continue;
}
count += group.ids.len();
let mut entries = group.ids.into_iter().collect::<Vec<_>>();
entries.sort_by_key(|id| p.named(*id).to_lowercase());
content = content.push(label(group.title));
for id in entries {
let detail = p
.elements
.get(&id)
.map(|e| format!("{} · {}", e.kind(), e.layer()))
.or_else(|| {
p.relations.get(&id).map(|r| {
format!(
"{} · {} → {}",
r.kind(),
p.named(r.source()),
p.named(r.target())
)
})
})
.or_else(|| p.artifact_links.get(&id).map(|a| a.uri.clone()))
.unwrap_or_default();
let mut entry = column![
action(&short(p.named(id), 42), Message::Select(id)).width(Fill),
text(detail).size(11).color(style::MUTED)
]
.spacing(3);
if let Some(r) = p.relations.get(&id) {
let other = if r.source() == selected {
r.target()
} else {
r.source()
};
entry = entry.push(
action(
&format!("Open {}", short(p.named(other), 32)),
Message::Select(other),
)
.width(Fill),
);
}
content = content.push(container(entry).padding(8).style(style::subtle));
}
}
let diagrams = diagrams_containing(p, selected);
if !diagrams.is_empty() {
content = content.push(label("SHOWN IN DIAGRAMS"));
for id in diagrams {
content = content.push(
action(
&short(p.named(id), 42),
Message::RevealInDiagram(id, selected),
)
.width(Fill),
);
}
} else if p.elements.contains_key(&selected) {
content = content.push(
text("This element has no diagram placement yet.")
.size(12)
.color(style::MUTED),
);
}
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.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semantic_browser_finds_behavior_users_without_confusing_containment() {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let process = p
.add_element(Kind::Process, Layer::Oa, "Response route", None)
.unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(process))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let result = groups(&p, work);
assert!(
result
.iter()
.find(|g| g.title == "USED BY BEHAVIOR & CONTRACTS")
.unwrap()
.ids
.contains(&occurrence)
);
let result = groups(&p, occurrence);
assert!(
result
.iter()
.find(|g| g.title == "OWNED BY")
.unwrap()
.ids
.contains(&process)
);
assert!(
result
.iter()
.find(|g| g.title == "REFERENCES")
.unwrap()
.ids
.contains(&work)
);
assert!(
!result
.iter()
.find(|g| g.title == "REFERENCES")
.unwrap()
.ids
.contains(&process)
);
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Literal source search and line navigation, independent of accepted model state.
use iced::widget::text_editor::{Cursor, Position};
#[derive(Debug, Clone)]
pub enum Event {
Open(bool),
Close,
Query(String),
Next(bool),
Line(String),
Go,
}
#[derive(Default)]
pub struct State {
pub open: bool,
pub query: String,
pub line: String,
matches: Vec<Cursor>,
selected: Option<usize>,
}
impl State {
pub fn refresh(&mut self, source: &str) {
self.matches.clear();
self.selected = None;
if self.query.is_empty() {
return;
}
let starts: Vec<_> = std::iter::once(0)
.chain(source.match_indices('\n').map(|(i, _)| i + 1))
.collect();
let position = |offset: usize| {
let line = starts
.partition_point(|start| *start <= offset)
.saturating_sub(1);
// Iced's text renderer uses UTF-8 byte columns, not scalar counts.
Position {
line,
column: offset - starts[line],
}
};
self.matches = source
.match_indices(&self.query)
.map(|(offset, matched)| Cursor {
position: position(offset),
selection: Some(position(offset + matched.len())),
})
.collect();
}
pub fn next(&mut self, backwards: bool) -> Option<Cursor> {
let count = self.matches.len();
if count == 0 {
return None;
}
let selected = match self.selected {
None => {
if backwards {
count - 1
} else {
0
}
}
Some(i) => {
if backwards {
(i + count - 1) % count
} else {
(i + 1) % count
}
}
};
self.selected = Some(selected);
Some(self.matches[selected])
}
pub fn label(&self) -> String {
if self.query.is_empty() {
return "Case sensitive".into();
}
match self.selected {
Some(i) => format!("{} / {}", i + 1, self.matches.len()),
None => format!("{} matches", self.matches.len()),
}
}
pub fn destination(&self, line_count: usize) -> Result<Cursor, String> {
let line: usize = self
.line
.trim()
.parse()
.map_err(|_| "Enter a line number.".to_string())?;
if line == 0 || line > line_count {
return Err(format!("Choose a line from 1 to {line_count}."));
}
Ok(Cursor {
position: Position {
line: line - 1,
column: 0,
},
selection: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_uses_utf8_columns_wraps_and_refreshes_after_edits() {
let mut state = State {
query: "救助".into(),
..State::default()
};
state.refresh("é 救助\n救助");
let first = state.next(false).unwrap();
assert_eq!(first.position, Position { line: 0, column: 3 });
assert_eq!(first.selection, Some(Position { line: 0, column: 9 }));
assert_eq!(state.next(false).unwrap().position.line, 1);
assert_eq!(state.next(false), Some(first));
assert_eq!(state.next(true).unwrap().position.line, 1);
state.refresh("no matches");
assert!(state.next(false).is_none());
assert_eq!(state.label(), "0 matches");
state.line = "0".into();
assert!(state.destination(2).is_err());
state.line = "2".into();
assert_eq!(state.destination(2).unwrap().position.line, 1);
state.line = "3".into();
assert!(state.destination(2).is_err());
}
}
+99
View File
@@ -0,0 +1,99 @@
use iced::{
Border, Color, Theme,
widget::{button, container},
};
use oa_core::Kind;
pub const BG: Color = Color::from_rgb(0.945, 0.956, 0.969);
pub const INK: Color = Color::from_rgb(0.13, 0.18, 0.25);
pub const MUTED: Color = Color::from_rgb(0.43, 0.48, 0.55);
pub const TEAL: Color = Color::from_rgb(0.0, 0.48, 0.43);
pub const LINE: Color = Color::from_rgb(0.86, 0.89, 0.91);
pub fn theme() -> Theme {
Theme::custom(
"Arcadia Light",
iced::theme::Palette {
background: BG,
text: INK,
primary: TEAL,
success: TEAL,
warning: Color::from_rgb(0.72, 0.47, 0.10),
danger: Color::from_rgb(0.78, 0.22, 0.24),
},
)
}
pub fn card(_: &Theme) -> container::Style {
container::Style {
background: Some(Color::WHITE.into()),
border: Border {
color: LINE,
width: 1.,
radius: 10.into(),
},
..Default::default()
}
}
pub fn panel(_: &Theme) -> container::Style {
container::Style {
background: Some(Color::WHITE.into()),
border: Border {
color: LINE,
width: 1.,
radius: 0.into(),
},
..Default::default()
}
}
pub fn subtle(_: &Theme) -> container::Style {
container::Style {
background: Some(BG.into()),
border: Border {
radius: 8.into(),
..Default::default()
},
..Default::default()
}
}
pub fn nav(theme: &Theme, status: button::Status) -> button::Style {
let mut s = button::text(theme, status);
if status != button::Status::Disabled {
s.text_color = INK;
}
s.border.radius = 7.into();
if matches!(status, button::Status::Hovered | button::Status::Pressed) {
s.background = Some(BG.into());
}
s
}
pub fn selected(theme: &Theme, status: button::Status) -> button::Style {
let mut s = nav(theme, status);
s.background = Some(Color::from_rgb8(218, 238, 235).into());
s.text_color = INK;
s.border = Border {
color: Color::from_rgb8(151, 203, 195),
width: 1.,
radius: 7.into(),
};
s
}
pub fn kind_color(kind: Kind) -> Color {
match kind {
Kind::Entity | Kind::System => Color::from_rgb(0.16, 0.40, 0.71),
Kind::Actor | Kind::SystemActor | Kind::Role => Color::from_rgb(0.58, 0.35, 0.70),
Kind::Activity | Kind::Function => TEAL,
Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission => {
Color::from_rgb(0.72, 0.47, 0.10)
}
Kind::Process
| Kind::FunctionalChain
| Kind::Scenario
| Kind::Occurrence
| Kind::Lifeline => Color::from_rgb(0.65, 0.33, 0.40),
Kind::StateMachine
| Kind::Region
| Kind::State
| Kind::Mode
| Kind::Initial
| Kind::Final => Color::from_rgb(0.38, 0.39, 0.68),
_ => Color::from_rgb(0.25, 0.48, 0.56),
}
}
+938
View File
@@ -0,0 +1,938 @@
use crate::{
app::{App, Message, Page, Pending},
forms::Draft,
workflow::{Event, Workflow},
};
use iced::widget::text_editor;
use oa_core::*;
use serde_json::json;
fn send(app: &mut App, m: Message) {
let _ = app.update(m);
}
fn fixture() -> Project {
let mut p = Project::empty();
let owner = p.add(Kind::Entity, "Rescue team", 40., 40.);
let work = p.add(Kind::Activity, "Assess incident", 340., 40.);
p.relate(RelationKind::Allocation, "Performs", owner, work);
p
}
fn source(app: &mut App, text: &str) {
send(app, Message::Page(Page::Text));
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
send(
app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
std::sync::Arc::new(text.into()),
))),
);
}
#[test]
fn opening_and_creating_models_preserve_the_actual_window_size_for_fit() {
let size = iced::Size::new(1100., 700.);
let project = fixture();
let mut expected = App::with_project(project.clone());
send(&mut expected, Message::WindowResized(size));
send(&mut expected, Message::Fit);
let mut app = App::with_project(Project::empty());
send(&mut app, Message::WindowResized(size));
send(
&mut app,
Message::Loaded(Ok(Some((
std::path::PathBuf::from("resized-model.arcadia"),
project.clone(),
std::sync::Arc::from(language::format(&project).into_bytes()),
)))),
);
assert_eq!(app.window_size, size);
assert_eq!(app.zoom, expected.zoom);
assert_eq!(app.pan, expected.pan);
send(&mut app, Message::Fit);
assert_eq!(app.zoom, expected.zoom);
send(&mut app, Message::Request(Pending::New));
assert!(app.project().elements.is_empty());
assert_eq!(app.window_size, size);
send(&mut app, Message::Request(Pending::Demo));
assert_eq!(app.window_size, size);
let mut expected_demo = App::with_project(app.project().clone());
send(&mut expected_demo, Message::WindowResized(size));
send(&mut expected_demo, Message::Fit);
assert_eq!(app.zoom, expected_demo.zoom);
assert_eq!(app.pan, expected_demo.pan);
}
#[test]
fn every_demo_record_has_a_lossless_graphical_draft() {
let p = Project::demo();
for (id, _) in p.identities() {
let draft = Draft::from_project(&p, id).expect("Graphical record");
assert!(!draft.dirty());
let candidate = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
assert_eq!(candidate, p, "untouched property form changed {id}");
}
}
#[test]
fn fit_uses_measured_canvas_bounds_and_preserves_them_on_open() {
let p = fixture();
let mut app = App::with_project(p.clone());
let canvas = iced::Size::new(510., 275.);
send(&mut app, Message::CanvasResized(canvas));
send(
&mut app,
Message::Loaded(Ok(Some((
std::path::PathBuf::from("measured.arcadia"),
p.clone(),
std::sync::Arc::from(language::format(&p).into_bytes()),
)))),
);
send(&mut app, Message::Fit);
for node in projection::project_view(app.project(), app.active_diagram.unwrap()).nodes {
let x = app.pan.x + node.position.x * app.zoom;
let y = app.pan.y + node.position.y * app.zoom;
assert!(x >= 23.9 && y >= 23.9);
assert!(x + node.size.width * app.zoom <= canvas.width - 23.9);
assert!(y + node.size.height * app.zoom <= canvas.height - 23.9);
}
}
#[test]
fn fit_adapts_to_canvas_returning_after_help_but_preserves_manual_navigation() {
let p = fixture();
let mut app = App::with_project(p);
send(
&mut app,
Message::CanvasResized(iced::Size::new(900., 600.)),
);
send(&mut app, Message::Page(Page::Guide));
send(
&mut app,
Message::WindowResized(iced::Size::new(1100., 700.)),
);
send(&mut app, Message::Page(Page::Diagram));
let compact = iced::Size::new(500., 270.);
send(&mut app, Message::CanvasResized(compact));
let fitted = (app.zoom, app.pan);
send(&mut app, Message::Fit);
assert_eq!((app.zoom, app.pan), fitted);
send(&mut app, Message::Zoom(1.2));
let manual = (app.zoom, app.pan);
send(
&mut app,
Message::CanvasResized(iced::Size::new(650., 400.)),
);
assert_eq!((app.zoom, app.pan), manual);
}
#[test]
fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() {
let p = fixture();
let id = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let mut app = App::with_project(p);
send(&mut app, Message::Select(id));
send(&mut app, Message::SourceForSelection);
assert_eq!(app.page, Page::Text);
assert!(app.source_jump.is_some());
send(&mut app, Message::SourceViewport);
assert!(app.source_jump.is_none());
send(&mut app, Message::SourceForSelection);
assert!(app.source_jump.is_some());
send(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
);
assert!(app.source_jump.is_none());
}
#[test]
fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
use crate::source_navigation::Event;
let mut app = App::with_project(fixture());
let accepted = app.project().clone();
source(&mut app, "é 救助\nthis is an unfinished model: 救助");
send(&mut app, Message::SourceNavigation(Event::Open(false)));
send(
&mut app,
Message::SourceNavigation(Event::Query("救助".into())),
);
send(&mut app, Message::SourceNavigation(Event::Next(false)));
let cursor = app.source_jump.unwrap();
assert_eq!(
cursor.position,
text_editor::Position { line: 0, column: 3 }
);
assert_eq!(
cursor.selection,
Some(text_editor::Position { line: 0, column: 9 })
);
send(&mut app, Message::SourceViewport);
assert_eq!(app.source_editor.cursor(), cursor);
send(
&mut app,
Message::SourceNavigation(Event::Line("99".into())),
);
send(&mut app, Message::SourceNavigation(Event::Go));
assert!(app.status.contains("1 to 2"));
assert_eq!(*app.project(), accepted);
assert!(app.source_dirty());
}
#[test]
fn graphical_rename_keeps_aliases_and_references_and_undo() {
let mut app = App::with_project(fixture());
let before = app.project().clone();
let id = before
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
send(&mut app, Message::Select(id));
send(
&mut app,
Message::Field("/name".into(), "Coordinate rescue".into()),
);
send(&mut app, Message::Apply);
assert_eq!(
app.project().elements[&id].alias,
before.elements[&id].alias
);
assert_eq!(
language::parse(&app.source_editor.text()).unwrap(),
*app.project()
);
send(&mut app, Message::Undo);
assert_eq!(*app.project(), before);
}
#[test]
fn identity_property_change_is_atomic_and_rewrites_all_live_references() {
let mut app = App::with_project(fixture());
let before = app.project().clone();
let old = before
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let new = Id::new_v4();
send(&mut app, Message::Select(old));
send(&mut app, Message::Field("/id".into(), new.to_string()));
send(
&mut app,
Message::Field("/name".into(), "New identity".into()),
);
send(&mut app, Message::Apply);
assert!(!app.project().contains(old));
assert!(app.project().relations.values().any(|r| r.target() == new));
assert!(
app.project()
.diagrams
.values()
.any(|d| d.nodes.values().any(|n| n.element == new))
);
send(&mut app, Message::Undo);
assert_eq!(*app.project(), before);
}
#[test]
fn invalid_numeric_draft_survives_navigation_save_and_help() {
let mut app = App::with_project(fixture());
let p = app.project().clone();
let node = p
.diagrams
.values()
.next()
.unwrap()
.nodes
.values()
.next()
.unwrap()
.id;
send(&mut app, Message::Select(node));
send(&mut app, Message::Field("/position/x".into(), "-".into()));
send(&mut app, Message::Save(false));
assert!(!app.busy);
assert_eq!(*app.project(), p);
send(&mut app, Message::Page(Page::Guide));
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.draft.as_ref().unwrap().inputs["/position/x"].raw, "-");
send(&mut app, Message::SelectProject);
assert_eq!(app.selected, node);
}
#[test]
fn malformed_source_preserves_model_and_is_recoverable() {
let mut app = App::with_project(fixture());
let p = app.project().clone();
source(&mut app, "arcadia 2; ?");
send(&mut app, Message::ApplySource);
assert_eq!(*app.project(), p);
assert!(!app.source_diagnostics.is_empty());
send(&mut app, Message::Request(Pending::New));
assert!(app.pending.is_some());
send(&mut app, Message::Cancel);
assert_eq!(app.source_editor.text(), "arcadia 2; ?");
send(&mut app, Message::RevertSource);
assert_eq!(language::parse(&app.source_editor.text()).unwrap(), p);
}
#[test]
fn escaped_string_mode_preserves_exact_control_characters() {
let mut app = App::with_project(fixture());
send(&mut app, Message::ExactStrings(true));
let exact = "Plan\r\nRescue\t\"North\"";
send(
&mut app,
Message::Field("/name".into(), serde_json::to_string(exact).unwrap()),
);
send(&mut app, Message::Apply);
assert_eq!(app.project().name, exact);
assert!(!app.draft_dirty());
send(
&mut app,
Message::Field("/name".into(), "not quoted".into()),
);
send(&mut app, Message::Apply);
assert_eq!(app.project().name, exact);
assert!(app.draft_dirty());
send(&mut app, Message::RevertProperties);
assert!(!app.draft_dirty());
}
#[test]
fn explicit_relationship_builder_never_mutates_on_preview() {
let mut p = Project::empty();
let owner = p.add(Kind::Entity, "Team", 0., 0.);
let work = p.add(Kind::Activity, "Respond", 300., 0.);
let mut workflow = Workflow::relationship(&p, owner, Layer::Oa);
let before = p.clone();
workflow
.update(&p, Event::RelationName("Performs".into()))
.unwrap();
workflow
.update(
&p,
Event::Target(crate::app::Choice {
id: work,
label: "Respond".into(),
}),
)
.unwrap();
assert_eq!(p, before);
let result = workflow.update(&p, Event::Apply).unwrap().unwrap();
assert_eq!(result.relations.len(), 1);
}
#[test]
fn explorer_and_creation_selection_follow_their_own_placement() {
let p = fixture();
let owner = p
.elements
.values()
.find(|e| e.kind() == Kind::Entity)
.unwrap()
.id;
let work = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let mut app = App::with_project(p);
send(&mut app, Message::Select(owner));
let owner_node = app.selected_node.expect("placed explorer selection");
send(&mut app, Message::Select(work));
let work_node = app.selected_node.expect("placed work selection");
assert_ne!(owner_node, work_node);
let before = app.project().clone();
send(&mut app, Message::PlaceSelected);
assert_eq!(
*app.project(),
before,
"showing an existing placement does not create a duplicate"
);
send(&mut app, Message::CreateKind(Kind::Capability));
send(&mut app, Message::Add(false));
let diagram = &app.project().diagrams[&app.active_diagram.unwrap()];
let new_node = &diagram.nodes[&app.selected_node.expect("new element placement")];
assert_eq!(new_node.element, app.selected);
assert_ne!(
new_node.id, work_node,
"placement actions cannot target previously selected work"
);
}
#[test]
fn bidirectional_transition_ui_commits_once_and_preserves_other_diagrams() {
let mut p = Project::empty();
let activity = p.add(Kind::Activity, "Coordinate rescue", 120.5, 85.25);
let original = p.clone();
let mut w = Workflow::transition(&p, activity).unwrap();
let next = w.update(&p, Event::Apply).unwrap().unwrap();
assert_eq!(
next.diagrams[&original.diagrams.keys().copied().next().unwrap()],
*original.diagrams.values().next().unwrap()
);
let function = next
.elements
.values()
.find(|e| e.kind() == Kind::Function)
.unwrap()
.id;
let mut reverse = Workflow::transition(&next, function).unwrap();
let again = reverse.update(&next, Event::Apply).unwrap().unwrap();
assert_eq!(again.elements.len(), next.elements.len());
assert_eq!(again.traces.len(), 1);
let mut app = App::with_project(original.clone());
send(&mut app, Message::Command(Command::Replace(next)));
send(&mut app, Message::Undo);
assert_eq!(*app.project(), original);
}
#[test]
fn mission_capability_authoring_is_contextual_atomic_and_text_equivalent() {
for (layer, mission_kind, capability_kind, participant_kind) in [
(
Layer::Oa,
Kind::OperationalMission,
Kind::Capability,
Kind::Actor,
),
(
Layer::Sa,
Kind::Mission,
Kind::SystemCapability,
Kind::SystemActor,
),
] {
let mut p = Project::empty();
let mission = p
.add_element(mission_kind, layer, "Protect life", None)
.unwrap();
let base = p
.add_element(capability_kind, layer, "Rescue people", None)
.unwrap();
let extension = p
.add_element(capability_kind, layer, "Rescue in heavy weather", None)
.unwrap();
let actor = p
.add_element(participant_kind, layer, "Rescue operator", None)
.unwrap();
let mut app = App::with_project(p);
send(&mut app, Message::Select(mission));
send(&mut app, Message::AddRelation);
assert!(
matches!(app.workflow.as_ref(), Some(Workflow::Relation(d)) if d.kind == RelationKind::MissionCapability && d.source.as_ref().unwrap().id == mission)
);
send(
&mut app,
Message::Workflow(Event::Target(crate::app::Choice {
id: base,
label: "Rescue people".into(),
})),
);
send(&mut app, Message::Workflow(Event::Apply));
assert_eq!(app.project().relations.len(), 1);
assert!(
app.project()
.relations
.values()
.any(|r| r.name == "Protect life exploits Rescue people")
);
send(&mut app, Message::Select(mission));
send(
&mut app,
Message::AddRelationKind(RelationKind::MissionInvolvement, true),
);
assert!(
matches!(app.workflow.as_ref(), Some(Workflow::Relation(d)) if d.target.as_ref().unwrap().id == mission && d.source.is_none())
);
send(
&mut app,
Message::Workflow(Event::Source(crate::app::Choice {
id: actor,
label: "Rescue operator".into(),
})),
);
send(&mut app, Message::Workflow(Event::Apply));
let before = app.project().clone();
send(&mut app, Message::Select(extension));
send(
&mut app,
Message::AddRelationKind(RelationKind::CapabilityExtend, false),
);
send(
&mut app,
Message::Workflow(Event::Target(crate::app::Choice {
id: base,
label: "Rescue people".into(),
})),
);
let condition = "Sea state >= 6\nAND crew is qualified";
send(
&mut app,
Message::Workflow(Event::Condition(text_editor::Action::Edit(
text_editor::Edit::Paste(std::sync::Arc::new(condition.into())),
))),
);
assert_eq!(
*app.project(),
before,
"preview must preserve committed model"
);
send(&mut app, Message::Workflow(Event::Apply));
assert!(app.project().relations.values().any(|r| matches!(&r.body, RelationBody::CapabilityExtend {source,target,condition: value} if *source == extension && *target == base && value == condition)));
assert_eq!(
language::parse(&app.source_editor.text()).unwrap(),
*app.project()
);
send(&mut app, Message::Undo);
assert_eq!(*app.project(), before);
send(&mut app, Message::Redo);
assert_eq!(app.project().relations.len(), 3);
}
}
#[test]
fn property_enum_and_optional_reference_roundtrip() {
let mut p = Project::empty();
let item = p
.add_element(Kind::DataType, Layer::Oa, "Alarm severity", None)
.unwrap();
let mut d = Draft::from_project(&p, item).unwrap();
d.shape(
"/body/Oa/Shared/DataType/definition",
json!({"Enumeration":{"literals":["minor","major"]}}),
)
.unwrap();
let p = execute_batch(&p, &[d.command().unwrap()]).unwrap();
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
}
#[test]
fn modal_owns_background_commands_and_shortcuts() {
let mut app = App::with_project(fixture());
send(&mut app, Message::AddRelation);
let before = app.project().clone();
send(&mut app, Message::Layer(Layer::Sa));
send(&mut app, Message::Page(Page::Text));
send(&mut app, Message::ShortcutUndo(false));
send(&mut app, Message::Save(false));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.page, Page::Diagram);
assert!(!app.busy);
assert_eq!(*app.project(), before);
send(&mut app, Message::Escape);
assert!(app.workflow.is_none());
}
#[test]
fn selecting_current_constructors_preserves_payloads() {
let mut app = App::with_project(Project::demo());
let before = app.project().clone();
for e in before.elements.values() {
send(&mut app, Message::Select(e.id));
send(&mut app, Message::ElementKind(e.kind()));
assert!(!app.draft_dirty());
}
for r in before.relations.values() {
send(&mut app, Message::Select(r.id));
send(&mut app, Message::RelationKind(r.kind()));
assert!(!app.draft_dirty());
}
let mut p = Project::empty();
let id = p
.add_element(Kind::DataType, Layer::Oa, "Severity", None)
.unwrap();
let mut app = App::with_project(p);
send(&mut app, Message::Select(id));
let path = "/body/Oa/Shared/DataType/definition";
send(
&mut app,
Message::FieldVariant(
path.into(),
json!({"Enumeration":{"literals":["minor","major"]}}),
),
);
send(&mut app, Message::Apply);
let accepted = app.project().clone();
send(
&mut app,
Message::FieldVariant(path.into(), json!({"Enumeration":{"literals":[]}})),
);
send(&mut app, Message::Apply);
assert_eq!(*app.project(), accepted);
}
#[test]
fn placement_identity_edit_then_drag_and_diagram_identity_then_arrange() {
let mut app = App::with_project(fixture());
let diagram = app.active_diagram.unwrap();
let node = *app.project().diagrams[&diagram]
.nodes
.keys()
.next()
.unwrap();
send(&mut app, Message::Select(node));
let changed = Id::new_v4();
send(&mut app, Message::Field("/id".into(), changed.to_string()));
send(&mut app, Message::SelectNode(node));
send(
&mut app,
Message::MoveNode(node, Position { x: 200., y: 150. }),
);
assert_eq!(
app.project().diagrams[&diagram].nodes[&changed].position,
Position { x: 200., y: 150. }
);
send(&mut app, Message::Select(diagram));
let changed_diagram = Id::new_v4();
send(
&mut app,
Message::Field("/id".into(), changed_diagram.to_string()),
);
send(&mut app, Message::Command(Command::Layout(diagram)));
assert_eq!(app.active_diagram, Some(changed_diagram));
assert!(!app.project().contains(diagram));
}
#[test]
fn placement_relocation_follows_cross_layer_traceability_view() {
let mut p = fixture();
let original = p.ensure_diagram(Layer::Oa);
let target = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&target).unwrap().projection = ProjectionKind::Traceability;
let node = *p.diagrams[&original].nodes.keys().next().unwrap();
let mut app = App::with_project(p);
send(&mut app, Message::Select(node));
send(
&mut app,
Message::Field("/diagram".into(), target.to_string()),
);
assert!(app.apply(), "{}", app.status);
assert_eq!(app.layer, Layer::Sa);
assert_eq!(app.active_diagram, Some(target));
assert_eq!(app.selected_node, Some(node));
assert!(app.project().diagrams[&target].nodes.contains_key(&node));
assert!(!app.project().diagrams[&original].nodes.contains_key(&node));
send(&mut app, Message::Select(target));
send(&mut app, Message::Field("/layer".into(), "Oa".into()));
assert!(app.apply(), "{}", app.status);
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.active_diagram, Some(target));
}
#[test]
fn manual_trace_requires_target_and_selection_normalizes_creation_kind() {
let p = Project::demo();
let a = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let b = p
.elements
.values()
.find(|e| e.kind() == Kind::Function)
.unwrap()
.id;
let mut app = App::with_project(p.clone());
send(&mut app, Message::Select(b));
assert_eq!(app.create_kind, Kind::Function);
send(&mut app, Message::AddTrace);
send(&mut app, Message::Workflow(Event::Apply));
assert_eq!(*app.project(), p);
send(
&mut app,
Message::Workflow(Event::Target(crate::app::Choice {
id: a,
label: "OA".into(),
})),
);
send(&mut app, Message::Workflow(Event::Apply));
assert_eq!(app.project().traces.len(), p.traces.len() + 1);
send(&mut app, Message::Select(a));
assert_eq!(app.create_kind, Kind::Activity);
}
#[test]
fn invalid_save_and_continue_returns_to_repairable_draft() {
let mut app = App::with_project(fixture());
source(&mut app, "arcadia 2; ?");
send(&mut app, Message::Request(Pending::New));
send(&mut app, Message::SaveAndContinue);
assert!(app.pending.is_none());
assert_eq!(app.page, Page::Text);
send(&mut app, Message::RevertSource);
assert!(!app.source_dirty());
}
#[test]
fn counterpart_creation_focuses_visible_target_and_reuse_keeps_views() {
let mut app = App::with_project(fixture());
let source = app
.project()
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
send(&mut app, Message::Select(source));
send(&mut app, Message::StartTransition);
send(
&mut app,
Message::Workflow(Event::Scope("Selected only".into())),
);
send(&mut app, Message::Workflow(Event::Apply));
let target = app.selected;
assert_eq!(app.project().elements[&target].kind(), Kind::Function);
assert!(
app.project().diagrams[&app.active_diagram.unwrap()]
.nodes
.values()
.any(|n| n.element == target)
);
let before = app.project().clone();
send(&mut app, Message::StartTransition);
send(
&mut app,
Message::Workflow(Event::Scope("Selected only".into())),
);
send(&mut app, Message::Workflow(Event::Apply));
assert_eq!(app.project().diagrams, before.diagrams);
assert_eq!(app.selected, source);
assert!(
app.project().diagrams[&app.active_diagram.unwrap()]
.nodes
.values()
.any(|n| n.element == source)
);
}
#[test]
fn transition_resolutions_belong_to_the_effective_target_and_kind() {
use oa_core::transitions::{FieldDecision, TransitionField};
fn transition(workflow: &Workflow) -> &crate::workflow::Transition {
let Workflow::Transition(transition) = workflow else {
panic!("Transition workflow")
};
transition
}
let mut p = Project::empty();
let source = p
.add_element(Kind::Activity, Layer::Oa, "Source work", None)
.unwrap();
let first = p
.add_element(Kind::Function, Layer::Sa, "First target", None)
.unwrap();
let second = p
.add_element(Kind::Function, Layer::Sa, "Second target", None)
.unwrap();
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "work_trace".into(),
name: "Work trace".into(),
description: String::new(),
oa: source,
sa: first,
kind: TraceKind::Refinement,
baseline: None,
},
);
let mut workflow = Workflow::transition(&p, source).unwrap();
workflow
.update(
&p,
Event::Resolve(
source,
TransitionField::Name,
FieldDecision::Value("Merged for first target".into()),
),
)
.unwrap();
assert!(
transition(&workflow).request.mappings.is_empty(),
"First target was inferred"
);
let choose = |id| crate::app::Choice {
id,
label: String::new(),
};
workflow
.update(&p, Event::MappingTarget(source, choose(first)))
.unwrap();
assert_eq!(
transition(&workflow).request.resolutions.len(),
1,
"Same inferred target preserves merged draft"
);
workflow
.update(&p, Event::MappingTarget(source, choose(second)))
.unwrap();
assert!(transition(&workflow).request.resolutions.is_empty());
assert!(
transition(&workflow)
.plan
.conflicts
.iter()
.any(|c| c.source == Some(source)
&& c.target == Some(second)
&& c.field == Some(TransitionField::Name))
);
assert!(
workflow.update(&p, Event::Apply).is_err(),
"Old resolution cannot overwrite second target"
);
workflow
.update(
&p,
Event::Resolve(
source,
TransitionField::Name,
FieldDecision::Value("Reviewed second target".into()),
),
)
.unwrap();
workflow
.update(&p, Event::MappingTarget(source, choose(second)))
.unwrap();
assert_eq!(transition(&workflow).request.resolutions.len(), 1);
let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap();
assert_eq!(accepted.elements[&second].name, "Reviewed second target");
assert_eq!(accepted.elements[&first].name, "First target");
workflow
.update(&p, Event::MappingTarget(source, choose(Id::nil())))
.unwrap();
assert!(
transition(&workflow).request.resolutions.is_empty(),
"Creation is a different target context"
);
workflow
.update(
&p,
Event::Resolve(
source,
TransitionField::Description,
FieldDecision::Value("Draft for inferred function kind".into()),
),
)
.unwrap();
workflow
.update(&p, Event::MappingKind(source, Kind::Function))
.unwrap();
assert_eq!(
transition(&workflow).request.resolutions.len(),
1,
"Same inferred kind preserves draft"
);
let participant = p
.add_element(Kind::Entity, Layer::Oa, "Participant", None)
.unwrap();
let mut kinds = Workflow::transition(&p, participant).unwrap();
kinds
.update(&p, Event::MappingKind(participant, Kind::SystemActor))
.unwrap();
kinds
.update(
&p,
Event::Resolve(
participant,
TransitionField::Description,
FieldDecision::Value("Actor review".into()),
),
)
.unwrap();
kinds
.update(&p, Event::MappingKind(participant, Kind::SystemActor))
.unwrap();
assert_eq!(transition(&kinds).request.resolutions.len(), 1);
kinds
.update(&p, Event::MappingKind(participant, Kind::System))
.unwrap();
assert!(
transition(&kinds).request.resolutions.is_empty(),
"Changed kind requires a new decision"
);
}
#[test]
fn behavior_and_data_authoring_do_not_place_nodes_in_unrelated_architecture_views() {
let p = Project::demo();
let scenario = p
.elements
.values()
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Scenario)
.unwrap()
.id;
let mut app = App::with_project(p);
send(&mut app, Message::Layer(Layer::Sa));
send(&mut app, Message::Page(Page::Behavior));
let diagrams = app.project().diagrams.clone();
send(&mut app, Message::Select(scenario));
send(&mut app, Message::CreateKind(Kind::Occurrence));
send(&mut app, Message::Add(true));
let fragment = app.selected;
send(
&mut app,
Message::FieldVariant(
"/body/Sa/Shared/Occurrence/body".into(),
json!({"Fragment":{"operator":"Optional","branches":[]}}),
),
);
assert!(app.apply(), "{}", app.status);
send(&mut app, Message::Select(fragment));
send(&mut app, Message::Add(true));
let branch = app.selected;
send(&mut app, Message::Select(branch));
send(&mut app, Message::Add(true));
let occurrence = app.selected;
assert_eq!(app.project().elements[&fragment].parent, Some(scenario));
assert!(
app.project().elements[&scenario]
.steps()
.unwrap()
.contains(&fragment)
);
assert!(matches!(app.project().elements[&fragment].shared(),
Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { branches, .. }, .. })
if branches == &vec![branch]));
assert_eq!(app.project().elements[&occurrence].parent, Some(branch));
assert_eq!(
app.project().elements[&branch].steps().unwrap(),
&vec![occurrence]
);
assert_eq!(app.project().diagrams, diagrams);
send(&mut app, Message::Page(Page::Data));
send(&mut app, Message::CreateKind(Kind::ExchangeItem));
send(&mut app, Message::Add(false));
assert_eq!(
app.project().elements[&app.selected].kind(),
Kind::ExchangeItem
);
assert_eq!(app.project().diagrams, diagrams);
send(&mut app, Message::Page(Page::Diagram));
let active = app.active_diagram.unwrap();
send(&mut app, Message::CreateKind(Kind::Function));
send(&mut app, Message::Add(false));
assert_eq!(
app.project().diagrams[&active].nodes.len(),
diagrams[&active].nodes.len() + 1
);
assert!(
app.project().diagrams[&active]
.nodes
.values()
.any(|node| node.element == app.selected)
);
for (id, before) in &diagrams {
if *id != active {
assert_eq!(&app.project().diagrams[id], before);
}
}
}
File diff suppressed because it is too large Load Diff
+900
View File
@@ -0,0 +1,900 @@
//! Explicit, reviewable relationship and cross-perspective operations.
use crate::{
app::{Choice, Message},
style,
views::short,
};
use iced::{
Element, Fill,
widget::{Space, button, column, pick_list, row, scrollable, text, text_editor, text_input},
};
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 {
Cancel,
Apply,
RelationName(String),
RelationKind(RelationKind),
Condition(text_editor::Action),
Source(Choice),
Target(Choice),
Scope(String),
MappingTarget(Id, Choice),
MappingKind(Id, Kind),
Resolve(Id, TransitionField, FieldDecision),
Preview,
}
pub enum Workflow {
Relation(Relationship),
ManualTrace {
name: String,
source: Choice,
target: Option<Choice>,
},
Transition(Box<Transition>),
}
pub struct Relationship {
pub name: String,
pub kind: RelationKind,
pub source: Option<Choice>,
pub target: Option<Choice>,
pub condition: text_editor::Content,
layer: Layer,
sources: Vec<Choice>,
targets: Vec<Choice>,
}
impl Relationship {
fn accepts_source(&self, p: &Project, id: Id) -> bool {
if p.layer_of(id) != Some(self.layer) {
return false;
}
if self.kind == RelationKind::ExchangeAllocation {
return p
.relations
.get(&id)
.is_some_and(|r| r.kind() == RelationKind::BoundaryExchange);
}
p.elements.get(&id).is_some_and(|e| {
Kind::ALL
.into_iter()
.any(|target| target.supports(self.layer) && self.kind.permits(e.kind(), target))
})
}
fn refresh_choices(&mut self, p: &Project) {
self.sources = p
.elements
.keys()
.chain(p.relations.keys())
.copied()
.filter(|id| self.accepts_source(p, *id))
.map(|id| choice(p, id))
.collect();
self.sources.sort_by(|a, b| a.label.cmp(&b.label));
self.targets = p
.elements
.keys()
.chain(p.relations.keys())
.copied()
.filter(|id| {
self.source
.as_ref()
.is_some_and(|source| validation::can_connect(p, self.kind, source.id, *id))
})
.map(|id| choice(p, id))
.collect();
self.targets.sort_by(|a, b| a.label.cmp(&b.label));
}
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
if !self.name.trim().is_empty() {
return self.name.clone();
}
let verb = match self.kind {
RelationKind::CapabilityInclude => "includes",
RelationKind::CapabilityExtend => "extends",
RelationKind::CapabilityGeneralization => "specializes",
RelationKind::MissionCapability => "exploits",
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
"participates in"
}
RelationKind::Allocation => "performs",
_ => return format!("{}: {} → {}", self.kind, p.named(source), p.named(target)),
};
format!("{} {verb} {}", p.named(source), p.named(target))
}
}
pub struct Transition {
pub request: TransitionRequest,
pub plan: TransitionPlan,
}
impl Workflow {
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
if p.layer_of(selected).is_none()
|| !(p.elements.contains_key(&selected) || p.relations.contains_key(&selected))
{
return Err("Select an OA or SA element or relationship to trace.".into());
}
Ok(Self::ManualTrace {
name: "Engineering correspondence".into(),
source: choice(p, selected),
target: None,
})
}
pub fn relationship(p: &Project, selected: Id, layer: Layer) -> Self {
let kind = match p.elements.get(&selected).map(oa_core::Element::kind) {
Some(Kind::OperationalMission | Kind::Mission) => RelationKind::MissionCapability,
Some(Kind::Capability | Kind::SystemCapability) => RelationKind::CapabilityInclude,
Some(Kind::Role) => RelationKind::RoleAssignment,
Some(Kind::Process | Kind::FunctionalChain) => RelationKind::ChainInvolvement,
Some(kind) if kind.is_performer() => RelationKind::Allocation,
Some(kind) if kind.is_state() => RelationKind::StateTransition,
_ if layer == Layer::Oa => RelationKind::Interaction,
_ => RelationKind::FunctionalExchange,
};
Self::relationship_kind(p, selected, layer, kind, false)
}
pub fn relationship_kind(
p: &Project,
selected: Id,
layer: Layer,
kind: RelationKind,
incoming: bool,
) -> Self {
let mut draft = Relationship {
name: String::new(),
kind,
source: (!incoming).then(|| choice(p, selected)),
target: incoming.then(|| choice(p, selected)),
condition: text_editor::Content::new(),
layer,
sources: vec![],
targets: vec![],
};
if draft
.source
.as_ref()
.is_some_and(|s| !draft.accepts_source(p, s.id))
{
draft.source = None;
}
draft.refresh_choices(p);
Self::Relation(draft)
}
pub fn transition(p: &Project, selected: Id) -> Result<Self, String> {
let layer = p
.layer_of(selected)
.ok_or("Select an element or relationship in OA or SA.")?;
let direction = if layer == Layer::Oa {
TransitionDirection::OaToSa
} else {
TransitionDirection::SaToOa
};
let mut request = TransitionRequest::new(direction, vec![selected]);
request.scope = DependencyScope::Required;
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
Ok(Self::Transition(Box::new(Transition { request, plan })))
}
/// Return a complete accepted candidate only on explicit Apply. Intermediate
/// choices and previews never mutate the Session.
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
match self {
Self::ManualTrace {
name,
source,
target,
} => match event {
Event::RelationName(value) => *name = value,
Event::Target(value) => *target = Some(value),
Event::Apply => {
let target = target
.as_ref()
.ok_or("Choose a counterpart in the other perspective.")?
.id;
if name.trim().is_empty() {
return Err("Give the trace an engineering name.".into());
}
let (oa, sa) = if p.layer_of(source.id) == Some(Layer::Oa) {
(source.id, target)
} else {
(target, source.id)
};
let id = Id::new_v4();
let trace = Trace {
id,
alias: p.fresh_alias("correspondence", "trace", id),
name: name.clone(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
};
return execute_batch(p, &[Command::CreateTrace(trace)])
.map(Some)
.map_err(|e| e.to_string());
}
_ => {}
},
Self::Relation(d) => match event {
Event::RelationName(name) => d.name = name,
Event::RelationKind(kind) => {
d.kind = kind;
d.target = None;
if d.source
.as_ref()
.is_some_and(|s| !d.accepts_source(p, s.id))
{
d.source = None;
}
d.refresh_choices(p);
}
Event::Source(source) => {
d.source = Some(source);
if d.target.as_ref().is_some_and(|target| {
!validation::can_connect(
p,
d.kind,
d.source.as_ref().unwrap().id,
target.id,
)
}) {
d.target = None;
}
d.refresh_choices(p);
}
Event::Target(target) => d.target = Some(target),
Event::Condition(action) => d.condition.perform(action),
Event::Apply => {
let a = d.source.as_ref().ok_or("Choose a source.")?.id;
let b = d.target.as_ref().ok_or("Choose a target.")?.id;
let id = Id::new_v4();
let name = d.engineering_name(p, a, b);
let mut body = RelationBody::new(d.kind, a, b);
if let RelationBody::CapabilityExtend { condition, .. } = &mut body {
*condition = d.condition.text();
}
let relation = Relation {
id,
alias: p.fresh_alias(&name, "relation", id),
name,
description: String::new(),
body,
};
return execute_batch(p, &[Command::CreateRelation(relation)])
.map(Some)
.map_err(|e| e.to_string());
}
_ => {}
},
Self::Transition(d) => {
match event {
Event::Scope(scope) => {
d.request.scope = match scope.as_str() {
"Required references" => DependencyScope::Required,
"Whole subtree" => DependencyScope::Subtree,
_ => DependencyScope::SelectedOnly,
}
}
Event::MappingTarget(source, target) => {
let target = (!target.id.is_nil()).then_some(target.id);
let previous = d
.request
.mappings
.iter()
.find(|m| m.source == source)
.map(|m| m.target)
.unwrap_or_else(|| {
d.plan
.changes
.iter()
.find(|c| c.source == source)
.map(|c| c.target)
.or_else(|| {
d.plan
.conflicts
.iter()
.find(|c| c.source == Some(source))
.and_then(|c| c.target)
})
// Planned new identities are not an existing counterpart.
.filter(|id| p.contains(*id))
});
if previous != target {
d.request.resolutions.retain(|r| r.source != source);
}
if let Some(mapping) =
d.request.mappings.iter_mut().find(|m| m.source == source)
{
mapping.target = target;
} else {
d.request.mappings.push(TransitionMapping {
source,
target,
target_kind: None,
});
}
}
Event::MappingKind(source, kind) => {
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let planned_target = d
.plan
.changes
.iter()
.find(|c| c.source == source)
.map(|c| c.target);
let previous = mapping
.and_then(|m| m.target_kind)
.or_else(|| {
planned_target.and_then(|target| {
p.elements.get(&target).map(|e| e.kind()).or_else(|| {
d.plan.commands.iter().find_map(|command| match command {
Command::CreateElement(e) if e.id == target => {
Some(e.kind())
}
_ => None,
})
})
})
})
.or_else(|| {
p.elements.get(&source).and_then(|e| {
let kinds = counterpart_kinds(e.kind(), d.request.direction);
(kinds.len() == 1).then(|| kinds[0])
})
});
// Adding a kind mapping explicitly requests creation. If
// it replaces an inferred existing target, that is also
// a new review context even when its kind is identical.
let replaces_existing = mapping.is_none()
&& planned_target.is_some_and(|target| p.contains(target));
if previous != Some(kind) || replaces_existing {
d.request.resolutions.retain(|r| r.source != source);
}
if let Some(mapping) =
d.request.mappings.iter_mut().find(|m| m.source == source)
{
mapping.target_kind = Some(kind);
} else {
d.request.mappings.push(TransitionMapping {
source,
target: None,
target_kind: Some(kind),
});
}
}
Event::Resolve(source, field, decision) => {
d.request
.resolutions
.retain(|r| r.source != source || r.field != field);
d.request.resolutions.push(FieldResolution {
source,
field,
decision,
});
}
Event::Apply => {
let mut candidate =
apply_transition(p, &d.plan).map_err(|e| e.to_string())?;
let layer = d.request.direction.target_layer();
let diagram = d
.request
.destination_diagram
.or_else(|| {
candidate
.diagrams
.values()
.find(|d| d.layer == layer)
.map(|d| d.id)
})
.unwrap_or_else(|| candidate.ensure_diagram(layer));
for change in &d.plan.changes {
if candidate.elements.contains_key(&change.target) {
let index = candidate.diagrams[&diagram].nodes.len();
candidate.place(
diagram,
change.target,
Position {
x: 60. + (index % 3) as f32 * 280.,
y: 60. + (index / 3) as f32 * 150.,
},
);
}
}
return execute_batch(&candidate, &[])
.map(Some)
.map_err(|e| e.to_string());
}
_ => {}
}
d.plan = plan_transition(p, &d.request).map_err(|e| e.to_string())?;
}
}
Ok(None)
}
pub fn title(&self) -> &'static str {
match self {
Self::ManualTrace { .. } => "Link existing counterparts",
Self::Relation(_) => "Create relationship",
Self::Transition(t) => {
if t.request.direction == TransitionDirection::OaToSa {
"Operational → System"
} else {
"System → Operational"
}
}
}
}
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
let mut content = column![text(self.title()).size(23)].spacing(14);
match self {
Self::ManualTrace {
name,
source,
target,
} => {
let mut choices = p
.elements
.keys()
.chain(p.relations.keys())
.filter(|id| {
p.layer_of(**id)
.is_some_and(|layer| Some(layer) != p.layer_of(source.id))
})
.map(|id| choice(p, *id))
.collect::<Vec<_>>();
choices.sort_by(|a, b| a.label.cmp(&b.label));
content = content.push(text("Record an explicit engineering correspondence between existing records. This link preserves both records and does not propagate properties.").size(13).color(style::MUTED))
.push(text(format!("From: {}",source.label)).size(14))
.push(text_input("Trace name",name).padding(10).on_input(|v|Message::Workflow(Event::RelationName(v))))
.push(pick_list(choices,target.clone(),|v|Message::Workflow(Event::Target(v))).placeholder("Choose counterpart in the other perspective").width(Fill).padding(10).text_size(13));
}
Self::Relation(d) => {
let kinds = RelationKind::ALL
.into_iter()
.filter(|kind| {
*kind == RelationKind::ExchangeAllocation
|| Kind::ALL.into_iter().any(|source| {
source.supports(d.layer)
&& Kind::ALL.into_iter().any(|target| {
target.supports(d.layer) && kind.permits(source, target)
})
})
})
.collect::<Vec<_>>();
let (source_label, target_label) = match d.kind {
RelationKind::CapabilityInclude => {
("Including capability", "Included capability")
}
RelationKind::CapabilityExtend => {
("Extending capability", "Extended capability")
}
RelationKind::CapabilityGeneralization => {
("Specific capability", "General capability")
}
RelationKind::MissionCapability => ("Mission", "Exploited capability"),
RelationKind::MissionInvolvement => ("Participant", "Mission"),
RelationKind::CapabilityInvolvement => ("Participant or work", "Capability"),
_ => ("Source", "Target"),
};
content = content
.push(text(d.kind.hint()).size(13).color(style::MUTED))
.push(
pick_list(kinds, Some(d.kind), |v| {
Message::Workflow(Event::RelationKind(v))
})
.text_size(13)
.padding(9)
.width(Fill),
)
.push(text(source_label).size(12))
.push(
pick_list(d.sources.as_slice(), d.source.as_ref(), |v| {
Message::Workflow(Event::Source(v))
})
.placeholder("Choose source")
.text_size(13)
.padding(9)
.width(Fill),
)
.push(text(target_label).size(12))
.push(
pick_list(d.targets.as_slice(), d.target.as_ref(), |v| {
Message::Workflow(Event::Target(v))
})
.placeholder("Choose a compatible target")
.text_size(13)
.padding(9)
.width(Fill),
);
if d.source.is_some() && d.targets.is_empty() {
content = content.push(text("No compatible target yet. Create the needed model element, then connect it here.").size(12).color(style::MUTED));
}
if d.kind == RelationKind::CapabilityExtend {
content = content.push(text("Extension condition").size(12)).push(
text_editor(&d.condition)
.placeholder("When does the extending capability add behavior?")
.on_action(|action| Message::Workflow(Event::Condition(action)))
.height(72)
.padding(9),
);
}
content = content.push(
text_input("Name (generated from endpoints when blank)", &d.name)
.on_input(|v| Message::Workflow(Event::RelationName(v)))
.padding(10)
.size(13),
);
if let (Some(source), Some(target)) = (&d.source, &d.target) {
content = content.push(
text(d.engineering_name(p, source.id, target.id))
.size(13)
.color(style::TEAL),
);
}
}
Self::Transition(d) => {
content=content.push(text("Create or reconcile traced counterparts. Parent and performer remain independent. Descriptive updates are reviewed here; structural changes are created only for new counterparts.").size(12).color(style::MUTED));
let scope = match d.request.scope {
DependencyScope::SelectedOnly => "Selected only",
DependencyScope::Required => "Required references",
DependencyScope::Subtree => "Whole subtree",
}
.to_string();
content = content.push(
row![
text("Include").size(12),
pick_list(
vec![
"Selected only".to_string(),
"Required references".to_string(),
"Whole subtree".to_string()
],
Some(scope),
|v| Message::Workflow(Event::Scope(v))
)
.text_size(12)
.padding(8),
Space::new().width(Fill),
button(text("Refresh preview").size(12))
.style(style::nav)
.on_press(Message::Workflow(Event::Preview))
]
.spacing(10)
.align_y(iced::Center),
);
let sources = d
.request
.sources
.iter()
.copied()
.chain(d.plan.changes.iter().map(|c| c.source))
.chain(d.plan.conflicts.iter().filter_map(|c| c.source))
.collect::<BTreeSet<_>>();
for source in sources {
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let existing = p
.traces
.values()
.filter_map(|t| {
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
{
Some(t.sa)
} else if d.request.direction == TransitionDirection::SaToOa
&& t.sa == source
{
Some(t.oa)
} else {
None
}
})
.collect::<Vec<_>>();
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
if existing.len() == 1 {
Some(existing[0])
} else {
None
}
});
let mut targets = vec![Choice {
id: Id::nil(),
label: "Create new counterpart".into(),
}];
targets.extend(
p.elements
.keys()
.chain(p.relations.keys())
.filter(|id| {
p.layer_of(**id) == Some(d.request.direction.target_layer())
&& p.elements.contains_key(&source)
== p.elements.contains_key(id)
})
.map(|id| choice(p, *id)),
);
let selected = targets
.iter()
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
.cloned();
let mut item = column![
text(format!(
"{} · {}",
short(p.named(source), 70),
p.elements
.get(&source)
.map(|e| e.kind().label())
.unwrap_or("Relationship")
))
.size(14),
pick_list(targets, selected, move |v| Message::Workflow(
Event::MappingTarget(source, v)
))
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(7);
if target.is_none()
&& let Some(e) = p.elements.get(&source)
{
let kinds = counterpart_kinds(e.kind(), d.request.direction);
if !kinds.is_empty() {
let selected = mapping.and_then(|m| m.target_kind).or_else(|| {
if kinds.len() == 1 {
Some(kinds[0])
} else {
None
}
});
item = item.push(
pick_list(kinds, selected, move |kind| {
Message::Workflow(Event::MappingKind(source, kind))
})
.placeholder("Choose counterpart kind / system boundary")
.text_size(12)
.padding(8)
.width(Fill),
);
}
}
content = content.push(
iced::widget::container(item)
.padding(12)
.style(style::subtle),
);
}
let mut changes = column![].spacing(8);
for change in &d.plan.changes {
changes = changes.push(
text(format!("{:?} · {}", change.action, p.named(change.source)))
.size(12)
.color(style::TEAL),
);
for field in &change.fields {
changes = changes.push(
text(format!(
"{:?}: {} → {}",
field.field,
short(&field.before, 90),
short(&field.after, 90)
))
.size(12),
);
}
}
content = content.push(changes);
let mut fields = d
.plan
.conflicts
.iter()
.filter_map(|c| Some((c.source?, c.field?)))
.chain(d.request.resolutions.iter().map(|r| (r.source, r.field)))
.fold(Vec::new(), |mut out, field| {
if !out.contains(&field) {
out.push(field);
}
out
});
// Keep property widgets in the same order while a resolution
// changes the preview, so continuous typing retains focus.
fields.sort_by_key(|(source, field)| (*source, *field as u8));
let mut general_conflicts = column![].spacing(8);
for conflict in &d.plan.conflicts {
if conflict.field.is_none() {
general_conflicts = general_conflicts.push(
text(&conflict.message)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
content = content.push(general_conflicts);
for (source, field) in fields {
let target = d
.plan
.conflicts
.iter()
.find(|c| c.source == Some(source) && c.field == Some(field))
.and_then(|c| c.target)
.or_else(|| {
d.request
.mappings
.iter()
.find(|m| m.source == source)
.and_then(|m| m.target)
})
.or_else(|| {
d.plan
.changes
.iter()
.find(|c| c.source == source)
.map(|c| c.target)
})
.or_else(|| {
p.traces.values().find_map(|t| {
if d.request.direction == TransitionDirection::OaToSa
&& t.oa == source
{
Some(t.sa)
} else if d.request.direction == TransitionDirection::SaToOa
&& t.sa == source
{
Some(t.oa)
} else {
None
}
})
});
let value = |id| {
p.properties(id)
.map(|v| match field {
TransitionField::Name => v.name,
TransitionField::Description => v.description,
})
.unwrap_or_default()
};
let resolution = d
.request
.resolutions
.iter()
.find(|r| r.source == source && r.field == field);
let merged = resolution
.and_then(|r| {
if let FieldDecision::Value(v) = &r.decision {
Some(v.as_str())
} else {
None
}
})
.unwrap_or("");
let status = resolution
.map(|r| match &r.decision {
FieldDecision::KeepTarget => "Keep target",
FieldDecision::TakeSource => "Take source",
FieldDecision::Value(_) => "Use merged text",
})
.unwrap_or("Choose how to resolve this conflict");
let card = column![
text(format!(
"{:?} · {} → {}",
field,
p.named(source),
target.map(|id| p.named(id)).unwrap_or("New counterpart")
))
.size(14),
text(format!(
"Current target: {}",
target.map(value).unwrap_or_default()
))
.size(12),
text(format!("Proposed source: {}", value(source))).size(12),
text(status).size(12).color(style::TEAL),
row![
button(text("Keep target").size(12))
.padding(8)
.style(style::nav)
.on_press(Message::Workflow(Event::Resolve(
source,
field,
FieldDecision::KeepTarget
))),
button(text("Take source").size(12))
.padding(8)
.style(style::nav)
.on_press(Message::Workflow(Event::Resolve(
source,
field,
FieldDecision::TakeSource
)))
]
.spacing(8),
text_input("Or enter merged text", merged)
.padding(9)
.size(12)
.on_input(move |v| Message::Workflow(Event::Resolve(
source,
field,
FieldDecision::Value(v)
)))
]
.spacing(8);
content = content.push(
iced::widget::container(card)
.padding(12)
.style(style::subtle),
);
}
for warning in &d.plan.warnings {
content = content.push(
text(format!("Modeling warning · {warning}"))
.size(12)
.color(style::MUTED),
);
}
content = content.push(
text(format!(
"{} changes · {} unresolved conflicts · {} modeling warnings",
d.plan.changes.len(),
d.plan.conflicts.len(),
d.plan.warnings.len()
))
.size(12),
);
}
}
let enabled = match self {
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
Self::Relation(d) => d
.source
.as_ref()
.zip(d.target.as_ref())
.is_some_and(|(a, b)| validation::can_connect(p, d.kind, a.id, b.id)),
Self::Transition(d) => d.plan.conflicts.is_empty(),
};
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
row![
Space::new().width(Fill),
button(text("Cancel").size(12))
.padding(10)
.style(style::nav)
.on_press(Message::Workflow(Event::Cancel)),
button(text("Apply reviewed change").size(12))
.padding(10)
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
]
.spacing(10)
]
.spacing(18)
.into()
}
}
fn choice(p: &Project, id: Id) -> Choice {
Choice {
id,
label: format!(
"{} · {}",
p.named(id),
p.layer_of(id).map(|l| l.to_string()).unwrap_or_default()
),
}
}