Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
@@ -0,0 +1,787 @@
|
||||
//! Lazy qualified authoring over the core address resolver. A folded reference
|
||||
//! never replaces its retained endpoint with the reference or a same-named leaf.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use oa_core::topology_context::TopologyIndex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Purpose {
|
||||
#[default]
|
||||
Precedence,
|
||||
Exchange,
|
||||
CommonWork,
|
||||
}
|
||||
impl std::fmt::Display for Purpose {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Precedence => "Precedence",
|
||||
Self::Exchange => "Exchange assembly",
|
||||
Self::CommonWork => "Common work incidence",
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Side(bool),
|
||||
Pick(OccurrenceEndpoint),
|
||||
Expand(OccurrenceEndpoint),
|
||||
Up,
|
||||
Root,
|
||||
Purpose(Purpose),
|
||||
Evidence(Choice),
|
||||
Guard(String),
|
||||
Search(String),
|
||||
Page(usize),
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Draft {
|
||||
edit: Option<Id>,
|
||||
source: Option<OccurrenceEndpoint>,
|
||||
target: Option<OccurrenceEndpoint>,
|
||||
target_side: bool,
|
||||
purpose: Purpose,
|
||||
evidence: Option<Id>,
|
||||
guard: String,
|
||||
search: String,
|
||||
page: usize,
|
||||
#[serde(default)]
|
||||
scope: Vec<Id>,
|
||||
}
|
||||
impl Draft {
|
||||
pub(super) fn seed_source(&mut self, endpoint: OccurrenceEndpoint) {
|
||||
if self.edit.is_none() {
|
||||
self.source = Some(endpoint);
|
||||
}
|
||||
}
|
||||
pub(super) fn new(p: &Project, owner: Id, selected: Option<Id>) -> Self {
|
||||
if let Some(r) = selected.and_then(|id| p.relations.get(&id))
|
||||
&& let RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} = &r.body
|
||||
&& *caller == owner
|
||||
{
|
||||
let (purpose, evidence, guard) = match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
(Purpose::Precedence, None, guard.clone())
|
||||
}
|
||||
ContextualLinkPurpose::Exchange { exchange } => {
|
||||
(Purpose::Exchange, Some(*exchange), String::new())
|
||||
}
|
||||
ContextualLinkPurpose::CommonWork { work } => {
|
||||
(Purpose::CommonWork, Some(*work), String::new())
|
||||
}
|
||||
};
|
||||
return Self {
|
||||
edit: Some(r.id),
|
||||
source: Some(source.clone()),
|
||||
target: Some(target.clone()),
|
||||
purpose,
|
||||
evidence,
|
||||
guard,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
Self {
|
||||
source: selected
|
||||
.filter(|id| is_contribution(p, owner, *id))
|
||||
.map(|occurrence| OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
pub(super) fn update(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Side(target) => self.target_side = target,
|
||||
Event::Pick(endpoint) => {
|
||||
if self.target_side {
|
||||
self.target = Some(endpoint);
|
||||
} else {
|
||||
self.source = Some(endpoint);
|
||||
}
|
||||
}
|
||||
Event::Expand(endpoint) => {
|
||||
self.scope = endpoint.references;
|
||||
self.scope.push(endpoint.occurrence);
|
||||
self.search.clear();
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Up => {
|
||||
self.scope.pop();
|
||||
self.search.clear();
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Root => {
|
||||
self.scope.clear();
|
||||
self.search.clear();
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Purpose(purpose) => self.purpose = purpose,
|
||||
Event::Evidence(choice) => self.evidence = Some(choice.id),
|
||||
Event::Guard(raw) => self.guard = raw,
|
||||
Event::Search(raw) => {
|
||||
self.search = raw;
|
||||
self.page = 0;
|
||||
}
|
||||
Event::Page(page) => self.page = page,
|
||||
}
|
||||
}
|
||||
fn purpose(&self) -> Result<ContextualLinkPurpose, String> {
|
||||
Ok(match self.purpose {
|
||||
Purpose::Precedence => ContextualLinkPurpose::Precedence {
|
||||
guard: self.guard.clone(),
|
||||
},
|
||||
Purpose::Exchange => ContextualLinkPurpose::Exchange {
|
||||
exchange: self
|
||||
.evidence
|
||||
.ok_or("Choose the exact exchange used for assembly.")?,
|
||||
},
|
||||
Purpose::CommonWork => ContextualLinkPurpose::CommonWork {
|
||||
work: self.evidence.ok_or("Choose the shared work identity.")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
pub(super) fn command(&self, p: &Project, owner: Id, name: &str) -> Result<Command, String> {
|
||||
let source = self
|
||||
.source
|
||||
.as_ref()
|
||||
.ok_or("Choose the qualified source contribution.")?;
|
||||
let target = self
|
||||
.target
|
||||
.as_ref()
|
||||
.ok_or("Choose the qualified target contribution.")?;
|
||||
let purpose = self.purpose()?;
|
||||
TopologyIndex::new(p)
|
||||
.validate_link(owner, source, target, &purpose)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut relation = if let Some(id) = self.edit {
|
||||
p.relations.get(&id).filter(|r| matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller==owner)).ok_or("This caller-owned link no longer exists.")?.clone()
|
||||
} else {
|
||||
let id = Id::new_v4();
|
||||
Relation {
|
||||
id,
|
||||
alias: p.fresh_alias("contextual_link", "relation", id),
|
||||
name: self.purpose.to_string(),
|
||||
description: String::new(),
|
||||
body: RelationBody::ContextualLink {
|
||||
owner,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
purpose: purpose.clone(),
|
||||
},
|
||||
}
|
||||
};
|
||||
if !name.trim().is_empty() {
|
||||
relation.name = name.to_owned();
|
||||
}
|
||||
relation.body = RelationBody::ContextualLink {
|
||||
owner,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
purpose,
|
||||
};
|
||||
let command = if self.edit.is_some() {
|
||||
Command::EditRelation(relation.id, relation)
|
||||
} else {
|
||||
Command::CreateRelation(relation)
|
||||
};
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn address_label(p: &Project, owner: Id, endpoint: &OccurrenceEndpoint) -> String {
|
||||
std::iter::once(&owner)
|
||||
.chain(endpoint.references.iter())
|
||||
.chain(std::iter::once(&endpoint.occurrence))
|
||||
.map(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| format!("Unavailable {id}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ")
|
||||
}
|
||||
struct AddressRow {
|
||||
endpoint: OccurrenceEndpoint,
|
||||
reference: bool,
|
||||
}
|
||||
fn rows(p: &Project, owner: Id, scope: &[Id], search: &str) -> Result<Vec<AddressRow>, String> {
|
||||
let index = TopologyIndex::new(p);
|
||||
let sequence = index
|
||||
.resolve_scope(owner, scope)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let query = search.trim().to_lowercase();
|
||||
Ok(index
|
||||
.contributions(sequence)
|
||||
.into_iter()
|
||||
.filter_map(|occurrence| {
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: scope.to_vec(),
|
||||
occurrence,
|
||||
};
|
||||
if !address_label(p, owner, &endpoint)
|
||||
.to_lowercase()
|
||||
.contains(&query)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let reference = matches!(
|
||||
p.elements.get(&occurrence).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
|
||||
..
|
||||
})
|
||||
);
|
||||
Some(AddressRow {
|
||||
endpoint,
|
||||
reference,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn event(value: Event) -> Message {
|
||||
super::event(super::Event::Contextual(value))
|
||||
}
|
||||
|
||||
pub(super) fn preview(
|
||||
index: &TopologyIndex<'_>,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
reference: Id,
|
||||
expanded: &BTreeSet<OccurrenceEndpoint>,
|
||||
limit: usize,
|
||||
) -> Vec<OccurrenceEndpoint> {
|
||||
let children = |endpoint: &OccurrenceEndpoint| {
|
||||
if index.resolve(owner, endpoint).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
let sequence = p
|
||||
.elements
|
||||
.get(&endpoint.occurrence)
|
||||
.and_then(|e| e.shared())
|
||||
.and_then(|body| match body {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => *sequence,
|
||||
_ => None,
|
||||
});
|
||||
let Some(sequence) = sequence else {
|
||||
return vec![];
|
||||
};
|
||||
let available: BTreeSet<_> = index.contributions(sequence).into_iter().collect();
|
||||
let mut references = endpoint.references.clone();
|
||||
references.push(endpoint.occurrence);
|
||||
p.elements
|
||||
.get(&sequence)
|
||||
.and_then(|e| e.steps())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|id| available.contains(id))
|
||||
.map(|occurrence| OccurrenceEndpoint {
|
||||
references: references.clone(),
|
||||
occurrence: *occurrence,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let root = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: reference,
|
||||
};
|
||||
let mut pending = children(&root);
|
||||
pending.reverse();
|
||||
let mut visible = vec![];
|
||||
while let Some(endpoint) = pending.pop() {
|
||||
if visible.len() == limit {
|
||||
break;
|
||||
}
|
||||
if index.resolve(owner, &endpoint).is_err() {
|
||||
continue;
|
||||
}
|
||||
if expanded.contains(&endpoint) {
|
||||
pending.extend(children(&endpoint).into_iter().rev());
|
||||
}
|
||||
visible.push(endpoint);
|
||||
}
|
||||
visible
|
||||
}
|
||||
pub(super) fn view<'a>(p: &'a Project, owner: Id, d: &'a Draft) -> Element<'a, Message> {
|
||||
let mut out = column![
|
||||
text("Choose each endpoint in its caller context. Enter a reference to browse all its contributions; repeated uses stay distinct. Browsing preserves both selected endpoints.").size(12),
|
||||
row![button("Choose source").style(if !d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(false))), button("Choose target").style(if d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(true)))].spacing(6),
|
||||
text(format!("Source: {}", d.source.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12),
|
||||
text(format!("Target: {}", d.target.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12),
|
||||
pick_list(vec![Purpose::Precedence,Purpose::Exchange,Purpose::CommonWork],Some(d.purpose),|p| event(Event::Purpose(p))).width(Fill).text_size(12).padding(8)
|
||||
].spacing(7);
|
||||
match d.purpose {
|
||||
Purpose::Precedence => {
|
||||
out = out.push(
|
||||
text_input("Precedence guard (optional)", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
}
|
||||
purpose => {
|
||||
let choices = if purpose == Purpose::Exchange {
|
||||
targets(p, owner, Tool::Exchange)
|
||||
} else {
|
||||
targets(p, owner, Tool::Work)
|
||||
};
|
||||
let selected = d.evidence.map(|id| Choice {
|
||||
id,
|
||||
label: p.named(id).to_owned(),
|
||||
});
|
||||
out = out
|
||||
.push(
|
||||
pick_list(choices, selected, |c| event(Event::Evidence(c)))
|
||||
.placeholder("Choose exact engineering identity…")
|
||||
.width(Fill)
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
)
|
||||
.push(
|
||||
text("Assembly incidence does not imply execution order or fuse executions.")
|
||||
.size(11),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(
|
||||
text_input("Search all contributions in this scope…", &d.search)
|
||||
.on_input(|v| event(Event::Search(v)))
|
||||
.padding(7)
|
||||
.size(12),
|
||||
);
|
||||
let scope_label = std::iter::once(&owner)
|
||||
.chain(d.scope.iter())
|
||||
.map(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| format!("Unavailable {id}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ");
|
||||
out = out
|
||||
.push(text(format!("Browsing: {scope_label}")).size(12))
|
||||
.push(
|
||||
row![
|
||||
button("Root").on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Root))),
|
||||
button("Up one reference")
|
||||
.on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Up)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
let rows = match rows(p, owner, &d.scope, &d.search) {
|
||||
Ok(rows) => rows,
|
||||
Err(error) => return out.push(text(error).size(12)).into(),
|
||||
};
|
||||
let range = super::view::choice_page(rows.len(), d.page);
|
||||
let page = range.start / 24;
|
||||
out = out.push(
|
||||
row![
|
||||
button("Previous").on_press_maybe((page > 0).then(|| event(Event::Page(page - 1)))),
|
||||
text(format!(
|
||||
"{} matching contributions · page {}",
|
||||
rows.len(),
|
||||
page + 1
|
||||
))
|
||||
.size(11),
|
||||
button("Next")
|
||||
.on_press_maybe((range.end < rows.len()).then(|| event(Event::Page(page + 1))))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
for r in &rows[range] {
|
||||
let mut line = row![
|
||||
button(text(address_label(p, owner, &r.endpoint)).size(12))
|
||||
.width(Fill)
|
||||
.on_press(event(Event::Pick(r.endpoint.clone())))
|
||||
]
|
||||
.spacing(5);
|
||||
if r.reference {
|
||||
line = line
|
||||
.push(button("Enter reference").on_press(event(Event::Expand(r.endpoint.clone()))));
|
||||
}
|
||||
out = out.push(line);
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id, Id) {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let sequence = p
|
||||
.make_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
"Reusable check",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sequence_id = sequence.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap();
|
||||
let leaf = occurrence(
|
||||
&p,
|
||||
sequence_id,
|
||||
"Verify pressure",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let leaf_id = leaf.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(leaf)]).unwrap();
|
||||
let a = occurrence(
|
||||
&p,
|
||||
f.owner,
|
||||
"First check",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let b = occurrence(
|
||||
&p,
|
||||
f.owner,
|
||||
"Second check",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let (a_id, b_id) = (a.id, b.id);
|
||||
p = execute_batch(&p, &[Command::CreateElement(a), Command::CreateElement(b)]).unwrap();
|
||||
(p, f.owner, a_id, b_id, leaf_id, f.work[0])
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_repeated_sources_remain_distinct_and_apply_has_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (p, owner, a, b, leaf, work) = fixture(layer);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: leaf,
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![b],
|
||||
occurrence: leaf,
|
||||
};
|
||||
for event in [
|
||||
Event::Pick(source.clone()),
|
||||
Event::Side(true),
|
||||
Event::Pick(target.clone()),
|
||||
Event::Purpose(Purpose::CommonWork),
|
||||
Event::Evidence(Choice {
|
||||
id: work,
|
||||
label: String::new(),
|
||||
}),
|
||||
] {
|
||||
state
|
||||
.update(&p, super::super::Event::Contextual(event))
|
||||
.unwrap();
|
||||
}
|
||||
let raw = state.recovery_snapshot().unwrap().unwrap();
|
||||
let restored = State::from_recovery(raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw));
|
||||
let command = restored.prepare_pending(&p).unwrap();
|
||||
let Command::CreateRelation(record) = &command else {
|
||||
panic!("one caller-owned record")
|
||||
};
|
||||
assert_eq!(
|
||||
record.body,
|
||||
RelationBody::ContextualLink {
|
||||
owner,
|
||||
source: source.clone(),
|
||||
target: target.clone(),
|
||||
purpose: ContextualLinkPurpose::CommonWork { work }
|
||||
}
|
||||
);
|
||||
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
|
||||
let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
doc.execute(command.clone()).unwrap();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(
|
||||
doc.project().elements,
|
||||
p.elements,
|
||||
"no copied work or source occurrences"
|
||||
);
|
||||
let after = doc.bundle().clone();
|
||||
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
assert_ne!(
|
||||
address_label(&p, owner, &source),
|
||||
address_label(&p, owner, &target)
|
||||
);
|
||||
assert!(
|
||||
Graph::new(&expected, owner, record.id)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == record.id && edge.incidence)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_rejected_endpoint_keeps_exact_raw_intent_after_recovery() {
|
||||
let (p, owner, a, _, leaf, _) = fixture(Layer::Oa);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
for event in [
|
||||
Event::Pick(OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: leaf,
|
||||
}),
|
||||
Event::Side(true),
|
||||
Event::Pick(OccurrenceEndpoint {
|
||||
references: vec![Id::new_v4()],
|
||||
occurrence: leaf,
|
||||
}),
|
||||
Event::Guard(" pressure > && ".into()),
|
||||
] {
|
||||
state
|
||||
.update(&p, super::super::Event::Contextual(event))
|
||||
.unwrap();
|
||||
}
|
||||
let before = state.recovery_snapshot().unwrap();
|
||||
assert!(state.prepare_pending(&p).is_err());
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), before);
|
||||
let restored = State::from_recovery(before.clone().unwrap()).unwrap();
|
||||
assert!(restored.prepare_pending(&p).is_err());
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), before);
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_deleted_transient_selection_does_not_seed_or_hide_retained_authored_endpoint()
|
||||
{
|
||||
let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa);
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: leaf,
|
||||
};
|
||||
let mut state = State::default();
|
||||
state.select_context(oa_core::topology_context::ContextAddress {
|
||||
owner,
|
||||
endpoint: endpoint.clone(),
|
||||
});
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
let before = state.recovery_snapshot().unwrap();
|
||||
let deleted = execute_batch(&p, &[Command::Delete(leaf)]).unwrap();
|
||||
assert!(state.qualified_selection(&deleted, owner).is_none());
|
||||
assert_eq!(
|
||||
state.recovery_snapshot().unwrap(),
|
||||
before,
|
||||
"Authored endpoint is retained for explicit repair"
|
||||
);
|
||||
assert!(state.prepare_pending(&deleted).is_err());
|
||||
state.remember(owner, reference);
|
||||
assert_eq!(state.back(&deleted), Some((owner, reference)));
|
||||
assert!(state.selected_context.is_none());
|
||||
state.draft = None;
|
||||
state
|
||||
.update(
|
||||
&deleted,
|
||||
super::super::Event::Start(owner, Tool::Contextual, None),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
state
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contextual
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.source
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_scope_paging_and_search_reach_tail_beyond_512_without_losing_endpoints() {
|
||||
let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa);
|
||||
let sequence = p.elements[&leaf].parent.unwrap();
|
||||
let mut commands = Vec::new();
|
||||
let mut tails = Vec::new();
|
||||
for (scope_index, scope) in [owner, sequence].into_iter().enumerate() {
|
||||
for n in 0..520 {
|
||||
let mut record = occurrence(
|
||||
&p,
|
||||
scope,
|
||||
&format!("Contribution {n:04}"),
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
record.alias = format!("scope_{scope_index}_contribution_{n:04}");
|
||||
if n == 519 {
|
||||
tails.push(record.id);
|
||||
}
|
||||
commands.push(Command::CreateElement(record));
|
||||
}
|
||||
}
|
||||
let p = execute_batch(&p, &commands).unwrap();
|
||||
let all = rows(&p, owner, &[], "").unwrap();
|
||||
assert!(all.len() > 512);
|
||||
let last_page = super::super::view::choice_page(all.len(), usize::MAX);
|
||||
assert!(
|
||||
all[last_page]
|
||||
.iter()
|
||||
.any(|row| row.endpoint.occurrence == tails[0])
|
||||
);
|
||||
let found = rows(&p, owner, &[], "Contribution 0519").unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].endpoint.occurrence, tails[0]);
|
||||
let mut draft = Draft::default();
|
||||
draft.update(Event::Pick(OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: leaf,
|
||||
}));
|
||||
draft.update(Event::Side(true));
|
||||
draft.update(Event::Pick(found[0].endpoint.clone()));
|
||||
let chosen = (draft.source.clone(), draft.target.clone());
|
||||
draft.update(Event::Expand(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: reference,
|
||||
}));
|
||||
draft.update(Event::Search("Contribution 0519".into()));
|
||||
let nested = rows(&p, owner, &draft.scope, &draft.search).unwrap();
|
||||
assert_eq!(nested.len(), 1);
|
||||
assert_eq!(
|
||||
nested[0].endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: tails[1]
|
||||
}
|
||||
);
|
||||
let saved = serde_json::to_value(&draft).unwrap();
|
||||
let mut restored: Draft = serde_json::from_value(saved.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&restored).unwrap(), saved);
|
||||
restored.update(Event::Up);
|
||||
assert!(restored.scope.is_empty());
|
||||
assert_eq!((restored.source.clone(), restored.target.clone()), chosen);
|
||||
assert!(restored.command(&p, owner, "Tail constraint").is_ok());
|
||||
let mut historical = saved;
|
||||
historical.as_object_mut().unwrap().remove("scope");
|
||||
let restored: Draft = serde_json::from_value(historical).unwrap();
|
||||
assert!(restored.scope.is_empty());
|
||||
assert_eq!((restored.source, restored.target), chosen);
|
||||
}
|
||||
#[test]
|
||||
fn qualified_link_gui_nested_preview_folds_only_the_exact_reference_instance() {
|
||||
let (mut p, owner, a, b, leaf, _) = fixture(Layer::Oa);
|
||||
let source = p.elements[&leaf].parent.unwrap();
|
||||
let deep = p
|
||||
.make_element(Kind::Process, Layer::Oa, "Nested definition", None)
|
||||
.unwrap();
|
||||
let deep_id = deep.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(deep)]).unwrap();
|
||||
let deep_leaf = occurrence(
|
||||
&p,
|
||||
deep_id,
|
||||
"Nested control",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let deep_leaf_id = deep_leaf.id;
|
||||
let nested = occurrence(
|
||||
&p,
|
||||
source,
|
||||
"Nested use",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(deep_id),
|
||||
},
|
||||
);
|
||||
let nested_id = nested.id;
|
||||
p = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::CreateElement(deep_leaf),
|
||||
Command::CreateElement(nested),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let path = OccurrenceEndpoint {
|
||||
references: vec![a],
|
||||
occurrence: nested_id,
|
||||
};
|
||||
let mut state = State::default();
|
||||
assert!(state.expand_context(&p, owner, path.clone()));
|
||||
state.expanded.insert(b);
|
||||
let graph = Graph::expanded_qualified(
|
||||
&p,
|
||||
owner,
|
||||
owner,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
assert!(
|
||||
graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == a)
|
||||
.unwrap()
|
||||
.preview
|
||||
.iter()
|
||||
.any(|(v, _)| v.references == vec![a, nested_id] && v.occurrence == deep_leaf_id)
|
||||
);
|
||||
assert!(
|
||||
!graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == b)
|
||||
.unwrap()
|
||||
.preview
|
||||
.iter()
|
||||
.any(|(v, _)| v.occurrence == deep_leaf_id)
|
||||
);
|
||||
assert!(state.expand_context(&p, owner, path));
|
||||
let graph = Graph::expanded_qualified(
|
||||
&p,
|
||||
owner,
|
||||
owner,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
assert!(
|
||||
!graph
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.preview.iter().any(|(v, _)| v.occurrence == deep_leaf_id))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Contextual routes use outer-card gutters, never inner preview x coordinates.
|
||||
use super::*;
|
||||
|
||||
pub(super) struct Anchor {
|
||||
point: Point,
|
||||
lane: f32,
|
||||
right: bool,
|
||||
}
|
||||
pub(super) fn anchor(node: &GraphNode, endpoint: &OccurrenceEndpoint, incoming: bool) -> Anchor {
|
||||
let preview = node
|
||||
.preview
|
||||
.iter()
|
||||
.position(|(address, _)| address == endpoint);
|
||||
// The outward-facing side of each preview column has no sibling mini-card
|
||||
// between it and the host boundary. Hidden addresses use the host itself.
|
||||
let right = preview.map_or(!incoming, |index| index % 2 == 1);
|
||||
let rect = node.endpoint_rect(endpoint);
|
||||
Anchor {
|
||||
point: Point::new(
|
||||
if right { rect.x + rect.width } else { rect.x },
|
||||
rect.center_y(),
|
||||
),
|
||||
lane: if right {
|
||||
node.rect.x + node.rect.width + 18.
|
||||
} else {
|
||||
node.rect.x - 16.
|
||||
},
|
||||
right,
|
||||
}
|
||||
}
|
||||
pub(super) fn route(a: Anchor, mut b: Anchor, below: f32, first: bool) -> (Vec<Point>, Rectangle) {
|
||||
let same_lane = (a.lane - b.lane).abs() < 0.1;
|
||||
let short = first && same_lane && a.right && b.right;
|
||||
// A small separate return lane keeps a left-side detour from retracing itself.
|
||||
// Both lanes remain outside the first card's x=24 boundary.
|
||||
if same_lane && !short {
|
||||
b.lane += 4.;
|
||||
}
|
||||
let mut points = vec![a.point, Point::new(a.lane, a.point.y)];
|
||||
if !short {
|
||||
points.extend([Point::new(a.lane, below), Point::new(b.lane, below)]);
|
||||
}
|
||||
points.extend([Point::new(b.lane, b.point.y), b.point]);
|
||||
points.dedup();
|
||||
let label = Rectangle::new(
|
||||
if short {
|
||||
Point::new(a.lane - 6., (a.point.y + b.point.y) / 2. - 18.)
|
||||
} else {
|
||||
Point::new(((a.lane + b.lane) / 2. - 66.).max(4.), below - 38.)
|
||||
},
|
||||
Size::new(132., 36.),
|
||||
);
|
||||
(points, label)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn baseline() -> Project {
|
||||
persistence::decode(include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/source7-native/base/expected-baseline.json"
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
fn id(p: &Project, alias: &str) -> Id {
|
||||
p.elements
|
||||
.values()
|
||||
.find(|element| element.alias == alias)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
fn add(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
) -> (Project, Id) {
|
||||
let id = Id::new_v4();
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: p.fresh_alias("native_gate", "relation", id),
|
||||
name: "First assessment gate".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose: ContextualLinkPurpose::Precedence {
|
||||
guard: "Check accepted".into(),
|
||||
},
|
||||
},
|
||||
};
|
||||
(
|
||||
execute_batch(p, &[Command::CreateRelation(relation)]).unwrap(),
|
||||
id,
|
||||
)
|
||||
}
|
||||
fn crosses(a: Point, b: Point, rect: Rectangle) -> bool {
|
||||
if a.x == b.x {
|
||||
a.x > rect.x
|
||||
&& a.x < rect.x + rect.width
|
||||
&& a.y.min(b.y) < rect.y + rect.height
|
||||
&& a.y.max(b.y) > rect.y
|
||||
} else {
|
||||
assert_eq!(a.y, b.y, "Every route segment is orthogonal");
|
||||
a.y > rect.y
|
||||
&& a.y < rect.y + rect.height
|
||||
&& a.x.min(b.x) < rect.x + rect.width
|
||||
&& a.x.max(b.x) > rect.x
|
||||
}
|
||||
}
|
||||
fn assert_clear(
|
||||
graph: &Graph<'_>,
|
||||
edge: &GraphEdge,
|
||||
source: &OccurrenceEndpoint,
|
||||
target: &OccurrenceEndpoint,
|
||||
) {
|
||||
for node in &graph.nodes {
|
||||
if node.id != edge.source && node.id != edge.target {
|
||||
assert!(
|
||||
!edge
|
||||
.points
|
||||
.windows(2)
|
||||
.any(|line| crosses(line[0], line[1], node.rect)),
|
||||
"Route crosses unrelated {}",
|
||||
node.title
|
||||
);
|
||||
}
|
||||
for (endpoint, _) in &node.preview {
|
||||
if endpoint != source && endpoint != target {
|
||||
let mini = node.endpoint_rect(endpoint);
|
||||
assert!(
|
||||
!edge
|
||||
.points
|
||||
.windows(2)
|
||||
.any(|line| crosses(line[0], line[1], mini)),
|
||||
"Route crosses another qualified contribution {endpoint:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_native_x_to_first_b_keeps_visible_arrow_above_background_and_clear_of_other_references()
|
||||
{
|
||||
for prefix in ["oa", "sa"] {
|
||||
let p = baseline();
|
||||
let owner = id(&p, &format!("{prefix}_caller"));
|
||||
let reference = id(&p, &format!("{prefix}_r1"));
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id(&p, &format!("{prefix}_x")),
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![reference],
|
||||
occurrence: id(&p, &format!("{prefix}_b")),
|
||||
};
|
||||
let (q, link) = add(&p, owner, source.clone(), target.clone());
|
||||
let graph = Graph::new(&q, owner, link);
|
||||
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let host = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == reference)
|
||||
.unwrap();
|
||||
let mini = host.endpoint_rect(&target);
|
||||
assert_eq!(
|
||||
mini,
|
||||
Rectangle::new(Point::new(161., 282.), Size::new(113., 32.))
|
||||
);
|
||||
assert_eq!(
|
||||
edge.points,
|
||||
vec![
|
||||
Point::new(294., 76.),
|
||||
Point::new(312., 76.),
|
||||
Point::new(312., 298.),
|
||||
Point::new(274., 298.)
|
||||
]
|
||||
);
|
||||
assert_clear(&graph, edge, &source, &target);
|
||||
assert_eq!(
|
||||
graph.size.height,
|
||||
Graph::new(&p, owner, owner).size.height,
|
||||
"This adjacent target needs no below-canvas detour"
|
||||
);
|
||||
assert_eq!(edge.layer, EdgeLayer::Contextual);
|
||||
let position = |pass| {
|
||||
PAINT_PASSES
|
||||
.iter()
|
||||
.position(|actual| *actual == pass)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(position(PaintPass::NodeBackgrounds) < position(PaintPass::ContextualEdges));
|
||||
assert!(position(PaintPass::ContextualEdges) < position(PaintPass::NodeContents));
|
||||
assert!(position(PaintPass::DirectEdges) < position(PaintPass::NodeBackgrounds));
|
||||
// The arrow points left into B's right border. Its head stays in the
|
||||
// host's clear right padding and is not covered by any mini-card.
|
||||
let end = *edge.points.last().unwrap();
|
||||
assert_eq!(end, Point::new(mini.x + mini.width, mini.center_y()));
|
||||
let stub = Point::new(end.x + 10., end.y);
|
||||
assert!(host.rect.contains(stub));
|
||||
let context = Context {
|
||||
document: q.id,
|
||||
owner,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
};
|
||||
for scale in [0.45, 1.] {
|
||||
let click = |point: Point| {
|
||||
let mut interactive = Graph::new(&q, owner, link);
|
||||
interactive.interaction = Some((context, CanvasTool::Select, true));
|
||||
interactive.scale = scale;
|
||||
interactive
|
||||
.input(
|
||||
&mut Input::default(),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(
|
||||
Point::ORIGIN,
|
||||
Size::new(
|
||||
interactive.size.width * scale,
|
||||
interactive.size.height * scale,
|
||||
),
|
||||
),
|
||||
mouse::Cursor::Available(Point::new(point.x * scale, point.y * scale)),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
};
|
||||
assert!(
|
||||
matches!(click(stub), Some(Message::TopologyCanvas(CanvasEvent::Select(_, id))) if id == link)
|
||||
);
|
||||
assert!(
|
||||
matches!(click(mini.center()), Some(Message::TopologyCanvas(CanvasEvent::SelectQualified(_, endpoint))) if endpoint == target)
|
||||
);
|
||||
}
|
||||
assert_eq!(q.elements, p.elements);
|
||||
let (parallel_project, second) = add(&q, owner, source.clone(), target.clone());
|
||||
let parallel = Graph::new(¶llel_project, owner, second);
|
||||
let first_edge = parallel.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let second_edge = parallel
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == second)
|
||||
.unwrap();
|
||||
assert_ne!(first_edge.points, second_edge.points);
|
||||
assert_ne!(first_edge.label, second_edge.label);
|
||||
assert_clear(¶llel, first_edge, &source, &target);
|
||||
assert_clear(¶llel, second_edge, &source, &target);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_shared_anchor_preserves_selected_direct_reconnect_at_fit_and_full_scale() {
|
||||
for prefix in ["oa", "sa"] {
|
||||
let p = baseline();
|
||||
let owner = id(&p, &format!("{prefix}_caller"));
|
||||
let x = id(&p, &format!("{prefix}_x"));
|
||||
let r1 = id(&p, &format!("{prefix}_r1"));
|
||||
let r2 = id(&p, &format!("{prefix}_r2"));
|
||||
let (p, contextual) = add(
|
||||
&p,
|
||||
owner,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: x,
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![r1],
|
||||
occurrence: id(&p, &format!("{prefix}_b")),
|
||||
},
|
||||
);
|
||||
let command = link(&p, x, r1, "Direct caller order");
|
||||
let Command::CreateRelation(relation) = &command else {
|
||||
unreachable!()
|
||||
};
|
||||
let direct = relation.id;
|
||||
let p = execute_batch(&p, &[command]).unwrap();
|
||||
let context = Context {
|
||||
document: p.id,
|
||||
owner,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
};
|
||||
for scale in [0.45, 1.] {
|
||||
let mut graph = Graph::new(&p, owner, direct);
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
graph.scale = scale;
|
||||
let source = graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == direct)
|
||||
.unwrap()
|
||||
.points[0];
|
||||
assert_eq!(
|
||||
source,
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == contextual)
|
||||
.unwrap()
|
||||
.points[0]
|
||||
);
|
||||
let destination = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == r2)
|
||||
.unwrap()
|
||||
.rect
|
||||
.center();
|
||||
let screen = |point: Point| Point::new(point.x * scale, point.y * scale);
|
||||
let bounds = Rectangle::new(
|
||||
Point::ORIGIN,
|
||||
Size::new(graph.size.width * scale, graph.size.height * scale),
|
||||
);
|
||||
let mut input = Input::default();
|
||||
let press = graph
|
||||
.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(source)),
|
||||
)
|
||||
.expect("The selected endpoint captures its press")
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(
|
||||
press.is_none(),
|
||||
"A shared contextual anchor must not steal the selected direct handle"
|
||||
);
|
||||
let _ = graph.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: screen(destination),
|
||||
}),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(destination)),
|
||||
);
|
||||
let release = graph
|
||||
.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(destination)),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0);
|
||||
assert!(
|
||||
matches!(release, Some(Message::TopologyCanvas(CanvasEvent::Reconnect {
|
||||
context: actual_context, link, end: direct::End::Source, target,
|
||||
})) if actual_context == context && link == direct && target == r2)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_nested_visible_hidden_and_parallel_paths_keep_exact_addresses_and_legacy_routes()
|
||||
{
|
||||
let p = baseline();
|
||||
let owner = id(&p, "oa_caller");
|
||||
let r3 = id(&p, "oa_r3");
|
||||
let inner = id(&p, "oa_inner");
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id(&p, "oa_x"),
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![r3, inner],
|
||||
occurrence: id(&p, "oa_b"),
|
||||
};
|
||||
let (q, link) = add(&p, owner, source.clone(), target.clone());
|
||||
let expanded = BTreeSet::from([r3]);
|
||||
let paths = BTreeSet::from([OccurrenceEndpoint {
|
||||
references: vec![r3],
|
||||
occurrence: inner,
|
||||
}]);
|
||||
let graph = Graph::expanded_qualified(&q, owner, link, &expanded, &paths);
|
||||
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
assert_clear(&graph, edge, &source, &target);
|
||||
let host = graph.nodes.iter().find(|node| node.id == r3).unwrap();
|
||||
let mini = host.endpoint_rect(&target);
|
||||
assert_ne!(mini, host.rect);
|
||||
assert_eq!(
|
||||
edge.points.last(),
|
||||
Some(&Point::new(mini.x, mini.center_y()))
|
||||
);
|
||||
let folded = Graph::new(&q, owner, link);
|
||||
let folded_edge = folded.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let folded_host = folded.nodes.iter().find(|node| node.id == r3).unwrap();
|
||||
assert_eq!(
|
||||
folded_edge.points.last(),
|
||||
Some(&Point::new(folded_host.rect.x, folded_host.rect.center_y()))
|
||||
);
|
||||
assert_clear(&folded, folded_edge, &source, &target);
|
||||
let (q2, other) = add(&q, owner, source.clone(), target.clone());
|
||||
let parallel = Graph::expanded_qualified(&q2, owner, link, &expanded, &paths);
|
||||
let a = parallel.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let b = parallel.edges.iter().find(|edge| edge.id == other).unwrap();
|
||||
assert_ne!(a.points, b.points);
|
||||
assert_ne!(a.label, b.label);
|
||||
assert_clear(¶llel, a, &source, &target);
|
||||
assert_clear(¶llel, b, &source, &target);
|
||||
let procedure = id(&p, "oa_procedure");
|
||||
let old = Graph::new(&p, procedure, procedure);
|
||||
let new = Graph::new(&q2, procedure, procedure);
|
||||
assert_eq!(old.size, new.size);
|
||||
assert_eq!(
|
||||
old.edges
|
||||
.iter()
|
||||
.map(|e| (&e.points, e.label, e.layer))
|
||||
.collect::<Vec<_>>(),
|
||||
new.edges
|
||||
.iter()
|
||||
.map(|e| (&e.points, e.label, e.layer))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(new.edges.iter().all(|edge| edge.layer == EdgeLayer::Direct));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
SelectQualified(Context, OccurrenceEndpoint),
|
||||
ExpandQualified(Context, OccurrenceEndpoint),
|
||||
OpenQualified(Context, OccurrenceEndpoint),
|
||||
AppendExchange(Context),
|
||||
Begin {
|
||||
context: Context,
|
||||
@@ -57,6 +60,7 @@ pub enum CanvasEvent {
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Focus(Context),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
@@ -65,11 +69,15 @@ impl CanvasEvent {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::SelectQualified(c, ..)
|
||||
| Self::ExpandQualified(c, ..)
|
||||
| Self::OpenQualified(c, ..)
|
||||
| Self::Expand(c, ..)
|
||||
| Self::Open(c, ..)
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
@@ -79,6 +87,53 @@ impl CanvasEvent {
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
|
||||
self.selected_context = Some(address);
|
||||
}
|
||||
pub(crate) fn clear_context(&mut self) {
|
||||
self.selected_context = None;
|
||||
}
|
||||
pub(super) fn qualified_selection(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
) -> Option<&oa_core::topology_context::ContextAddress> {
|
||||
self.selected_context.as_ref().filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
})
|
||||
}
|
||||
pub(crate) fn expand_context(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
endpoint: OccurrenceEndpoint,
|
||||
) -> bool {
|
||||
if oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &endpoint)
|
||||
.is_err()
|
||||
|| !matches!(
|
||||
p.elements
|
||||
.get(&endpoint.occurrence)
|
||||
.and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = endpoint.references.first() {
|
||||
self.expanded.insert(*root);
|
||||
}
|
||||
if !self.contextual_expanded.remove(&endpoint) {
|
||||
self.contextual_expanded.insert(endpoint);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
@@ -90,6 +145,7 @@ impl State {
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
@@ -133,11 +189,18 @@ impl State {
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to.push((owner, occurrence));
|
||||
self.return_to
|
||||
.push((owner, occurrence, self.selected_context.clone()));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence)) = self.return_to.pop() {
|
||||
while let Some((owner, occurrence, address)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
self.selected_context = address.filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
});
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
@@ -145,6 +208,11 @@ impl State {
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
return Some(r.id);
|
||||
}
|
||||
let Command::Batch(commands) = command else {
|
||||
return None;
|
||||
};
|
||||
@@ -355,6 +423,20 @@ impl Graph<'_> {
|
||||
.find(|n| n.rect.contains(point))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
fn qualified_at(&self, point: Point) -> Option<OccurrenceEndpoint> {
|
||||
self.nodes.iter().find_map(|node| {
|
||||
node.preview.iter().enumerate().find_map(|(i, (id, _))| {
|
||||
let rect = Rectangle::new(
|
||||
Point::new(
|
||||
node.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
node.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
);
|
||||
rect.contains(point).then(|| id.clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
fn edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
@@ -366,6 +448,19 @@ impl Graph<'_> {
|
||||
})
|
||||
.map(|e| e.id)
|
||||
}
|
||||
fn contextual_edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|edge| edge.layer == EdgeLayer::Contextual)
|
||||
.find(|edge| {
|
||||
edge.label.contains(point)
|
||||
|| edge.points.windows(2).any(|segment| {
|
||||
crate::diagram::distance_to_segment(point, segment[0], segment[1])
|
||||
< 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
}
|
||||
fn handle(&self, id: Id) -> Option<Rectangle> {
|
||||
let n = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(Rectangle::new(
|
||||
@@ -374,6 +469,9 @@ impl Graph<'_> {
|
||||
))
|
||||
}
|
||||
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
||||
if self.project.relations.get(&self.selected)?.kind() != RelationKind::SequenceLink {
|
||||
return None;
|
||||
}
|
||||
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
||||
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
||||
.into_iter()
|
||||
@@ -497,13 +595,17 @@ impl Graph<'_> {
|
||||
});
|
||||
}
|
||||
CanvasTool::Select => {
|
||||
if let Some((link, end)) = self.endpoint_at(point) {
|
||||
if let Some(endpoint) = self.qualified_at(point) {
|
||||
return publish(CanvasEvent::SelectQualified(context, endpoint));
|
||||
} else if let Some((link, end)) = self.endpoint_at(point) {
|
||||
state.press = Some(Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else if let Some(link) = self.contextual_edge_at(point) {
|
||||
return publish(CanvasEvent::Select(context, link));
|
||||
} else if self
|
||||
.handle(self.selected)
|
||||
.is_some_and(|r| r.contains(point))
|
||||
@@ -728,13 +830,17 @@ impl State {
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph = Graph::expanded(p, owner, id, &self.expanded);
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
let rect = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.map(|n| n.rect)
|
||||
.map(|n| {
|
||||
self.qualified_selection(p, owner)
|
||||
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
|
||||
})
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
|
||||
@@ -896,6 +896,7 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha
|
||||
f.owner,
|
||||
"Ordered contribution",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -136,3 +136,246 @@ fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible
|
||||
assert_eq!(closed.scrolls[0].1.height, size.height);
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_workspace(
|
||||
pending: bool,
|
||||
details: bool,
|
||||
header_height: f32,
|
||||
) -> iced::Element<'static, Message, iced::Theme, ()> {
|
||||
let canvas = container(
|
||||
scrollable(iced::widget::Space::new().width(1800.).height(2400.))
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let graph = explicit_pane(
|
||||
iced::widget::Space::new().height(header_height).into(),
|
||||
iced::widget::Space::new().height(1800.).into(),
|
||||
canvas.into(),
|
||||
details,
|
||||
pending,
|
||||
);
|
||||
let form = if pending {
|
||||
editor_panel(
|
||||
container(iced::widget::Space::new().height(32))
|
||||
.id("test-process-actions")
|
||||
.into(),
|
||||
iced::widget::Space::new().height(2000).into(),
|
||||
)
|
||||
} else {
|
||||
iced::widget::Space::new().height(0).into()
|
||||
};
|
||||
panes(graph, form, pending)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_layout_measures_remaining_canvas_with_details_forms_and_window_resize() {
|
||||
// Minimum/default panes both with side panels and focused; tall content
|
||||
// deliberately cannot influence the enclosing scrollable allocations.
|
||||
for size in [
|
||||
Size::new(500., 570.),
|
||||
Size::new(1050., 570.),
|
||||
Size::new(840., 770.),
|
||||
Size::new(1394., 802.),
|
||||
] {
|
||||
for pending in [false, true] {
|
||||
for details in [false, true] {
|
||||
for header_height in [68., 96.] {
|
||||
let mut view = explicit_workspace(pending, details, header_height);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(node.size(), size);
|
||||
let canvas = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.unwrap()
|
||||
.1;
|
||||
let pane_bottom = if pending {
|
||||
size.height / 2.
|
||||
} else {
|
||||
size.height
|
||||
};
|
||||
assert!(canvas.x >= 0. && canvas.y >= 0.);
|
||||
assert!(
|
||||
canvas.x + canvas.width <= size.width
|
||||
&& canvas.y + canvas.height <= pane_bottom,
|
||||
"Canvas must be visible, not merely allocated outside a parent clip: {size:?} {canvas:?}"
|
||||
);
|
||||
assert!(canvas.height >= if pending { 75. } else { 300. });
|
||||
let mut state = State::default();
|
||||
state.viewport(canvas.size());
|
||||
state.zoom(None);
|
||||
let graph = Size::new(900., 1400.);
|
||||
let scale = state.scale(graph, 1.);
|
||||
assert!(
|
||||
graph.width * scale <= canvas.width
|
||||
&& graph.height * scale <= canvas.height
|
||||
);
|
||||
assert!(!state.fit_limited(graph, 1.));
|
||||
if pending {
|
||||
let form = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-form")))
|
||||
.unwrap()
|
||||
.1;
|
||||
let actions = actual.actions.unwrap();
|
||||
assert!(form.height >= 200. && form.y + form.height <= size.height);
|
||||
assert!(actions.y >= pane_bottom && actions.y + actions.height <= form.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_layout_disclosures_and_pending_editor_keep_real_manual_canvas_scroll() {
|
||||
let mut view = explicit_workspace(false, false, 68.);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.));
|
||||
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("process-canvas"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: Some(90.),
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||
let (_, before) = geometry(&mut view, &mut tree, Size::new(500., 570.));
|
||||
let initial: Vec<_> = before
|
||||
.scrolls
|
||||
.iter()
|
||||
.filter(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.collect();
|
||||
assert_eq!(initial.len(), 1, "The exact canvas ID must be unique");
|
||||
assert_eq!(
|
||||
initial[0].2,
|
||||
Vector::new(90., 180.),
|
||||
"Establish the actual canvas scroll before any tree diff"
|
||||
);
|
||||
for (pending, details, height, size) in [
|
||||
(false, true, 96., Size::new(500., 570.)),
|
||||
(true, true, 68., Size::new(1050., 570.)),
|
||||
(true, false, 68., Size::new(1394., 802.)),
|
||||
(false, false, 96., Size::new(500., 570.)),
|
||||
] {
|
||||
view = explicit_workspace(pending, details, height);
|
||||
tree.diff(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
let canvas = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
canvas.2,
|
||||
Vector::new(90., 180.),
|
||||
"Shelf/header/form/focus resize must retain the same scroll state"
|
||||
);
|
||||
if pending {
|
||||
let actions = actual.actions;
|
||||
let mut form_scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("process-form"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(600.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut form_scroll);
|
||||
let (_, scrolled) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(scrolled.actions, actions);
|
||||
assert!(
|
||||
scrolled
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.)
|
||||
);
|
||||
assert!(
|
||||
scrolled
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-canvas"))
|
||||
&& s.2 == Vector::new(90., 180.))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_whole_app_focus_and_disclosures_retain_canvas_tree_with_pending_raw_form() {
|
||||
use iced::advanced::widget::tree;
|
||||
use std::rc::Rc;
|
||||
fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option<Vec<usize>> {
|
||||
if tree.tag == tag {
|
||||
return Some(vec![]);
|
||||
}
|
||||
tree.children.iter().enumerate().find_map(|(i, child)| {
|
||||
let mut path = path_to(child, tag)?;
|
||||
path.insert(0, i);
|
||||
Some(path)
|
||||
})
|
||||
}
|
||||
fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree {
|
||||
for &i in path {
|
||||
tree = &mut tree.children[i]
|
||||
}
|
||||
tree
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut app = App::with_project(f.p);
|
||||
let _ = app.update(Message::Select(f.nodes[1]));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1100., 700.);
|
||||
let graph: Element<'_, Message> =
|
||||
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
|
||||
let tag = widget::Tree::new(graph.as_widget()).tag;
|
||||
drop(graph);
|
||||
let mut tree = widget::Tree::new(crate::views::view(&app).as_widget());
|
||||
let mut path = path_to(&tree, tag).unwrap();
|
||||
path.pop();
|
||||
let marker = Rc::new(());
|
||||
at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker));
|
||||
let _ = app.update(event(Event::Start(f.owner, Tool::Contextual, None)));
|
||||
let _ = app.update(event(Event::Name(" preserved raw 雨 ".into())));
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
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);
|
||||
tree.diff(crate::views::view(&app).as_widget());
|
||||
let mut current = path_to(&tree, tag).unwrap();
|
||||
current.pop();
|
||||
assert_eq!(
|
||||
current, path,
|
||||
"Main must not shift when explorer/inspector are hidden"
|
||||
);
|
||||
assert_eq!(Rc::strong_count(&marker), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
at(&mut tree, &path).state.downcast_ref::<Rc<()>>(),
|
||||
&marker
|
||||
));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,7 @@ pub(super) fn view<'a>(
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
// Keep the canvas at the same sibling position when its palette becomes a
|
||||
// pending-form notice, so Iced retains the scrollable's local offsets.
|
||||
let mut controls = Column::new().spacing(14);
|
||||
let mut controls = Column::new().spacing(6);
|
||||
if !pending && explicit {
|
||||
controls = controls
|
||||
.push(
|
||||
@@ -162,39 +160,179 @@ pub(super) fn view<'a>(
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
out = out.push(controls);
|
||||
if explicit {
|
||||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
body:
|
||||
RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
},
|
||||
..
|
||||
}) = p.relations.get(&app.selected)
|
||||
&& *caller == owner
|
||||
{
|
||||
navigation=navigation.push(text(format!("From: {}",super::contextual::address_label(p,owner,source))).size(12))
|
||||
.push(text(format!("To: {}",super::contextual::address_label(p,owner,target))).size(12))
|
||||
.push(text(match purpose {ContextualLinkPurpose::Precedence {..}=>"Precedence · hidden contributions attach visually to their folded reference",_=>"Assembly incidence · no execution order is implied"}).size(11));
|
||||
}
|
||||
if let Some(address) = state.qualified_selection(p, owner) {
|
||||
navigation=navigation.push(text(super::contextual::address_label(p,owner,&address.endpoint)).size(12))
|
||||
.push(text("Qualified contribution · definition properties affect every use of the source.").size(11))
|
||||
.push(button("Open contribution definition").on_press(Message::TopologyCanvas(CanvasEvent::OpenQualified(context,address.endpoint.clone()))));
|
||||
if matches!(
|
||||
p.elements
|
||||
.get(&address.endpoint.occurrence)
|
||||
.and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
navigation = navigation.push(
|
||||
button(if state.contextual_expanded.contains(&address.endpoint) {
|
||||
"Fold nested reference"
|
||||
} else {
|
||||
"Expand nested reference"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::ExpandQualified(
|
||||
context,
|
||||
address.endpoint.clone(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !pending {
|
||||
controls = controls.push(button("Qualified link / assembly…").on_press(event(
|
||||
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
||||
)));
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
navigation = navigation.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
navigation = navigation.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
navigation = navigation.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
let diagram = if explicit {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
graph.scroll_offset = state.scroll_offset();
|
||||
// Explicit graphs no longer have a scrolling page ancestor. A retained
|
||||
// offset from an ordered view must not distort their pointer coordinates.
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
let scale = graph.scale;
|
||||
out = out.push(
|
||||
let mut header = column![
|
||||
row![
|
||||
button("Fit overview")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
out.width(Fill),
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context)))
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
button(text("Tools").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Tools) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Tools))),
|
||||
button(text("Selection details").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Selection) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Selection))),
|
||||
button(text("Fit overview").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button(text("−").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button(text("+").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button(text("100%").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
]
|
||||
.spacing(6);
|
||||
if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11));
|
||||
}
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(label(
|
||||
controls = controls.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
@@ -209,26 +347,44 @@ pub(super) fn view<'a>(
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(if pending {
|
||||
(app.window_size.height - 510.).clamp(160., 240.)
|
||||
} else {
|
||||
(app.window_size.height - 420.).clamp(190., 420.)
|
||||
});
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let canvas = container(
|
||||
iced::widget::sensor(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
);
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
};
|
||||
explicit_pane(
|
||||
header.into(),
|
||||
details,
|
||||
canvas.into(),
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
} else {
|
||||
out = out
|
||||
.push(
|
||||
button(if state.focused {
|
||||
"Restore explorer and properties"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))),
|
||||
)
|
||||
.push(controls)
|
||||
.push(navigation);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
@@ -264,204 +420,180 @@ pub(super) fn view<'a>(
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
out = out.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
out = out.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
)
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
@@ -470,21 +602,49 @@ pub(super) fn view<'a>(
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
),
|
||||
form_panel,
|
||||
pending,
|
||||
)
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
/// A permanent three-slot tree: variable controls never remount the canvas.
|
||||
/// The shelf scrolls independently; the canvas receives actual remaining height.
|
||||
fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: iced::Element<'a, Message, iced::Theme, R>,
|
||||
details: iced::Element<'a, Message, iced::Theme, R>,
|
||||
canvas: iced::Element<'a, Message, iced::Theme, R>,
|
||||
show_details: bool,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
// Column::push drops Fixed(0) children. from_vec retains the closed shelf
|
||||
// slot so its disclosure cannot move/remount the following canvas tree.
|
||||
Column::from_vec(vec![
|
||||
container(header).width(Fill).into(),
|
||||
scrollable(details)
|
||||
.id("process-details")
|
||||
.width(Fill)
|
||||
.height(if show_details {
|
||||
if pending { 64. } else { 112. }
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.into(),
|
||||
container(canvas).width(Fill).height(Fill).into(),
|
||||
])
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||
|
||||
Reference in New Issue
Block a user