Merge commit '1a3164b' into codex/data-contract-integration
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user