Files
ARCADIA-Studio/crates/oa-desktop/src/app/process_canvas.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2026-09-10 10:06:55 -07:00

671 lines
28 KiB
Rust

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::ExpandQualified(_, endpoint) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand_context(self.project(), context.owner, endpoint);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::OpenQualified(_, endpoint) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint);
if let Ok(resolved) = resolved {
self.behavior.topology.remember(
context.owner,
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::SelectQualified(_, endpoint) => {
if !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
let reference = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
self.select(reference);
self.behavior.topology.select_context(resolved.address);
}
}
CanvasEvent::Focus(_) => {
self.behavior.topology.focused = !self.behavior.topology.focused;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
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;
self.behavior.topology.clear_context();
}
}
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 process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
let (mut app, f) = setup();
let original = app.document.snapshot();
let initial = app.selected;
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Name(" unfinished contextual work ".into()),
);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(app.behavior.topology.focused);
let raw = app.behavior.recovery_snapshot().unwrap();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[1],
)));
assert_eq!(app.selected, initial);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(!app.behavior.topology.focused);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
}
#[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()));
}