Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
@@ -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 ");
|
||||
}
|
||||
@@ -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(¶llel_project, owner, second);
|
||||
let first_edge = parallel.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let second_edge = parallel
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == second)
|
||||
.unwrap();
|
||||
assert_ne!(first_edge.points, second_edge.points);
|
||||
assert_ne!(first_edge.label, second_edge.label);
|
||||
assert_clear(¶llel, first_edge, &source, &target);
|
||||
assert_clear(¶llel, second_edge, &source, &target);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_shared_anchor_preserves_selected_direct_reconnect_at_fit_and_full_scale() {
|
||||
for prefix in ["oa", "sa"] {
|
||||
let p = baseline();
|
||||
let owner = id(&p, &format!("{prefix}_caller"));
|
||||
let x = id(&p, &format!("{prefix}_x"));
|
||||
let r1 = id(&p, &format!("{prefix}_r1"));
|
||||
let r2 = id(&p, &format!("{prefix}_r2"));
|
||||
let (p, contextual) = add(
|
||||
&p,
|
||||
owner,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: x,
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![r1],
|
||||
occurrence: id(&p, &format!("{prefix}_b")),
|
||||
},
|
||||
);
|
||||
let command = link(&p, x, r1, "Direct caller order");
|
||||
let Command::CreateRelation(relation) = &command else {
|
||||
unreachable!()
|
||||
};
|
||||
let direct = relation.id;
|
||||
let p = execute_batch(&p, &[command]).unwrap();
|
||||
let context = Context {
|
||||
document: p.id,
|
||||
owner,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
};
|
||||
for scale in [0.45, 1.] {
|
||||
let mut graph = Graph::new(&p, owner, direct);
|
||||
graph.interaction = Some((context, CanvasTool::Select, true));
|
||||
graph.scale = scale;
|
||||
let source = graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == direct)
|
||||
.unwrap()
|
||||
.points[0];
|
||||
assert_eq!(
|
||||
source,
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == contextual)
|
||||
.unwrap()
|
||||
.points[0]
|
||||
);
|
||||
let destination = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == r2)
|
||||
.unwrap()
|
||||
.rect
|
||||
.center();
|
||||
let screen = |point: Point| Point::new(point.x * scale, point.y * scale);
|
||||
let bounds = Rectangle::new(
|
||||
Point::ORIGIN,
|
||||
Size::new(graph.size.width * scale, graph.size.height * scale),
|
||||
);
|
||||
let mut input = Input::default();
|
||||
let press = graph
|
||||
.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(source)),
|
||||
)
|
||||
.expect("The selected endpoint captures its press")
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(
|
||||
press.is_none(),
|
||||
"A shared contextual anchor must not steal the selected direct handle"
|
||||
);
|
||||
let _ = graph.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: screen(destination),
|
||||
}),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(destination)),
|
||||
);
|
||||
let release = graph
|
||||
.input(
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(screen(destination)),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0);
|
||||
assert!(
|
||||
matches!(release, Some(Message::TopologyCanvas(CanvasEvent::Reconnect {
|
||||
context: actual_context, link, end: direct::End::Source, target,
|
||||
})) if actual_context == context && link == direct && target == r2)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contextual_route_nested_visible_hidden_and_parallel_paths_keep_exact_addresses_and_legacy_routes()
|
||||
{
|
||||
let p = baseline();
|
||||
let owner = id(&p, "oa_caller");
|
||||
let r3 = id(&p, "oa_r3");
|
||||
let inner = id(&p, "oa_inner");
|
||||
let source = OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id(&p, "oa_x"),
|
||||
};
|
||||
let target = OccurrenceEndpoint {
|
||||
references: vec![r3, inner],
|
||||
occurrence: id(&p, "oa_b"),
|
||||
};
|
||||
let (q, link) = add(&p, owner, source.clone(), target.clone());
|
||||
let expanded = BTreeSet::from([r3]);
|
||||
let paths = BTreeSet::from([OccurrenceEndpoint {
|
||||
references: vec![r3],
|
||||
occurrence: inner,
|
||||
}]);
|
||||
let graph = Graph::expanded_qualified(&q, owner, link, &expanded, &paths);
|
||||
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
assert_clear(&graph, edge, &source, &target);
|
||||
let host = graph.nodes.iter().find(|node| node.id == r3).unwrap();
|
||||
let mini = host.endpoint_rect(&target);
|
||||
assert_ne!(mini, host.rect);
|
||||
assert_eq!(
|
||||
edge.points.last(),
|
||||
Some(&Point::new(mini.x, mini.center_y()))
|
||||
);
|
||||
let folded = Graph::new(&q, owner, link);
|
||||
let folded_edge = folded.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let folded_host = folded.nodes.iter().find(|node| node.id == r3).unwrap();
|
||||
assert_eq!(
|
||||
folded_edge.points.last(),
|
||||
Some(&Point::new(folded_host.rect.x, folded_host.rect.center_y()))
|
||||
);
|
||||
assert_clear(&folded, folded_edge, &source, &target);
|
||||
let (q2, other) = add(&q, owner, source.clone(), target.clone());
|
||||
let parallel = Graph::expanded_qualified(&q2, owner, link, &expanded, &paths);
|
||||
let a = parallel.edges.iter().find(|edge| edge.id == link).unwrap();
|
||||
let b = parallel.edges.iter().find(|edge| edge.id == other).unwrap();
|
||||
assert_ne!(a.points, b.points);
|
||||
assert_ne!(a.label, b.label);
|
||||
assert_clear(¶llel, a, &source, &target);
|
||||
assert_clear(¶llel, b, &source, &target);
|
||||
let procedure = id(&p, "oa_procedure");
|
||||
let old = Graph::new(&p, procedure, procedure);
|
||||
let new = Graph::new(&q2, procedure, procedure);
|
||||
assert_eq!(old.size, new.size);
|
||||
assert_eq!(
|
||||
old.edges
|
||||
.iter()
|
||||
.map(|e| (&e.points, e.label, e.layer))
|
||||
.collect::<Vec<_>>(),
|
||||
new.edges
|
||||
.iter()
|
||||
.map(|e| (&e.points, e.label, e.layer))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(new.edges.iter().all(|edge| edge.layer == EdgeLayer::Direct));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
SelectQualified(Context, OccurrenceEndpoint),
|
||||
ExpandQualified(Context, OccurrenceEndpoint),
|
||||
OpenQualified(Context, OccurrenceEndpoint),
|
||||
AppendExchange(Context),
|
||||
Begin {
|
||||
context: Context,
|
||||
@@ -57,6 +60,7 @@ pub enum CanvasEvent {
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Focus(Context),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
@@ -65,11 +69,15 @@ impl CanvasEvent {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::SelectQualified(c, ..)
|
||||
| Self::ExpandQualified(c, ..)
|
||||
| Self::OpenQualified(c, ..)
|
||||
| Self::Expand(c, ..)
|
||||
| Self::Open(c, ..)
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Focus(c)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
@@ -79,6 +87,53 @@ impl CanvasEvent {
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
|
||||
self.selected_context = Some(address);
|
||||
}
|
||||
pub(crate) fn clear_context(&mut self) {
|
||||
self.selected_context = None;
|
||||
}
|
||||
pub(super) fn qualified_selection(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
) -> Option<&oa_core::topology_context::ContextAddress> {
|
||||
self.selected_context.as_ref().filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
})
|
||||
}
|
||||
pub(crate) fn expand_context(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
endpoint: OccurrenceEndpoint,
|
||||
) -> bool {
|
||||
if oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &endpoint)
|
||||
.is_err()
|
||||
|| !matches!(
|
||||
p.elements
|
||||
.get(&endpoint.occurrence)
|
||||
.and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = endpoint.references.first() {
|
||||
self.expanded.insert(*root);
|
||||
}
|
||||
if !self.contextual_expanded.remove(&endpoint) {
|
||||
self.contextual_expanded.insert(endpoint);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
@@ -90,6 +145,7 @@ impl State {
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
@@ -133,11 +189,18 @@ impl State {
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to.push((owner, occurrence));
|
||||
self.return_to
|
||||
.push((owner, occurrence, self.selected_context.clone()));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence)) = self.return_to.pop() {
|
||||
while let Some((owner, occurrence, address)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
self.selected_context = address.filter(|address| {
|
||||
address.owner == owner
|
||||
&& oa_core::topology_context::TopologyIndex::new(p)
|
||||
.resolve(owner, &address.endpoint)
|
||||
.is_ok()
|
||||
});
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
@@ -145,6 +208,11 @@ impl State {
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
return Some(r.id);
|
||||
}
|
||||
let Command::Batch(commands) = command else {
|
||||
return None;
|
||||
};
|
||||
@@ -355,6 +423,20 @@ impl Graph<'_> {
|
||||
.find(|n| n.rect.contains(point))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
fn qualified_at(&self, point: Point) -> Option<OccurrenceEndpoint> {
|
||||
self.nodes.iter().find_map(|node| {
|
||||
node.preview.iter().enumerate().find_map(|(i, (id, _))| {
|
||||
let rect = Rectangle::new(
|
||||
Point::new(
|
||||
node.rect.x + 12. + (i % 2) as f32 * 125.,
|
||||
node.rect.y + 118. + (i / 2) as f32 * 44.,
|
||||
),
|
||||
Size::new(113., 32.),
|
||||
);
|
||||
rect.contains(point).then(|| id.clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
fn edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
@@ -366,6 +448,19 @@ impl Graph<'_> {
|
||||
})
|
||||
.map(|e| e.id)
|
||||
}
|
||||
fn contextual_edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|edge| edge.layer == EdgeLayer::Contextual)
|
||||
.find(|edge| {
|
||||
edge.label.contains(point)
|
||||
|| edge.points.windows(2).any(|segment| {
|
||||
crate::diagram::distance_to_segment(point, segment[0], segment[1])
|
||||
< 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
}
|
||||
fn handle(&self, id: Id) -> Option<Rectangle> {
|
||||
let n = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(Rectangle::new(
|
||||
@@ -374,6 +469,9 @@ impl Graph<'_> {
|
||||
))
|
||||
}
|
||||
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
||||
if self.project.relations.get(&self.selected)?.kind() != RelationKind::SequenceLink {
|
||||
return None;
|
||||
}
|
||||
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
||||
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
||||
.into_iter()
|
||||
@@ -497,13 +595,17 @@ impl Graph<'_> {
|
||||
});
|
||||
}
|
||||
CanvasTool::Select => {
|
||||
if let Some((link, end)) = self.endpoint_at(point) {
|
||||
if let Some(endpoint) = self.qualified_at(point) {
|
||||
return publish(CanvasEvent::SelectQualified(context, endpoint));
|
||||
} else if let Some((link, end)) = self.endpoint_at(point) {
|
||||
state.press = Some(Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else if let Some(link) = self.contextual_edge_at(point) {
|
||||
return publish(CanvasEvent::Select(context, link));
|
||||
} else if self
|
||||
.handle(self.selected)
|
||||
.is_some_and(|r| r.contains(point))
|
||||
@@ -728,13 +830,17 @@ impl State {
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph = Graph::expanded(p, owner, id, &self.expanded);
|
||||
let graph =
|
||||
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
let rect = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.map(|n| n.rect)
|
||||
.map(|n| {
|
||||
self.qualified_selection(p, owner)
|
||||
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
|
||||
})
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
|
||||
@@ -896,6 +896,7 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha
|
||||
f.owner,
|
||||
"Ordered contribution",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -136,3 +136,246 @@ fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible
|
||||
assert_eq!(closed.scrolls[0].1.height, size.height);
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_workspace(
|
||||
pending: bool,
|
||||
details: bool,
|
||||
header_height: f32,
|
||||
) -> iced::Element<'static, Message, iced::Theme, ()> {
|
||||
let canvas = container(
|
||||
scrollable(iced::widget::Space::new().width(1800.).height(2400.))
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let graph = explicit_pane(
|
||||
iced::widget::Space::new().height(header_height).into(),
|
||||
iced::widget::Space::new().height(1800.).into(),
|
||||
canvas.into(),
|
||||
details,
|
||||
pending,
|
||||
);
|
||||
let form = if pending {
|
||||
editor_panel(
|
||||
container(iced::widget::Space::new().height(32))
|
||||
.id("test-process-actions")
|
||||
.into(),
|
||||
iced::widget::Space::new().height(2000).into(),
|
||||
)
|
||||
} else {
|
||||
iced::widget::Space::new().height(0).into()
|
||||
};
|
||||
panes(graph, form, pending)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_layout_measures_remaining_canvas_with_details_forms_and_window_resize() {
|
||||
// Minimum/default panes both with side panels and focused; tall content
|
||||
// deliberately cannot influence the enclosing scrollable allocations.
|
||||
for size in [
|
||||
Size::new(500., 570.),
|
||||
Size::new(1050., 570.),
|
||||
Size::new(840., 770.),
|
||||
Size::new(1394., 802.),
|
||||
] {
|
||||
for pending in [false, true] {
|
||||
for details in [false, true] {
|
||||
for header_height in [68., 96.] {
|
||||
let mut view = explicit_workspace(pending, details, header_height);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(node.size(), size);
|
||||
let canvas = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.unwrap()
|
||||
.1;
|
||||
let pane_bottom = if pending {
|
||||
size.height / 2.
|
||||
} else {
|
||||
size.height
|
||||
};
|
||||
assert!(canvas.x >= 0. && canvas.y >= 0.);
|
||||
assert!(
|
||||
canvas.x + canvas.width <= size.width
|
||||
&& canvas.y + canvas.height <= pane_bottom,
|
||||
"Canvas must be visible, not merely allocated outside a parent clip: {size:?} {canvas:?}"
|
||||
);
|
||||
assert!(canvas.height >= if pending { 75. } else { 300. });
|
||||
let mut state = State::default();
|
||||
state.viewport(canvas.size());
|
||||
state.zoom(None);
|
||||
let graph = Size::new(900., 1400.);
|
||||
let scale = state.scale(graph, 1.);
|
||||
assert!(
|
||||
graph.width * scale <= canvas.width
|
||||
&& graph.height * scale <= canvas.height
|
||||
);
|
||||
assert!(!state.fit_limited(graph, 1.));
|
||||
if pending {
|
||||
let form = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-form")))
|
||||
.unwrap()
|
||||
.1;
|
||||
let actions = actual.actions.unwrap();
|
||||
assert!(form.height >= 200. && form.y + form.height <= size.height);
|
||||
assert!(actions.y >= pane_bottom && actions.y + actions.height <= form.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_layout_disclosures_and_pending_editor_keep_real_manual_canvas_scroll() {
|
||||
let mut view = explicit_workspace(false, false, 68.);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.));
|
||||
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("process-canvas"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: Some(90.),
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||
let (_, before) = geometry(&mut view, &mut tree, Size::new(500., 570.));
|
||||
let initial: Vec<_> = before
|
||||
.scrolls
|
||||
.iter()
|
||||
.filter(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.collect();
|
||||
assert_eq!(initial.len(), 1, "The exact canvas ID must be unique");
|
||||
assert_eq!(
|
||||
initial[0].2,
|
||||
Vector::new(90., 180.),
|
||||
"Establish the actual canvas scroll before any tree diff"
|
||||
);
|
||||
for (pending, details, height, size) in [
|
||||
(false, true, 96., Size::new(500., 570.)),
|
||||
(true, true, 68., Size::new(1050., 570.)),
|
||||
(true, false, 68., Size::new(1394., 802.)),
|
||||
(false, false, 96., Size::new(500., 570.)),
|
||||
] {
|
||||
view = explicit_workspace(pending, details, height);
|
||||
tree.diff(view.as_widget());
|
||||
let (node, actual) = geometry(&mut view, &mut tree, size);
|
||||
let canvas = actual
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-canvas")))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
canvas.2,
|
||||
Vector::new(90., 180.),
|
||||
"Shelf/header/form/focus resize must retain the same scroll state"
|
||||
);
|
||||
if pending {
|
||||
let actions = actual.actions;
|
||||
let mut form_scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("process-form"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(600.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut form_scroll);
|
||||
let (_, scrolled) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(scrolled.actions, actions);
|
||||
assert!(
|
||||
scrolled
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.)
|
||||
);
|
||||
assert!(
|
||||
scrolled
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-canvas"))
|
||||
&& s.2 == Vector::new(90., 180.))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_fit_whole_app_focus_and_disclosures_retain_canvas_tree_with_pending_raw_form() {
|
||||
use iced::advanced::widget::tree;
|
||||
use std::rc::Rc;
|
||||
fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option<Vec<usize>> {
|
||||
if tree.tag == tag {
|
||||
return Some(vec![]);
|
||||
}
|
||||
tree.children.iter().enumerate().find_map(|(i, child)| {
|
||||
let mut path = path_to(child, tag)?;
|
||||
path.insert(0, i);
|
||||
Some(path)
|
||||
})
|
||||
}
|
||||
fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree {
|
||||
for &i in path {
|
||||
tree = &mut tree.children[i]
|
||||
}
|
||||
tree
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut app = App::with_project(f.p);
|
||||
let _ = app.update(Message::Select(f.nodes[1]));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1100., 700.);
|
||||
let graph: Element<'_, Message> =
|
||||
canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into();
|
||||
let tag = widget::Tree::new(graph.as_widget()).tag;
|
||||
drop(graph);
|
||||
let mut tree = widget::Tree::new(crate::views::view(&app).as_widget());
|
||||
let mut path = path_to(&tree, tag).unwrap();
|
||||
path.pop();
|
||||
let marker = Rc::new(());
|
||||
at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker));
|
||||
let _ = app.update(event(Event::Start(f.owner, Tool::Contextual, None)));
|
||||
let _ = app.update(event(Event::Name(" preserved raw 雨 ".into())));
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
for (shelf, expected) in [
|
||||
(Shelf::Tools, Some(Shelf::Tools)),
|
||||
(Shelf::Selection, Some(Shelf::Selection)),
|
||||
(Shelf::Selection, None),
|
||||
] {
|
||||
let focused = app.behavior.topology.focused;
|
||||
let _ = app.update(event(Event::Shelf(shelf)));
|
||||
assert_eq!(app.behavior.topology.shelf, expected);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert_eq!(app.behavior.topology.focused, !focused);
|
||||
tree.diff(crate::views::view(&app).as_widget());
|
||||
let mut current = path_to(&tree, tag).unwrap();
|
||||
current.pop();
|
||||
assert_eq!(
|
||||
current, path,
|
||||
"Main must not shift when explorer/inspector are hidden"
|
||||
);
|
||||
assert_eq!(Rc::strong_count(&marker), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
at(&mut tree, &path).state.downcast_ref::<Rc<()>>(),
|
||||
&marker
|
||||
));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,7 @@ pub(super) fn view<'a>(
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
// Keep the canvas at the same sibling position when its palette becomes a
|
||||
// pending-form notice, so Iced retains the scrollable's local offsets.
|
||||
let mut controls = Column::new().spacing(14);
|
||||
let mut controls = Column::new().spacing(6);
|
||||
if !pending && explicit {
|
||||
controls = controls
|
||||
.push(
|
||||
@@ -162,39 +160,179 @@ pub(super) fn view<'a>(
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
out = out.push(controls);
|
||||
if explicit {
|
||||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
body:
|
||||
RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
},
|
||||
..
|
||||
}) = p.relations.get(&app.selected)
|
||||
&& *caller == owner
|
||||
{
|
||||
navigation=navigation.push(text(format!("From: {}",super::contextual::address_label(p,owner,source))).size(12))
|
||||
.push(text(format!("To: {}",super::contextual::address_label(p,owner,target))).size(12))
|
||||
.push(text(match purpose {ContextualLinkPurpose::Precedence {..}=>"Precedence · hidden contributions attach visually to their folded reference",_=>"Assembly incidence · no execution order is implied"}).size(11));
|
||||
}
|
||||
if let Some(address) = state.qualified_selection(p, owner) {
|
||||
navigation=navigation.push(text(super::contextual::address_label(p,owner,&address.endpoint)).size(12))
|
||||
.push(text("Qualified contribution · definition properties affect every use of the source.").size(11))
|
||||
.push(button("Open contribution definition").on_press(Message::TopologyCanvas(CanvasEvent::OpenQualified(context,address.endpoint.clone()))));
|
||||
if matches!(
|
||||
p.elements
|
||||
.get(&address.endpoint.occurrence)
|
||||
.and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
navigation = navigation.push(
|
||||
button(if state.contextual_expanded.contains(&address.endpoint) {
|
||||
"Fold nested reference"
|
||||
} else {
|
||||
"Expand nested reference"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::ExpandQualified(
|
||||
context,
|
||||
address.endpoint.clone(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !pending {
|
||||
controls = controls.push(button("Qualified link / assembly…").on_press(event(
|
||||
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
||||
)));
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
navigation = navigation.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
navigation = navigation.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
navigation = navigation.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
let diagram = if explicit {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
app.selected,
|
||||
&state.expanded,
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
graph.scroll_offset = state.scroll_offset();
|
||||
// Explicit graphs no longer have a scrolling page ancestor. A retained
|
||||
// offset from an ordered view must not distort their pointer coordinates.
|
||||
graph.scroll_offset = state.canvas_offset();
|
||||
let scale = graph.scale;
|
||||
out = out.push(
|
||||
let mut header = column![
|
||||
row![
|
||||
button("Fit overview")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
out.width(Fill),
|
||||
button(
|
||||
text(if state.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context)))
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
button(text("Tools").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Tools) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Tools))),
|
||||
button(text("Selection details").size(12))
|
||||
.padding(6)
|
||||
.style(if state.shelf == Some(Shelf::Selection) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Shelf(Shelf::Selection))),
|
||||
button(text("Fit overview").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button(text("−").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button(text("+").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button(text("100%").size(12))
|
||||
.padding(6)
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
]
|
||||
.spacing(6);
|
||||
if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11));
|
||||
}
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(label(
|
||||
controls = controls.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
@@ -209,26 +347,44 @@ pub(super) fn view<'a>(
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(if pending {
|
||||
(app.window_size.height - 510.).clamp(160., 240.)
|
||||
} else {
|
||||
(app.window_size.height - 420.).clamp(190., 420.)
|
||||
});
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let canvas = container(
|
||||
iced::widget::sensor(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
);
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
};
|
||||
explicit_pane(
|
||||
header.into(),
|
||||
details,
|
||||
canvas.into(),
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
} else {
|
||||
out = out
|
||||
.push(
|
||||
button(if state.focused {
|
||||
"Restore explorer and properties"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))),
|
||||
)
|
||||
.push(controls)
|
||||
.push(navigation);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
@@ -264,204 +420,180 @@ pub(super) fn view<'a>(
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
out = out.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
out = out.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
)
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
@@ -470,21 +602,49 @@ pub(super) fn view<'a>(
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
),
|
||||
form_panel,
|
||||
pending,
|
||||
)
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
/// A permanent three-slot tree: variable controls never remount the canvas.
|
||||
/// The shelf scrolls independently; the canvas receives actual remaining height.
|
||||
fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
header: iced::Element<'a, Message, iced::Theme, R>,
|
||||
details: iced::Element<'a, Message, iced::Theme, R>,
|
||||
canvas: iced::Element<'a, Message, iced::Theme, R>,
|
||||
show_details: bool,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
// Column::push drops Fixed(0) children. from_vec retains the closed shelf
|
||||
// slot so its disclosure cannot move/remount the following canvas tree.
|
||||
Column::from_vec(vec![
|
||||
container(header).width(Fill).into(),
|
||||
scrollable(details)
|
||||
.id("process-details")
|
||||
.width(Fill)
|
||||
.height(if show_details {
|
||||
if pending { 64. } else { 112. }
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.into(),
|
||||
container(canvas).width(Fill).height(Fill).into(),
|
||||
])
|
||||
.spacing(6)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||
|
||||
Reference in New Issue
Block a user