Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2101 lines
78 KiB
Rust
2101 lines
78 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;
|
||
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<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),
|
||
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<Id>,
|
||
scroll_offsets: [iced::Vector; 2],
|
||
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
|
||
draft: Option<Draft>,
|
||
viewport: Option<Size>,
|
||
zoom: Option<f32>,
|
||
fit: bool,
|
||
pub(crate) focused: bool,
|
||
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
|
||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||
shelf: Option<Shelf>,
|
||
}
|
||
#[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<Id>,
|
||
alternate: Option<Id>,
|
||
before: Option<Id>,
|
||
after: Option<Id>,
|
||
guard: String,
|
||
alternate_guard: String,
|
||
control: ControlKind,
|
||
#[serde(default)]
|
||
contextual: Option<contextual::Draft>,
|
||
}
|
||
impl State {
|
||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, 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<Self, String> {
|
||
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<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::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<S: serde::Serializer>(
|
||
value: &Insertion,
|
||
serializer: S,
|
||
) -> Result<S::Ok, S::Error> {
|
||
(value.link, value.before).serialize(serializer)
|
||
}
|
||
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
|
||
deserializer: D,
|
||
) -> Result<Insertion, D::Error> {
|
||
let (link, before) = <(Option<Id>, Option<Id>)>::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<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());
|
||
}
|
||
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>) -> 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<(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<Point>,
|
||
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<Point>,
|
||
}
|
||
#[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<GraphNode>,
|
||
edges: Vec<GraphEdge>,
|
||
ordered_paths: Vec<OrderedPath>,
|
||
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<Id>) -> Self {
|
||
Self::expanded_qualified(p, owner, selected, expanded, &BTreeSet::new())
|
||
}
|
||
fn expanded_qualified(
|
||
p: &'a Project,
|
||
owner: Id,
|
||
selected: Id,
|
||
expanded: &BTreeSet<Id>,
|
||
expanded_paths: &BTreeSet<OccurrenceEndpoint>,
|
||
) -> 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<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 = 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<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 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)
|
||
);
|
||
}
|
||
}
|
||
}
|