//! 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; pub(crate) mod contextual; 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, serde::Serialize, serde::Deserialize)] pub enum Tool { Work, Exchange, Reuse, Control, Connect, Branch, Contextual, } 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", Self::Contextual => "Connect qualified contributions", } } } #[derive(Debug, Clone)] pub enum Event { Start(Id, Tool, Option), 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), Contextual(contextual::Event), Shelf(Shelf), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Shelf { Tools, Selection, } #[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, scroll_offsets: [iced::Vector; 2], return_to: Vec<(Id, Id, Option)>, draft: Option, viewport: Option, zoom: Option, fit: bool, pub(crate) focused: bool, contextual_expanded: BTreeSet, selected_context: Option, shelf: Option, } #[derive(Default, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct Browser { search: String, page: usize, } #[derive(serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct Draft { #[serde(with = "recovery_insertion")] insertion: Insertion, fixed: bool, browsers: [Browser; 4], owner: Id, tool: Tool, name: String, target: Option, alternate: Option, before: Option, after: Option, guard: String, alternate_guard: String, control: ControlKind, #[serde(default)] contextual: Option, } impl State { pub(super) fn recovery_snapshot(&self) -> Result, String> { self.draft .as_ref() .map(serde_json::to_value) .transpose() .map_err(|e| e.to_string()) } pub(super) fn from_recovery(value: serde_json::Value) -> Result { let draft = serde_json::from_value(value).map_err(|e| e.to_string())?; Ok(Self { draft: Some(draft), ..Default::default() }) } pub(super) fn pending_owner(&self) -> Option { self.draft.as_ref().map(|d| d.owner) } pub(super) fn prepare_pending(&self, p: &Project) -> Result { 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, String> { if self.draft.is_some() && matches!(&event, Event::Start(..)) { return Err(PENDING_DRAFT.into()); } match event { Event::Shelf(shelf) => { self.shelf = (self.shelf != Some(shelf)).then_some(shelf); } 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, contextual: (tool == Tool::Contextual) .then(|| contextual::Draft::new(p, owner, selected)), }); if let Some(endpoint) = self .qualified_selection(p, owner) .map(|address| address.endpoint.clone()) && let Some(contextual) = self.draft.as_mut().and_then(|d| d.contextual.as_mut()) { contextual.seed_source(endpoint); } } 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, Event::Contextual(event) => { if let Some(contextual) = &mut d.contextual { contextual.update(event); } } _ => {} } } } } Ok(None) } fn scale(&self, graph: Size, fallback_width: f32) -> f32 { if !self.fit { return self.zoom.unwrap_or(1.); } let available = self.fit_area(fallback_width); (available.width / graph.width.max(1.)) .min(available.height / graph.height.max(1.)) .clamp(0.05, 1.) } fn fit_area(&self, fallback_width: f32) -> Size { // The sensor measures the inner scroller, independent of graph content. // Leave a small gutter for scrollbars; before first layout use a compact // provisional viewport and recompute when the sensor reports actual size. let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.)); Size::new( (viewport.width - 24.).max(1.), (viewport.height - 10.).max(1.), ) } fn canvas_offset(&self) -> iced::Vector { self.scroll_offsets[0] } fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool { let available = self.fit_area(fallback_width); self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height) } } mod recovery_insertion { use super::*; use serde::{Deserialize, Serialize}; pub fn serialize( value: &Insertion, serializer: S, ) -> Result { (value.link, value.before).serialize(serializer) } pub fn deserialize<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result { let (link, before) = <(Option, Option)>::deserialize(deserializer)?; Ok(Insertion { link, before }) } } 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 { 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 { 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 { 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 { 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 { 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 { 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()); } if (self.tool == Tool::Contextual) != self.contextual.is_some() { return Err("The qualified link draft and selected tool do not agree.".into()); } if let Some(draft) = &self.contextual { return draft.command(p, self.owner, &self.name); } 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 { operation: None, 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 { let mut aliases: BTreeSet = 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, source: Option, preview: Vec<(OccurrenceEndpoint, String)>, preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>, preview_total: usize, } impl GraphNode { fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle { self.preview .iter() .position(|(candidate, _)| candidate == endpoint) .map(|i| { Rectangle::new( Point::new( self.rect.x + 12. + (i % 2) as f32 * 125., self.rect.y + 118. + (i / 2) as f32 * 44., ), Size::new(113., 32.), ) }) .unwrap_or(self.rect) } } struct GraphEdge { id: Id, source: Id, target: Id, guard: String, points: Vec, label: Rectangle, incidence: bool, layer: EdgeLayer, } // Ordered exchange adjacency is a projection, never a persisted relation ID. struct OrderedPath { source: Id, target: Id, points: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EdgeLayer { Direct, Contextual, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PaintPass { DirectEdges, NodeBackgrounds, ContextualEdges, NodeContents, Labels, } const PAINT_PASSES: [PaintPass; 5] = [ PaintPass::DirectEdges, PaintPass::NodeBackgrounds, PaintPass::ContextualEdges, PaintPass::NodeContents, PaintPass::Labels, ]; mod contextual_routing; struct Graph<'a> { project: &'a Project, interaction: Option<(Context, CanvasTool, bool)>, scroll_offset: iced::Vector, nodes: Vec, edges: Vec, ordered_paths: Vec, ordered: bool, 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) -> Self { Self::expanded_qualified(p, owner, selected, expanded, &BTreeSet::new()) } fn expanded_qualified( p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet, expanded_paths: &BTreeSet, ) -> Self { let context_index = oa_core::topology_context::TopologyIndex::new(p); let choices = contributions(p, owner); let links = sequence_links(p, owner); let order = legacy_order(p, owner); let adjacency: Vec<_> = links .iter() .map(|r| (r.source(), r.target())) .chain(order.windows(2).map(|pair| (pair[0], pair[1]))) .collect(); 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 &(_, target) in &adjacency { *incoming.entry(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 &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) { 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 = 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 = contextual::preview( &context_index, p, owner, e.id, expanded_paths, if expanded.contains(&e.id) { 512 } else { 4 }, ) .into_iter() .map(|endpoint| { let name = format!( "{}{}", "› ".repeat(endpoint.references.len().saturating_sub(1).min(3)), p.named(endpoint.occurrence) ); (endpoint, name) }) .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.clone()).collect(); node.preview_links = sequence_links(p, *source) .into_iter() .filter(|r| { preview_ids.contains(&OccurrenceEndpoint { references: vec![e.id], occurrence: r.source(), }) && preview_ids.contains(&OccurrenceEndpoint { references: vec![e.id], occurrence: r.target(), }) }) .map(|r| { ( OccurrenceEndpoint { references: vec![e.id], occurrence: r.source(), }, OccurrenceEndpoint { references: vec![e.id], occurrence: 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 ordered_paths = order .windows(2) .map(|pair| { let a = nodes .iter() .find(|n| n.id == pair[0]) .expect("ordered source") .rect; let b = nodes .iter() .find(|n| n.id == pair[1]) .expect("ordered target") .rect; OrderedPath { source: pair[0], target: pair[1], points: vec![ Point::new(a.x + a.width, a.center_y()), Point::new(b.x, b.center_y()), ], } }) .collect(); 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, incidence: false, layer: EdgeLayer::Direct, }); } let mut contextual_pairs = BTreeSet::new(); for r in p.relations.values() { let RelationBody::ContextualLink { owner: caller, source, target, purpose, } = &r.body else { continue; }; if *caller != owner { continue; } let source_id = source .references .first() .copied() .unwrap_or(source.occurrence); let target_id = target .references .first() .copied() .unwrap_or(target.occurrence); let Some(a) = nodes .iter() .find(|n| n.id == source_id) .map(|n| contextual_routing::anchor(n, source, false)) else { continue; }; let Some(b) = nodes .iter() .find(|n| n.id == target_id) .map(|n| contextual_routing::anchor(n, target, true)) else { continue; }; let below = size.height + 30. + edges.len() as f32 * 48.; let first = contextual_pairs.insert((source.clone(), target.clone())); let (points, label) = contextual_routing::route(a, b, below, first); let (caption, incidence) = match purpose { ContextualLinkPurpose::Precedence { guard } => { (format!("Precedence · {guard}"), false) } ContextualLinkPurpose::Exchange { exchange } => { (format!("Assembly · {}", p.named(*exchange)), true) } ContextualLinkPurpose::CommonWork { work } => { (format!("Common work · {}", p.named(*work)), true) } }; edges.push(GraphEdge { id: r.id, source: source_id, target: target_id, guard: caption, points, label, incidence, layer: EdgeLayer::Contextual, }); } for e in &edges { for pt in &e.points { size.height = size.height.max(pt.y + 30.); if e.layer == EdgeLayer::Contextual { size.width = size.width.max(pt.x + 24.); } } if e.layer == EdgeLayer::Contextual { size.width = size.width.max(e.label.x + e.label.width + 24.); size.height = size.height.max(e.label.y + e.label.height + 24.); } } Self { project: p, interaction: None, scroll_offset: iced::Vector::ZERO, nodes, edges, ordered_paths, ordered: !order.is_empty(), 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 = value .split_whitespace() .flat_map(|word| { word.chars() .collect::>() .chunks(width.max(1)) .map(|chunk| chunk.iter().collect::()) .collect::>() }) .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 for Graph<'_> { type State = Input; fn update( &self, state: &mut Input, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { self.input(state, event, bounds, cursor) } fn draw( &self, state: &Input, renderer: &Renderer, _: &Theme, bounds: Rectangle, cursor: mouse::Cursor, ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); f.scale(self.scale); for pass in PAINT_PASSES { match pass { PaintPass::DirectEdges | PaintPass::ContextualEdges => { if pass == PaintPass::DirectEdges { for path in &self.ordered_paths { let selected = path.source == self.selected || path.target == self.selected; draw_arrow( &mut f, &path.points, if selected { style::TEAL } else { style::MUTED }, if selected { 2.5 } else { 1.5 }, ); } } for e in self.edges.iter().filter(|edge| { edge.layer == if pass == PaintPass::DirectEdges { EdgeLayer::Direct } else { EdgeLayer::Contextual } }) { let selected = e.id == self.selected || e.source == self.selected || e.target == self.selected; if e.incidence { f.stroke( &Path::new(|path| { path.move_to(e.points[0]); for point in &e.points[1..] { path.line_to(*point); } }), Stroke::default() .with_color(if selected { style::TEAL } else { style::MUTED }) .with_width(if selected { 2.5 } else { 1.5 }), ); } else { draw_arrow( &mut f, &e.points, if selected { style::TEAL } else { style::MUTED }, if selected { 2.5 } else { 1.5 }, ); } } } PaintPass::NodeBackgrounds => { 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. }), ); } } PaintPass::NodeContents => { for n in &self.nodes { 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!( "{} shown · {} direct contributions", 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.clone(), 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, ); } } } } PaintPass::Labels => { 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.ordered_paths.iter().any(|path| { path.points.windows(2).any(|pair| { crate::diagram::distance_to_segment(p, pair[0], pair[1]) < 8. / self.scale }) }) || 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, contextual: None, } } 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 { operation: None, 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, OccurrenceEndpoint { references: vec![reference.id], occurrence: 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.); } #[test] fn process_fit_uses_measured_height_and_reports_minimum_without_changing_manual_zoom() { let mut state = State::default(); let graph = Size::new(420., 960.); state.zoom(None); let mut previous = 0.; for height in [180., 320., 600.] { state.viewport(Size::new(1000., height)); let scale = state.scale(graph, 200.); assert!(scale > previous); assert!(graph.height * scale <= height - 10. + 0.01); assert!(!state.fit_limited(graph, 200.)); previous = scale; } state.viewport(Size::new(500., 180.)); let huge = Size::new(500., 10_000.); assert_eq!(state.scale(huge, 200.), 0.05); assert!(state.fit_limited(huge, 200.)); state.zoom(Some(1.)); for height in [180., 600.] { state.viewport(Size::new(500., height)); assert_eq!(state.scale(huge, 200.), 1.); assert!(!state.fit_limited(huge, 200.)); } } #[test] fn process_fit_disclosures_and_focus_preserve_rejected_raw_draft_and_accepted_source() { let (p, owner, _, _, _, _) = fixture(); let mut app = App::with_project(p); let _ = app.update(Message::Select(owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); for input in [ Event::Start(owner, Tool::Contextual, None), Event::Name(" pending 雨 ".into()), Event::Contextual(contextual::Event::Guard(" unavailable && 雨 ".into())), ] { let _ = app.update(event(input)); } let before = app.behavior.recovery_snapshot().unwrap(); let accepted = app.document.snapshot(); assert!(app.behavior.prepare_pending(app.project()).is_err()); for (shelf, expected) in [ (Shelf::Tools, Some(Shelf::Tools)), (Shelf::Selection, Some(Shelf::Selection)), (Shelf::Selection, None), ] { let focused = app.behavior.topology.focused; let _ = app.update(event(Event::Shelf(shelf))); assert_eq!(app.behavior.topology.shelf, expected); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context))); assert_eq!(app.behavior.topology.focused, !focused); assert_eq!(app.behavior.recovery_snapshot().unwrap(), before); assert!(app.behavior.prepare_pending(app.project()).is_err()); assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted)); } } #[test] fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() { let (p, owner, _, _, _, _) = fixture(); let mut state = State::default(); state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.)); state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.)); let context = Context { document: p.id, owner, revision: 0, epoch: 0, }; for scale in [0.45, 1.] { let mut graph = Graph::new(&p, owner, owner); graph.scale = scale; graph.scroll_offset = state.canvas_offset(); graph.interaction = Some((context, CanvasTool::Select, true)); let node = &graph.nodes[0]; let id = node.id; let point = node.rect.center(); let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.)); let screen = Point::new( bounds.x + point.x * scale - 11., bounds.y + point.y * scale - 19., ); let message = graph .input( &mut Input::default(), &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), bounds, mouse::Cursor::Available(screen), ) .and_then(|a| a.into_inner().0); assert!( matches!(message,Some(Message::TopologyCanvas(CanvasEvent::Select(c,actual))) if c==context && actual==id) ); } } }