Add direct process and chain occurrence editing

This commit is contained in:
2026-09-06 05:33:42 +03:00
parent a306d6b2a8
commit 7a1e12bc7c
8 changed files with 2798 additions and 430 deletions
@@ -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());
+471
View File
@@ -0,0 +1,471 @@
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::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_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()));
}