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

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:
Codex
2026-09-10 10:06:55 -07:00
parent 4b6b5fc197
commit e2ecfea69b
162 changed files with 17595 additions and 926 deletions
+164 -16
View File
@@ -5,6 +5,10 @@ use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
#[cfg(test)]
#[path = "scenario_operation_tests.rs"]
mod operation_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Lifeline,
@@ -125,6 +129,9 @@ pub enum Event {
Receiver(Choice),
Call(Choice),
Style(MessageStyle),
OperationIntent(bool),
Operation(Choice),
ClearOperation,
Parent(Choice),
Expression(String),
SecondGuard(String),
@@ -175,6 +182,10 @@ struct Draft {
receiver: Option<Id>,
call: Option<Id>,
style: MessageStyle,
#[serde(default)]
operation: Option<OperationUse>,
#[serde(default)]
disabled_operation: Option<OperationUse>,
expression: String,
second_guard: String,
operator: FragmentOperator,
@@ -330,6 +341,8 @@ impl State {
receiver: None,
call: None,
style: MessageStyle::CallReply,
operation: None,
disabled_operation: None,
expression: String::new(),
second_guard: "else".into(),
operator: FragmentOperator::Alternative,
@@ -351,6 +364,27 @@ impl State {
Event::Name(s) => d.name = s,
Event::Expression(s) => d.expression = s,
Event::SecondGuard(s) => d.second_guard = s,
Event::OperationIntent(enabled) => {
if enabled {
d.operation = Some(
d.operation
.clone()
.or_else(|| d.disabled_operation.take())
.unwrap_or(OperationUse { allocation: None }),
);
} else {
d.disabled_operation =
d.operation.take().or_else(|| d.disabled_operation.take());
}
}
Event::Operation(c) => {
d.operation = Some(OperationUse {
allocation: Some(c.id),
})
}
Event::ClearOperation => {
d.operation = Some(OperationUse { allocation: None })
}
Event::Target(c) => {
d.target = Some(c.id);
d.bindings.clear();
@@ -358,7 +392,9 @@ impl State {
Event::Exchange(c) => {
if d.exchange != Some(c.id) {
d.exchange = Some(c.id);
d.reset_values();
if !d.has_operation_intent(p) {
d.reset_values();
}
if !d.fixed_endpoints {
d.sender = None;
d.receiver = None;
@@ -394,7 +430,9 @@ impl State {
std::mem::swap(&mut d.sender, &mut d.receiver);
d.call = None;
}
d.retain_value_paths(p);
if !d.has_operation_intent(p) {
d.retain_value_paths(p);
}
}
}
Event::Call(c) => {
@@ -410,7 +448,7 @@ impl State {
..
}) = p.elements.get(&c.id).and_then(|e| e.shared())
{
if d.exchange != *exchange {
if d.exchange != *exchange && !d.has_operation_intent(p) {
d.reset_values();
}
d.exchange = *exchange;
@@ -426,18 +464,24 @@ impl State {
d.values.insert(id, s);
}
Event::ValueFocus(key)
if d.exchange
.and_then(|id| p.relations.get(&id))
.and_then(|r| crate::data_values::resolve(p, r, &d.kind(), &key))
if d.signature(p)
.ok()
.flatten()
.and_then(|r| {
crate::data_values::resolve(p, &r, &d.value_kind(), &key)
})
.is_some() =>
{
d.value_focus = Some(key);
}
Event::ValueBrowse(key) => {
if key.as_ref().is_none_or(|key| {
d.exchange
.and_then(|id| p.relations.get(&id))
.and_then(|r| crate::data_values::resolve(p, r, &d.kind(), key))
d.signature(p)
.ok()
.flatten()
.and_then(|r| {
crate::data_values::resolve(p, &r, &d.value_kind(), key)
})
.is_some()
}) {
d.value_browse = key;
@@ -767,6 +811,33 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
.collect()
}
impl Draft {
fn has_operation_intent(&self, p: &Project) -> bool {
self.operation.is_some()
|| self
.call
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| {
matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
..
},
..
})
)
})
}
fn proposed_message(&self, p: &Project) -> oa_core::Element {
let mut message = self.append(p, "Operation preview", self.message_body());
// This is a transient resolver input; no generated identity reaches a command.
message.id = Id::nil();
message
}
fn signature(&self, p: &Project) -> Result<Option<Relation>, String> {
crate::data_values::message_signature(p, &self.proposed_message(p))
}
fn reset_values(&mut self) {
self.values.clear();
self.value_focus = None;
@@ -804,8 +875,8 @@ impl Draft {
exchange: &Relation,
) -> Result<Vec<crate::data_values::FieldPathChoice>, String> {
match &self.value_browse {
Some(key) => crate::data_values::children(p, exchange, &self.kind(), key),
None => Ok(crate::data_values::roots(p, exchange, &self.kind())),
Some(key) => crate::data_values::children(p, exchange, &self.value_kind(), key),
None => Ok(crate::data_values::roots(p, exchange, &self.value_kind())),
}
}
fn authored_values(&self) -> Vec<(&crate::data_values::FieldPathKey, &str)> {
@@ -815,6 +886,9 @@ impl Draft {
.map(|(key, value)| (key, value.as_str()))
.collect()
}
fn value_kind(&self) -> MessageKind {
crate::data_values::operation_value_phase(&self.kind(), self.operation.as_ref())
}
fn kind(&self) -> MessageKind {
match self.style {
MessageStyle::Asynchronous => MessageKind::Asynchronous,
@@ -841,6 +915,14 @@ impl Draft {
self.receiver
},
message_kind: self.kind(),
operation: if matches!(
self.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
) {
self.operation.clone()
} else {
None
},
reply_to: self.call,
values: self
.values
@@ -974,6 +1056,7 @@ impl Draft {
sender: self.receiver,
receiver: self.sender,
message_kind: MessageKind::Reply,
operation: None,
reply_to: Some(call_id),
values: vec![],
},
@@ -1136,7 +1219,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a
use crate::data_values;
let mut out = column![text("Message values").size(14)].spacing(7);
if let Some(browse) = &d.value_browse {
let title = data_values::resolve(p, exchange, &d.kind(), browse)
let title = data_values::resolve(p, exchange, &d.value_kind(), browse)
.map(|choice| format!("Fields inside {}", choice.label))
.unwrap_or_else(|| "This record path is no longer available in this message.".into());
out = out.push(text(title).size(11)).push(
@@ -1182,7 +1265,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a
match d
.value_focus
.as_ref()
.map(|key| data_values::resolve(p, exchange, &d.kind(), key))
.map(|key| data_values::resolve(p, exchange, &d.value_kind(), key))
{
Some(Some(focus)) => {
let key = focus.key;
@@ -1233,7 +1316,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a
ValueSection::Authored,
));
for (key, expression) in &authored[value_page(authored.len(), d.value_authored_page)] {
let title = data_values::resolve(p, exchange, &d.kind(), key)
let title = data_values::resolve(p, exchange, &d.value_kind(), key)
.map(|choice| choice.label)
.unwrap_or_else(|| format!("Unavailable field {} · review this value", key.field));
out = out.push(
@@ -1579,8 +1662,71 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
.size(12),
);
}
if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) {
form = form.push(value_panel(p, d, exchange));
if p.layer_of(owner) == Some(Layer::Sa)
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
&& matches!(
d.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
)
{
form = form.push(
iced::widget::checkbox(d.operation.is_some())
.label("Invoke a specific interface operation")
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
);
if let Some(operation) = &d.operation {
form = form
.push(bounded_picker(
crate::data_values::operation_choices(p, &d.proposed_message(p)),
d,
operation.allocation,
"Choose the exact interface operation allocation…",
Event::Operation,
))
.push(
button("Keep operation unresolved")
.on_press(event(Event::ClearOperation)),
);
if let Some(id) = operation.allocation {
form = form.push(label(crate::data_values::operation_identity(p, id)));
}
}
}
if let Some(description) =
crate::data_values::operation_label(p, &d.proposed_message(p))
{
form = form.push(label(description));
}
match d.signature(p) {
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
Err(problem) => {
form = form.push(label(problem));
for (key, expression) in d.authored_values() {
form = form
.push(
text(format!(
"Retained value · {} / {}",
key.path
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" → "),
p.named(key.field)
))
.size(11),
)
.push(
text_input("Value expression", expression)
.on_input({
let key = key.clone();
move |raw| event(Event::Value(key.clone(), raw))
})
.padding(8)
.size(12),
);
}
}
Ok(None) => {}
}
if d.style == MessageStyle::CallReply {
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
@@ -2072,6 +2218,7 @@ pub(crate) mod direct_tests {
f.owner,
&format!("Call {index}"),
OccurrenceBody::Exchange {
operation: None,
exchange: Some(f.exchange),
sender: Some(f.sender),
receiver: Some(f.receiver),
@@ -3173,6 +3320,7 @@ mod data_contract_tests {
f.scenario,
name,
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: Some(lifelines[0]),
receiver: Some(lifelines[1]),
@@ -0,0 +1,304 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::ProjectLimits;
fn fixture() -> Project {
persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap()
}
fn id(p: &Project, n: u32) -> Id {
let alias = format!("operation_probe_{n}");
p.elements
.values()
.find(|e| e.alias == alias)
.map(|e| e.id)
.or_else(|| {
p.relations
.values()
.find(|r| r.alias == alias)
.map(|r| r.id)
})
.unwrap()
}
fn choice(p: &Project, n: u32) -> Choice {
Choice {
id: id(p, n),
label: p.named(id(p, n)).to_owned(),
}
}
fn prepare(p: &Project) -> State {
let mut state = State::default();
for event in [
Event::Start(id(p, 40), Tool::Message),
Event::Style(MessageStyle::CallReply),
Event::Exchange(choice(p, 34)),
Event::Sender(choice(p, 41)),
Event::Receiver(choice(p, 42)),
Event::OperationIntent(true),
] {
state.update(p, event).unwrap();
}
state
}
#[test]
fn operation_gui_selects_two_parameterless_allocations_without_inventing_refinement_and_reply_derives_call()
{
let p = fixture();
let mut state = prepare(&p);
let choices = crate::data_values::operation_choices(
&p,
&state.draft.as_ref().unwrap().proposed_message(&p),
);
assert_eq!(
choices.iter().map(|c| c.id).collect::<BTreeSet<_>>(),
BTreeSet::from([id(&p, 30), id(&p, 31)])
);
for allocation in [30, 31] {
state
.update(&p, Event::Operation(choice(&p, allocation)))
.unwrap();
let command = state.prepare_pending(&p).unwrap();
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
let added: Vec<_> = expected
.elements
.values()
.filter(|e| !p.contains(e.id))
.collect();
assert_eq!(added.len(), 2);
let call = added
.iter()
.find(|e| {
matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
)
})
.unwrap();
let reply = added
.iter()
.find(|e| {
matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
)
})
.unwrap();
assert!(
matches!(call.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:Some(OperationUse {allocation:Some(selected)}),..},..}) if *selected==id(&p,allocation))
);
assert!(
matches!(reply.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:None,reply_to:Some(selected),..},..}) if *selected==call.id)
);
let label = crate::data_values::operation_label(&expected, reply).unwrap();
assert!(label.contains("Reply operation from Call"));
assert!(label.contains(if allocation == 30 {
"Reset"
} else {
"ReadStatus"
}));
assert_eq!(expected.relations, p.relations);
let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
doc.execute(command).unwrap();
let after = doc.bundle().clone();
assert_eq!(doc.project(), &expected);
assert_eq!(
Document::from_bundle(after.clone(), ProjectLimits::default())
.unwrap()
.project(),
&expected
);
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
}
}
#[test]
fn operation_gui_unresolved_and_disabled_selection_recovery_preserves_rejected_raw_values() {
let p = fixture();
let mut state = prepare(&p);
state.update(&p, Event::Operation(choice(&p, 30))).unwrap();
state.update(&p, Event::OperationIntent(false)).unwrap();
let raw = state.recovery_snapshot().unwrap().unwrap();
let mut state = State::from_recovery(raw.clone()).unwrap();
assert_eq!(state.recovery_snapshot().unwrap(), Some(raw));
state.update(&p, Event::OperationIntent(true)).unwrap();
assert_eq!(
state
.draft
.as_ref()
.unwrap()
.operation
.as_ref()
.unwrap()
.allocation,
Some(id(&p, 30))
);
let key = crate::data_values::FieldPathKey {
path: vec![],
field: id(&p, 20),
};
state
.update(&p, Event::Value(key.clone(), " rejected raw && ".into()))
.unwrap();
state.update(&p, Event::ClearOperation).unwrap();
assert!(state.draft.as_ref().unwrap().signature(&p).is_err());
let raw = state.recovery_snapshot().unwrap().unwrap();
let mut restored = State::from_recovery(raw.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone()));
assert!(restored.prepare_pending(&p).is_err());
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw));
restored
.update(&p, Event::Operation(choice(&p, 31)))
.unwrap();
assert_eq!(
restored.draft.as_ref().unwrap().values[&key],
" rejected raw && "
);
assert!(restored.prepare_pending(&p).is_err());
restored
.update(&p, Event::Value(key, String::new()))
.unwrap();
assert!(restored.prepare_pending(&p).is_ok());
}
#[test]
fn operation_gui_async_picker_uses_request_root_and_nested_directions_without_erasing_rejected_values()
{
let mut p = fixture();
let allocation = id(&p, 30);
if let RelationBody::InterfaceItemAllocation { transmission, .. } =
&mut p.relations.get_mut(&allocation).unwrap().body
{
*transmission = TransmissionProtocol::Asynchronous;
}
let record = p
.add_element(
Kind::DataType,
Layer::Sa,
"Operation parameter record",
None,
)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Nested value", Some(record))
.unwrap();
let item = id(&p, 20);
let mut fields = vec![];
for (name, direction) in [
("Request input", ParameterDirection::In),
("Response output", ParameterDirection::Out),
("Return result", ParameterDirection::Return),
] {
let field = p
.add_element(Kind::DataField, Layer::Sa, name, Some(item))
.unwrap();
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(record),
direction,
minimum: 0,
maximum: Some(1),
unit: String::new(),
storage: FieldStorage::Value,
default: None,
};
fields.push(field);
}
validate_candidate(&p).unwrap();
let mut state = prepare(&p);
state
.update(&p, Event::Style(MessageStyle::Asynchronous))
.unwrap();
state.update(&p, Event::Operation(choice(&p, 30))).unwrap();
let signature = state
.draft
.as_ref()
.unwrap()
.signature(&p)
.unwrap()
.unwrap();
let choices = state
.draft
.as_ref()
.unwrap()
.browsed_values(&p, &signature)
.unwrap();
assert_eq!(
choices.iter().map(|c| c.key.field).collect::<Vec<_>>(),
vec![fields[0]]
);
state
.update(
&p,
Event::ValueBrowse(Some(crate::data_values::FieldPathKey {
path: vec![],
field: fields[0],
})),
)
.unwrap();
let choices = state
.draft
.as_ref()
.unwrap()
.browsed_values(&p, &signature)
.unwrap();
assert_eq!(choices.len(), 1);
assert_eq!(
choices[0].key,
crate::data_values::FieldPathKey {
path: vec![fields[0]],
field: leaf
}
);
let rejected = crate::data_values::FieldPathKey {
path: vec![fields[1]],
field: leaf,
};
state
.update(
&p,
Event::Value(rejected.clone(), " retained output expression ".into()),
)
.unwrap();
assert!(state.prepare_pending(&p).is_err());
let raw = state.recovery_snapshot().unwrap().unwrap();
let mut restored = State::from_recovery(raw.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw));
assert_eq!(
restored.draft.as_ref().unwrap().values[&rejected],
" retained output expression "
);
restored.update(&p, Event::OperationIntent(false)).unwrap();
restored.update(&p, Event::ValueBrowse(None)).unwrap();
let d = restored.draft.as_ref().unwrap();
let ordinary = d.signature(&p).unwrap().unwrap();
assert!(
d.browsed_values(&p, &ordinary)
.unwrap()
.iter()
.any(|c| c.key.field == fields[1]),
"Ordinary asynchronous channel behavior is preserved"
);
assert_eq!(d.values[&rejected], " retained output expression ");
}
+518 -163
View File
@@ -3,6 +3,7 @@
use super::*;
use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
pub(crate) mod contextual;
mod direct;
mod view;
use direct::Input;
@@ -20,6 +21,7 @@ pub enum Tool {
Control,
Connect,
Branch,
Contextual,
}
impl Tool {
fn title(self) -> &'static str {
@@ -30,6 +32,7 @@ impl Tool {
Self::Control => "Add control",
Self::Connect => "Connect contributions",
Self::Branch => "Insert two paths",
Self::Contextual => "Connect qualified contributions",
}
}
}
@@ -50,6 +53,13 @@ pub enum Event {
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);
@@ -70,11 +80,15 @@ pub struct State {
tool: CanvasTool,
expanded: BTreeSet<Id>,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<(Id, Id)>,
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)]
@@ -99,6 +113,8 @@ struct Draft {
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> {
@@ -140,6 +156,9 @@ impl State {
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
@@ -178,7 +197,17 @@ impl State {
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),
@@ -200,6 +229,11 @@ impl State {
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);
}
}
_ => {}
}
}
@@ -212,11 +246,31 @@ impl State {
if !self.fit {
return self.zoom.unwrap_or(1.);
}
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
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 {
@@ -461,6 +515,12 @@ impl Draft {
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());
@@ -638,6 +698,7 @@ impl Draft {
lifeline: None,
},
Tool::Exchange => OccurrenceBody::Exchange {
operation: None,
exchange: target,
sender: None,
receiver: None,
@@ -744,10 +805,27 @@ struct GraphNode {
detail: String,
control: Option<ControlKind>,
source: Option<Id>,
preview: Vec<(Id, String)>,
preview_links: Vec<(Id, 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,
@@ -755,7 +833,30 @@ struct GraphEdge {
guard: String,
points: Vec<Point>,
label: Rectangle,
incidence: bool,
layer: EdgeLayer,
}
#[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)>,
@@ -771,6 +872,16 @@ impl<'a> Graph<'a> {
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 ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
@@ -864,26 +975,51 @@ impl<'a> Graph<'a> {
if let Some(source) = sequence {
let contributions = contributions(p, *source);
node.preview_total = contributions.len();
node.preview = contributions
.into_iter()
.take(if expanded.contains(&e.id) {
usize::MAX
} else {
4
})
.map(|c| (c.id, p.named(c.id).to_owned()))
.collect();
node.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).collect();
node.preview.iter().map(|(id, _)| id.clone()).collect();
node.preview_links = sequence_links(p, *source)
.into_iter()
.filter(|r| {
preview_ids.contains(&r.source())
&& preview_ids.contains(&r.target())
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(),
},
)
})
.map(|r| (r.source(), r.target()))
.collect();
}
}
@@ -944,11 +1080,83 @@ impl<'a> Graph<'a> {
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 {
@@ -1053,155 +1261,196 @@ impl canvas::Program<Message> for Graph<'_> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.scale(self.scale);
for e in &self.edges {
let selected =
e.id == self.selected || e.source == self.selected || e.target == self.selected;
draw_arrow(
&mut f,
&e.points,
if selected { style::TEAL } else { style::MUTED },
if selected { 2.5 } else { 1.5 },
);
}
for n in &self.nodes {
let rect = Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into());
f.fill(&rect, Color::from_rgb8(243, 248, 248));
f.stroke(
&rect,
Stroke::default()
.with_color(if n.id == self.selected {
style::TEAL
} else {
style::LINE
})
.with_width(if n.id == self.selected { 2.5 } else { 1. }),
);
let text_x = n.rect.x + if n.control.is_some() { 68. } else { 12. };
if let Some(kind) = n.control {
let center = Point::new(n.rect.x + 33., n.rect.y + 34.);
let glyph = match kind {
ControlKind::Choice | ControlKind::Merge => Path::new(|p| {
p.move_to(Point::new(center.x, center.y - 22.));
p.line_to(Point::new(center.x + 23., center.y));
p.line_to(Point::new(center.x, center.y + 22.));
p.line_to(Point::new(center.x - 23., center.y));
p.close();
}),
ControlKind::Fork | ControlKind::Join => Path::rectangle(
Point::new(center.x - 23., center.y - 5.),
Size::new(46., 10.),
),
ControlKind::Loop => Path::circle(center, 23.),
};
f.stroke(
&glyph,
Stroke::default().with_color(style::TEAL).with_width(2.),
);
if matches!(kind, ControlKind::Fork | ControlKind::Join) {
f.fill(&glyph, style::TEAL);
} else {
draw_text(
&mut f,
if kind == ControlKind::Loop {
"IT"
for pass in PAINT_PASSES {
match pass {
PaintPass::DirectEdges | PaintPass::ContextualEdges => {
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 {
"OR"
draw_arrow(
&mut f,
&e.points,
if selected { style::TEAL } else { style::MUTED },
if selected { 2.5 } else { 1.5 },
);
}
.into(),
Point::new(center.x - 10., center.y - 8.),
12.,
style::TEAL,
);
}
}
}
draw_text(
&mut f,
wrapped(&n.title, if n.control.is_some() { 25 } else { 34 }, 2),
Point::new(text_x, n.rect.y + 12.),
13.,
style::INK,
);
draw_text(
&mut f,
wrapped(&n.detail, 37, 2),
Point::new(n.rect.x + 12., n.rect.y + 60.),
11.,
style::MUTED,
);
if n.source.is_some() {
draw_text(
&mut f,
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
Point::new(n.rect.x + 12., n.rect.y + 94.),
11.,
style::TEAL,
);
let mini: BTreeMap<_, _> = n
.preview
.iter()
.enumerate()
.map(|(i, (id, _))| {
(
*id,
Rectangle::new(
Point::new(
n.rect.x + 12. + (i % 2) as f32 * 125.,
n.rect.y + 118. + (i / 2) as f32 * 44.,
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.),
),
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.,
);
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,
);
}
}
}
}
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,
);
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,
);
}
}
}
if n.preview.is_empty() {
draw_text(
&mut f,
"Source has no contributions yet".into(),
Point::new(n.rect.x + 12., n.rect.y + 136.),
11.,
style::MUTED,
);
}
}
}
for e in &self.edges {
if !e.guard.is_empty() {
f.fill_rectangle(e.label.position(), e.label.size(), Color::WHITE);
draw_text(
&mut f,
wrapped(&format!("[{}]", e.guard), 19, 2),
e.label.position(),
11.,
style::INK,
);
}
}
self.overlay(state, &mut f, cursor, bounds);
@@ -1296,6 +1545,7 @@ mod tests {
guard: "request accepted".into(),
alternate_guard: "else".into(),
control: ControlKind::Choice,
contextual: None,
}
}
fn legacy_fixture() -> (Project, Id) {
@@ -1339,6 +1589,7 @@ mod tests {
owner_id,
&format!("Ordered contribution {index}"),
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchanges[index % exchanges.len()]),
sender: None,
receiver: None,
@@ -1565,7 +1816,13 @@ mod tests {
.iter()
.find(|n| n.source == Some(source_id))
.unwrap();
assert_eq!(reference.preview[0].0, inside_id);
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)
@@ -1687,4 +1944,102 @@ mod tests {
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)
);
}
}
}
@@ -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(&parallel_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(&parallel, first_edge, &source, &target);
assert_clear(&parallel, 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(&parallel, a, &source, &target);
assert_clear(&parallel, 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));
}
}
}
+403 -243
View File
@@ -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