Merge commit '1a3164b' into codex/data-contract-integration

This commit is contained in:
2026-09-06 05:45:58 +03:00
10 changed files with 3266 additions and 430 deletions
+24 -3
View File
@@ -224,6 +224,7 @@ mod direct_scenario_tests {
#[cfg(test)]
mod behavior_lifecycle_tests;
mod process_canvas;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
@@ -359,6 +360,7 @@ pub enum Message {
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -791,7 +793,11 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let scenario_selection = self.behavior.scenario.created_selection(&command);
let scenario_selection = self
.behavior
.scenario
.created_selection(&command)
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = scenario_selection.or(selected) {
@@ -956,6 +962,10 @@ impl App {
if matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
| Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::Scrolled(..)
| crate::behavior::topology::CanvasEvent::Viewport(..)
)
) {
return self.handle(message);
}
@@ -1116,6 +1126,11 @@ impl App {
let entering_catalog =
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
let mut task = self.handle(message);
if let Some(context) = self.topology_context() {
self.behavior
.topology
.enter(context.document, context.owner);
}
if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context()
|| self.page != Page::Diagram
@@ -1267,6 +1282,7 @@ impl App {
}
}
}
Message::TopologyCanvas(event) => return self.edit_process_canvas(event),
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()
@@ -1854,6 +1870,9 @@ impl App {
Ok(Some(command)) => {
if self.execute(command) {
self.behavior.acknowledge_commit();
if self.topology_context().is_some() {
return self.reveal_process_selection();
}
}
}
Ok(None) => {}
@@ -2581,8 +2600,10 @@ impl App {
}
Message::WindowResized(size) => {
// The canvas can retain its capped size during a real window
// resize. Invalidate any scenario pointer/slot gesture anyway.
if self.window_size != size && self.scenario_context().is_some() {
// resize. Invalidate any sequence pointer/slot gesture anyway.
if self.window_size != size
&& (self.scenario_context().is_some() || self.topology_context().is_some())
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.window_size = size;
@@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
Message::Field("/name".into(), "Unapplied state name".into()),
);
assert!(app.draft_dirty());
let context = behavior::topology::Context {
document: app.canvas_document,
owner: fixture.machine,
revision: app.document.revision(),
epoch: app.canvas_epoch,
};
for event in [
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
behavior::Event::Topology(behavior::topology::Event::Fit),
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
behavior::topology::CanvasEvent::Zoom(context, None),
] {
send(app, Message::Behavior(event));
send(app, Message::TopologyCanvas(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
+591
View File
@@ -0,0 +1,591 @@
use super::*;
use crate::behavior::topology::{self, CanvasEvent};
impl App {
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
let owner = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& matches!(
self.project().elements.get(&owner)?.kind(),
Kind::Process | Kind::FunctionalChain
))
.then_some(topology::Context {
document: self.canvas_document,
owner,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Viewport(_, size) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.viewport(size)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
self.behavior
.topology
.enter(context.document, context.owner);
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.zoom(zoom);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand(self.project(), context.owner, id);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::Open(_, occurrence) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
if let Some(target) =
topology::referenced(self.project(), context.owner, occurrence)
{
if self
.project()
.elements
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(target);
}
}
CanvasEvent::Back(_) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.topology);
let back = state.back(self.project());
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
self.select(occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the process.".into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
tool,
control,
insertion,
..
} => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.begin(self.project(), context, tool, control, insertion);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status="Choose the contextual contribution and apply at the captured position.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::AppendExchange(_) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.append_exchange(self.project(), context.owner);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Connect { source, target, .. } => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.connect(self.project(), context.owner, source, target);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Describe the path condition, then Create.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reconnect {
link, end, target, ..
} => {
match topology::reconnect(self.project(), context.owner, link, end, target)
{
Ok(command) => {
if self.execute(command) {
self.selected = link;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reorder {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
_ => unreachable!(),
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::behavior::{
self,
topology::{
CanvasTool, Event, Insertion, Tool,
direct_tests::{self, Fixture},
},
};
fn setup() -> (App, Fixture) {
let f = direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.topology_context().unwrap().owner, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
}
fn begin(app: &App) -> CanvasEvent {
CanvasEvent::Begin {
context: app.topology_context().unwrap(),
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
}
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let empty = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Exchange,
control: ControlKind::Choice,
insertion: Insertion::default(),
}));
assert!(app.behavior.has_pending());
form(
&mut app,
Event::Target(Choice {
id: f.exchange,
label: "Assessment".into(),
}),
);
form(&mut app, Event::Name("First contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let first_id = app.selected;
let first = app.document.snapshot();
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id]
);
assert_eq!(app.project().relations, empty.decoded.project.relations);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
context,
)));
assert!(
app.behavior.has_pending(),
"reachable single-exchange continuation: {}",
app.status
);
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
form(
&mut app,
Event::Target(Choice {
id: next,
label: "Response".into(),
}),
);
form(&mut app, Event::Name("Second contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let second_id = app.selected;
let second = app.document.snapshot();
assert_ne!(first_id, second_id);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id, second_id]
);
let links = app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::SequenceLink)
.collect::<Vec<_>>();
assert_eq!(links.len(), 1);
assert_eq!(
(links[0].source(), links[0].target()),
(first_id, second_id)
);
assert_eq!(
app.project().elements.len(),
empty.decoded.project.elements.len() + 2
);
for (id, relation) in &empty.decoded.project.relations {
assert_eq!(app.project().relations[id], *relation);
}
for work in f.work {
assert_eq!(
app.project().elements[&work],
empty.decoded.project.elements[&work]
);
}
let reopened = Document::from_bundle(
second.bundle.clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), &second.bundle);
for expected in [&first, &empty] {
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(
!app.document.can_undo(),
"each Create must add exactly one history entry"
);
for expected in [&first, &second] {
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(!app.document.can_redo());
}
}
#[test]
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
let (mut app, _) = setup();
let snapshot = app.document.snapshot();
for field in 0..4 {
let mut event = begin(&app);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match field {
0 => context.document = Id::new_v4(),
1 => context.owner = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
};
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
let context = app.topology_context().unwrap();
for event in [
CanvasEvent::Zoom(context, Some(0.7)),
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
] {
let _ = app.update(Message::TopologyCanvas(event));
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[test]
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 3000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Target(Choice {
id: f.work[0],
label: "Assess".into(),
}),
);
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_ne!(app.selected, f.owner);
assert_eq!(app.project().named(app.selected), "Recovered contribution");
let created = app.selected;
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert!(app.project().contains(created));
}
#[test]
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Connect,
)));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[0],
)));
assert_eq!(app.topology_context(), Some(context));
let other = app
.project()
.make_element(Kind::Process, Layer::Oa, "Other", None)
.unwrap();
let other_id = other.id;
assert!(app.execute(Command::CreateElement(other)));
let _ = app.update(Message::BehaviorOwner(Choice {
id: other_id,
label: "Other".into(),
}));
assert_eq!(app.topology_context().unwrap().owner, other_id);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
context,
source: f.nodes[0],
target: f.nodes[2],
}));
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
let mut stale = context;
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
document: context.document,
revision: context.revision,
snapshot: original.clone(),
path: root.join(&original.bundle.entry),
disk: Some(source_files::DiskProject {
root,
bundle: original.bundle.clone(),
expectations: Default::default(),
reachable: original.bundle.files.keys().cloned().collect(),
diagnostics: vec![],
}),
};
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.topology_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.topology_context(), Some(context));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(&mut app, Event::Name("Keep this form".into()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 80.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
let (mut app, f) = setup();
let source = app
.project()
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
.unwrap();
let source_id = source.id;
assert!(app.execute(Command::CreateElement(source)));
let mut occurrence = app
.project()
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
.unwrap();
let occurrence_id = occurrence.id;
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
*body = OccurrenceBody::SequenceReference {
sequence: Some(source_id),
};
}
assert!(app.execute(Command::CreateElement(occurrence)));
let snapshot = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
context,
occurrence_id,
)));
assert_eq!(app.selected, source_id);
assert_eq!(app.topology_context().unwrap().owner, source_id);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(app.selected, occurrence_id);
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[cfg(test)]
#[test]
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
let f = topology::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let original = app.document.snapshot();
let edited = format!(
"{}\n// Retained source draft\n",
app.source_workspace.text()
);
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(edited.clone().into()),
)));
assert!(app.source_dirty());
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
assert!(!app.behavior.has_pending());
assert!(app.source_dirty());
assert_eq!(app.source_workspace.text(), edited);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
}
+46 -8
View File
@@ -38,14 +38,33 @@ mod scenario_scroll {
content: content.into(),
context,
area,
process: false,
})
}
pub(super) fn observe_topology<'a>(
context: topology::Context,
area: scenario::ScrollArea,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
Element::new(Observer {
content: content.into(),
context: scenario::Context {
document: context.document,
scenario: context.owner,
revision: context.revision,
epoch: context.epoch,
},
area,
process: true,
})
}
struct Observer<'a, R = Renderer> {
content: Element<'a, Message, Theme, R>,
context: scenario::Context,
area: scenario::ScrollArea,
process: bool,
}
type Report = Option<(Id, Id, Vector)>;
type Report = Option<(bool, Id, Id, Vector)>;
#[derive(Default)]
struct Translation(Option<Vector>);
impl widget::Operation for Translation {
@@ -77,15 +96,33 @@ mod scenario_scroll {
&mut offset,
);
if let Some(offset) = offset.0 {
let next = Some((self.context.document, self.context.scenario, offset));
let next = Some((
self.process,
self.context.document,
self.context.scenario,
offset,
));
let previous = tree.state.downcast_mut::<Report>();
if *previous != next {
*previous = next;
shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
self.context,
self.area,
offset,
)));
shell.publish(if self.process {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
topology::Context {
document: self.context.document,
owner: self.context.scenario,
revision: self.context.revision,
epoch: self.context.epoch,
},
self.area,
offset,
))
} else {
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
self.context,
self.area,
offset,
))
});
}
}
}
@@ -220,6 +257,7 @@ mod scenario_scroll {
Observer {
context,
area: scenario::ScrollArea::Canvas,
process: false,
content: scrollable(iced::widget::Space::new().width(200.).height(height))
.width(200.)
.height(200.)
@@ -342,7 +380,7 @@ impl Event {
pub struct State {
pub active_owner: Option<Id>,
pub diagram: state_view::State,
topology: topology::State,
pub(crate) topology: topology::State,
pub(crate) scenario: scenario::State,
states: states::State,
}
+197 -413
View File
@@ -1,8 +1,16 @@
//! Process/chain authoring and projection use contextual occurrences and explicit
//! SequenceLinks. Presentation order never invents control-flow relationships.
use super::*;
use iced::widget::{Column, column, text_input};
use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
mod direct;
mod view;
use direct::Input;
pub(crate) use direct::{
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
};
#[cfg(test)]
pub(crate) mod direct_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
@@ -40,9 +48,8 @@ pub enum Event {
Guard(String),
AlternateGuard(String),
Control(ControlChoice),
Viewport(Size),
Zoom(f32),
Fit,
Search(usize, String),
Page(usize, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ControlChoice(pub ControlKind);
@@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice {
}
#[derive(Default)]
pub struct State {
identity: Option<(Id, Id)>,
tool: CanvasTool,
expanded: BTreeSet<Id>,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<(Id, Id)>,
draft: Option<Draft>,
viewport: Option<Size>,
zoom: Option<f32>,
fit: bool,
}
#[derive(Default)]
struct Browser {
search: String,
page: usize,
}
struct Draft {
insertion: Insertion,
fixed: bool,
browsers: [Browser; 4],
owner: Id,
tool: Tool,
name: String,
@@ -99,9 +120,6 @@ impl State {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Viewport(size) => self.viewport = Some(size),
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
Event::Fit => self.zoom = None,
Event::Start(owner, tool, selected) => {
let e = p
.elements
@@ -127,6 +145,9 @@ impl State {
}
});
self.draft = Some(Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool,
name: String::new(),
@@ -154,6 +175,11 @@ impl State {
Event::Guard(value) => d.guard = value,
Event::AlternateGuard(value) => d.alternate_guard = value,
Event::Control(value) => d.control = value.0,
Event::Search(index, value) if index < 4 => {
d.browsers[index].search = value;
d.browsers[index].page = 0;
}
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
_ => {}
}
}
@@ -163,12 +189,13 @@ impl State {
}
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
self.zoom.unwrap_or_else(|| {
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.))
.clamp(0.05, 1.)
})
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.))
.clamp(0.05, 1.)
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
@@ -247,13 +274,24 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.name, e.alias),
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
})
.collect()
};
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
fn semantic_path(p: &Project, id: Id) -> String {
let mut path = Vec::new();
let mut current = Some(id);
while let Some(id) = current {
let Some(e) = p.elements.get(&id) else { break };
path.push(e.alias.as_str());
current = e.parent;
}
path.reverse();
path.join(" / ")
}
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
p.elements
.get(&owner)
@@ -394,6 +432,40 @@ impl Draft {
if self.before.is_some() && self.before == self.after {
return Err("Choose different preceding and following contributions.".into());
}
let captured = self
.insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, self.owner, r.source())
&& is_contribution(p, self.owner, r.target())
})
.ok_or("The captured path no longer exists in this process.")
})
.transpose()?;
if captured
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
{
return Err("The captured path changed. Choose its position again.".into());
}
let matches: Vec<_> = sequence_links(p, self.owner)
.into_iter()
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
.collect();
let split_link = if self.tool == Tool::Connect {
None
} else if captured.is_some() {
captured
} else if !self.fixed && matches.len() == 1 {
Some(matches[0])
} else if !self.fixed && matches.len() > 1 {
return Err("Select the specific sequence link to insert on.".into());
} else {
None
};
let mut commands = preserved_order(
p,
self.owner,
@@ -418,7 +490,6 @@ impl Draft {
{
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
}
require_guard(p, source, &self.guard)?;
commands.push(link(p, source, target, &self.guard));
return Ok(unique_batch(p, commands));
}
@@ -459,12 +530,6 @@ impl Draft {
return Err("Choose same-layer work for the second path.".into());
}
let parallel = self.control == ControlKind::Fork;
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
{
return Err(
"Describe both alternative guards; use 'else' for the fallback path.".into(),
);
}
let split = occurrence(
p,
self.owner,
@@ -552,30 +617,47 @@ impl Draft {
commands.push(Command::CreateElement(e));
(id, id)
};
if let Some(before) = self.before {
// Branch guards belong to the split's two outgoing paths. A branch
// inserted after another choice must first use an explicit link.
let guard = if self.tool == Tool::Branch {
""
} else {
&self.guard
};
require_guard(p, before, guard)?;
commands.push(link(p, before, first, guard));
if let Some(existing) = split_link {
let mut updated = existing.clone();
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
*target = first;
}
commands.push(Command::EditRelation(existing.id, updated));
} else if let Some(before) = self.before {
commands.push(link(
p,
before,
first,
if self.tool == Tool::Branch {
""
} else {
&self.guard
},
));
}
if let Some(after) = self.after {
commands.push(link(p, last, after, ""));
}
// Inserting between two selected contributions replaces their direct
// precedence while preserving other links and their semantic identities.
if let (Some(before), Some(after)) = (self.before, self.after) {
commands.extend(
sequence_links(p, self.owner)
.into_iter()
.filter(|r| r.source() == before && r.target() == after)
.map(|r| Command::Delete(r.id)),
);
}
let new_ids: Vec<_> = commands
.iter()
.filter_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
_ => None,
})
.collect();
let mut order = owner.steps().expect("process steps").clone();
let slot = self.insertion.before.or(self.after);
let index = slot
.map(|id| {
order
.iter()
.position(|v| *v == id)
.ok_or("The presentation slot changed.")
})
.transpose()?
.unwrap_or(order.len());
order.splice(index..index, new_ids);
commands.push(Command::SetSteps(self.owner, order));
Ok(unique_batch(p, commands))
}
}
@@ -598,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
}
Command::Batch(commands)
}
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
if guard.trim().is_empty()
&& matches!(
p.elements.get(&source).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Control {
kind: ControlKind::Choice | ControlKind::Loop
},
..
})
)
{
Err("Describe this choice / repeat condition, or enter 'else'.".into())
} else {
Ok(())
}
}
fn event(e: Event) -> Message {
Message::Behavior(if matches!(e, Event::Submit) {
super::Event::Submit
@@ -622,307 +687,9 @@ fn event(e: Event) -> Message {
super::Event::Topology(e)
})
}
fn picker<'a>(
choices: Vec<Choice>,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
pick_list(choices, selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(9)
.text_size(12)
.width(Fill)
.into()
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
view::view(app, owner, out)
}
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
if !p.elements[&owner].description.is_empty() {
out = out.push(
text(&p.elements[&owner].description)
.size(12)
.color(style::MUTED),
);
}
out = out
.push(
row![
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
action(
"+ Exchange",
event(Event::Start(owner, Tool::Exchange, selected))
),
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
]
.spacing(7),
)
.push(
row![
action(
"+ Control",
event(Event::Start(owner, Tool::Control, selected))
),
action(
"Branch / parallel…",
event(Event::Start(owner, Tool::Branch, selected))
),
action(
"Connect…",
event(Event::Start(owner, Tool::Connect, selected))
),
action("Properties", Message::EditProperties(owner)),
]
.spacing(7),
);
if let Some(d) = app
.behavior
.topology
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
let legacy = legacy_order(p, owner);
if legacy.len() > 1 {
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices: Vec<_> = targets(p, owner, d.tool)
.into_iter()
.filter(|choice| compatible_source(p, d, choice.id))
.collect();
if choices.is_empty() {
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
}
form = form.push(picker(
choices,
d.target,
"Choose an existing source…",
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source name)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.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(9)
.text_size(12)
.width(Fill),
);
}
if d.tool == Tool::Branch {
form = form.push(picker(
targets(p, owner, Tool::Work),
d.alternate,
"Work on the second path…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form.push(
row![
text_input("First path condition", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
text_input("Second path condition", &d.alternate_guard)
.on_input(|s| event(Event::AlternateGuard(s)))
.padding(9)
.size(12),
]
.spacing(7),
);
}
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
}
form = form
.push(
row![
picker(
contributions(p, owner),
d.before,
if d.tool == Tool::Connect {
"From contribution…"
} else {
"After contribution (optional)…"
},
Event::Before
),
action("Clear", event(Event::ClearBefore)),
]
.spacing(7),
)
.push(
row![
picker(
contributions(p, owner),
d.after,
if d.tool == Tool::Connect {
"To contribution…"
} else {
"Before contribution (optional)…"
},
Event::After
),
action("Clear", event(Event::ClearAfter)),
]
.spacing(7),
);
if d.tool != Tool::Branch {
form = form.push(
text_input("Sequence guard (for a choice or loop)", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
);
}
if let Err(problem) = d.command(p) {
form = form.push(label(problem));
}
form = form.push(
row![
button("Create")
.padding(9)
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7),
);
out = out.push(container(form).padding(14).style(style::card));
}
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
{
let mut graph = Graph::new(p, owner, app.selected);
if graph.nodes.is_empty() {
out = out.push(container(column![
text("Start this process").size(18),
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
].spacing(12)).padding(24).width(Fill).style(style::card));
return scrollable(out.padding(24)).into();
} else if graph.edges.is_empty() {
out = out.push(label(
"No explicit sequence links. Connect contributions to describe topology.",
));
}
graph.scale = app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.));
let scale = graph.scale;
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
out = out.push(
row![
action("Fit", event(Event::Fit)),
action("−", event(Event::Zoom(scale / 1.25))),
label(format!("{:.0}%", scale * 100.)),
action("+", event(Event::Zoom(scale * 1.25))),
action("100%", event(Event::Zoom(1.))),
]
.spacing(8)
.align_y(iced::Center),
);
(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height)
.into(),
size,
)
} else {
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
let conversion = preserved_order(p, owner, None);
out = out.push(label(
"Ordered exchange path · the stored contribution order defines this sequence.",
));
if !conversion.is_empty() {
out = out.push(action(
"Convert ordered path to explicit links",
Message::Command(unique_batch(p, conversion)),
));
}
(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
size,
)
};
out = out.push(
iced::widget::sensor(
container(
scrollable(canvas)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(size.height.clamp(200., 470.)),
)
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(|size| event(Event::Viewport(size)))
.on_resize(|size| event(Event::Viewport(size))),
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference {
sequence: Some(target),
},
..
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
{
out = out.push(action(
&format!("Open source: {} →", p.named(*target)),
Message::Select(*target),
));
}
for r in sequence_links(p, owner) {
let guard = match &r.body {
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
_ => String::new(),
};
out = out.push(
action(
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
Message::Select(r.id),
)
.width(Fill),
);
}
scrollable(out.padding(24)).into()
}
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
p.relations
.values()
@@ -942,6 +709,7 @@ struct GraphNode {
source: Option<Id>,
preview: Vec<(Id, String)>,
preview_links: Vec<(Id, Id)>,
preview_total: usize,
}
struct GraphEdge {
id: Id,
@@ -951,15 +719,21 @@ struct GraphEdge {
points: Vec<Point>,
label: Rectangle,
}
struct Graph {
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
size: Size,
selected: Id,
scale: f32,
}
impl Graph {
fn new(p: &Project, owner: Id, selected: Id) -> Self {
impl<'a> Graph<'a> {
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
Self::expanded(p, owner, selected, &BTreeSet::new())
}
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
@@ -1000,17 +774,17 @@ impl Graph {
fallback += 1;
}
}
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
for c in choices {
let e = &p.elements[&c.id];
let rank = ranks[&c.id];
let slot = rows.entry(rank).or_default();
let slot = rows.entry(rank).or_insert(24.);
let mut node = GraphNode {
id: e.id,
rect: Rectangle::new(
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
Point::new(24. + rank as f32 * 420., *slot),
Size::new(270., 104.),
),
title: e.name.clone(),
@@ -1019,8 +793,8 @@ impl Graph {
source: None,
preview: vec![],
preview_links: vec![],
preview_total: 0,
};
*slot += 1;
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
match body {
OccurrenceBody::Action { action, .. } => {
@@ -1051,11 +825,19 @@ impl Graph {
.map(|id| format!("ref · {}", p.named(id)))
.unwrap_or("Choose source in Properties".into());
if let Some(source) = sequence {
node.preview = contributions(p, *source)
let contributions = contributions(p, *source);
node.preview_total = contributions.len();
node.preview = contributions
.into_iter()
.take(4)
.take(if expanded.contains(&e.id) {
usize::MAX
} else {
4
})
.map(|c| (c.id, p.named(c.id).to_owned()))
.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_links = sequence_links(p, *source)
@@ -1071,11 +853,13 @@ impl Graph {
_ => {}
}
}
*slot += node.rect.height + 36.;
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
nodes.push(node);
}
let mut edges = vec![];
let mut routed_pairs = BTreeSet::new();
for (i, r) in links.into_iter().enumerate() {
let a = nodes
.iter()
@@ -1089,11 +873,14 @@ impl Graph {
.rect;
let start = Point::new(a.x + a.width, a.center_y());
let end = Point::new(b.x, b.center_y());
let points = if end.x > start.x {
// Parallel records keep distinct visible paths and guard hit targets.
// Their semantic endpoints and IDs remain unchanged.
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
let points = if forward {
let mid = (start.x + end.x) / 2.;
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
} else {
let below = size.height + 24. + i as f32 * 30.;
let below = size.height + 24. + i as f32 * 42.;
vec![
start,
Point::new(start.x + 18., start.y),
@@ -1107,7 +894,7 @@ impl Graph {
RelationBody::SequenceLink { guard, .. } => guard.clone(),
_ => String::new(),
};
let label_at = if end.x > start.x {
let label_at = if forward {
Point::new(end.x - 138., end.y - 43.)
} else {
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
@@ -1128,6 +915,9 @@ impl Graph {
}
}
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
size,
@@ -1204,47 +994,24 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
Stroke::default().with_color(color).with_width(width),
);
}
impl canvas::Program<Message> for Graph {
type State = ();
impl canvas::Program<Message> for Graph<'_> {
type State = Input;
fn update(
&self,
_: &mut (),
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && let Some(point) = cursor.position_in(bounds)
{
let point = Point::new(point.x / self.scale, point.y / self.scale);
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
let id = if point.y > n.rect.y + 75. {
n.source.unwrap_or(n.id)
} else {
n.id
};
return Some(Action::publish(Message::Select(id)).and_capture());
}
if let Some(e) = self.edges.iter().find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points
.windows(2)
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
}) {
return Some(Action::publish(Message::Select(e.id)).and_capture());
}
}
None
self.input(state, event, bounds, cursor)
}
fn draw(
&self,
_: &(),
state: &Input,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
@@ -1327,7 +1094,7 @@ impl canvas::Program<Message> for Graph {
if n.source.is_some() {
draw_text(
&mut f,
"Open source →".into(),
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
Point::new(n.rect.x + 12., n.rect.y + 94.),
11.,
style::TEAL,
@@ -1400,11 +1167,12 @@ impl canvas::Program<Message> for Graph {
);
}
}
self.overlay(state, &mut f, cursor, bounds);
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
_: &(),
_: &Input,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
@@ -1478,6 +1246,9 @@ mod tests {
}
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool: Tool::Branch,
name: String::new(),
@@ -1666,7 +1437,7 @@ mod tests {
let command = branch(owner, a, b, before, after).command(&p).unwrap();
session.execute(command).unwrap();
let result = session.project();
assert!(!result.relations.contains_key(&old_link));
assert!(result.relations.contains_key(&old_link));
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
assert_eq!(sequence_links(result, owner).len(), 6);
assert_eq!(
@@ -1777,10 +1548,26 @@ mod tests {
draft: Some(branch(owner, a, b, before, after)),
..State::default()
};
state.update(&p, Event::Guard(String::new())).unwrap();
state
.update(
&p,
Event::Target(Choice {
id: Id::nil(),
label: "Missing".into(),
}),
)
.unwrap();
assert!(state.update(&p, Event::Submit).is_err());
assert!(state.draft.is_some());
state.update(&p, Event::Guard("ready".into())).unwrap();
state
.update(
&p,
Event::Target(Choice {
id: a,
label: "Work".into(),
}),
)
.unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.draft.is_some());
assert!(matches!(command, Command::Batch(_)));
@@ -1836,9 +1623,8 @@ mod tests {
.unwrap();
let mut graph = Graph::new(&p, owner, owner);
let mut state = State::default();
state
.update(&p, Event::Viewport(Size::new(486., 460.)))
.unwrap();
state.viewport(Size::new(486., 460.));
state.zoom(None);
graph.scale = state.scale(graph.size, 500.);
assert!(graph.size.width * graph.scale <= 462.1);
assert!(graph.size.height * graph.scale <= 450.1);
@@ -1849,7 +1635,7 @@ mod tests {
);
let result = canvas::Program::update(
&graph,
&mut (),
&mut Input::default(),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
mouse::Cursor::Available(click),
@@ -1858,12 +1644,10 @@ mod tests {
.into_inner()
.0;
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
state.update(&p, Event::Zoom(1.)).unwrap();
state
.update(&p, Event::Viewport(Size::new(800., 460.)))
.unwrap();
state.zoom(Some(1.));
state.viewport(Size::new(800., 460.));
assert_eq!(state.scale(graph.size, 500.), 1.);
state.update(&p, Event::Fit).unwrap();
state.zoom(None);
assert!(state.scale(graph.size, 500.) < 1.);
}
}
@@ -0,0 +1,747 @@
//! Local pointer state and ordinary command proposals for the derived process graph.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Insertion {
pub link: Option<Id>,
pub before: Option<Id>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CanvasTool {
#[default]
Select,
Add(Tool, ControlKind),
Connect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum End {
Source,
Target,
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Scrolled(Context, scenario::ScrollArea, iced::Vector),
Choose(Context, CanvasTool),
Select(Context, Id),
AppendExchange(Context),
Begin {
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
},
Connect {
context: Context,
source: Id,
target: Id,
},
Reconnect {
context: Context,
link: Id,
end: End,
target: Id,
},
Reorder {
context: Context,
occurrence: Id,
before: Id,
},
Expand(Context, Id),
Open(Context, Id),
Back(Context),
Zoom(Context, Option<f32>),
Viewport(Context, Size),
}
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(c, ..)
| Self::Choose(c, ..)
| Self::Select(c, ..)
| Self::Expand(c, ..)
| Self::Open(c, ..)
| Self::Back(c)
| Self::AppendExchange(c)
| Self::Zoom(c, ..)
| Self::Viewport(c, ..) => *c,
Self::Begin { context, .. }
| Self::Connect { context, .. }
| Self::Reconnect { context, .. }
| Self::Reorder { context, .. } => *context,
}
}
}
impl State {
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.identity != Some((document, owner)) {
self.identity = Some((document, owner));
self.tool = CanvasTool::Select;
self.zoom = Some(1.);
self.fit = false;
self.viewport = None;
self.expanded.clear();
self.scroll_offsets = [iced::Vector::ZERO; 2];
}
}
pub(crate) fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
}
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
scenario::ScrollArea::Canvas => 0,
scenario::ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
pub(crate) fn choose(&mut self, tool: CanvasTool) {
self.tool = tool;
if tool != CanvasTool::Select && self.fit {
self.fit = false;
self.zoom = Some(1.);
}
}
pub(crate) fn zoom(&mut self, value: Option<f32>) {
self.fit = value.is_none();
self.zoom = value.map(|v| v.clamp(0.5, 2.));
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
let changed = self.viewport != Some(size);
self.viewport = Some(size);
changed
}
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
if !is_contribution(p, owner, id)
|| !matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
)
{
return false;
}
if !self.expanded.remove(&id) {
self.expanded.insert(id);
}
true
}
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
self.return_to.push((owner, occurrence));
}
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
while let Some((owner, occurrence)) = self.return_to.pop() {
if is_contribution(p, owner, occurrence) {
return Some((owner, occurrence));
}
}
None
}
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
let Command::Batch(commands) = command else {
return None;
};
commands.iter().find_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
_ => None,
})
}
pub(crate) fn begin(
&mut self,
p: &Project,
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !matches!(
tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
) {
return Err("Choose a contribution tool.".into());
}
if !legacy_order(p, context.owner).is_empty() {
return Err("Convert the ordered path to explicit links before drawing.".into());
}
let link = insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, context.owner, r.source())
&& is_contribution(p, context.owner, r.target())
})
.ok_or("Choose a sequence link in this process.")
})
.transpose()?;
if insertion
.before
.is_some_and(|id| !is_contribution(p, context.owner, id))
{
return Err("Choose a presentation slot in this process.".into());
}
self.update(p, Event::Start(context.owner, tool, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.insertion = insertion;
d.control = control;
if let Some(r) = link {
d.before = Some(r.source());
d.after = Some(r.target());
}
Ok(())
}
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
let last = legacy_order(p, owner)
.last()
.copied()
.ok_or("Choose an ordered exchange path to append to.")?;
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
self.draft.as_mut().expect("started form").fixed = true;
Ok(())
}
pub(crate) fn connect(
&mut self,
p: &Project,
owner: Id,
source: Id,
target: Id,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !is_contribution(p, owner, source)
|| !is_contribution(p, owner, target)
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
{
return Err("Choose compatible contributions directly owned by this process.".into());
}
self.update(p, Event::Start(owner, Tool::Connect, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.before = Some(source);
d.after = Some(target);
Ok(())
}
}
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
if !is_contribution(p, owner, occurrence) {
return None;
}
match p.elements[&occurrence].shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action { action, .. },
..
} => *action,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => *exchange,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => *sequence,
_ => None,
}
}
pub(crate) fn reconnect(
p: &Project,
owner: Id,
id: Id,
end: End,
endpoint: Id,
) -> Result<Command, String> {
let mut r = p
.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, owner, r.source())
&& is_contribution(p, owner, r.target())
})
.ok_or("Choose a sequence link in this process.")?
.clone();
if !is_contribution(p, owner, endpoint) {
return Err("Choose a local contribution endpoint.".into());
}
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
match end {
End::Source => *source = endpoint,
End::Target => *target = endpoint,
}
}
let command = Command::EditRelation(id, r);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
return Err("Reorder contributions in this process only.".into());
}
let graph = Graph::new(p, owner, id);
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
if position(id) != position(before) {
return Err("Reorder within this column; links determine the path columns.".into());
}
let mut steps = p.elements[&owner].steps().unwrap().clone();
if id != before {
steps.retain(|v| *v != id);
let index = steps
.iter()
.position(|v| *v == before)
.ok_or("This slot no longer exists.")?;
steps.insert(index, id);
}
let command = Command::SetSteps(owner, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
#[derive(Default)]
pub(super) struct Input {
context: Option<Context>,
tool: CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<Id>,
press: Option<Press>,
}
#[derive(Clone, Copy)]
enum Press {
Connect {
source: Id,
first: bool,
start: Point,
moved: bool,
},
Reconnect {
link: Id,
end: End,
start: Point,
moved: bool,
},
Reorder {
occurrence: Id,
start: Point,
moved: bool,
},
Add {
insertion: Insertion,
},
}
impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
}
}
impl Graph<'_> {
fn node_at(&self, point: Point) -> Option<Id> {
self.nodes
.iter()
.rev()
.find(|n| n.rect.contains(point))
.map(|n| n.id)
}
fn edge_at(&self, point: Point) -> Option<Id> {
self.edges
.iter()
.find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points.windows(2).any(|v| {
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
})
})
.map(|e| e.id)
}
fn handle(&self, id: Id) -> Option<Rectangle> {
let n = self.nodes.iter().find(|n| n.id == id)?;
Some(Rectangle::new(
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
Size::new(23., 24.),
))
}
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
let e = self.edges.iter().find(|e| e.id == self.selected)?;
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
.into_iter()
.find(|(p, _)| p.distance(point) < 10. / self.scale)
.map(|(_, end)| (e.id, end))
}
fn connectable(&self, source: Id, target: Id) -> bool {
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
}
pub(super) fn input(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
(Some(c), t, e)
});
if state.context != context
|| state.tool != tool
|| state.bounds.is_some_and(|b| b != bounds)
{
state.cancel();
state.pointer = None;
state.context = context;
state.tool = tool;
}
state.bounds = Some(bounds);
if !editable {
state.cancel();
}
let visible = cursor.position().is_some();
if !visible
&& matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
)
{
state.cancel();
return Some(Action::request_redraw());
}
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(
Press::Connect { start, moved, .. }
| Press::Reconnect { start, moved, .. }
| Press::Reorder { start, moved, .. },
) = &mut state.press
{
*moved |= position.distance(*start) >= 3.;
}
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
| canvas::Event::Window(
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
) => {
state.cancel();
state.pointer = None;
return Some(Action::request_redraw());
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
state.cancel();
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
_ => cursor,
};
let world = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
let point = world?;
let Some(context) = context else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.map(|id| Action::publish(Message::Select(id)).and_capture());
};
if !editable {
return None;
}
let start = cursor.position()? - self.scroll_offset;
match tool {
CanvasTool::Add(..) => {
state.press = Some(Press::Add {
insertion: Insertion {
link: self
.edge_at(point)
.filter(|_| self.node_at(point).is_none()),
before: self.node_at(point),
},
});
}
CanvasTool::Connect => {
let first = state.source.is_none();
let source = state.source.or_else(|| self.node_at(point))?;
state.source = Some(source);
state.press = Some(Press::Connect {
source,
first,
start,
moved: false,
});
}
CanvasTool::Select => {
if let Some((link, end)) = self.endpoint_at(point) {
state.press = Some(Press::Reconnect {
link,
end,
start,
moved: false,
});
} else if self
.handle(self.selected)
.is_some_and(|r| r.contains(point))
{
state.press = Some(Press::Reorder {
occurrence: self.selected,
start,
moved: false,
});
} else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.and_then(|id| publish(CanvasEvent::Select(context, id)));
}
}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let press = state.press.take()?;
let context = context?;
match press {
Press::Add { insertion } if world.is_some() => {
if let CanvasTool::Add(tool, control) = tool {
state.cancel();
return publish(CanvasEvent::Begin {
context,
tool,
control,
insertion,
});
}
}
Press::Connect {
source,
first,
moved,
..
} if moved || !first => {
if let Some(target) = world
.and_then(|p| self.node_at(p))
.filter(|target| self.connectable(source, *target))
{
state.cancel();
return publish(CanvasEvent::Connect {
context,
source,
target,
});
}
}
Press::Reconnect {
link,
end,
moved: true,
..
} => {
if let Some(target) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reconnect {
context,
link,
end,
target,
});
}
}
Press::Reorder {
occurrence,
moved: true,
..
} => {
if let Some(before) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reorder {
context,
occurrence,
before,
});
}
}
_ => {}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
Some(Action::request_redraw())
}
_ => None,
}
}
pub(super) fn overlay(
&self,
state: &Input,
f: &mut Frame,
cursor: mouse::Cursor,
bounds: Rectangle,
) {
let Some((context, tool, editable)) = self.interaction else {
return;
};
if !editable {
return;
}
let point = state
.pointer
.map(|p| p + self.scroll_offset)
.or_else(|| cursor.position())
.filter(|p| bounds.contains(*p))
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
let live =
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
if live && let Some(source) = state.source {
for n in &self.nodes {
if n.id == source || self.connectable(source, n.id) {
f.stroke(
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
draw_arrow(
f,
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
style::TEAL,
2.,
);
}
}
if live
&& let Some(Press::Reconnect { link, end, .. }) = state.press
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
{
for node in &self.nodes {
let (source, target) = match end {
End::Source => (node.id, edge.target),
End::Target => (edge.source, node.id),
};
if self.connectable(source, target) {
f.stroke(
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let Some(point) = point {
let (from, to) = match end {
End::Source => (point, *edge.points.last().unwrap()),
End::Target => (edge.points[0], point),
};
draw_arrow(f, &[from, to], style::TEAL, 2.5);
}
}
if live
&& let Some(Press::Reorder { occurrence, .. }) = state.press
&& let Some(point) = point
&& let Some(target) = self.node_at(point)
{
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
if source.rect.x == target.rect.x {
f.stroke(
&Path::line(
Point::new(target.rect.x, target.rect.y - 7.),
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
draw_text(
f,
"Order before · paths unchanged".into(),
Point::new(target.rect.x, target.rect.y - 24.),
12.,
style::TEAL,
);
}
}
if tool == CanvasTool::Select {
if let Some(r) = self.handle(self.selected) {
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
}
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
for p in [e.points[0], *e.points.last().unwrap()] {
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
}
}
}
if let CanvasTool::Add(..) = tool
&& let Some(point) = point
{
let at = self
.edge_at(point)
.filter(|_| self.node_at(point).is_none());
let caption = if let Some(id) = at {
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
draw_arrow(f, &e.points, style::TEAL, 3.);
}
"Insert on this path · preserve its guard"
} else if self.node_at(point).is_some() {
"Add before this contribution · unconnected"
} else {
"Append contribution · unconnected"
};
draw_text(
f,
caption.into(),
Point::new(point.x + 12., point.y + 16.),
12. / self.scale,
style::TEAL,
);
}
}
}
impl State {
pub(crate) fn selection_offset(
&self,
p: &Project,
owner: Id,
id: Id,
) -> Option<scrollable::AbsoluteOffset> {
let graph = Graph::expanded(p, owner, id, &self.expanded);
let scale = self.scale(graph.size, 500.);
let rect = graph
.nodes
.iter()
.find(|n| n.id == id)
.map(|n| n.rect)
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
Some(scrollable::AbsoluteOffset {
x: (rect.x * scale - 12.).max(0.),
y: (rect.y * scale - 12.).max(0.),
})
}
}
@@ -0,0 +1,970 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::{ProjectLimits, SourceBundle};
pub(crate) struct Fixture {
pub p: Project,
pub owner: Id,
pub work: [Id; 3],
pub nodes: [Id; 3],
pub exchange: Id,
pub link: Id,
}
pub(crate) fn fixture(layer: Layer) -> Fixture {
let mut p = Project::empty();
let owner = p
.make_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Respond safely",
None,
)
.unwrap();
let owner_id = owner.id;
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
let mut work = Vec::new();
let mut nodes = Vec::new();
for name in ["Assess", "Respond", "Recover"] {
let e = p
.make_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
name,
None,
)
.unwrap();
let id = e.id;
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
work.push(id);
let e = occurrence(
&p,
owner_id,
name,
OccurrenceBody::Action {
action: Some(id),
lifeline: None,
},
);
let id = e.id;
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
nodes.push(id);
}
let kind = if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
p = execute_batch(
&p,
&[Command::Connect(
kind,
"Assessment".into(),
work[0],
work[1],
)],
)
.unwrap();
let exchange = p.relations.values().find(|r| r.kind() == kind).unwrap().id;
let Command::CreateRelation(mut r) = link(&p, nodes[0], nodes[1], "approved") else {
unreachable!()
};
r.alias = "assessed_path".into();
r.description = "Reviewed safety rationale".into();
let link_id = r.id;
p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap();
Fixture {
p,
owner: owner_id,
work: work.try_into().unwrap(),
nodes: nodes.try_into().unwrap(),
exchange,
link: link_id,
}
}
pub(crate) fn context(owner: Id) -> Context {
Context {
document: Id::new_v4(),
owner,
revision: 0,
epoch: 0,
}
}
fn command(f: &Fixture, tool: Tool, target: Option<Id>, insertion: Insertion) -> Command {
let mut state = State::default();
state
.begin(&f.p, context(f.owner), tool, ControlKind::Choice, insertion)
.unwrap();
if let Some(id) = target {
state
.update(
&f.p,
Event::Target(Choice {
id,
label: "Source".into(),
}),
)
.unwrap();
}
state.prepare_pending(&f.p).unwrap()
}
fn created(p: &Project, after: &Project, owner: Id) -> Id {
after
.children(owner)
.find(|e| !p.contains(e.id))
.unwrap()
.id
}
#[test]
fn direct_process_oa_sa_repeated_occurrences_share_sources_and_split_one_guarded_link() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let original = f.p.clone();
let add = command(&f, Tool::Work, Some(f.work[0]), Insertion::default());
let result = execute_batch(&f.p, &[add]).unwrap();
let repeated = created(&f.p, &result, f.owner);
assert_ne!(repeated, f.nodes[0]);
assert_eq!(result.elements[&f.work[0]], f.p.elements[&f.work[0]]);
assert!(
matches!(result.elements[&repeated].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Action{action:Some(id),..},..})if *id==f.work[0])
);
f.p = result;
let plan = command(
&f,
Tool::Exchange,
Some(f.exchange),
Insertion {
link: Some(f.link),
before: None,
},
);
let serialized = serde_json::to_string(&plan).unwrap();
let api: Command = serde_json::from_str(&serialized).unwrap();
assert_eq!(api, plan);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
doc.execute(plan).unwrap();
let inserted = created(&f.p, doc.project(), f.owner);
let r = &doc.project().relations[&f.link];
let expected = &f.p.relations[&f.link];
assert_eq!(r.id, expected.id);
assert_eq!(r.alias, expected.alias);
assert_eq!(r.description, expected.description);
assert_eq!(r.source(), expected.source());
assert_eq!(r.target(), inserted);
assert!(matches!(&r.body,RelationBody::SequenceLink{guard,..}if guard=="approved"));
assert!(
sequence_links(doc.project(), f.owner)
.iter()
.any(|r| r.source() == inserted && r.target() == f.nodes[1])
);
let steps = doc.project().elements[&f.owner].steps().unwrap();
assert_eq!(&steps[..3], &[f.nodes[0], inserted, f.nodes[1]]);
assert_eq!(
doc.project().relations[&f.exchange],
original.relations[&f.exchange]
);
assert_eq!(doc.project(), &execute_batch(&f.p, &[api]).unwrap());
let after = doc.bundle().clone();
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), doc.project());
}
}
#[test]
fn direct_process_specific_parallel_link_and_incompatible_adjacency() {
let mut f = fixture(Layer::Oa);
let Command::CreateRelation(mut second) = link(&f.p, f.nodes[0], f.nodes[1], "urgent") else {
unreachable!()
};
second.alias = "urgent_path".into();
let second_id = second.id;
f.p = execute_batch(&f.p, &[Command::CreateRelation(second)]).unwrap();
let graph = Graph::new(&f.p, f.owner, f.owner);
for edge in &graph.edges {
let clicked = send(
&graph,
&mut Input::default(),
mouse::Event::ButtonPressed(mouse::Button::Left),
edge.label.center(),
Rectangle::new(Point::ORIGIN, graph.size),
);
assert!(
matches!(clicked,Some(Message::Select(id))if id==edge.id),
"Each guarded parallel path must be individually selectable"
);
}
let plan = command(
&f,
Tool::Control,
None,
Insertion {
link: Some(f.link),
before: None,
},
);
let after = execute_batch(&f.p, &[plan]).unwrap();
assert_eq!(after.relations[&second_id], f.p.relations[&second_id]);
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Exchange,
ControlKind::Choice,
Insertion {
link: Some(f.link),
before: None,
},
)
.unwrap();
let wrong = execute_batch(
&f.p,
&[Command::Connect(
RelationKind::Interaction,
"Reverse".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let wrong_id = wrong
.relations
.values()
.find(|r| r.name == "Reverse")
.unwrap()
.id;
state
.update(
&wrong,
Event::Target(Choice {
id: wrong_id,
label: "Wrong".into(),
}),
)
.unwrap();
assert!(state.prepare_pending(&wrong).is_err());
assert!(state.draft.is_some());
assert!(direct::reconnect(&f.p, f.owner, f.link, direct::End::Target, f.nodes[0]).is_err());
}
#[test]
fn direct_process_controls_allow_incomplete_work_and_preserve_branch_semantics() {
let f = fixture(Layer::Oa);
for kind in [
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
] {
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Control,
kind,
Insertion::default(),
)
.unwrap();
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
let id = created(&f.p, &result, f.owner);
assert!(
matches!(result.elements[&id].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Control{kind:k},..})if *k==kind)
);
assert!(
validation::validate(&result)
.iter()
.any(|d| d.element == Some(id)
&& d.code == "control_cardinality"
&& d.severity == validation::Severity::Warning)
);
}
for kind in [ControlKind::Choice, ControlKind::Fork] {
let mut state = State::default();
state
.update(&f.p, Event::Start(f.owner, Tool::Branch, Some(f.nodes[0])))
.unwrap();
for event in [
Event::Target(Choice {
id: f.work[0],
label: "A".into(),
}),
Event::Alternate(Choice {
id: f.work[2],
label: "B".into(),
}),
Event::Control(ControlChoice(kind)),
Event::Guard("ready".into()),
] {
state.update(&f.p, event).unwrap();
}
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
assert!(
matches!(&result.relations[&f.link].body,RelationBody::SequenceLink{guard,..}if guard=="approved")
);
let branch_guards: Vec<_> = sequence_links(&result, f.owner)
.into_iter()
.filter(|r| !f.p.relations.contains_key(&r.id))
.filter_map(|r| {
if let RelationBody::SequenceLink { guard, .. } = &r.body {
Some(guard.as_str())
} else {
None
}
})
.collect();
assert_eq!(
branch_guards.contains(&"ready"),
kind == ControlKind::Choice
);
assert_eq!(branch_guards.contains(&"else"), kind == ControlKind::Choice);
}
}
#[test]
fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources() {
let f = fixture(Layer::Oa);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
doc.execute(
direct::reconnect(
doc.project(),
f.owner,
f.link,
direct::End::Target,
f.nodes[2],
)
.unwrap(),
)
.unwrap();
assert_eq!(doc.project().relations[&f.link].target(), f.nodes[2]);
assert_eq!(doc.project().relations[&f.link].alias, "assessed_path");
let reconnected = doc.bundle().clone();
doc.execute(Command::Delete(f.nodes[0])).unwrap();
assert!(doc.project().contains(f.work[0]));
assert!(doc.project().contains(f.exchange));
assert!(doc.project().contains(f.nodes[1]));
assert!(!doc.project().contains(f.link));
doc.undo();
assert_eq!(doc.bundle(), &reconnected);
doc.undo();
assert_eq!(doc.bundle(), &original);
let reordered = execute_batch(
&f.p,
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
)
.unwrap();
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
assert_eq!(reordered.relations, f.p.relations);
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
}
#[test]
fn direct_process_reuse_preview_is_local_expandable_and_recursive_sources_are_rejected() {
let mut f = fixture(Layer::Oa);
let source =
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
.unwrap();
let source_id = source.id;
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
for i in 0..8 {
let e = occurrence(
&f.p,
source_id,
&format!("Shared step {i}"),
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
}
let shared = f.p.clone();
let mut references = Vec::new();
for _ in 0..2 {
let plan = command(&f, Tool::Reuse, Some(source_id), Insertion::default());
let result = execute_batch(&f.p, &[plan]).unwrap();
references.push(created(&f.p, &result, f.owner));
f.p = result;
}
let graph = Graph::new(&f.p, f.owner, references[0]);
let node = graph.nodes.iter().find(|n| n.id == references[0]).unwrap();
assert_eq!(node.preview.len(), 4);
assert_eq!(node.preview_total, 8);
let mut input = Input::default();
let point = Point::new(node.rect.x + 20., node.rect.y + 140.);
let clicked = send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
Rectangle::new(Point::ORIGIN, graph.size),
);
assert!(
matches!(clicked,Some(Message::Select(id))if id==references[0]),
"preview click must select the local reference"
);
let expanded = Graph::expanded(
&f.p,
f.owner,
references[0],
&BTreeSet::from([references[0]]),
);
assert_eq!(
expanded
.nodes
.iter()
.find(|n| n.id == references[0])
.unwrap()
.preview
.len(),
8
);
assert_eq!(
expanded
.nodes
.iter()
.find(|n| n.id == references[1])
.unwrap()
.preview
.len(),
4
);
for e in shared.children(source_id) {
assert_eq!(f.p.elements[&e.id], *e);
}
assert!(
!targets(&f.p, source_id, Tool::Reuse)
.iter()
.any(|c| c.id == f.owner)
);
let inside = f.p.elements[&source_id].steps().unwrap()[0];
assert!(!validation::can_connect(
&f.p,
RelationKind::SequenceLink,
references[0],
inside
));
}
#[test]
fn direct_process_nested_source_reopen_and_api_command_use_one_transaction() {
let bundle=SourceBundle{entry:"project.arcadia".into(),files:[
("project.arcadia".into(),"arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; include \"oa/process.arcadia\"; }\n".into()),
("oa/work.arcadia".into(),"// Shared work\noa_activity assess \"Assess\" {}\n".into()),
("oa/process.arcadia".into(),"// Contextual process\noa_process respond \"Respond\" {}\n".into()),
].into()};
let mut doc = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
let owner = doc
.project()
.elements
.values()
.find(|e| e.alias == "respond")
.unwrap()
.id;
let work = doc
.project()
.elements
.values()
.find(|e| e.alias == "assess")
.unwrap()
.id;
let mut state = State::default();
state
.begin(
doc.project(),
context(owner),
Tool::Work,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: work,
label: "Assess".into(),
}),
)
.unwrap();
let plan = state.prepare_pending(doc.project()).unwrap();
let expected = execute_batch(
doc.project(),
&[serde_json::from_value(serde_json::to_value(&plan).unwrap()).unwrap()],
)
.unwrap();
doc.execute(plan).unwrap();
let id = doc.project().elements[&owner].steps().unwrap()[0];
assert_eq!(
doc.compiled().index.declarations[&id].path,
"oa/process.arcadia"
);
assert_eq!(
doc.bundle().files["oa/work.arcadia"],
original.files["oa/work.arcadia"]
);
assert_eq!(doc.project(), &expected);
let after = doc.bundle().clone();
doc.undo();
assert_eq!(doc.bundle(), &original);
doc.redo();
assert_eq!(doc.bundle(), &after);
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
}
fn graph(f: &Fixture, tool: CanvasTool) -> Graph<'_> {
let mut graph = Graph::new(&f.p, f.owner, f.owner);
graph.interaction = Some((context(f.owner), tool, true));
graph
}
fn send(
graph: &Graph<'_>,
input: &mut Input,
event: mouse::Event,
point: Point,
bounds: Rectangle,
) -> Option<Message> {
canvas::Program::update(
graph,
input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point + graph.scroll_offset),
)
.and_then(|a| a.into_inner().0)
}
fn position(graph: &Graph<'_>, id: Id) -> Point {
graph
.nodes
.iter()
.find(|n| n.id == id)
.unwrap()
.rect
.center()
}
#[test]
fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() {
let f = fixture(Layer::Oa);
for drag in [false, true] {
let mut graph = graph(&f, CanvasTool::Connect);
graph.scroll_offset = iced::Vector::new(35., 90.);
let bounds = Rectangle::new(Point::new(10., 20.), graph.size);
let screen =
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset;
let a = screen(f.nodes[0]);
let b = screen(f.nodes[2]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
if !drag {
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
a,
bounds
)
.is_none()
);
}
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
if !drag {
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
bounds,
);
}
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Connect{source,target,..}))if source==f.nodes[0]&&target==f.nodes[2])
);
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
bounds
)
.is_none()
);
}
let graph = graph(&f, CanvasTool::Add(Tool::Exchange, ControlKind::Choice));
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
let e = &graph.edges[0];
let point = Point::new((e.points[0].x + e.points[1].x) / 2., e.points[0].y);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),point,bounds),Some(Message::TopologyCanvas(CanvasEvent::Begin{insertion:Insertion{link:Some(id),..},..}))if id==f.link)
);
}
#[test]
fn direct_process_production_stale_focus_scroll_bounds_and_pending_cancel() {
let f = fixture(Layer::Oa);
for change in 0..8 {
let mut graph = graph(&f, CanvasTool::Connect);
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
let a = position(&graph, f.nodes[0]);
let b = position(&graph, f.nodes[2]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
let mut end_bounds = bounds;
match change {
0 => graph.interaction.as_mut().unwrap().0.epoch += 1,
1 => graph.interaction.as_mut().unwrap().2 = false,
2 => end_bounds.y -= 40.,
3 => {
canvas::Program::update(
&graph,
&mut input,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds,
mouse::Cursor::Available(b),
);
}
4 => {
send(
&graph,
&mut input,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
b,
bounds,
);
}
5 => graph.interaction.as_mut().unwrap().0.document = Id::new_v4(),
6 => graph.interaction.as_mut().unwrap().1 = CanvasTool::Select,
_ => {
canvas::Program::update(
&graph,
&mut input,
&canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
bounds,
mouse::Cursor::Available(b),
);
}
}
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
end_bounds
)
.is_none(),
"change {change}"
);
}
}
#[test]
fn direct_process_readable_default_fit_expansion_and_bounded_choices() {
let f = fixture(Layer::Oa);
let mut state = State::default();
state.enter(Id::new_v4(), f.owner);
state.viewport(Size::new(500., 400.));
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
state.zoom(None);
assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2);
state.zoom(Some(0.1));
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5);
state.enter(Id::new_v4(), f.owner);
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
let mut all = Vec::new();
for page in 0..500usize.div_ceil(24) {
let range = view::choice_page(500, page);
assert!(range.len() <= 24);
all.extend(range);
}
assert_eq!(all, (0..500).collect::<Vec<_>>());
assert_eq!(view::choice_page(0, 90), 0..0);
}
#[test]
fn direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity() {
let f = fixture(Layer::Oa);
let mut graph = graph(&f, CanvasTool::Select);
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
graph.selected = f.link;
let mut input = Input::default();
let a = *graph
.edges
.iter()
.find(|e| e.id == f.link)
.unwrap()
.points
.last()
.unwrap();
let b = position(&graph, f.nodes[2]);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reconnect{link,end:direct::End::Target,target,..}))if link==f.link&&target==f.nodes[2])
);
graph.selected = f.nodes[2];
let n = graph.nodes.iter().find(|n| n.id == f.nodes[2]).unwrap();
let a = Point::new(n.rect.x + n.rect.width - 20., n.rect.y + 18.);
let b = position(&graph, f.nodes[0]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reorder{occurrence,before,..}))if occurrence==f.nodes[2]&&before==f.nodes[0])
);
}
#[test]
fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() {
let f = fixture(Layer::Oa);
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Work,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
&f.p,
Event::Target(Choice {
id: f.work[0],
label: "Keep".into(),
}),
)
.unwrap();
let mut choices: Vec<_> = (0..500)
.map(|i| Choice {
id: Id::new_v4(),
label: format!("Activity {i}"),
})
.collect();
choices.push(Choice {
id: f.work[0],
label: "Selected source".into(),
});
fn nodes(tree: &iced::advanced::widget::Tree) -> usize {
1 + tree.children.iter().map(nodes).sum::<usize>()
}
for page in 0..21 {
state.update(&f.p, Event::Page(0, page)).unwrap();
let d = state.draft.as_ref().unwrap();
let view = view::picker(choices.clone(), d, 0, d.target, "Choose", Event::Target);
assert!(nodes(&iced::advanced::widget::Tree::new(view.as_widget())) < 40);
}
state
.update(&f.p, Event::Search(0, "Activity 499".into()))
.unwrap();
assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0]));
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
}
pub(crate) fn empty_exchange_fixture(layer: Layer) -> (Fixture, Id) {
let mut f = fixture(layer);
let mut commands = f.nodes.into_iter().map(Command::Delete).collect::<Vec<_>>();
commands.push(Command::Connect(
f.p.relations[&f.exchange].kind(),
"Response".into(),
f.work[1],
f.work[2],
));
f.p = execute_batch(&f.p, &commands).unwrap();
let next =
f.p.relations
.values()
.find(|r| r.name == "Response")
.unwrap()
.id;
assert!(f.p.elements[&f.owner].steps().unwrap().is_empty());
(f, next)
}
#[test]
fn direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
let kind = f.p.relations[&f.exchange].kind();
f.p = execute_batch(
&f.p,
&[Command::Connect(
kind,
"Recovery".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let final_exchange =
f.p.relations
.values()
.find(|r| r.name == "Recovery")
.unwrap()
.id;
for exchange in [f.exchange, next] {
let e = occurrence(
&f.p,
f.owner,
"Ordered contribution",
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
}
let before = f.p.elements[&f.owner].steps().unwrap().clone();
assert_eq!(legacy_order(&f.p, f.owner), before);
assert!(sequence_links(&f.p, f.owner).is_empty());
let mut state = State::default();
state.append_exchange(&f.p, f.owner).unwrap();
let draft = state.draft.as_ref().unwrap();
assert!(draft.fixed);
assert_eq!(draft.before, before.last().copied());
assert_eq!(draft.after, None);
assert!(!compatible_source(&f.p, draft, f.exchange));
assert!(compatible_source(&f.p, draft, final_exchange));
state
.update(
&f.p,
Event::Target(Choice {
id: f.exchange,
label: "Incompatible".into(),
}),
)
.unwrap();
assert!(state.prepare_pending(&f.p).is_err());
assert!(state.draft.is_some());
state
.update(
&f.p,
Event::Target(Choice {
id: final_exchange,
label: "Recovery".into(),
}),
)
.unwrap();
let command = state.prepare_pending(&f.p).unwrap();
let Command::Batch(commands) = &command else {
panic!("one ordinary batch")
};
assert_eq!(
commands.len(),
4,
"materialize old path + occurrence + append link + order"
);
let serialized = serde_json::to_value(command).unwrap();
let result = execute_batch(&f.p, &[serde_json::from_value(serialized).unwrap()]).unwrap();
let after = result.elements[&f.owner].steps().unwrap();
assert_eq!(&after[..2], &before);
assert_eq!(after.len(), 3);
let links = sequence_links(&result, f.owner);
assert_eq!(links.len(), 2);
for pair in after.windows(2) {
assert_eq!(
links
.iter()
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
.count(),
1
);
}
assert!(uses_explicit_topology(&result, f.owner));
for (id, relation) in &f.p.relations {
assert_eq!(result.relations[id], *relation);
}
}
}
@@ -0,0 +1,478 @@
use super::*;
use iced::widget::column;
pub(super) fn picker<'a>(
mut choices: Vec<Choice>,
d: &'a Draft,
index: usize,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
let browser = &d.browsers[index];
let query = browser.search.trim().to_lowercase();
choices.retain(|c| c.label.to_lowercase().contains(&query));
let range = choice_page(choices.len(), browser.page);
let page = range.start / 24;
let mut out = column![
text_input("Search compatible choices…", &browser.search)
.on_input(move |v| event(Event::Search(index, v)))
.padding(7)
.size(12)
]
.spacing(5);
if let Some(selected) = selected
.as_ref()
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
{
out = out.push(label(format!("Selected: {}", selected.label)));
}
out = out.push(
row![
button("Previous")
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
text(format!(
"{} choices · {}/{}",
choices.len(),
page + 1,
choices.len().div_ceil(24).max(1)
))
.size(11),
button("Next").on_press_maybe(
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
)
]
.spacing(6),
);
out.push(
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(8)
.text_size(12)
.width(Fill),
)
.into()
}
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
let start = requested.min(len.saturating_sub(1) / 24) * 24;
start..(start + 24).min(len)
}
pub(super) fn view<'a>(
app: &'a App,
owner: Id,
mut out: Column<'a, Message>,
) -> Element<'a, Message> {
let p = app.project();
let context = app.topology_context().expect("visible process context");
let state = &app.behavior.topology;
let pending = state.draft.is_some();
let explicit =
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));
if !pending && explicit {
out = out
.push(
row![
button("Select / reorder")
.style(if state.tool == CanvasTool::Select {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Select)),
button("Work")
.style(
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
button("Exchange")
.style(
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
button("Reuse")
.style(
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
]
.spacing(6),
)
.push(
row![
pick_list(
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop
]
.into_iter()
.map(ControlChoice)
.collect::<Vec<_>>(),
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
Some(ControlChoice(kind))
} else {
None
},
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Add(Tool::Control, c.0)
))
)
.placeholder("Draw control…")
.text_size(12)
.padding(8),
button("Sequence link")
.style(if state.tool == CanvasTool::Connect {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Connect)),
button("Two paths…").on_press(event(Event::Start(
owner,
Tool::Branch,
selected
)))
]
.spacing(6),
);
}
out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
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"}}));
if explicit {
let mut graph = Graph::expanded(p, owner, app.selected, &state.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();
let scale = graph.scale;
out = out.push(
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.)
)))
]
.spacing(6),
);
if graph.nodes.is_empty() {
out = out.push(label(
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
));
}
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
let canvas = scrollable(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height),
)
.id("process-canvas")
.direction(scrollable::Direction::Both {
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),
)
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
);
} else {
out = out.push(label(
"Ordered exchange path · append the next exchange or convert to explicit links.",
));
if !pending {
out = out.push(
button("Append exchange…")
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
}
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
out = out.push(
button("Convert ordered path to explicit links")
.on_press(Message::Command(unique_batch(p, commands))),
);
} else {
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
}
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
out = out.push(
scrollable(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.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))),
);
}
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();
form = form.push(picker(
choices,
d,
0,
d.target,
"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(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.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(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.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) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
out = out.push(
container(
column![
actions,
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
]
.spacing(8),
)
.padding(12)
.style(style::card),
);
}
super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Page,
scrollable(out.padding(18)),
)
}
+17
View File
@@ -2,6 +2,23 @@
## Objective and phase
Direct process/chain editing is frozen in `codex/process-direct` at production
8bc564b, based on 1bf8ebc, in `D:/CodexBuilds/Capella2-scenario-direct`. The prior
scenario branch is retained. Independent review found and this revision fixes
the empty-owner → first exchange continuation dead end: **Append exchange…**
captures the last ordered contribution and creates the next occurrence plus its
explicit sequence link in one validated model/source Batch. No mode flag or
arbitrary topology is added. Both OA/SA regressions cover exact source history,
reopen, preservation of longer ordered paths and incompatible-source rejection.
Full Windows desktop tests pass 305/305; strict all-target desktop Clippy and
optimized build pass. Original independent core topology/diagnostics/nested-source
verification remains 19/19 at 7a1e12b. The current frozen binary, manifest and logs
are at `D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`.
`release-process-editing.md` records behavior, verification and exact artifact hash.
No native operation was performed by this worker. Root review/native follow-up
must include empty → first exchange → Append exchange on Windows/Linux. The
accepted model/source/API contract is unchanged; cross-reference inner-chain
linking and disconnected exchange-only topology remain explicit model boundaries.
Direct scenario work is now in isolated `codex/scenario-direct`, checkout
`D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target
`D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in
+186
View File
@@ -0,0 +1,186 @@
# Direct process / functional-chain editing
Status: implementation and automated Windows verification passed on 2026-09-06 in `codex/process-direct`, based on
1bf8ebc in `D:/CodexBuilds/Capella2-scenario-direct`; dedicated target remains
`D:/CodexBuilds/Capella2-scenario-target`. The prior scenario branch is retained.
No native acceptance is claimed; root owns Windows/Linux native operation.
## Decisions and observable outcome
An engineer can involve existing work/exchanges more than once, draw guarded
sequence links and controls, insert on a selected path, reconnect its endpoint,
inspect/reuse a subprocess, and undo/save/reopen with contextual identities intact.
Extend the existing derived topology graph and ordinary typed drafts. No core
schema, persisted geometry, source grammar or REST route is added.
Palette tools capture document/owner/revision/epoch and a specific contribution,
link or presentation slot. Canvas click-click and drag connect local occurrence
IDs. A link insertion preserves the captured link UUID, alias, description and
guard on the upstream segment, creates the downstream segment and new occurrence,
and updates `steps` in one validated Batch. Membership/presentation order remains
separate from SequenceLink topology. Incomplete control guards/cardinalities are
warnings, as in the core. Reconnection retains the link's authored properties.
Select always means the local occurrence/relation. Shared source navigation uses
explicit Open; reusable previews expand without copying or reparenting source
contributions. Inner preview endpoints are not caller-owned endpoints. Capella's
richer cross-reference hierarchy linking is an unresolved semantic extension,
not implied by this milestone. Existing explicit legacy-path conversion remains.
Keep canvas and bounded draft independently scrollable, use searchable paged
contextual choices, and default to readable zoom with explicit Fit and scrolling.
Transient drawing cancels on context, tool, bounds, scroll, resize or focus changes;
form inputs survive failed validation/source updates. Reuse passive scroll
observations including async-save delivery. Commit through Document::execute,
acknowledging and selecting the created occurrence only after model/source success.
## Ownership and verification
Own `behavior/topology.rs` and local tests, with narrow `behavior.rs` and `app.rs`
routing/lifecycle changes. Do not change DATA/Linux/semantic checkouts or perform
native input. Verify production pointer events, planners, App and model/source:
1. OA/SA repeated work/exchange involvement preserves shared records.
2. Alternative/parallel/loop creation, guards and incomplete warnings.
3. Specific guarded-link insertion preserves metadata; incompatible adjacency rejects.
4. Reconnect/delete/Undo/Redo retain unrelated paths and exact source.
5. Repeated subprocess reuse, explicit navigation, expansion and recursive rejection.
6. Legacy conversion and presentation reorder independent of explicit topology.
7. Dirty/source-failure/stale/scroll-save/resize/Escape/focus lifetimes retain drafts.
8. Nested source save/reopen and ordinary serialized command/model parity.
Run focused invariants, all desktop tests, strict all-target desktop Clippy and
optimized desktop build with --locked --offline and the dedicated target. Record
actual evidence and remaining native tasks before freezing. Root independently
reviews and native-tests the freeze; full-release completeness remains separate.
## Original implementation freeze and actual evidence — 2026-09-06
Production is `7a1e12bc7c18c5693de688f054293bacd095f4d8` (`7a1e12b`).
The prior `codex/scenario-direct` branch remains at its original head. Production
changes are confined to desktop topology and its local direct/view/test modules,
App process routing, the existing passive-scroll observer and one updated passive
lifecycle regression. `scenario.rs` and scenario message-label construction are
unchanged, allowing the root's separate DATA native fixes to merge independently.
The palette now draws work/exchange/reuse/control contributions and sequence links
with click-click or drag. Selected link endpoint handles reconnect by identity;
selected contribution handles reorder peers within one derived path column.
Source navigation is explicit. Expanded reference previews expose all directly
owned source contributions without copying them or making inner endpoints local.
Every picker has search and 24-item pages. Active tools have distinct styling.
Create/Cancel are outside the form's internal scroller. The canvas stays visible
while forms are pending. New occurrence selection is restored after the final
SetSteps command, and successful edits reveal it in the graph.
The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit
may produce a small overview; choosing a drawing tool leaves that overview at
100%. No positions are persisted. A source-only exchange sequence retains its
legacy linear meaning: two or more entries have explicit Convert. The original
one-exchange fallback offered only Work/Control/Reuse; independent review found
that this blocked exchange-only continuation. The correction is recorded below.
Disconnected exchange-only topology cannot be represented by the accepted
contract; the editor does not introduce an invisible mode flag.
The new App resize regression initially failed because existing window handling
only invalidated scenario contexts; it now invalidates process contexts too.
Passive viewport and both scroll reports are accepted before busy/modal gates,
validated by document/owner/revision, and cannot apply properties or discard a
behavior draft. The shared observer distinguishes its scenario/process delivery.
A separate captured-link probe failed because two existing parallel guards had
the same hit target. Distinct derived routes now make each relation selectable;
source/target/UUID/guard remain unchanged. The failing probe is saved alongside
the passing final suite as `process-parallel-selection-before.log`.
Actual commands used `--locked --offline` and the dedicated scenario target:
- Full desktop tests: **303 passed**, zero failed/ignored. Seventeen new process
tests exercise production pointer sequences, exact guarded-link insertion,
duplicate-link selection, same-owner reorder, source failure/drafts, busy save
scrolling, dirty source/properties, real resize, reusable-source Open/Back,
bounded widget trees and nested multi-file source/serialized-command parity.
- Strict all-target desktop Clippy: **passed** (`-D warnings`).
- Independent existing core suites `release_topology`, `process_diagnostics` and
`source_nested_updates`: **19 passed**. These cover complete alternative,
parallel and loop topology; two-direction transition closure; deletion and
reidentification; legacy sequences and exact nested source ownership/history.
- Optimized desktop build: **passed**. Owned sources were formatted with Rustfmt
edition 2024; `git diff --check` passed before the production commit.
The source/JSON commands and core invariants are unchanged. These tests do not
claim a new actual HTTP transport trial or native acceptance.
Frozen Windows directory:
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-7a1e12b`.
It contains the binary, manifest and five verification/probe logs. Binary size:
20409856 bytes. SHA-256:
`701ECE6CD329A1CBE50D4DAEBFCE33AD7A93E6906295E74C3290653706F8455A`.
The frozen copy was hash-compared with the release target binary.
## Independent-review correction — exchange-only continuation
Production follow-up is `8bc564b4b2c3b6d12eb61fc09aec7f129f6e45b7` (`8bc564b`).
Independent review found a real dead end: after the first Exchange was created in
an empty process/chain, the accepted model classified it as an ordered exchange
path. There was no path to convert and the fallback omitted another Exchange.
Ordered paths now offer **Append exchange…**, including a single contribution.
The form names the captured final contribution and explains that Create adds an
explicit sequence link. Its contextual picker accepts exchanges whose source
matches the preceding exchange's target. Create emits one existing validated
Batch: new exchange occurrence, preceding-to-new SequenceLink and SetSteps;
existing implied links in longer ordered paths are materialized in that same
transaction. The second contribution therefore restores the direct topology
palette without inventing disconnected topology, global exchanges or a mode flag.
Source reuse, same-owner endpoints and the cross-reference semantic boundary remain.
Two new regressions both exercise OA and SA. The App regression starts with an
empty owner, creates its first exchange, invokes the actual Append event and
creates its second exchange. It verifies exactly one link, two distinct occurrence
IDs, unchanged shared records, one history entry per Create, exact model/source
Undo/Redo and source reopen. The independent proposal regression preserves a
multi-entry ordered path, rejects an incompatible exchange without losing its
form, and validates the serialized ordinary Batch through core execution.
Focused regressions: **2 passed**. Full desktop tests: **305 passed**, zero
failed/ignored. Strict all-target desktop Clippy: **passed** (`-D warnings`).
Commands used `--locked --offline` and the same dedicated target. The final full
suite took 10.75 seconds; Clippy took 4.96 seconds. Production changes are confined
to `topology/direct.rs`, `topology/view.rs`, local tests and `app/process_canvas.rs`.
No native input or new actual HTTP transport trial was performed.
Updated optimized build: **passed** in 38.80 seconds. Current frozen Windows
artifact, manifest and three verification logs are in
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`.
Binary size: 20430336 bytes; SHA-256:
`8BD5C84DF0F4E333C1955E6E9299F431081912E1A504ED13355E0BFA70F8B8BD`.
The frozen copy was hash-compared with the release target binary. The prior
`freeze-process-7a1e12b` remains available as historical evidence.
## Root-owned native acceptance still required
At 1440x900 and 1100x700 on Windows and Linux, operate the actual application:
1. Start an OA process and SA chain, involve matching existing work/exchanges,
repeat a shared source with a distinct contextual name, and draw links with
both click-click and drag. Inspect source identity and contextual properties.
2. Draw Choice/Merge alternatives and Fork/Join parallel paths; enter `ready` and
`else`, create a loop repeat/exit, and inspect incomplete warnings. Insert an
exchange/control on an existing guarded path, reconnect an endpoint and reject
an incompatible target; confirm the original path metadata and other branch.
3. Reuse a subprocess twice, select its body without navigation, expand one local
reference and inspect the count, Open the shared source, then Back to its caller.
Reorder peer contributions and verify links are unchanged; cross-column movement
is rejected with the existing topology explanation.
4. Test Fit/100%, inner and outer wheel/scrollbar changes, resize, Escape and
unfocus during an armed or held gesture. Verify no stale endpoint/slot commits.
Confirm Create/Cancel remains accessible while a long form is open, search/page
changes retain its values, navigation is guarded and save-time scrolling recovers.
5. Start an empty owner, add its first exchange, use Append exchange for its second,
and verify the visible link/direct palette. Also append/convert a legacy multi-exchange path.
Undo/Redo the conversion and direct edits, save/reopen a nested source project,
then inspect the same occurrence/link IDs, guards and shared source records.
No native/UI input was performed in this task. Cross-reference inner-chain linking
remains an explicit unresolved semantic extension; this milestone does not claim
full Capella functional-chain equivalence or complete the first release.