Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/topology.rs
T

1654 lines
59 KiB
Rust

//! Process/chain authoring and projection use contextual occurrences and explicit
//! SequenceLinks. Presentation order never invents control-flow relationships.
use super::*;
use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
mod direct;
mod view;
use direct::Input;
pub(crate) use direct::{
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
};
#[cfg(test)]
pub(crate) mod direct_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Work,
Exchange,
Reuse,
Control,
Connect,
Branch,
}
impl Tool {
fn title(self) -> &'static str {
match self {
Self::Work => "Involve work",
Self::Exchange => "Involve exchange",
Self::Reuse => "Reuse process / chain",
Self::Control => "Add control",
Self::Connect => "Connect contributions",
Self::Branch => "Insert two paths",
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Start(Id, Tool, Option<Id>),
Cancel,
Submit,
Name(String),
Target(Choice),
Alternate(Choice),
Before(Choice),
After(Choice),
ClearBefore,
ClearAfter,
Guard(String),
AlternateGuard(String),
Control(ControlChoice),
Search(usize, String),
Page(usize, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ControlChoice(pub ControlKind);
impl std::fmt::Display for ControlChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self.0 {
ControlKind::Choice => "OR · alternative paths",
ControlKind::Merge => "OR · merge alternatives",
ControlKind::Fork => "AND · parallel paths",
ControlKind::Join => "AND · join parallel paths",
ControlKind::Loop => "IT · repeat / exit",
})
}
}
#[derive(Default)]
pub struct State {
identity: Option<(Id, Id)>,
tool: CanvasTool,
expanded: BTreeSet<Id>,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<(Id, Id)>,
draft: Option<Draft>,
viewport: Option<Size>,
zoom: Option<f32>,
fit: bool,
}
#[derive(Default)]
struct Browser {
search: String,
page: usize,
}
struct Draft {
insertion: Insertion,
fixed: bool,
browsers: [Browser; 4],
owner: Id,
tool: Tool,
name: String,
target: Option<Id>,
alternate: Option<Id>,
before: Option<Id>,
after: Option<Id>,
guard: String,
alternate_guard: String,
control: ControlKind,
}
impl State {
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.owner)
}
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
let command = self
.draft
.as_ref()
.ok_or("Choose a contribution tool first.")?
.command(p)?;
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(super) fn acknowledge_commit(&mut self) {
self.draft = None;
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Start(owner, tool, selected) => {
let e = p
.elements
.get(&owner)
.ok_or("This process or chain no longer exists.")?;
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return Err("Choose an operational process or system functional chain.".into());
}
let before = selected.filter(|id| is_contribution(p, owner, *id));
let after = before.and_then(|id| {
let outgoing: Vec<_> = sequence_links(p, owner)
.into_iter()
.filter(|r| r.source() == id)
.map(Relation::target)
.collect();
if outgoing.len() == 1 {
outgoing.first().copied()
} else {
legacy_order(p, owner)
.windows(2)
.find(|pair| pair[0] == id)
.map(|pair| pair[1])
}
});
self.draft = Some(Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool,
name: String::new(),
target: None,
alternate: None,
before,
after,
guard: String::new(),
alternate_guard: "else".into(),
control: ControlKind::Choice,
});
}
Event::Cancel => self.acknowledge_commit(),
Event::Submit => return self.prepare_pending(p).map(Some),
other => {
if let Some(d) = &mut self.draft {
match other {
Event::Name(value) => d.name = value,
Event::Target(value) => d.target = Some(value.id),
Event::Alternate(value) => d.alternate = Some(value.id),
Event::Before(value) => d.before = Some(value.id),
Event::After(value) => d.after = Some(value.id),
Event::ClearBefore => d.before = None,
Event::ClearAfter => d.after = None,
Event::Guard(value) => d.guard = value,
Event::AlternateGuard(value) => d.alternate_guard = value,
Event::Control(value) => d.control = value.0,
Event::Search(index, value) if index < 4 => {
d.browsers[index].search = value;
d.browsers[index].page = 0;
}
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
_ => {}
}
}
}
}
Ok(None)
}
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
if !self.fit {
return self.zoom.unwrap_or(1.);
}
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
.clamp(0.05, 1.)
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.parent == Some(owner)
&& matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Action { .. }
| OccurrenceBody::Exchange { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. },
..
})
)
})
}
fn reference_reaches(p: &Project, from: Id, to: Id) -> bool {
let mut pending = vec![from];
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
if id == to {
return true;
}
if seen.insert(id) {
pending.extend(p.children(id).filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
}) => *sequence,
_ => None,
}));
}
}
false
}
fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
let Some(e) = p.elements.get(&owner) else {
return vec![];
};
let mut choices: Vec<_> = if tool == Tool::Exchange {
p.relations
.values()
.filter(|r| {
p.layer_of(r.id) == Some(e.layer())
&& r.kind()
== if e.layer() == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
}
})
.map(|r| Choice {
id: r.id,
label: format!(
"{} · {} → {}",
r.name,
p.named(r.source()),
p.named(r.target())
),
})
.collect()
} else {
p.elements
.values()
.filter(|candidate| {
candidate.layer() == e.layer()
&& match tool {
Tool::Work | Tool::Branch => candidate.kind().is_work(),
Tool::Reuse => {
candidate.kind() == e.kind()
&& !reference_reaches(p, candidate.id, owner)
}
_ => false,
}
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
})
.collect()
};
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
fn semantic_path(p: &Project, id: Id) -> String {
let mut path = Vec::new();
let mut current = Some(id);
while let Some(id) = current {
let Some(e) = p.elements.get(&id) else { break };
path.push(e.alias.as_str());
current = e.parent;
}
path.reverse();
path.join(" / ")
}
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
p.elements
.get(&owner)
.and_then(|e| e.steps())
.into_iter()
.flatten()
.filter(|id| is_contribution(p, owner, **id))
.enumerate()
.map(|(i, id)| Choice {
id: *id,
label: format!("{}. {}", i + 1, p.named(*id)),
})
.collect()
}
fn legacy_order(p: &Project, owner: Id) -> Vec<Id> {
if uses_explicit_topology(p, owner) {
return vec![];
}
let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) else {
return vec![];
};
if steps.iter().all(|id| {
matches!(
p.elements.get(id).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
}) {
steps.clone()
} else {
vec![]
}
}
fn preserved_order(p: &Project, owner: Id, replace: Option<(Id, Id)>) -> Vec<Command> {
legacy_order(p, owner)
.windows(2)
.filter(|pair| replace != Some((pair[0], pair[1])))
.map(|pair| link(p, pair[0], pair[1], ""))
.collect()
}
fn contribution_work(p: &Project, id: Id) -> Option<Id> {
match p.elements.get(&id)?.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action { action, .. },
..
} => *action,
_ => None,
}
}
fn contribution_exchange(p: &Project, id: Id) -> Option<&Relation> {
match p.elements.get(&id)?.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => exchange.and_then(|id| p.relations.get(&id)),
_ => None,
}
}
fn compatible_source(p: &Project, draft: &Draft, id: Id) -> bool {
match draft.tool {
Tool::Work => {
draft
.before
.and_then(|id| contribution_exchange(p, id))
.is_none_or(|r| r.target() == id)
&& draft
.after
.and_then(|id| contribution_exchange(p, id))
.is_none_or(|r| r.source() == id)
}
Tool::Exchange => {
let Some(exchange) = p.relations.get(&id) else {
return false;
};
let preceding = draft.before.and_then(|id| {
contribution_work(p, id)
.or_else(|| contribution_exchange(p, id).map(Relation::target))
});
let following = draft.after.and_then(|id| {
contribution_work(p, id)
.or_else(|| contribution_exchange(p, id).map(Relation::source))
});
preceding.is_none_or(|id| exchange.source() == id)
&& following.is_none_or(|id| exchange.target() == id)
}
_ => true,
}
}
pub(super) fn occurrence(
p: &Project,
owner: Id,
name: &str,
body: OccurrenceBody,
) -> oa_core::Element {
let mut e = p
.make_element(
Kind::Occurrence,
p.elements[&owner].layer(),
name,
Some(owner),
)
.expect("Occurrence layer");
if let Some(SharedBody::Occurrence { body: value, .. }) = e.shared_mut() {
*value = body;
}
e
}
fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
let id = Id::new_v4();
Command::CreateRelation(Relation {
id,
alias: p.fresh_alias("sequence_link", "relation", id),
name: "Sequence link".into(),
description: String::new(),
body: RelationBody::SequenceLink {
source,
target,
guard: guard.to_owned(),
},
})
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
let owner = p
.elements
.get(&self.owner)
.ok_or("The process or chain no longer exists.")?;
if !matches!(owner.kind(), Kind::Process | Kind::FunctionalChain) {
return Err("Choose a process or chain.".into());
}
for id in [self.before, self.after].into_iter().flatten() {
if !is_contribution(p, self.owner, id) {
return Err("Choose contributions from this process or chain.".into());
}
}
if self.before.is_some() && self.before == self.after {
return Err("Choose different preceding and following contributions.".into());
}
let captured = self
.insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, self.owner, r.source())
&& is_contribution(p, self.owner, r.target())
})
.ok_or("The captured path no longer exists in this process.")
})
.transpose()?;
if captured
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
{
return Err("The captured path changed. Choose its position again.".into());
}
let matches: Vec<_> = sequence_links(p, self.owner)
.into_iter()
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
.collect();
let split_link = if self.tool == Tool::Connect {
None
} else if captured.is_some() {
captured
} else if !self.fixed && matches.len() == 1 {
Some(matches[0])
} else if !self.fixed && matches.len() > 1 {
return Err("Select the specific sequence link to insert on.".into());
} else {
None
};
let mut commands = preserved_order(
p,
self.owner,
if self.tool == Tool::Connect {
None
} else {
self.before.zip(self.after)
},
);
if self.tool == Tool::Connect {
let source = self.before.ok_or("Choose the preceding contribution.")?;
let target = self.after.ok_or("Choose the following contribution.")?;
if sequence_links(p, self.owner)
.iter()
.any(|r| r.source() == source && r.target() == target)
{
return Err("These contributions already have a sequence link; select it to edit its guard.".into());
}
if legacy_order(p, self.owner)
.windows(2)
.any(|pair| pair == [source, target])
{
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
}
commands.push(link(p, source, target, &self.guard));
return Ok(unique_batch(p, commands));
}
let target = if matches!(self.tool, Tool::Control) {
None
} else {
let id = self
.target
.ok_or("Choose the existing work, exchange or process to involve.")?;
if !targets(p, self.owner, self.tool).iter().any(|c| c.id == id)
|| !compatible_source(p, self, id)
{
return Err(
"The selected source is incompatible or would introduce recursive reuse."
.into(),
);
}
Some(id)
};
let default_name = target.map(|id| p.named(id)).unwrap_or(match self.control {
ControlKind::Choice => "Choose path",
ControlKind::Merge => "Merge paths",
ControlKind::Fork => "Start parallel paths",
ControlKind::Join => "Join paths",
ControlKind::Loop => "Repeat or exit",
});
let name = if self.name.trim().is_empty() {
default_name
} else {
self.name.trim()
};
let (first, last) = if self.tool == Tool::Branch {
let alternate = self.alternate.ok_or("Choose work for the second path.")?;
if !targets(p, self.owner, Tool::Work)
.iter()
.any(|c| c.id == alternate)
{
return Err("Choose same-layer work for the second path.".into());
}
let parallel = self.control == ControlKind::Fork;
let split = occurrence(
p,
self.owner,
if parallel {
"Start parallel paths"
} else {
"Choose path"
},
OccurrenceBody::Control {
kind: if parallel {
ControlKind::Fork
} else {
ControlKind::Choice
},
},
);
let merge = occurrence(
p,
self.owner,
if parallel {
"Join paths"
} else {
"Merge alternatives"
},
OccurrenceBody::Control {
kind: if parallel {
ControlKind::Join
} else {
ControlKind::Merge
},
},
);
let a = occurrence(
p,
self.owner,
name,
OccurrenceBody::Action {
action: target,
lifeline: None,
},
);
let b = occurrence(
p,
self.owner,
p.named(alternate),
OccurrenceBody::Action {
action: Some(alternate),
lifeline: None,
},
);
let ids = (split.id, a.id, b.id, merge.id);
commands.extend([split, a, b, merge].into_iter().map(Command::CreateElement));
commands.extend([
link(p, ids.0, ids.1, if parallel { "" } else { &self.guard }),
link(
p,
ids.0,
ids.2,
if parallel { "" } else { &self.alternate_guard },
),
link(p, ids.1, ids.3, ""),
link(p, ids.2, ids.3, ""),
]);
(ids.0, ids.3)
} else {
let body = match self.tool {
Tool::Work => OccurrenceBody::Action {
action: target,
lifeline: None,
},
Tool::Exchange => OccurrenceBody::Exchange {
exchange: target,
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
Tool::Reuse => OccurrenceBody::SequenceReference { sequence: target },
Tool::Control => OccurrenceBody::Control { kind: self.control },
_ => unreachable!(),
};
let e = occurrence(p, self.owner, name, body);
let id = e.id;
commands.push(Command::CreateElement(e));
(id, id)
};
if let Some(existing) = split_link {
let mut updated = existing.clone();
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
*target = first;
}
commands.push(Command::EditRelation(existing.id, updated));
} else if let Some(before) = self.before {
commands.push(link(
p,
before,
first,
if self.tool == Tool::Branch {
""
} else {
&self.guard
},
));
}
if let Some(after) = self.after {
commands.push(link(p, last, after, ""));
}
let new_ids: Vec<_> = commands
.iter()
.filter_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
_ => None,
})
.collect();
let mut order = owner.steps().expect("process steps").clone();
let slot = self.insertion.before.or(self.after);
let index = slot
.map(|id| {
order
.iter()
.position(|v| *v == id)
.ok_or("The presentation slot changed.")
})
.transpose()?
.unwrap_or(order.len());
order.splice(index..index, new_ids);
commands.push(Command::SetSteps(self.owner, order));
Ok(unique_batch(p, commands))
}
}
pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
let mut aliases: BTreeSet<String> = p
.identities()
.into_iter()
.map(|(_, alias)| alias.to_owned())
.collect();
for command in &mut commands {
let (id, alias) = match command {
Command::CreateElement(e) => (e.id, &mut e.alias),
Command::CreateRelation(r) => (r.id, &mut r.alias),
_ => continue,
};
if !aliases.insert(alias.clone()) {
*alias = format!("{}_{}", alias, id.simple());
aliases.insert(alias.clone());
}
}
Command::Batch(commands)
}
fn event(e: Event) -> Message {
Message::Behavior(if matches!(e, Event::Submit) {
super::Event::Submit
} else {
super::Event::Topology(e)
})
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
view::view(app, owner, out)
}
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
p.relations
.values()
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, owner, r.source())
&& is_contribution(p, owner, r.target())
})
.collect()
}
struct GraphNode {
id: Id,
rect: Rectangle,
title: String,
detail: String,
control: Option<ControlKind>,
source: Option<Id>,
preview: Vec<(Id, String)>,
preview_links: Vec<(Id, Id)>,
preview_total: usize,
}
struct GraphEdge {
id: Id,
source: Id,
target: Id,
guard: String,
points: Vec<Point>,
label: Rectangle,
}
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
size: Size,
selected: Id,
scale: f32,
}
impl<'a> Graph<'a> {
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
Self::expanded(p, owner, selected, &BTreeSet::new())
}
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
for r in &links {
*incoming.entry(r.target()).or_default() += 1;
}
let mut queue: Vec<_> = choices
.iter()
.filter(|c| incoming[&c.id] == 0)
.map(|c| c.id)
.collect();
let mut done = BTreeSet::new();
let mut offset = 0;
while offset < queue.len() {
let id = queue[offset];
offset += 1;
if !done.insert(id) {
continue;
}
for r in links.iter().filter(|r| r.source() == id) {
let next = r.target();
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
let count = incoming.get_mut(&next).expect("Graph endpoint");
*count = count.saturating_sub(1);
if *count == 0 {
queue.push(next);
}
}
}
// Cycles remain explicit edges; deterministic fallback placement must
// terminate even while users are building a loop or incomplete graph.
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
for c in &choices {
if !done.contains(&c.id) {
ranks.insert(c.id, fallback);
fallback += 1;
}
}
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
for c in choices {
let e = &p.elements[&c.id];
let rank = ranks[&c.id];
let slot = rows.entry(rank).or_insert(24.);
let mut node = GraphNode {
id: e.id,
rect: Rectangle::new(
Point::new(24. + rank as f32 * 420., *slot),
Size::new(270., 104.),
),
title: e.name.clone(),
detail: String::new(),
control: None,
source: None,
preview: vec![],
preview_links: vec![],
preview_total: 0,
};
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
match body {
OccurrenceBody::Action { action, .. } => {
node.detail = action
.map(|id| format!("Work · {}", p.named(id)))
.unwrap_or("Choose work in Properties".into())
}
OccurrenceBody::Exchange { exchange, .. } => {
node.detail = exchange
.and_then(|id| p.relations.get(&id))
.map(|r| {
format!(
"Exchange · {} → {}",
p.named(r.source()),
p.named(r.target())
)
})
.unwrap_or("Choose exchange in Properties".into())
}
OccurrenceBody::Control { kind } => {
node.control = Some(*kind);
node.detail = ControlChoice(*kind).to_string();
}
OccurrenceBody::SequenceReference { sequence } => {
node.source = *sequence;
node.rect.height = 218.;
node.detail = sequence
.map(|id| format!("ref · {}", p.named(id)))
.unwrap_or("Choose source in Properties".into());
if let Some(source) = sequence {
let contributions = contributions(p, *source);
node.preview_total = contributions.len();
node.preview = contributions
.into_iter()
.take(if expanded.contains(&e.id) {
usize::MAX
} else {
4
})
.map(|c| (c.id, p.named(c.id).to_owned()))
.collect();
node.rect.height =
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
let preview_ids: BTreeSet<_> =
node.preview.iter().map(|(id, _)| *id).collect();
node.preview_links = sequence_links(p, *source)
.into_iter()
.filter(|r| {
preview_ids.contains(&r.source())
&& preview_ids.contains(&r.target())
})
.map(|r| (r.source(), r.target()))
.collect();
}
}
_ => {}
}
}
*slot += node.rect.height + 36.;
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
nodes.push(node);
}
let mut edges = vec![];
let mut routed_pairs = BTreeSet::new();
for (i, r) in links.into_iter().enumerate() {
let a = nodes
.iter()
.find(|n| n.id == r.source())
.expect("Graph source")
.rect;
let b = nodes
.iter()
.find(|n| n.id == r.target())
.expect("Graph target")
.rect;
let start = Point::new(a.x + a.width, a.center_y());
let end = Point::new(b.x, b.center_y());
// Parallel records keep distinct visible paths and guard hit targets.
// Their semantic endpoints and IDs remain unchanged.
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
let points = if forward {
let mid = (start.x + end.x) / 2.;
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
} else {
let below = size.height + 24. + i as f32 * 42.;
vec![
start,
Point::new(start.x + 18., start.y),
Point::new(start.x + 18., below),
Point::new(end.x - 16., below),
Point::new(end.x - 16., end.y),
end,
]
};
let guard = match &r.body {
RelationBody::SequenceLink { guard, .. } => guard.clone(),
_ => String::new(),
};
let label_at = if forward {
Point::new(end.x - 138., end.y - 43.)
} else {
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
};
let label = Rectangle::new(label_at, Size::new(132., 36.));
edges.push(GraphEdge {
id: r.id,
source: r.source(),
target: r.target(),
guard,
points,
label,
});
}
for e in &edges {
for pt in &e.points {
size.height = size.height.max(pt.y + 30.);
}
}
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
size,
selected,
scale: 1.,
}
}
}
fn wrapped(value: &str, width: usize, lines: usize) -> String {
let mut output = Vec::new();
let mut current = String::new();
let words: Vec<String> = value
.split_whitespace()
.flat_map(|word| {
word.chars()
.collect::<Vec<_>>()
.chunks(width.max(1))
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<_>>()
})
.collect();
for word in &words {
if !current.is_empty() && current.chars().count() + word.chars().count() + 1 > width {
output.push(current);
current = String::new();
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
output.push(current);
}
let more = output.len() > lines;
output.truncate(lines);
if more && let Some(last) = output.last_mut() {
last.push('…');
}
output.join("\n")
}
fn draw_text(f: &mut Frame, content: String, position: Point, size: f32, color: Color) {
f.fill_text(Text {
content,
position,
size: size.into(),
color,
..Default::default()
});
}
fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
if points.len() < 2 {
return;
}
f.stroke(
&Path::new(|p| {
p.move_to(points[0]);
for pt in &points[1..] {
p.line_to(*pt);
}
}),
Stroke::default().with_color(color).with_width(width),
);
let end = points[points.len() - 1];
let delta = end - points[points.len() - 2];
let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.);
let d = delta / length;
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).with_width(width),
);
}
impl canvas::Program<Message> for Graph<'_> {
type State = Input;
fn update(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
self.input(state, event, bounds, cursor)
}
fn draw(
&self,
state: &Input,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
cursor: 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 e in &self.edges {
let selected =
e.id == self.selected || e.source == self.selected || e.target == self.selected;
draw_arrow(
&mut f,
&e.points,
if selected { style::TEAL } else { style::MUTED },
if selected { 2.5 } else { 1.5 },
);
}
for n in &self.nodes {
let rect = Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into());
f.fill(&rect, Color::from_rgb8(243, 248, 248));
f.stroke(
&rect,
Stroke::default()
.with_color(if n.id == self.selected {
style::TEAL
} else {
style::LINE
})
.with_width(if n.id == self.selected { 2.5 } else { 1. }),
);
let text_x = n.rect.x + if n.control.is_some() { 68. } else { 12. };
if let Some(kind) = n.control {
let center = Point::new(n.rect.x + 33., n.rect.y + 34.);
let glyph = match kind {
ControlKind::Choice | ControlKind::Merge => Path::new(|p| {
p.move_to(Point::new(center.x, center.y - 22.));
p.line_to(Point::new(center.x + 23., center.y));
p.line_to(Point::new(center.x, center.y + 22.));
p.line_to(Point::new(center.x - 23., center.y));
p.close();
}),
ControlKind::Fork | ControlKind::Join => Path::rectangle(
Point::new(center.x - 23., center.y - 5.),
Size::new(46., 10.),
),
ControlKind::Loop => Path::circle(center, 23.),
};
f.stroke(
&glyph,
Stroke::default().with_color(style::TEAL).with_width(2.),
);
if matches!(kind, ControlKind::Fork | ControlKind::Join) {
f.fill(&glyph, style::TEAL);
} else {
draw_text(
&mut f,
if kind == ControlKind::Loop {
"IT"
} else {
"OR"
}
.into(),
Point::new(center.x - 10., center.y - 8.),
12.,
style::TEAL,
);
}
}
draw_text(
&mut f,
wrapped(&n.title, if n.control.is_some() { 25 } else { 34 }, 2),
Point::new(text_x, n.rect.y + 12.),
13.,
style::INK,
);
draw_text(
&mut f,
wrapped(&n.detail, 37, 2),
Point::new(n.rect.x + 12., n.rect.y + 60.),
11.,
style::MUTED,
);
if n.source.is_some() {
draw_text(
&mut f,
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
Point::new(n.rect.x + 12., n.rect.y + 94.),
11.,
style::TEAL,
);
let mini: BTreeMap<_, _> = n
.preview
.iter()
.enumerate()
.map(|(i, (id, _))| {
(
*id,
Rectangle::new(
Point::new(
n.rect.x + 12. + (i % 2) as f32 * 125.,
n.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
),
)
})
.collect();
for (a, b) in &n.preview_links {
let a = mini[a];
let b = mini[b];
draw_arrow(
&mut f,
&[
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
style::MUTED,
1.,
);
}
for (id, name) in &n.preview {
let r = mini[id];
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
f.stroke(
&Path::rectangle(r.position(), r.size()),
Stroke::default().with_color(style::LINE),
);
draw_text(
&mut f,
wrapped(name, 17, 2),
Point::new(r.x + 4., r.y + 3.),
9.,
style::INK,
);
}
if n.preview.is_empty() {
draw_text(
&mut f,
"Source has no contributions yet".into(),
Point::new(n.rect.x + 12., n.rect.y + 136.),
11.,
style::MUTED,
);
}
}
}
for e in &self.edges {
if !e.guard.is_empty() {
f.fill_rectangle(e.label.position(), e.label.size(), Color::WHITE);
draw_text(
&mut f,
wrapped(&format!("[{}]", e.guard), 19, 2),
e.label.position(),
11.,
style::INK,
);
}
}
self.overlay(state, &mut f, cursor, bounds);
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
_: &Input,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if cursor.position_in(bounds).is_some_and(|p| {
let p = Point::new(p.x / self.scale, p.y / self.scale);
self.nodes.iter().any(|n| n.rect.contains(p))
|| self.edges.iter().any(|e| {
e.label.contains(p)
|| e.points
.windows(2)
.any(|v| crate::diagram::distance_to_segment(p, v[0], v[1]) < 8.)
})
}) {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (Project, Id, Id, Id, Id, Id) {
let mut p = Project::demo();
let owner = p
.make_element(Kind::Process, Layer::Oa, "Branch test", None)
.unwrap();
let owner_id = owner.id;
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
let a = p
.make_element(Kind::Activity, Layer::Oa, "Shared display name", None)
.unwrap();
let a_id = a.id;
p = execute_batch(&p, &[Command::CreateElement(a)]).unwrap();
let b = p
.make_element(Kind::Activity, Layer::Oa, "Shared display name", None)
.unwrap();
let b_id = b.id;
p = execute_batch(&p, &[Command::CreateElement(b)]).unwrap();
let before = occurrence(
&p,
owner_id,
"Start work",
OccurrenceBody::Action {
action: Some(a_id),
lifeline: None,
},
);
let after = occurrence(
&p,
owner_id,
"Finish work",
OccurrenceBody::Action {
action: Some(b_id),
lifeline: None,
},
);
let before_id = before.id;
let after_id = after.id;
p = execute_batch(
&p,
&[
Command::CreateElement(before),
Command::CreateElement(after),
],
)
.unwrap();
(p, owner_id, a_id, b_id, before_id, after_id)
}
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool: Tool::Branch,
name: String::new(),
target: Some(a),
alternate: Some(b),
before: Some(before),
after: Some(after),
guard: "request accepted".into(),
alternate_guard: "else".into(),
control: ControlKind::Choice,
}
}
fn legacy_fixture() -> (Project, Id) {
let mut p = Project::demo();
let owner = p
.make_element(Kind::Process, Layer::Oa, "Existing ordered path", None)
.unwrap();
let owner_id = owner.id;
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
let mut work = vec![];
for index in 0..4 {
let e = p
.make_element(
Kind::Activity,
Layer::Oa,
&format!("Ordered work {index}"),
None,
)
.unwrap();
work.push(e.id);
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
}
let mut exchanges = vec![];
for (index, pair) in work.windows(2).enumerate() {
let id = Id::new_v4();
let name = format!("Ordered exchange {index}");
let r = Relation {
id,
alias: p.fresh_alias(&name, "relation", id),
name,
description: String::new(),
body: RelationBody::new(RelationKind::Interaction, pair[0], pair[1]),
};
p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap();
exchanges.push(id);
}
let commands = (0..3)
.map(|index| {
Command::CreateElement(occurrence(
&p,
owner_id,
&format!("Ordered contribution {index}"),
OccurrenceBody::Exchange {
exchange: Some(exchanges[index % exchanges.len()]),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
))
})
.collect();
p = execute_batch(&p, &[unique_batch(&p, commands)]).unwrap();
(p, owner_id)
}
#[test]
fn empty_process_can_start_through_normal_authoring_events() {
let mut p = Project::demo();
let owner = p
.make_element(Kind::Process, Layer::Oa, "New operational process", None)
.unwrap();
let owner_id = owner.id;
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
let source = targets(&p, owner_id, Tool::Work).first().unwrap().clone();
let mut state = State::default();
state
.update(&p, Event::Start(owner_id, Tool::Work, None))
.unwrap();
state.update(&p, Event::Target(source.clone())).unwrap();
state
.update(&p, Event::Name("First contribution".into()))
.unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
let result = execute_batch(&p, &[command]).unwrap();
let steps = result.elements[&owner_id].steps().unwrap();
assert_eq!(steps.len(), 1);
assert!(uses_explicit_topology(&result, owner_id));
assert!(
matches!(result.elements[&steps[0]].shared(), Some(SharedBody::Occurrence {
body: OccurrenceBody::Action { action: Some(id), .. }, .. }) if *id == source.id)
);
assert_eq!(Graph::new(&result, owner_id, owner_id).nodes.len(), 1);
}
#[test]
fn legacy_edit_preserves_order_and_new_work_in_one_undo() {
let (p, owner) = legacy_fixture();
let original_order = legacy_order(&p, owner);
let source = targets(&p, owner, Tool::Work).first().unwrap().clone();
let mut state = State::default();
state
.update(&p, Event::Start(owner, Tool::Work, None))
.unwrap();
state.update(&p, Event::Target(source)).unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
let mut session = Session::new(p.clone());
session.execute(command).unwrap();
let links = sequence_links(session.project(), owner);
for pair in original_order.windows(2) {
assert!(
links
.iter()
.any(|r| r.source() == pair[0] && r.target() == pair[1])
);
}
assert_eq!(
session.project().elements[&owner].steps().unwrap().len(),
original_order.len() + 1
);
assert!(uses_explicit_topology(session.project(), owner));
session.undo();
assert_eq!(session.project(), &p);
}
#[test]
fn legacy_branch_insertion_replaces_only_the_selected_implied_link() {
let (p, owner) = legacy_fixture();
let order = legacy_order(&p, owner);
let sources = targets(&p, owner, Tool::Work);
let mut state = State::default();
state
.update(&p, Event::Start(owner, Tool::Branch, Some(order[0])))
.unwrap();
assert_eq!(state.draft.as_ref().unwrap().after, Some(order[1]));
state.update(&p, Event::Target(sources[0].clone())).unwrap();
state
.update(&p, Event::Alternate(sources[1].clone()))
.unwrap();
state.update(&p, Event::Guard("ready".into())).unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
let result = execute_batch(&p, &[command]).unwrap();
let links = sequence_links(&result, owner);
assert!(
!links
.iter()
.any(|r| r.source() == order[0] && r.target() == order[1])
);
for pair in order.windows(2).skip(1) {
assert!(
links
.iter()
.any(|r| r.source() == pair[0] && r.target() == pair[1])
);
}
assert_eq!(links.len(), order.len() - 2 + 6);
assert_eq!(
result.elements[&owner].steps().unwrap().len(),
order.len() + 4
);
}
#[test]
fn insertion_picker_only_offers_work_matching_adjacent_exchanges() {
let (p, owner) = legacy_fixture();
let order = legacy_order(&p, owner);
let expected = contribution_exchange(&p, order[0]).unwrap().target();
let mut state = State::default();
state
.update(&p, Event::Start(owner, Tool::Work, Some(order[0])))
.unwrap();
let d = state.draft.as_ref().unwrap();
let choices: Vec<_> = targets(&p, owner, Tool::Work)
.into_iter()
.filter(|choice| compatible_source(&p, d, choice.id))
.collect();
assert_eq!(choices.len(), 1);
assert_eq!(choices[0].id, expected);
state.update(&p, Event::Target(choices[0].clone())).unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(execute_batch(&p, &[command]).is_ok());
}
#[test]
fn guarded_branch_is_one_transaction_with_distinct_source_identities() {
let (mut p, owner, a, b, before, after) = fixture();
p = execute_batch(&p, &[link(&p, before, after, "")]).unwrap();
let old_link = sequence_links(&p, owner)[0].id;
let mut session = Session::new(p.clone());
let command = branch(owner, a, b, before, after).command(&p).unwrap();
session.execute(command).unwrap();
let result = session.project();
assert!(result.relations.contains_key(&old_link));
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
assert_eq!(sequence_links(result, owner).len(), 6);
assert_eq!(
result
.elements
.values()
.filter(|e| e.kind() == Kind::Activity)
.count(),
p.elements
.values()
.filter(|e| e.kind() == Kind::Activity)
.count()
);
let involved: Vec<_> = result
.children(owner)
.filter(|e| !p.elements.contains_key(&e.id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Action { action, .. },
..
}) => *action,
_ => None,
})
.collect();
assert!(involved.contains(&a) && involved.contains(&b));
let graph = Graph::new(result, owner, owner);
assert_eq!(graph.edges.len(), 6);
assert!(graph.edges.iter().any(|e| e.guard == "request accepted"));
assert!(graph.edges.iter().any(|e| e.guard == "else"));
for edge in graph.edges.iter().filter(|e| !e.guard.is_empty()) {
assert!(
!graph
.nodes
.iter()
.any(|node| node.rect.contains(edge.label.center())),
"Guard must not cover a contribution"
);
}
let issues = validation::validate(result);
assert!(!issues.iter().any(|i| {
matches!(
i.code.as_str(),
"control_cardinality" | "ambiguous_sequence_branch" | "missing_sequence_guard"
) && i.element.is_some_and(|id| {
id == owner
|| result
.elements
.get(&id)
.is_some_and(|e| e.parent == Some(owner))
})
}));
session.undo();
assert_eq!(session.project(), &p);
}
#[test]
fn unlinked_contributions_do_not_gain_fictional_edges() {
let (p, owner, ..) = fixture();
let graph = Graph::new(&p, owner, owner);
assert_eq!(graph.nodes.len(), 2);
assert!(graph.edges.is_empty());
}
#[test]
fn reusable_preview_preserves_identity_and_filters_recursive_sources() {
let (mut p, owner, a, _, before, after) = fixture();
let source = p
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
.unwrap();
let source_id = source.id;
p = execute_batch(&p, &[Command::CreateElement(source)]).unwrap();
let inside = occurrence(
&p,
source_id,
"Existing contribution",
OccurrenceBody::Action {
action: Some(a),
lifeline: None,
},
);
let inside_id = inside.id;
p = execute_batch(&p, &[Command::CreateElement(inside)]).unwrap();
let mut d = branch(owner, a, a, before, after);
d.tool = Tool::Reuse;
d.target = Some(source_id);
let result = execute_batch(&p, &[d.command(&p).unwrap()]).unwrap();
let graph = Graph::new(&result, owner, owner);
let reference = graph
.nodes
.iter()
.find(|n| n.source == Some(source_id))
.unwrap();
assert_eq!(reference.preview[0].0, inside_id);
assert_eq!(result.children(source_id).count(), 1);
assert!(
!targets(&result, source_id, Tool::Reuse)
.iter()
.any(|c| c.id == owner)
);
assert!(
!targets(&result, owner, Tool::Reuse)
.iter()
.any(|c| c.id == owner)
);
}
#[test]
fn stale_or_invalid_submit_preserves_inputs_and_creates_nothing() {
let (p, owner, a, b, before, after) = fixture();
let mut state = State {
draft: Some(branch(owner, a, b, before, after)),
..State::default()
};
state
.update(
&p,
Event::Target(Choice {
id: Id::nil(),
label: "Missing".into(),
}),
)
.unwrap();
assert!(state.update(&p, Event::Submit).is_err());
assert!(state.draft.is_some());
state
.update(
&p,
Event::Target(Choice {
id: a,
label: "Work".into(),
}),
)
.unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.draft.is_some());
assert!(matches!(command, Command::Batch(_)));
assert_eq!(p.elements[&owner].steps().unwrap().len(), 2);
let result = execute_batch(&p, &[command]).unwrap();
state.acknowledge_commit();
assert!(state.draft.is_none());
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
}
#[test]
fn cycle_projection_terminates_and_retains_actual_links() {
let (mut p, owner, _, _, before, after) = fixture();
for command in [
link(&p, before, after, "continue"),
link(&p, after, before, "retry"),
] {
if let Command::CreateRelation(mut r) = command {
r.alias = format!("cycle_{}", r.id.simple());
p.relations.insert(r.id, r);
}
}
let graph = Graph::new(&p, owner, owner);
assert_eq!(graph.edges.len(), 2);
assert!(graph.size.width.is_finite() && graph.size.height.is_finite());
assert!(graph.edges.iter().any(|e| e.guard == "retry"));
assert!(
graph
.edges
.iter()
.all(|e| e.label.x >= 0. && e.label.y >= 0.)
);
}
#[test]
fn long_engineering_identifiers_stay_inside_label_width() {
let label = wrapped(
"HandlePassengerServiceAvailabilityWhileAircraftIsDecompressing",
19,
2,
);
assert!(label.ends_with('…'));
assert_eq!(label.lines().count(), 2);
assert!(label.lines().all(|line| line.chars().count() <= 20));
}
#[test]
fn fitting_a_branch_keeps_all_nodes_visible_and_selection_uses_scaled_coordinates() {
let (p, owner, a, b, before, after) = fixture();
let p = execute_batch(
&p,
&[branch(owner, a, b, before, after).command(&p).unwrap()],
)
.unwrap();
let mut graph = Graph::new(&p, owner, owner);
let mut state = State::default();
state.viewport(Size::new(486., 460.));
state.zoom(None);
graph.scale = state.scale(graph.size, 500.);
assert!(graph.size.width * graph.scale <= 462.1);
assert!(graph.size.height * graph.scale <= 450.1);
let node = graph.nodes.last().unwrap();
let click = Point::new(
(node.rect.x + 20.) * graph.scale,
(node.rect.y + 20.) * graph.scale,
);
let result = canvas::Program::update(
&graph,
&mut Input::default(),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
mouse::Cursor::Available(click),
)
.unwrap()
.into_inner()
.0;
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
state.zoom(Some(1.));
state.viewport(Size::new(800., 460.));
assert_eq!(state.scale(graph.size, 500.), 1.);
state.zoom(None);
assert!(state.scale(graph.size, 500.) < 1.);
}
}