Add direct process and chain occurrence editing
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
Reference in New Issue
Block a user