Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3164bf16 | ||
|
|
8bc564b4b2 | ||
|
|
dfc14bcd1f | ||
|
|
7a1e12bc7c | ||
|
|
a306d6b2a8 | ||
|
|
1bf8ebca96 | ||
|
|
dfd2e41259 | ||
|
|
05a84b217d | ||
|
|
e7e37d485d | ||
|
|
b35dede75f | ||
|
|
5fa9c905c8 | ||
|
|
7a666076bf | ||
|
|
f5467e50c5 | ||
|
|
74989b6f25 | ||
|
|
524dabb9c3 | ||
|
|
d1d8f0bbed | ||
|
|
0edadeebdb | ||
|
|
93e3137342 |
@@ -12,8 +12,219 @@ use oa_core::{
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
|
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod direct_scenario_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::behavior::{
|
||||||
|
self,
|
||||||
|
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
|
||||||
|
};
|
||||||
|
fn setup() -> (App, scenario::direct_tests::Fixture) {
|
||||||
|
let f = scenario::direct_tests::fixture();
|
||||||
|
let mut app = App::with_project(f.project.clone());
|
||||||
|
let _ = app.update(Message::Select(f.owner));
|
||||||
|
let _ = app.update(Message::Page(Page::Behavior));
|
||||||
|
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
|
||||||
|
(app, f)
|
||||||
|
}
|
||||||
|
fn form(app: &mut App, event: Event) {
|
||||||
|
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
|
||||||
|
}
|
||||||
|
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
|
||||||
|
CanvasEvent::Begin {
|
||||||
|
context: app.scenario_context().unwrap(),
|
||||||
|
slot: Slot {
|
||||||
|
parent: f.owner,
|
||||||
|
before: None,
|
||||||
|
},
|
||||||
|
sender: f.sender,
|
||||||
|
receiver: f.receiver,
|
||||||
|
style: MessageStyle::Asynchronous,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
|
||||||
|
let (mut app, f) = setup();
|
||||||
|
let original = app.document.bundle().clone();
|
||||||
|
let event = begin(&app, &f);
|
||||||
|
let context = app.scenario_context().unwrap();
|
||||||
|
let size = app.window_size;
|
||||||
|
let _ = app.update(Message::WindowResized(size));
|
||||||
|
assert_eq!(app.scenario_context(), Some(context));
|
||||||
|
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||||||
|
size.width + 20.,
|
||||||
|
size.height,
|
||||||
|
)));
|
||||||
|
assert_ne!(app.scenario_context(), Some(context));
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(event));
|
||||||
|
assert!(!app.behavior.has_pending());
|
||||||
|
let context = app.scenario_context().unwrap();
|
||||||
|
for (area, offset) in [
|
||||||
|
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
|
||||||
|
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
|
||||||
|
] {
|
||||||
|
let before = app.scenario_context().unwrap();
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||||
|
context, area, offset,
|
||||||
|
)));
|
||||||
|
assert_ne!(app.scenario_context(), Some(before));
|
||||||
|
let current = app.scenario_context().unwrap();
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||||
|
context, area, offset,
|
||||||
|
)));
|
||||||
|
assert_eq!(
|
||||||
|
app.scenario_context(),
|
||||||
|
Some(current),
|
||||||
|
"unchanged redraw notification retains context"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let event = begin(&app, &f);
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(event));
|
||||||
|
form(&mut app, Event::Name("Keep my draft".into()));
|
||||||
|
let context = app.scenario_context().unwrap();
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||||
|
context,
|
||||||
|
scenario::ScrollArea::Page,
|
||||||
|
iced::Vector::new(0., 200.),
|
||||||
|
)));
|
||||||
|
assert!(app.behavior.has_pending());
|
||||||
|
assert_eq!(app.document.bundle(), &original);
|
||||||
|
let current = app.scenario_context().unwrap();
|
||||||
|
for dimension in 0..3 {
|
||||||
|
let mut stale = current;
|
||||||
|
match dimension {
|
||||||
|
0 => stale.document = Id::new_v4(),
|
||||||
|
1 => stale.scenario = Id::new_v4(),
|
||||||
|
_ => stale.revision += 1,
|
||||||
|
}
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||||
|
stale,
|
||||||
|
scenario::ScrollArea::Canvas,
|
||||||
|
iced::Vector::new(0., 900.),
|
||||||
|
)));
|
||||||
|
assert_eq!(app.scenario_context(), Some(current));
|
||||||
|
}
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
|
||||||
|
let (mut app, f) = setup();
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
for dimension in 0..4 {
|
||||||
|
let mut event = begin(&app, &f);
|
||||||
|
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
match dimension {
|
||||||
|
0 => context.document = Id::new_v4(),
|
||||||
|
1 => context.scenario = Id::new_v4(),
|
||||||
|
2 => context.revision += 1,
|
||||||
|
_ => context.epoch += 1,
|
||||||
|
}
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(event));
|
||||||
|
assert!(!app.behavior.has_pending());
|
||||||
|
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||||
|
}
|
||||||
|
let _ = app.update(Message::Field(
|
||||||
|
"/name".into(),
|
||||||
|
"Pending scenario name".into(),
|
||||||
|
));
|
||||||
|
assert!(app.draft_dirty());
|
||||||
|
let event = begin(&app, &f);
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(event));
|
||||||
|
assert!(!app.behavior.has_pending());
|
||||||
|
assert!(app.draft_dirty());
|
||||||
|
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
|
||||||
|
let (mut app, f) = setup();
|
||||||
|
let original = app.document.bundle().clone();
|
||||||
|
let event = begin(&app, &f);
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(event));
|
||||||
|
form(
|
||||||
|
&mut app,
|
||||||
|
Event::Exchange(Choice {
|
||||||
|
id: f.exchange,
|
||||||
|
label: "Contract".into(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form(&mut app, Event::Submit);
|
||||||
|
assert!(!app.behavior.has_pending());
|
||||||
|
let created = app.selected;
|
||||||
|
assert_ne!(created, f.owner);
|
||||||
|
assert_eq!(
|
||||||
|
app.project().elements[&f.owner].steps().unwrap(),
|
||||||
|
&[f.call, f.reply, created]
|
||||||
|
);
|
||||||
|
let before_reorder = app.document.bundle().clone();
|
||||||
|
let context = app.scenario_context().unwrap();
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||||
|
context, f.sender,
|
||||||
|
)));
|
||||||
|
assert_eq!(
|
||||||
|
app.scenario_context(),
|
||||||
|
Some(context),
|
||||||
|
"same-owner selection retains gesture context"
|
||||||
|
);
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||||
|
context,
|
||||||
|
occurrence: created,
|
||||||
|
slot: Slot {
|
||||||
|
parent: f.owner,
|
||||||
|
before: Some(f.call),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert_eq!(app.selected, created);
|
||||||
|
assert_eq!(
|
||||||
|
app.project().elements[&f.owner].steps().unwrap(),
|
||||||
|
&[created, f.call, f.reply]
|
||||||
|
);
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.document.bundle(), &before_reorder);
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.document.bundle(), &original);
|
||||||
|
assert_eq!(app.project(), &f.project);
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
|
||||||
|
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>() + 4000;
|
||||||
|
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||||
|
app.sync();
|
||||||
|
let event = begin(&app, &f);
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(event));
|
||||||
|
form(
|
||||||
|
&mut app,
|
||||||
|
Event::Exchange(Choice {
|
||||||
|
id: f.exchange,
|
||||||
|
label: "Contract".into(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form(&mut app, Event::Name("Long message".repeat(1024)));
|
||||||
|
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 message".into()));
|
||||||
|
form(&mut app, Event::Submit);
|
||||||
|
assert!(!app.behavior.has_pending());
|
||||||
|
assert_eq!(app.project().named(app.selected), "Recovered message");
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert_eq!(app.document.bundle(), &bundle);
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod behavior_lifecycle_tests;
|
mod behavior_lifecycle_tests;
|
||||||
|
mod process_canvas;
|
||||||
mod state_diagram;
|
mod state_diagram;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod state_diagram_tests;
|
mod state_diagram_tests;
|
||||||
@@ -130,6 +341,7 @@ pub enum Message {
|
|||||||
FieldShape(String, Value),
|
FieldShape(String, Value),
|
||||||
FieldAppend(String, Value),
|
FieldAppend(String, Value),
|
||||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||||
|
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||||
FieldVariant(String, Value),
|
FieldVariant(String, Value),
|
||||||
FieldArray(String, Option<usize>, i32),
|
FieldArray(String, Option<usize>, i32),
|
||||||
Apply,
|
Apply,
|
||||||
@@ -147,6 +359,8 @@ pub enum Message {
|
|||||||
BehaviorOwner(Choice),
|
BehaviorOwner(Choice),
|
||||||
BehaviorDiagram(Choice),
|
BehaviorDiagram(Choice),
|
||||||
StateDiagram(crate::behavior::state_edit::Event),
|
StateDiagram(crate::behavior::state_edit::Event),
|
||||||
|
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||||
|
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||||
PlaceSelected,
|
PlaceSelected,
|
||||||
HideSelected,
|
HideSelected,
|
||||||
ToggleCollapse,
|
ToggleCollapse,
|
||||||
@@ -579,9 +793,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
fn execute(&mut self, command: Command) -> bool {
|
fn execute(&mut self, command: Command) -> bool {
|
||||||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||||||
|
let scenario_selection = self
|
||||||
|
.behavior
|
||||||
|
.scenario
|
||||||
|
.created_selection(&command)
|
||||||
|
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||||
match self.document.execute(command) {
|
match self.document.execute(command) {
|
||||||
Ok(selected) => {
|
Ok(selected) => {
|
||||||
if let Some(id) = selected {
|
if let Some(id) = scenario_selection.or(selected) {
|
||||||
self.selected = id;
|
self.selected = id;
|
||||||
}
|
}
|
||||||
self.sync();
|
self.sync();
|
||||||
@@ -737,6 +956,19 @@ impl App {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
// Scrollable geometry can change while async work or an overlay blocks
|
||||||
|
// commands. Keep the observed translation current; its passive handler
|
||||||
|
// still validates document, scenario and revision before accepting it.
|
||||||
|
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);
|
||||||
|
}
|
||||||
// Global subscriptions also receive keys consumed by an overlay.
|
// Global subscriptions also receive keys consumed by an overlay.
|
||||||
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
||||||
self.model_menu = None;
|
self.model_menu = None;
|
||||||
@@ -894,6 +1126,11 @@ impl App {
|
|||||||
let entering_catalog =
|
let entering_catalog =
|
||||||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||||||
let mut task = self.handle(message);
|
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| {
|
if self.connection.as_ref().is_some_and(|session| {
|
||||||
Some(session.context) != self.route_context()
|
Some(session.context) != self.route_context()
|
||||||
|| self.page != Page::Diagram
|
|| self.page != Page::Diagram
|
||||||
@@ -936,8 +1173,116 @@ impl App {
|
|||||||
task
|
task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
|
||||||
|
let scenario = crate::behavior::selected_owner(self)?;
|
||||||
|
(self.page == Page::Behavior
|
||||||
|
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
|
||||||
|
.then_some(crate::behavior::scenario::Context {
|
||||||
|
document: self.canvas_document,
|
||||||
|
scenario,
|
||||||
|
revision: self.document.revision(),
|
||||||
|
epoch: self.canvas_epoch,
|
||||||
|
})
|
||||||
|
}
|
||||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::ScenarioCanvas(event) => {
|
||||||
|
use crate::behavior::scenario::{self, CanvasEvent};
|
||||||
|
let context = event.context();
|
||||||
|
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||||
|
// Inner and outer scroll notifications may come from the
|
||||||
|
// same frame. Accept both across the first epoch bump,
|
||||||
|
// while still rejecting another owner/document/revision.
|
||||||
|
if self.scenario_context().is_some_and(|current| {
|
||||||
|
current.document == context.document
|
||||||
|
&& current.scenario == context.scenario
|
||||||
|
&& current.revision == context.revision
|
||||||
|
}) && self.behavior.scenario.scrolled(area, offset)
|
||||||
|
{
|
||||||
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
|
}
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if self.scenario_context() != Some(context) {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
CanvasEvent::Select(_, id) => {
|
||||||
|
if !self.behavior.has_pending()
|
||||||
|
&& crate::behavior::selected_behavior(self.project(), id)
|
||||||
|
== Some(context.scenario)
|
||||||
|
{
|
||||||
|
self.select(id);
|
||||||
|
self.selected_node = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CanvasEvent::Choose(_, tool) => {
|
||||||
|
if self.behavior.has_pending() || !self.apply() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.behavior.scenario.choose_tool(context.scenario, tool);
|
||||||
|
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 scenario."
|
||||||
|
.into();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
match mutation {
|
||||||
|
CanvasEvent::Begin {
|
||||||
|
slot,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
style,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||||
|
let result = state.begin_message(
|
||||||
|
self.project(),
|
||||||
|
context,
|
||||||
|
slot,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
style,
|
||||||
|
);
|
||||||
|
self.behavior.scenario = state;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
|
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
|
||||||
|
}
|
||||||
|
Err(error) => self.status = error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CanvasEvent::Reorder {
|
||||||
|
occurrence, slot, ..
|
||||||
|
} => {
|
||||||
|
match scenario::reorder(
|
||||||
|
self.project(),
|
||||||
|
context.scenario,
|
||||||
|
occurrence,
|
||||||
|
slot,
|
||||||
|
) {
|
||||||
|
Ok(command) => {
|
||||||
|
if self.execute(command) {
|
||||||
|
self.selected = occurrence;
|
||||||
|
self.selected_node = None;
|
||||||
|
self.refresh_draft();
|
||||||
|
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => self.status = error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::TopologyCanvas(event) => return self.edit_process_canvas(event),
|
||||||
Message::StateDiagram(event) => return self.edit_state_diagram(event),
|
Message::StateDiagram(event) => return self.edit_state_diagram(event),
|
||||||
Message::BehaviorOwner(choice) => {
|
Message::BehaviorOwner(choice) => {
|
||||||
if self.apply()
|
if self.apply()
|
||||||
@@ -1139,6 +1484,18 @@ impl App {
|
|||||||
self.status = error;
|
self.status = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Message::FieldValueBrowse(path, scope, page) => {
|
||||||
|
if let Some(draft) = &mut self.draft
|
||||||
|
&& draft
|
||||||
|
.value
|
||||||
|
.pointer(&path)
|
||||||
|
.is_some_and(|row| row.get("expression").is_some())
|
||||||
|
{
|
||||||
|
draft
|
||||||
|
.value_browsers
|
||||||
|
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||||
|
}
|
||||||
|
}
|
||||||
Message::FieldValuePath(path, key) => {
|
Message::FieldValuePath(path, key) => {
|
||||||
if let Some(draft) = &mut self.draft
|
if let Some(draft) = &mut self.draft
|
||||||
&& let Err(error) = draft.value_path(&path, key)
|
&& let Err(error) = draft.value_path(&path, key)
|
||||||
@@ -1513,6 +1870,9 @@ impl App {
|
|||||||
Ok(Some(command)) => {
|
Ok(Some(command)) => {
|
||||||
if self.execute(command) {
|
if self.execute(command) {
|
||||||
self.behavior.acknowledge_commit();
|
self.behavior.acknowledge_commit();
|
||||||
|
if self.topology_context().is_some() {
|
||||||
|
return self.reveal_process_selection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
@@ -2238,7 +2598,16 @@ impl App {
|
|||||||
Err(e) => e,
|
Err(e) => e,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Message::WindowResized(size) => self.window_size = size,
|
Message::WindowResized(size) => {
|
||||||
|
// The canvas can retain its capped size during a real window
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
Message::CanvasResized(size) => {
|
Message::CanvasResized(size) => {
|
||||||
let first_measurement = self.canvas_size.is_none();
|
let first_measurement = self.canvas_size.is_none();
|
||||||
let size_changed = self.canvas_size != Some(size);
|
let size_changed = self.canvas_size != Some(size);
|
||||||
@@ -2612,9 +2981,15 @@ impl App {
|
|||||||
.discovered(&disk.bundle, disk.reachable.clone());
|
.discovered(&disk.bundle, disk.reachable.clone());
|
||||||
self.show_source_file(false);
|
self.show_source_file(false);
|
||||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||||
|
self.migration = self.document.compiled().decoded.migration.clone();
|
||||||
|
if self.migration.is_some() {
|
||||||
|
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
|
||||||
|
self.page = Page::Validation;
|
||||||
|
} else {
|
||||||
self.status =
|
self.status =
|
||||||
"Source project opened. Included files and comments are preserved.".into();
|
"Source project opened. Included files and comments are preserved.".into();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.canvas_document = Id::new_v4();
|
self.canvas_document = Id::new_v4();
|
||||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
@@ -3243,7 +3618,9 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
|||||||
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
|
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
|
||||||
disk.expectations.get(&disk.bundle.entry)
|
disk.expectations.get(&disk.bundle.entry)
|
||||||
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
|
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
|
||||||
&& decoded.migration.is_some()
|
// Source1/2 require the historical single-document adapters.
|
||||||
|
// Source3+ must retain its authored bundle and token migration.
|
||||||
|
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
|
||||||
{
|
{
|
||||||
return Ok(LoadedSnapshot::Legacy(
|
return Ok(LoadedSnapshot::Legacy(
|
||||||
path,
|
path,
|
||||||
@@ -3328,6 +3705,97 @@ mod source_project_tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
|
||||||
|
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
|
||||||
|
for split in [false, true] {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.0.join("project.arcadia");
|
||||||
|
let mut authored = std::collections::BTreeMap::new();
|
||||||
|
if split {
|
||||||
|
let start = original.find("{\n").unwrap() + 2;
|
||||||
|
let end = original.rfind('}').unwrap();
|
||||||
|
authored.insert(
|
||||||
|
"project.arcadia",
|
||||||
|
format!(
|
||||||
|
"{} include \"dictionary.arcadia\";\n}}\n",
|
||||||
|
&original[..start]
|
||||||
|
),
|
||||||
|
);
|
||||||
|
authored.insert("dictionary.arcadia", original[start..end].to_owned());
|
||||||
|
} else {
|
||||||
|
authored.insert("project.arcadia", original.to_owned());
|
||||||
|
}
|
||||||
|
for (file, source) in &authored {
|
||||||
|
std::fs::write(directory.0.join(file), source).unwrap();
|
||||||
|
}
|
||||||
|
let mut app = App::with_project(Project::empty());
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||||
|
);
|
||||||
|
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
|
||||||
|
assert_eq!(app.page, Page::Validation);
|
||||||
|
assert_eq!(app.path, Some(path.clone()));
|
||||||
|
assert_eq!(app.document.bundle().files.len(), authored.len());
|
||||||
|
assert!(
|
||||||
|
app.dirty(),
|
||||||
|
"accepted source migration needs explicit saving"
|
||||||
|
);
|
||||||
|
for (file, source) in &authored {
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||||||
|
*source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let text = app
|
||||||
|
.document
|
||||||
|
.bundle()
|
||||||
|
.files
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect::<String>();
|
||||||
|
assert!(text.contains("// Reused record parameters retain their own identities."));
|
||||||
|
assert!(text.contains("// Frozen authored schema3 compatibility input."));
|
||||||
|
let packet = app
|
||||||
|
.project()
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.find(|e| e.alias == "packet")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let origin = app
|
||||||
|
.project()
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.find(|e| e.alias == "origin")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
assert_eq!(
|
||||||
|
app.document.compiled().index.lexical_owners.get(&origin),
|
||||||
|
Some(&packet)
|
||||||
|
);
|
||||||
|
let before = app.document.snapshot();
|
||||||
|
save(&mut app, path.clone()).unwrap();
|
||||||
|
assert!(!app.dirty());
|
||||||
|
for (file, source) in &before.bundle.files {
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||||||
|
*source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut reopened = App::with_project(Project::empty());
|
||||||
|
send(
|
||||||
|
&mut reopened,
|
||||||
|
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
|
||||||
|
);
|
||||||
|
assert!(reopened.migration.is_none());
|
||||||
|
assert_eq!(reopened.project(), app.project());
|
||||||
|
assert_eq!(reopened.document.bundle(), app.document.bundle());
|
||||||
|
assert!(!reopened.dirty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
|
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
|
||||||
let path = directory.0.join("project.arcadia");
|
let path = directory.0.join("project.arcadia");
|
||||||
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
|
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
|
||||||
|
|||||||
@@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
|
|||||||
Message::Field("/name".into(), "Unapplied state name".into()),
|
Message::Field("/name".into(), "Unapplied state name".into()),
|
||||||
);
|
);
|
||||||
assert!(app.draft_dirty());
|
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 [
|
for event in [
|
||||||
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
|
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
|
||||||
900., 600.,
|
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
|
||||||
))),
|
behavior::topology::CanvasEvent::Zoom(context, None),
|
||||||
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
|
|
||||||
behavior::Event::Topology(behavior::topology::Event::Fit),
|
|
||||||
] {
|
] {
|
||||||
send(app, Message::Behavior(event));
|
send(app, Message::TopologyCanvas(event));
|
||||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||||
assert!(app.draft_dirty());
|
assert!(app.draft_dirty());
|
||||||
assert!(!app.behavior.has_pending());
|
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()));
|
||||||
|
}
|
||||||
+1473
-54
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,16 @@
|
|||||||
//! Process/chain authoring and projection use contextual occurrences and explicit
|
//! Process/chain authoring and projection use contextual occurrences and explicit
|
||||||
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
||||||
use super::*;
|
use super::*;
|
||||||
use iced::widget::{Column, column, text_input};
|
use iced::widget::{Column, text_input};
|
||||||
use std::collections::BTreeSet;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Tool {
|
pub enum Tool {
|
||||||
@@ -40,9 +48,8 @@ pub enum Event {
|
|||||||
Guard(String),
|
Guard(String),
|
||||||
AlternateGuard(String),
|
AlternateGuard(String),
|
||||||
Control(ControlChoice),
|
Control(ControlChoice),
|
||||||
Viewport(Size),
|
Search(usize, String),
|
||||||
Zoom(f32),
|
Page(usize, usize),
|
||||||
Fit,
|
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ControlChoice(pub ControlKind);
|
pub struct ControlChoice(pub ControlKind);
|
||||||
@@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice {
|
|||||||
}
|
}
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct State {
|
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>,
|
draft: Option<Draft>,
|
||||||
viewport: Option<Size>,
|
viewport: Option<Size>,
|
||||||
zoom: Option<f32>,
|
zoom: Option<f32>,
|
||||||
|
fit: bool,
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Browser {
|
||||||
|
search: String,
|
||||||
|
page: usize,
|
||||||
}
|
}
|
||||||
struct Draft {
|
struct Draft {
|
||||||
|
insertion: Insertion,
|
||||||
|
fixed: bool,
|
||||||
|
browsers: [Browser; 4],
|
||||||
owner: Id,
|
owner: Id,
|
||||||
tool: Tool,
|
tool: Tool,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -99,9 +120,6 @@ impl State {
|
|||||||
return Err(PENDING_DRAFT.into());
|
return Err(PENDING_DRAFT.into());
|
||||||
}
|
}
|
||||||
match event {
|
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) => {
|
Event::Start(owner, tool, selected) => {
|
||||||
let e = p
|
let e = p
|
||||||
.elements
|
.elements
|
||||||
@@ -127,6 +145,9 @@ impl State {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
self.draft = Some(Draft {
|
self.draft = Some(Draft {
|
||||||
|
insertion: Insertion::default(),
|
||||||
|
fixed: false,
|
||||||
|
browsers: Default::default(),
|
||||||
owner,
|
owner,
|
||||||
tool,
|
tool,
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
@@ -154,6 +175,11 @@ impl State {
|
|||||||
Event::Guard(value) => d.guard = value,
|
Event::Guard(value) => d.guard = value,
|
||||||
Event::AlternateGuard(value) => d.alternate_guard = value,
|
Event::AlternateGuard(value) => d.alternate_guard = value,
|
||||||
Event::Control(value) => d.control = value.0,
|
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 {
|
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
|
||||||
self.zoom.unwrap_or_else(|| {
|
if !self.fit {
|
||||||
|
return self.zoom.unwrap_or(1.);
|
||||||
|
}
|
||||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||||
((width - 24.).max(1.) / graph.width.max(1.))
|
((width - 24.).max(1.) / graph.width.max(1.))
|
||||||
.min(450. / graph.height.max(1.))
|
.min(450. / graph.height.max(1.))
|
||||||
.clamp(0.05, 1.)
|
.clamp(0.05, 1.)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
|
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 {
|
.map(|e| Choice {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: format!("{} · {}", e.name, e.alias),
|
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
choices
|
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> {
|
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
|
||||||
p.elements
|
p.elements
|
||||||
.get(&owner)
|
.get(&owner)
|
||||||
@@ -394,6 +432,40 @@ impl Draft {
|
|||||||
if self.before.is_some() && self.before == self.after {
|
if self.before.is_some() && self.before == self.after {
|
||||||
return Err("Choose different preceding and following contributions.".into());
|
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(
|
let mut commands = preserved_order(
|
||||||
p,
|
p,
|
||||||
self.owner,
|
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());
|
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));
|
commands.push(link(p, source, target, &self.guard));
|
||||||
return Ok(unique_batch(p, commands));
|
return Ok(unique_batch(p, commands));
|
||||||
}
|
}
|
||||||
@@ -459,12 +530,6 @@ impl Draft {
|
|||||||
return Err("Choose same-layer work for the second path.".into());
|
return Err("Choose same-layer work for the second path.".into());
|
||||||
}
|
}
|
||||||
let parallel = self.control == ControlKind::Fork;
|
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(
|
let split = occurrence(
|
||||||
p,
|
p,
|
||||||
self.owner,
|
self.owner,
|
||||||
@@ -552,30 +617,47 @@ impl Draft {
|
|||||||
commands.push(Command::CreateElement(e));
|
commands.push(Command::CreateElement(e));
|
||||||
(id, id)
|
(id, id)
|
||||||
};
|
};
|
||||||
if let Some(before) = self.before {
|
if let Some(existing) = split_link {
|
||||||
// Branch guards belong to the split's two outgoing paths. A branch
|
let mut updated = existing.clone();
|
||||||
// inserted after another choice must first use an explicit link.
|
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
|
||||||
let guard = if self.tool == Tool::Branch {
|
*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 {
|
} else {
|
||||||
&self.guard
|
&self.guard
|
||||||
};
|
},
|
||||||
require_guard(p, before, guard)?;
|
));
|
||||||
commands.push(link(p, before, first, guard));
|
|
||||||
}
|
}
|
||||||
if let Some(after) = self.after {
|
if let Some(after) = self.after {
|
||||||
commands.push(link(p, last, after, ""));
|
commands.push(link(p, last, after, ""));
|
||||||
}
|
}
|
||||||
// Inserting between two selected contributions replaces their direct
|
let new_ids: Vec<_> = commands
|
||||||
// precedence while preserving other links and their semantic identities.
|
.iter()
|
||||||
if let (Some(before), Some(after)) = (self.before, self.after) {
|
.filter_map(|c| match c {
|
||||||
commands.extend(
|
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
|
||||||
sequence_links(p, self.owner)
|
_ => None,
|
||||||
.into_iter()
|
})
|
||||||
.filter(|r| r.source() == before && r.target() == after)
|
.collect();
|
||||||
.map(|r| Command::Delete(r.id)),
|
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))
|
Ok(unique_batch(p, commands))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
|
|||||||
}
|
}
|
||||||
Command::Batch(commands)
|
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 {
|
fn event(e: Event) -> Message {
|
||||||
Message::Behavior(if matches!(e, Event::Submit) {
|
Message::Behavior(if matches!(e, Event::Submit) {
|
||||||
super::Event::Submit
|
super::Event::Submit
|
||||||
@@ -622,307 +687,9 @@ fn event(e: Event) -> Message {
|
|||||||
super::Event::Topology(e)
|
super::Event::Topology(e)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn picker<'a>(
|
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||||
choices: Vec<Choice>,
|
view::view(app, owner, out)
|
||||||
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, 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> {
|
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||||
p.relations
|
p.relations
|
||||||
.values()
|
.values()
|
||||||
@@ -942,6 +709,7 @@ struct GraphNode {
|
|||||||
source: Option<Id>,
|
source: Option<Id>,
|
||||||
preview: Vec<(Id, String)>,
|
preview: Vec<(Id, String)>,
|
||||||
preview_links: Vec<(Id, Id)>,
|
preview_links: Vec<(Id, Id)>,
|
||||||
|
preview_total: usize,
|
||||||
}
|
}
|
||||||
struct GraphEdge {
|
struct GraphEdge {
|
||||||
id: Id,
|
id: Id,
|
||||||
@@ -951,15 +719,21 @@ struct GraphEdge {
|
|||||||
points: Vec<Point>,
|
points: Vec<Point>,
|
||||||
label: Rectangle,
|
label: Rectangle,
|
||||||
}
|
}
|
||||||
struct Graph {
|
struct Graph<'a> {
|
||||||
|
project: &'a Project,
|
||||||
|
interaction: Option<(Context, CanvasTool, bool)>,
|
||||||
|
scroll_offset: iced::Vector,
|
||||||
nodes: Vec<GraphNode>,
|
nodes: Vec<GraphNode>,
|
||||||
edges: Vec<GraphEdge>,
|
edges: Vec<GraphEdge>,
|
||||||
size: Size,
|
size: Size,
|
||||||
selected: Id,
|
selected: Id,
|
||||||
scale: f32,
|
scale: f32,
|
||||||
}
|
}
|
||||||
impl Graph {
|
impl<'a> Graph<'a> {
|
||||||
fn new(p: &Project, owner: Id, selected: Id) -> Self {
|
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 choices = contributions(p, owner);
|
||||||
let links = sequence_links(p, owner);
|
let links = sequence_links(p, owner);
|
||||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||||
@@ -1000,17 +774,17 @@ impl Graph {
|
|||||||
fallback += 1;
|
fallback += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
|
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||||
let mut nodes = vec![];
|
let mut nodes = vec![];
|
||||||
let mut size: Size = Size::new(640., 220.);
|
let mut size: Size = Size::new(640., 220.);
|
||||||
for c in choices {
|
for c in choices {
|
||||||
let e = &p.elements[&c.id];
|
let e = &p.elements[&c.id];
|
||||||
let rank = ranks[&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 {
|
let mut node = GraphNode {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
rect: Rectangle::new(
|
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.),
|
Size::new(270., 104.),
|
||||||
),
|
),
|
||||||
title: e.name.clone(),
|
title: e.name.clone(),
|
||||||
@@ -1019,8 +793,8 @@ impl Graph {
|
|||||||
source: None,
|
source: None,
|
||||||
preview: vec![],
|
preview: vec![],
|
||||||
preview_links: vec![],
|
preview_links: vec![],
|
||||||
|
preview_total: 0,
|
||||||
};
|
};
|
||||||
*slot += 1;
|
|
||||||
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
||||||
match body {
|
match body {
|
||||||
OccurrenceBody::Action { action, .. } => {
|
OccurrenceBody::Action { action, .. } => {
|
||||||
@@ -1051,11 +825,19 @@ impl Graph {
|
|||||||
.map(|id| format!("ref · {}", p.named(id)))
|
.map(|id| format!("ref · {}", p.named(id)))
|
||||||
.unwrap_or("Choose source in Properties".into());
|
.unwrap_or("Choose source in Properties".into());
|
||||||
if let Some(source) = sequence {
|
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()
|
.into_iter()
|
||||||
.take(4)
|
.take(if expanded.contains(&e.id) {
|
||||||
|
usize::MAX
|
||||||
|
} else {
|
||||||
|
4
|
||||||
|
})
|
||||||
.map(|c| (c.id, p.named(c.id).to_owned()))
|
.map(|c| (c.id, p.named(c.id).to_owned()))
|
||||||
.collect();
|
.collect();
|
||||||
|
node.rect.height =
|
||||||
|
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
|
||||||
let preview_ids: BTreeSet<_> =
|
let preview_ids: BTreeSet<_> =
|
||||||
node.preview.iter().map(|(id, _)| *id).collect();
|
node.preview.iter().map(|(id, _)| *id).collect();
|
||||||
node.preview_links = sequence_links(p, *source)
|
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.width = size.width.max(node.rect.x + node.rect.width + 28.);
|
||||||
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
}
|
}
|
||||||
let mut edges = vec![];
|
let mut edges = vec![];
|
||||||
|
let mut routed_pairs = BTreeSet::new();
|
||||||
for (i, r) in links.into_iter().enumerate() {
|
for (i, r) in links.into_iter().enumerate() {
|
||||||
let a = nodes
|
let a = nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1089,11 +873,14 @@ impl Graph {
|
|||||||
.rect;
|
.rect;
|
||||||
let start = Point::new(a.x + a.width, a.center_y());
|
let start = Point::new(a.x + a.width, a.center_y());
|
||||||
let end = Point::new(b.x, b.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.;
|
let mid = (start.x + end.x) / 2.;
|
||||||
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
|
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
|
||||||
} else {
|
} else {
|
||||||
let below = size.height + 24. + i as f32 * 30.;
|
let below = size.height + 24. + i as f32 * 42.;
|
||||||
vec![
|
vec![
|
||||||
start,
|
start,
|
||||||
Point::new(start.x + 18., start.y),
|
Point::new(start.x + 18., start.y),
|
||||||
@@ -1107,7 +894,7 @@ impl Graph {
|
|||||||
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
let label_at = if end.x > start.x {
|
let label_at = if forward {
|
||||||
Point::new(end.x - 138., end.y - 43.)
|
Point::new(end.x - 138., end.y - 43.)
|
||||||
} else {
|
} else {
|
||||||
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
||||||
@@ -1128,6 +915,9 @@ impl Graph {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
|
project: p,
|
||||||
|
interaction: None,
|
||||||
|
scroll_offset: iced::Vector::ZERO,
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
size,
|
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),
|
Stroke::default().with_color(color).with_width(width),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
impl canvas::Program<Message> for Graph {
|
impl canvas::Program<Message> for Graph<'_> {
|
||||||
type State = ();
|
type State = Input;
|
||||||
fn update(
|
fn update(
|
||||||
&self,
|
&self,
|
||||||
_: &mut (),
|
state: &mut Input,
|
||||||
event: &canvas::Event,
|
event: &canvas::Event,
|
||||||
bounds: Rectangle,
|
bounds: Rectangle,
|
||||||
cursor: mouse::Cursor,
|
cursor: mouse::Cursor,
|
||||||
) -> Option<Action<Message>> {
|
) -> Option<Action<Message>> {
|
||||||
if matches!(
|
self.input(state, event, bounds, cursor)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
fn draw(
|
fn draw(
|
||||||
&self,
|
&self,
|
||||||
_: &(),
|
state: &Input,
|
||||||
renderer: &Renderer,
|
renderer: &Renderer,
|
||||||
_: &Theme,
|
_: &Theme,
|
||||||
bounds: Rectangle,
|
bounds: Rectangle,
|
||||||
_: mouse::Cursor,
|
cursor: mouse::Cursor,
|
||||||
) -> Vec<canvas::Geometry> {
|
) -> Vec<canvas::Geometry> {
|
||||||
let mut f = Frame::new(renderer, bounds.size());
|
let mut f = Frame::new(renderer, bounds.size());
|
||||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||||
@@ -1327,7 +1094,7 @@ impl canvas::Program<Message> for Graph {
|
|||||||
if n.source.is_some() {
|
if n.source.is_some() {
|
||||||
draw_text(
|
draw_text(
|
||||||
&mut f,
|
&mut f,
|
||||||
"Open source →".into(),
|
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
||||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||||
11.,
|
11.,
|
||||||
style::TEAL,
|
style::TEAL,
|
||||||
@@ -1400,11 +1167,12 @@ impl canvas::Program<Message> for Graph {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.overlay(state, &mut f, cursor, bounds);
|
||||||
vec![f.into_geometry()]
|
vec![f.into_geometry()]
|
||||||
}
|
}
|
||||||
fn mouse_interaction(
|
fn mouse_interaction(
|
||||||
&self,
|
&self,
|
||||||
_: &(),
|
_: &Input,
|
||||||
bounds: Rectangle,
|
bounds: Rectangle,
|
||||||
cursor: mouse::Cursor,
|
cursor: mouse::Cursor,
|
||||||
) -> mouse::Interaction {
|
) -> mouse::Interaction {
|
||||||
@@ -1478,6 +1246,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||||
Draft {
|
Draft {
|
||||||
|
insertion: Insertion::default(),
|
||||||
|
fixed: false,
|
||||||
|
browsers: Default::default(),
|
||||||
owner,
|
owner,
|
||||||
tool: Tool::Branch,
|
tool: Tool::Branch,
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
@@ -1666,7 +1437,7 @@ mod tests {
|
|||||||
let command = branch(owner, a, b, before, after).command(&p).unwrap();
|
let command = branch(owner, a, b, before, after).command(&p).unwrap();
|
||||||
session.execute(command).unwrap();
|
session.execute(command).unwrap();
|
||||||
let result = session.project();
|
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!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||||
assert_eq!(sequence_links(result, owner).len(), 6);
|
assert_eq!(sequence_links(result, owner).len(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1777,10 +1548,26 @@ mod tests {
|
|||||||
draft: Some(branch(owner, a, b, before, after)),
|
draft: Some(branch(owner, a, b, before, after)),
|
||||||
..State::default()
|
..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.update(&p, Event::Submit).is_err());
|
||||||
assert!(state.draft.is_some());
|
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();
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||||
assert!(state.draft.is_some());
|
assert!(state.draft.is_some());
|
||||||
assert!(matches!(command, Command::Batch(_)));
|
assert!(matches!(command, Command::Batch(_)));
|
||||||
@@ -1836,9 +1623,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let mut graph = Graph::new(&p, owner, owner);
|
let mut graph = Graph::new(&p, owner, owner);
|
||||||
let mut state = State::default();
|
let mut state = State::default();
|
||||||
state
|
state.viewport(Size::new(486., 460.));
|
||||||
.update(&p, Event::Viewport(Size::new(486., 460.)))
|
state.zoom(None);
|
||||||
.unwrap();
|
|
||||||
graph.scale = state.scale(graph.size, 500.);
|
graph.scale = state.scale(graph.size, 500.);
|
||||||
assert!(graph.size.width * graph.scale <= 462.1);
|
assert!(graph.size.width * graph.scale <= 462.1);
|
||||||
assert!(graph.size.height * graph.scale <= 450.1);
|
assert!(graph.size.height * graph.scale <= 450.1);
|
||||||
@@ -1849,7 +1635,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let result = canvas::Program::update(
|
let result = canvas::Program::update(
|
||||||
&graph,
|
&graph,
|
||||||
&mut (),
|
&mut Input::default(),
|
||||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||||
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
||||||
mouse::Cursor::Available(click),
|
mouse::Cursor::Available(click),
|
||||||
@@ -1858,12 +1644,10 @@ mod tests {
|
|||||||
.into_inner()
|
.into_inner()
|
||||||
.0;
|
.0;
|
||||||
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
||||||
state.update(&p, Event::Zoom(1.)).unwrap();
|
state.zoom(Some(1.));
|
||||||
state
|
state.viewport(Size::new(800., 460.));
|
||||||
.update(&p, Event::Viewport(Size::new(800., 460.)))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(state.scale(graph.size, 500.), 1.);
|
assert_eq!(state.scale(graph.size, 500.), 1.);
|
||||||
state.update(&p, Event::Fit).unwrap();
|
state.zoom(None);
|
||||||
assert!(state.scale(graph.size, 500.) < 1.);
|
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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,10 +11,11 @@ pub struct FieldPathKey {
|
|||||||
pub struct FieldPathChoice {
|
pub struct FieldPathChoice {
|
||||||
pub key: FieldPathKey,
|
pub key: FieldPathKey,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
pub picker_label: String,
|
||||||
}
|
}
|
||||||
impl std::fmt::Display for FieldPathChoice {
|
impl std::fmt::Display for FieldPathChoice {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(&self.label)
|
f.write_str(&self.picker_label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +50,7 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
|
|||||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||||
.unwrap_or_else(|| id.to_string())
|
.unwrap_or_else(|| id.to_string())
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>();
|
||||||
.join(" → ");
|
|
||||||
let direction =
|
let direction =
|
||||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||||
match entry.direction {
|
match entry.direction {
|
||||||
@@ -63,31 +63,60 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
|
let terminal = qualified
|
||||||
|
.last()
|
||||||
|
.expect("A field path always has a terminal");
|
||||||
|
let owner_path = qualified[..qualified.len() - 1].join(" → ");
|
||||||
|
let picker_label = format!(
|
||||||
|
"{terminal}{direction} — {}{}",
|
||||||
|
project.named(entry.item),
|
||||||
|
if owner_path.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" / {owner_path}")
|
||||||
|
},
|
||||||
|
);
|
||||||
FieldPathChoice {
|
FieldPathChoice {
|
||||||
key: FieldPathKey {
|
key: FieldPathKey {
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
field: entry.field,
|
field: entry.field,
|
||||||
},
|
},
|
||||||
label: format!("{} / {qualified}{direction}", project.named(entry.item)),
|
label: format!(
|
||||||
|
"{} / {}{direction}",
|
||||||
|
project.named(entry.item),
|
||||||
|
qualified.join(" → ")
|
||||||
|
),
|
||||||
|
picker_label,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A bounded picker must expose its distinguishing field before a long path.
|
||||||
|
// Keep the full address separately for the wrapping detail text.
|
||||||
|
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
|
||||||
|
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
|
||||||
|
choices
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub fn choices(
|
pub fn choices(
|
||||||
project: &Project,
|
project: &Project,
|
||||||
exchange: &Relation,
|
exchange: &Relation,
|
||||||
phase: &MessageKind,
|
phase: &MessageKind,
|
||||||
) -> Result<Vec<FieldPathChoice>, String> {
|
) -> Result<Vec<FieldPathChoice>, String> {
|
||||||
Ok(data::try_carried_field_paths(project, exchange)?
|
Ok(sorted_choices(
|
||||||
|
data::try_carried_field_paths(project, exchange)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|entry| phase_allows(project, entry, phase))
|
.filter(|entry| phase_allows(project, entry, phase))
|
||||||
.map(|entry| named_choice(project, entry))
|
.map(|entry| named_choice(project, entry))
|
||||||
.collect())
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct carried fields only: an unrelated deep branch must not prevent
|
/// Direct carried fields only: an unrelated deep branch must not prevent
|
||||||
/// browsing a root field. Preserve the core resolver's first carried owner.
|
/// browsing a root field. Preserve the core resolver's first carried owner.
|
||||||
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
||||||
let mut seen = std::collections::BTreeSet::new();
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
sorted_choices(
|
||||||
exchange
|
exchange
|
||||||
.body
|
.body
|
||||||
.items()
|
.items()
|
||||||
@@ -110,7 +139,21 @@ pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec
|
|||||||
.filter(|entry| seen.insert(entry.field))
|
.filter(|entry| seen.insert(entry.field))
|
||||||
.filter(|entry| phase_allows(project, entry, phase))
|
.filter(|entry| phase_allows(project, entry, phase))
|
||||||
.map(|entry| named_choice(project, entry))
|
.map(|entry| named_choice(project, entry))
|
||||||
.collect()
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse one record at a time, including explicitly chosen recursive hops.
|
||||||
|
pub fn at_scope(
|
||||||
|
project: &Project,
|
||||||
|
exchange: &Relation,
|
||||||
|
phase: &MessageKind,
|
||||||
|
scope: Option<&FieldPathKey>,
|
||||||
|
) -> Result<Vec<FieldPathChoice>, String> {
|
||||||
|
match scope {
|
||||||
|
Some(key) => children(project, exchange, phase, key),
|
||||||
|
None => Ok(roots(project, exchange, phase)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||||
@@ -166,7 +209,8 @@ pub fn children(
|
|||||||
}
|
}
|
||||||
let mut path = key.path.clone();
|
let mut path = key.path.clone();
|
||||||
path.push(key.field);
|
path.push(key.field);
|
||||||
Ok(data::effective_fields(project, *record)
|
Ok(sorted_choices(
|
||||||
|
data::effective_fields(project, *record)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|field| {
|
.map(|field| {
|
||||||
named_choice(
|
named_choice(
|
||||||
@@ -179,7 +223,8 @@ pub fn children(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -482,4 +527,33 @@ pub(crate) mod tests {
|
|||||||
.contains("128")
|
.contains("128")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
|
||||||
|
let (f, next) = recursive_payload();
|
||||||
|
let exchange = &f.project.relations[&f.exchange];
|
||||||
|
let context = FieldPathKey {
|
||||||
|
path: vec![f.origin, next, next],
|
||||||
|
field: next,
|
||||||
|
};
|
||||||
|
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
|
||||||
|
let leaf = visible
|
||||||
|
.iter()
|
||||||
|
.find(|choice| choice.key.field == f.leaf)
|
||||||
|
.unwrap();
|
||||||
|
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
|
||||||
|
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
|
||||||
|
assert!(
|
||||||
|
visible.windows(2).all(
|
||||||
|
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
visible[0].to_string().chars().take(24).collect::<String>(),
|
||||||
|
visible[1].to_string().chars().take(24).collect::<String>()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||||
|
Some(leaf.clone())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ pub struct Input {
|
|||||||
pub raw: String,
|
pub raw: String,
|
||||||
pub editor: text_editor::Content,
|
pub editor: text_editor::Content,
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ValueBrowser {
|
||||||
|
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||||
|
pub page: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Draft {
|
pub struct Draft {
|
||||||
pub kind: RecordKind,
|
pub kind: RecordKind,
|
||||||
pub original: Id,
|
pub original: Id,
|
||||||
@@ -40,6 +46,7 @@ pub struct Draft {
|
|||||||
pub inputs: BTreeMap<String, Input>,
|
pub inputs: BTreeMap<String, Input>,
|
||||||
pub exact_strings: bool,
|
pub exact_strings: bool,
|
||||||
port_contract_drafts: BTreeMap<String, Value>,
|
port_contract_drafts: BTreeMap<String, Value>,
|
||||||
|
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Draft {
|
impl Draft {
|
||||||
@@ -77,11 +84,14 @@ impl Draft {
|
|||||||
inputs: BTreeMap::new(),
|
inputs: BTreeMap::new(),
|
||||||
exact_strings: false,
|
exact_strings: false,
|
||||||
port_contract_drafts: BTreeMap::new(),
|
port_contract_drafts: BTreeMap::new(),
|
||||||
|
value_browsers: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
draft.rebuild();
|
draft.rebuild();
|
||||||
Some(draft)
|
Some(draft)
|
||||||
}
|
}
|
||||||
fn rebuild(&mut self) {
|
fn rebuild(&mut self) {
|
||||||
|
// Array/variant edits can change the meaning of a row path.
|
||||||
|
self.value_browsers.clear();
|
||||||
self.inputs.clear();
|
self.inputs.clear();
|
||||||
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
|
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
|
||||||
match value {
|
match value {
|
||||||
@@ -710,10 +720,15 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
|||||||
..
|
..
|
||||||
},
|
},
|
||||||
..
|
..
|
||||||
} => project
|
} => project.relations.get(id).and_then(|r| {
|
||||||
.relations
|
let scope = address_path.split_last().map(|(&field, path)| {
|
||||||
.get(id)
|
crate::data_values::FieldPathKey {
|
||||||
.and_then(|r| crate::data_values::choices(project, r, message_kind).ok()),
|
path: path.to_vec(),
|
||||||
|
field,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
|
||||||
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|||||||
@@ -330,97 +330,152 @@ fn message_value<'a>(
|
|||||||
} => project.relations.get(id).map(|r| (r, message_kind)),
|
} => project.relations.get(id).map(|r| (r, message_kind)),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
let choices = context
|
let current_key = current
|
||||||
.map(|(r, phase)| crate::data_values::choices(project, r, phase))
|
|
||||||
.unwrap_or_else(|| Ok(vec![]));
|
|
||||||
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
|
|
||||||
match choices {
|
|
||||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
|
||||||
Ok(mut choices) => {
|
|
||||||
let current_key = current.as_ref().map(|v| crate::data_values::FieldPathKey {
|
|
||||||
path: v.path.clone(),
|
|
||||||
field: v.field,
|
|
||||||
});
|
|
||||||
let mut selected = current_key
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|key| choices.iter().find(|c| &c.key == key))
|
.map(|value| crate::data_values::FieldPathKey {
|
||||||
.cloned()
|
path: value.path.clone(),
|
||||||
.or_else(|| {
|
field: value.field,
|
||||||
context.and_then(|(r, phase)| {
|
});
|
||||||
|
let selected = context.and_then(|(exchange, phase)| {
|
||||||
current_key
|
current_key
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|key| crate::data_values::resolve(project, r, phase, key))
|
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
|
||||||
})
|
|
||||||
});
|
});
|
||||||
if let Some(choice) = &selected
|
let browser = draft
|
||||||
&& !choices.iter().any(|c| c.key == choice.key)
|
.value_browsers
|
||||||
{
|
.get(path)
|
||||||
choices.push(choice.clone());
|
.cloned()
|
||||||
}
|
.unwrap_or_else(|| ValueBrowser {
|
||||||
if selected.is_none()
|
scope: selected
|
||||||
&& let Some(key) = current_key.clone()
|
.as_ref()
|
||||||
|
.and_then(|choice| crate::data_values::parent(&choice.key)),
|
||||||
|
page: 0,
|
||||||
|
});
|
||||||
|
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
|
||||||
|
if let Some(choice) = &selected {
|
||||||
|
out = out.push(text(choice.label.clone()).size(11));
|
||||||
|
} else if let Some(key) = ¤t_key
|
||||||
&& !key.field.is_nil()
|
&& !key.field.is_nil()
|
||||||
{
|
{
|
||||||
out = out.push(text("Current address needs review. Choose an explicit path to resolve a legacy, ambiguous or incompatible value.").size(11).color(style::MUTED));
|
out = out.push(
|
||||||
let retained = crate::data_values::FieldPathChoice {
|
text(format!(
|
||||||
label: format!(
|
"Current address needs review: {} / {}. Choose an explicit field.",
|
||||||
"Current address · {} / {}",
|
|
||||||
key.path
|
key.path
|
||||||
.iter()
|
.iter()
|
||||||
.map(|id| project.named(*id))
|
.map(|id| project.named(*id))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" → "),
|
.join(" → "),
|
||||||
project.named(key.field)
|
project.named(key.field)
|
||||||
),
|
))
|
||||||
key,
|
.size(11)
|
||||||
};
|
.color(style::MUTED),
|
||||||
selected = Some(retained.clone());
|
);
|
||||||
choices.push(retained);
|
|
||||||
}
|
}
|
||||||
|
if let Some((exchange, phase)) = context {
|
||||||
|
let location = browser
|
||||||
|
.scope
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
|
||||||
|
.map(|choice| choice.label)
|
||||||
|
.unwrap_or_else(|| "Root carried fields".into());
|
||||||
|
out = out.push(
|
||||||
|
text(format!("Browse: {location}"))
|
||||||
|
.size(11)
|
||||||
|
.color(style::MUTED),
|
||||||
|
);
|
||||||
|
if let Some(scope) = &browser.scope {
|
||||||
|
let mut navigation = row![button(text("↑ Up").size(11)).style(style::nav).on_press(
|
||||||
|
Message::FieldValueBrowse(path.into(), crate::data_values::parent(scope), 0)
|
||||||
|
)]
|
||||||
|
.spacing(4);
|
||||||
|
if !scope.path.is_empty() {
|
||||||
|
navigation = navigation.push(
|
||||||
|
button(text("Root fields").size(11))
|
||||||
|
.style(style::nav)
|
||||||
|
.on_press(Message::FieldValueBrowse(path.into(), None, 0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out = out.push(navigation);
|
||||||
|
}
|
||||||
|
match crate::data_values::at_scope(project, exchange, phase, browser.scope.as_ref()) {
|
||||||
|
Ok(choices) => {
|
||||||
|
let count = choices.len();
|
||||||
|
let (visible, page, pages) = field_page(choices, browser.page);
|
||||||
|
// Selection detail stays visible independently of browser location/page.
|
||||||
|
let visible_selection = selected
|
||||||
|
.clone()
|
||||||
|
.filter(|choice| visible.iter().any(|c| c.key == choice.key));
|
||||||
let owned = path.to_owned();
|
let owned = path.to_owned();
|
||||||
out = out.push(
|
out = out.push(
|
||||||
pick_list(choices, selected, move |choice| {
|
pick_list(visible, visible_selection, move |choice| {
|
||||||
Message::FieldValuePath(owned.clone(), choice.key)
|
Message::FieldValuePath(owned.clone(), choice.key)
|
||||||
})
|
})
|
||||||
.placeholder("Choose a carried field path…")
|
.placeholder("Choose a field here…")
|
||||||
.text_size(12)
|
.text_size(12)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.width(Fill),
|
.width(Fill),
|
||||||
);
|
);
|
||||||
}
|
if pages > 1 {
|
||||||
}
|
|
||||||
if let (Some((exchange, phase)), Some(current)) = (context, current) {
|
|
||||||
let key = crate::data_values::FieldPathKey {
|
|
||||||
path: current.path,
|
|
||||||
field: current.field,
|
|
||||||
};
|
|
||||||
if crate::data_values::resolve(project, exchange, phase, &key).is_some() {
|
|
||||||
if let Some(parent) = crate::data_values::parent(&key) {
|
|
||||||
out = out.push(
|
out = out.push(
|
||||||
button(text("Up to enclosing field").size(11))
|
row![
|
||||||
|
button(text("Previous").size(11))
|
||||||
.style(style::nav)
|
.style(style::nav)
|
||||||
.on_press(Message::FieldValuePath(path.into(), parent)),
|
.on_press_maybe((page > 0).then(|| Message::FieldValueBrowse(
|
||||||
|
path.into(),
|
||||||
|
browser.scope.clone(),
|
||||||
|
page - 1
|
||||||
|
))),
|
||||||
|
text(format!("{} / {pages} · {count} fields", page + 1)).size(11),
|
||||||
|
button(text("Next").size(11))
|
||||||
|
.style(style::nav)
|
||||||
|
.on_press_maybe((page + 1 < pages).then(|| {
|
||||||
|
Message::FieldValueBrowse(
|
||||||
|
path.into(),
|
||||||
|
browser.scope.clone(),
|
||||||
|
page + 1,
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
);
|
||||||
|
} else if count == 0 {
|
||||||
|
out = out.push(
|
||||||
|
text("No compatible fields at this location.")
|
||||||
|
.size(11)
|
||||||
|
.color(style::MUTED),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
match crate::data_values::children(project, exchange, phase, &key) {
|
|
||||||
Ok(children) if !children.is_empty() => {
|
|
||||||
let path = path.to_owned();
|
|
||||||
out = out.push(
|
|
||||||
pick_list(
|
|
||||||
children,
|
|
||||||
None::<crate::data_values::FieldPathChoice>,
|
|
||||||
move |choice| Message::FieldValuePath(path.clone(), choice.key),
|
|
||||||
)
|
|
||||||
.placeholder("Choose a field inside this record…")
|
|
||||||
.text_size(12)
|
|
||||||
.padding(8)
|
|
||||||
.width(Fill),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
if let Some(choice) = &selected
|
||||||
|
&& browser.scope.as_ref() != Some(&choice.key)
|
||||||
|
&& crate::data_values::children(project, exchange, phase, &choice.key)
|
||||||
|
.is_ok_and(|children| !children.is_empty())
|
||||||
|
{
|
||||||
|
out = out.push(
|
||||||
|
button(
|
||||||
|
text(format!(
|
||||||
|
"Browse fields in {} →",
|
||||||
|
project.named(choice.key.field)
|
||||||
|
))
|
||||||
|
.size(11),
|
||||||
|
)
|
||||||
|
.style(style::nav)
|
||||||
|
.on_press(Message::FieldValueBrowse(
|
||||||
|
path.into(),
|
||||||
|
Some(choice.key.clone()),
|
||||||
|
0,
|
||||||
|
)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
out = out.push(
|
||||||
|
text("Choose a message exchange to browse its carried fields.")
|
||||||
|
.size(11)
|
||||||
|
.color(style::MUTED),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(expression) = value.get("expression") {
|
if let Some(expression) = value.get("expression") {
|
||||||
out = out.push(super::property(
|
out = out.push(super::property(
|
||||||
@@ -435,6 +490,25 @@ fn message_value<'a>(
|
|||||||
out.into()
|
out.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FIELDS_PER_PAGE: usize = 24;
|
||||||
|
|
||||||
|
fn field_page(
|
||||||
|
choices: Vec<crate::data_values::FieldPathChoice>,
|
||||||
|
requested: usize,
|
||||||
|
) -> (Vec<crate::data_values::FieldPathChoice>, usize, usize) {
|
||||||
|
let pages = choices.len().div_ceil(FIELDS_PER_PAGE).max(1);
|
||||||
|
let page = requested.min(pages - 1);
|
||||||
|
(
|
||||||
|
choices
|
||||||
|
.into_iter()
|
||||||
|
.skip(page * FIELDS_PER_PAGE)
|
||||||
|
.take(FIELDS_PER_PAGE)
|
||||||
|
.collect(),
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -490,6 +564,25 @@ mod tests {
|
|||||||
format!("{path}/expression"),
|
format!("{path}/expression"),
|
||||||
"newly typed".into(),
|
"newly typed".into(),
|
||||||
));
|
));
|
||||||
|
let before_browse = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||||
|
for (scope, page) in [
|
||||||
|
(None, 0),
|
||||||
|
(
|
||||||
|
Some(crate::data_values::FieldPathKey {
|
||||||
|
path: vec![f.origin],
|
||||||
|
field: next,
|
||||||
|
}),
|
||||||
|
999,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let _ = app.update(Message::FieldValueBrowse(path.into(), scope, page));
|
||||||
|
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
|
||||||
|
assert_eq!(
|
||||||
|
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||||
|
before_browse
|
||||||
|
);
|
||||||
|
assert_eq!(app.project(), &f.project);
|
||||||
|
}
|
||||||
let _ = app.update(Message::FieldValuePath(
|
let _ = app.update(Message::FieldValuePath(
|
||||||
path.into(),
|
path.into(),
|
||||||
crate::data_values::FieldPathKey {
|
crate::data_values::FieldPathKey {
|
||||||
@@ -553,6 +646,43 @@ mod tests {
|
|||||||
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
|
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_browser_pages_every_direct_field_without_flattening_other_records() {
|
||||||
|
let f = crate::data_values::tests::wide_payload();
|
||||||
|
let exchange = &f.project.relations[&f.exchange];
|
||||||
|
let roots =
|
||||||
|
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, None).unwrap();
|
||||||
|
assert!(roots.len() > FIELDS_PER_PAGE);
|
||||||
|
assert!(roots.iter().all(|choice| choice.key.path.is_empty()));
|
||||||
|
let scope = crate::data_values::FieldPathKey {
|
||||||
|
path: vec![],
|
||||||
|
field: f.origin,
|
||||||
|
};
|
||||||
|
let children =
|
||||||
|
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, Some(&scope))
|
||||||
|
.unwrap();
|
||||||
|
assert!(children.len() > FIELDS_PER_PAGE);
|
||||||
|
assert!(
|
||||||
|
children
|
||||||
|
.iter()
|
||||||
|
.all(|choice| choice.key.path == vec![f.origin])
|
||||||
|
);
|
||||||
|
for choices in [roots, children] {
|
||||||
|
let (_, _, pages) = field_page(choices.clone(), 0);
|
||||||
|
let visited: Vec<_> = (0..pages)
|
||||||
|
.flat_map(|page| {
|
||||||
|
let (visible, actual, _) = field_page(choices.clone(), page);
|
||||||
|
assert_eq!(actual, page);
|
||||||
|
assert!(visible.len() <= FIELDS_PER_PAGE);
|
||||||
|
visible
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(visited, choices);
|
||||||
|
assert_eq!(field_page(choices, usize::MAX).1, pages - 1);
|
||||||
|
}
|
||||||
|
assert_eq!(field_page(vec![], usize::MAX), (vec![], 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
|
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
|
||||||
let mut p = Project::empty();
|
let mut p = Project::empty();
|
||||||
|
|||||||
@@ -63,3 +63,9 @@ State camera/tool lifetime follows effective machine and diagram identity, even
|
|||||||
when selection changes through Catalog or Layer outside Behavior. Reset only on
|
when selection changes through Catalog or Layer outside Behavior. Reset only on
|
||||||
actual context change; preserve same-view return. Independent review reproduced
|
actual context change; preserve same-view return. Independent review reproduced
|
||||||
the leak and native Windows/Linux tests verified the correction in316dc55.
|
the leak and native Windows/Linux tests verified the correction in316dc55.
|
||||||
|
|
||||||
|
- Carried-value property browsing follows one exact record scope at a time, with
|
||||||
|
bounded pages and a separate read-only browser location. Flattened representative
|
||||||
|
paths were proven ambiguous in native recursive/reused data. Browsing does not
|
||||||
|
change the selected field or expression; choosing a field remains an explicit
|
||||||
|
draft edit through the ordinary validated command. No persistent browser schema.
|
||||||
|
|||||||
+85
-34
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
## Objective and phase
|
## 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
|
||||||
|
release-scenario-editing.md. Existing scene/order semantics and typed drafts are
|
||||||
|
retained; direct drawing, explicit insertion, same-parent reorder and correlated
|
||||||
|
activation rendering are implemented in d1d8f0b, with independent-review fixes
|
||||||
|
in 7a66607 for stale scroll slots, actual Iced scroll coordinates, operand-specific
|
||||||
|
activation segments and repeated picker cost, plus e7e37d4 for passive scroll
|
||||||
|
observations during async save. DATA b35dede is integrated in dfd2e41 without
|
||||||
|
conflicts. Combined Windows desktop tests pass 286/286, strict all-target desktop
|
||||||
|
Clippy and optimized build pass; a copied artifact/manifest is frozen under
|
||||||
|
`D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`. Exact artifact
|
||||||
|
hash, before/after failures and timings are in release-scenario-editing.md.
|
||||||
|
Focused independent recheck reports no remaining medium/high finding. Native
|
||||||
|
Windows/Linux trials remain pending and root-owned; this branch claims no native
|
||||||
|
acceptance and does not close process/chain work.
|
||||||
|
|
||||||
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
|
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
|
||||||
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
|
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
|
||||||
textual and REST semantics, artifact traceability, directly editable behavior,
|
textual and REST semantics, artifact traceability, directly editable behavior,
|
||||||
@@ -11,8 +45,10 @@ attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md.
|
|||||||
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
|
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
|
||||||
|
|
||||||
Direct state-machine editing is accepted through production316dc55. The main
|
Direct state-machine editing is accepted through production316dc55. The main
|
||||||
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts are now
|
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts
|
||||||
being implemented in a separate checkout; they are not accepted or released.
|
through b35dede are now integrated into the isolated direct-scenario checkout.
|
||||||
|
Root retains final main integration and acceptance; the combined direct scenario
|
||||||
|
has not yet completed native trials.
|
||||||
Meaningful implementation and runtime progress continues; no external blocker exists.
|
Meaningful implementation and runtime progress continues; no external blocker exists.
|
||||||
|
|
||||||
## Accepted foundations
|
## Accepted foundations
|
||||||
@@ -76,23 +112,17 @@ Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/
|
|||||||
with full data payload and dictionary slices together; existing typed ports remain.
|
with full data payload and dictionary slices together; existing typed ports remain.
|
||||||
See release-data-implementation.md and release-data-contract.md in that checkout.
|
See release-data-implementation.md and release-data-contract.md in that checkout.
|
||||||
|
|
||||||
Core is committed0b58107; source/migration1230ab8. Root API/docs are integrated
|
R6 core/source/desktop are frozen at ab661f2. Full Windows607 tests and Linux610
|
||||||
on the same working tree; desktop remains in flight and R6 is not accepted.
|
tests pass (one explicit benchmark ignored on each platform); strict workspace
|
||||||
Core15 R6 +28workflow tests passed again after final allocation-arrow suppression.
|
all-target Clippy passes on both. Optimized desktop/server Linux build also passed.
|
||||||
Linux0b58107 full core307 tests passed (one explicit probe ignored by default),
|
Windows desktop261 tests include the bounded scenario value editor. Exact logs,
|
||||||
with strict all-target core Clippy. Earlier13ports/14scenarios and115 source/lib
|
binaries and runtime evidence: release-data-verification.md.
|
||||||
tests also passed. Desktop final257 tests and strict all-target Clippy pass;
|
|
||||||
optimized Windows desktop build b403039 passed. Its SHA256 is recorded in the R6 verification note; native acceptance remains pending.
|
|
||||||
|
|
||||||
Root41 server tests passed; a fourth R6 HTTP workflow now also passes, alongside all4 data HTTP tests and strict server Clippy. The original3 actual HTTP workflows for the published
|
All4 R6 actual HTTP workflows pass, covering data protocols/artifact identity,
|
||||||
data example, protocol/artifact identity, atomic rejected defaults, query/export,
|
rejected defaults, qualified Call/Reply paths, validation locations, source edits,
|
||||||
reidentify/delete/restart, source3 bundle migration reports and old coupled3
|
reidentification, source3 migration and restart persistence. The published example's
|
||||||
envelope upgrade without write/revision. Tests exposed dropped bundle reports and
|
actual server responses passed served/local OpenAPI validation; obsolete shapes
|
||||||
rejected legitimate old source/model pairs; both root fixes passed retest.
|
reject. Strict reST rendering and81 OpenAPI schemas passed.
|
||||||
Strict reST HTML generation and81 OpenAPI schema definitions passed. Actual frozen
|
|
||||||
server32033FFE imported the published example; its complete Project/StoredProject
|
|
||||||
responses passed served/current JSON Schema validation and obsolete shapes rejected.
|
|
||||||
Evidence: api-r6-contract and r6-server-tests-after.log under the release directory.
|
|
||||||
|
|
||||||
Independent core review found association transition closure pulling unrelated
|
Independent core review found association transition closure pulling unrelated
|
||||||
types and missing empty-enum warning; both corrected and focused tests passed.
|
types and missing empty-enum warning; both corrected and focused tests passed.
|
||||||
@@ -106,14 +136,24 @@ pages replaces unbounded inherited rows; recursive paths support explicit drill-
|
|||||||
and imported finite paths; same-phase styles/same-exchange reselection preserve
|
and imported finite paths; same-phase styles/same-exchange reselection preserve
|
||||||
valid drafts. Read-only follow-up found no new path/style defect. Paging resets
|
valid drafts. Read-only follow-up found no new path/style defect. Paging resets
|
||||||
only on actual layer/document changes; its regression failed before and passes now.
|
only on actual layer/document changes; its regression failed before and passes now.
|
||||||
Root found another unresolved scale issue: scenario creation still enumerates every
|
The bounded scenario editor replaces whole-recursion enumeration and per-field
|
||||||
carried field into both a dropdown and individual input rows. A bounded focused value editor is now implemented and under automated/independent review; actual native operation remains required before R6 acceptance.
|
inputs with24-row browsing and a single focused value draft. The reply call picker
|
||||||
|
no longer filters alternative calls using values belonging to the old exchange.
|
||||||
|
Automated and independent checks pass; native scenario checks are next.
|
||||||
|
|
||||||
Current ownership: drawing_correctness owns desktop except root-owned guide.rs and
|
Root exclusively operates native R6. The Windows13-object data model has saved
|
||||||
the dedicated Windows Cargo target. state_architecture_review independently reviews
|
native bounds/default repair, a typed physical flow/field, allocation protocol
|
||||||
R6 spec/API consistency; its data_view.rs ownership has ended. data_core runs an
|
edit, allocation removal/Undo, and inherited-owner navigation. Association endpoint
|
||||||
isolated R3 typed-baseline experiment; Linux R6 core checkout is idle and verified.
|
labels/selectability were exercised. The1100x700 data layout has been inspected.
|
||||||
Root owns API/OpenAPI/reST, docs, integration and native input. No concurrent writers.
|
A separate53-object recursive-operation fixture was prepared with core APIs and
|
||||||
|
round-tripped for upcoming native scenario trials; fixture construction is not
|
||||||
|
claimed as native authoring. See the verification note for remaining gates.
|
||||||
|
|
||||||
|
Parallel isolated work: drawing_correctness implements direct scenario gestures in
|
||||||
|
Capella2-scenario-direct. data_core owns typed semantic review core and
|
||||||
|
state_architecture_review owns source5/migration in Capella2-semantic-review.
|
||||||
|
The typed facet C design won the bounded experiment/criticism round; its production
|
||||||
|
plan and decision are recorded in that checkout. No overlapping writers or targets.
|
||||||
|
|
||||||
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
|
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
|
||||||
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
|
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
|
||||||
@@ -121,8 +161,8 @@ Root owns API/OpenAPI/reST, docs, integration and native input. No concurrent wr
|
|||||||
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
|
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
|
||||||
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
|
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
|
||||||
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
|
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
|
||||||
baselines/reconciliation only cover prose. Prefer historical typed-body snapshots
|
baselines/reconciliation only cover prose. Use the experimentally selected typed facet review design C; the semantic
|
||||||
and finite proposals; avoid a duplicate recursive semantic schema without evidence.
|
integration checkout records the full scope and source5 migration plan.
|
||||||
4. Complete direct scenario/process gestures, persistent coverage/filters,
|
4. Complete direct scenario/process gestures, persistent coverage/filters,
|
||||||
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
|
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
|
||||||
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
|
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
|
||||||
@@ -130,13 +170,13 @@ Root owns API/OpenAPI/reST, docs, integration and native input. No concurrent wr
|
|||||||
|
|
||||||
## Environment and native ownership
|
## Environment and native ownership
|
||||||
|
|
||||||
Native input is paused after physical Escape; no R6 native trial has run.
|
The user explicitly reauthorized native testing after Escape. Root's f5467e5 R6
|
||||||
Root exclusively owns native input. Latest saved Windows trial is window1706782,
|
window3476204 and exec93768 closed normally after verified source migration and
|
||||||
ui-state-navigation-windows,49objects/28links/1 expected missing-owner warning.
|
55-object recursive scenario reopen; no runtime output. The property-browser
|
||||||
Linux navigation trial3148598 closed normally, exec46107 exit0; baseline Linux
|
replacement build b35dede has passed the root's Windows native retest. The
|
||||||
trial721472 also closed normally, exec72680 exit0. Independent Windows3672536 is
|
combined direct-scenario binary is frozen for its separate pending native trial.
|
||||||
saved at1100x700. Older root drawing windows are saved. Re-inventory before input.
|
Original Capella is present. Older protected user7210312/6228978 were absent;
|
||||||
Protected user7210312/6228978 were absent; do not reset/save/close if they return.
|
do not reset/save/close them if they return. Re-inventory before switching apps.
|
||||||
|
|
||||||
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
|
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
|
||||||
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
|
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
|
||||||
@@ -147,3 +187,14 @@ Windows: cargo test --locked --workspace; cargo clippy --locked --workspace
|
|||||||
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
|
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
|
||||||
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
|
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
|
||||||
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
|
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
|
||||||
|
|
||||||
|
Latest R6 checkpoint: b35dede Windows264 desktop tests, strict Clippy, optimized
|
||||||
|
build and root-owned native retest pass for the one-record property browser. Paging and
|
||||||
|
location changes retain accepted model and typed expressions. f5467e5 Windows
|
||||||
|
and Linux263 desktop tests, strict Clippy and optimized release passed. Native
|
||||||
|
Windows source3 migration preserved comments/nesting through explicit Save All
|
||||||
|
and clean source4 reopen. Native recursive55-object scenario values and paired
|
||||||
|
Call/Reply history were verified; flat property paths prompted the latest fix.
|
||||||
|
The independent bounded-browser review reports no remaining medium/high finding.
|
||||||
|
Linux native R6 workflows remain a root-owned acceptance gate. See
|
||||||
|
release-data-verification.md. Main remains a8f451e; R6 is not yet accepted.
|
||||||
|
|||||||
+12
-8
@@ -39,9 +39,13 @@ requires native workflows, semantic evaluators and fresh independent review.
|
|||||||
whole-model equality or flat reformatting. Independent counterexamples were fixed.
|
whole-model equality or flat reformatting. Independent counterexamples were fixed.
|
||||||
- R3 existing-target reconciliation remains name/description only. R6 data shape
|
- R3 existing-target reconciliation remains name/description only. R6 data shape
|
||||||
expansion is active implementation, not accepted functionality.
|
expansion is active implementation, not accepted functionality.
|
||||||
- R6 core/source and actual HTTP semantics pass focused Windows and full Linux
|
- R6 ab661f2 full suites passed Windows607/Linux610; f5467e5 desktop263 and
|
||||||
core checks; desktop257 tests pass after independent draft/path/paging fixes.
|
strict Clippy/release passed on both platforms. Native Windows data editing,
|
||||||
Native R6 acceptance and bounded scenario-value authoring remain outstanding.
|
typed defaults, allocations, recursive qualified Call/Reply values, history and
|
||||||
|
saved reopening passed. Source3 migration now preserves comments and nesting.
|
||||||
|
Native found flat property browsing still ambiguous; one-record browsing with
|
||||||
|
bounded pages now passes264 desktop tests/Clippy, pending rebuilt native checks.
|
||||||
|
Linux native R6 acceptance remains outstanding.
|
||||||
|
|
||||||
## Assumptions and open questions
|
## Assumptions and open questions
|
||||||
|
|
||||||
@@ -51,11 +55,11 @@ requires native workflows, semantic evaluators and fresh independent review.
|
|||||||
gestures still need their own occurrence/order semantics and task verification.
|
gestures still need their own occurrence/order semantics and task verification.
|
||||||
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
|
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
|
||||||
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
|
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
|
||||||
- R3 typed historical bodies plus finite proposals appear simpler than a separate
|
- R3 selected design C uses finite self-tagged typed facets, separate membership
|
||||||
recursive semantic-value schema. An isolated probe supports scalar partial review
|
and order acknowledgements, and transient constructor groups. Source5 historical
|
||||||
and deleted historical references, but ordered membership/order acknowledgements
|
references are implemented in the isolated semantic checkout. Independent review
|
||||||
need separate typed order checkpoints. Historical source reference parsing is
|
found narrow-acknowledgement/canonical-agreement/rollup defects; the worker has
|
||||||
still unimplemented; this remains a candidate awaiting independent review.
|
added regressions and fixes. Full catalog, GUI/API/spec and acceptance remain.
|
||||||
|
|
||||||
## Decisions and current strategy
|
## Decisions and current strategy
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,119 @@
|
|||||||
# R6 data contracts — verification in progress
|
# R6 data contracts — native verification in progress
|
||||||
|
|
||||||
R6 is not accepted. Main remains at state-editing acceptance a8f451e. The updated
|
R6 is not accepted. Main remains at a8f451e. The full first-release goal remains
|
||||||
objective10134df9-bea2-4c02-a820-67b53d6b86f7 was read fully on2026-09-06; product
|
active and incomplete. User explicitly reauthorized native testing after Escape.
|
||||||
scope is unchanged and reasoning effort must match the delegated task. The full
|
|
||||||
first release remains incomplete. Native input is paused after physical Escape.
|
|
||||||
|
|
||||||
## Current evidence
|
## Frozen production and automated evidence
|
||||||
|
|
||||||
- Core0b58107: Linux307 tests pass, one explicitly ignored benchmark; strict
|
Data integration ab661f2: Windows607 and Linux610 workspace tests pass, one explicit
|
||||||
all-target core Clippy passes. Windows15 R6+28 workflows pass after final
|
benchmark ignored each; strict workspace all-target Clippy passes on both.
|
||||||
allocation-edge filtering. Source migration/compatibility evidence is retained.
|
Windows261 desktop tests include the focused bounded scenario editor; Linux release
|
||||||
- Desktopb403039: Windows257 tests, strict all-target desktop Clippy and release
|
desktop/server build passed. Logs under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||||
build pass. Binary D:/CodexBuilds/Capella2-data-target/release/arcadia-studio.exe,
|
r6-workspace-windows-ab661f2.log and r6-workspace-windows-clippy-ab661f2.log.
|
||||||
SHA256 d4aa6ae7d36f429c0cb639cdf57e33cb8b99d158a9629c95a1510d24e406fab5.
|
Linux logs in Capella2-data-linux-review/target/linux-verification:
|
||||||
The later bounded scenario editor is not covered by this binary.
|
workspace-ab661f2.log, workspace-clippy-ab661f2.log, release-ab661f2.log.
|
||||||
- Optimized empty inheritance chains250/500/1000: Windows21.070/89.088/391.590ms;
|
|
||||||
Linux22.381/111.662/408.129ms. These are single-run validation measurements.
|
|
||||||
- Server41 prior tests passed. The new qualified Call/Reply workflow and all4 R6
|
|
||||||
HTTP tests now pass with strict all-target server Clippy. Actual HTTP proves
|
|
||||||
distinct reused/inherited/recursive field paths, preserved expression whitespace,
|
|
||||||
phase/path422 with unchanged ETag/model/source/disk, source edits, enclosing-hop
|
|
||||||
and terminal/base reidentification, correlation and exact restart persistence.
|
|
||||||
Initial new-test failures were fixture misuse of untyped Port construction and
|
|
||||||
incorrect assumption that transaction errors carry structured issues. Tests now
|
|
||||||
use make_port and the documented command_rejected/detail plus validation endpoint.
|
|
||||||
- Published data example was imported into actual frozen server32033FFE. Complete
|
|
||||||
Project/StoredProject responses passed served/local OpenAPI validation; obsolete
|
|
||||||
shapes rejected. reST strict rendering and81 OpenAPI schemas passed.
|
|
||||||
- Independent spec review found one stale identity-only port-compatibility paragraph;
|
|
||||||
fixed to directional explicit ancestry and strict HTML rendering passed. No other
|
|
||||||
material R6 spec/API contradiction found in that review.
|
|
||||||
|
|
||||||
Logs are under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
Windows frozen executable ui-r6-windows/arcadia-studio.exe SHA256
|
||||||
r6-http-paths-final.log, r6-http-all-data.log, r6-http-paths-clippy.log,
|
C3ABD9E550E1AD6824DFE273EB27A3ABB35C5C0DA97C819DCB0AB2586AF5450C.
|
||||||
r6-server-tests-after.log and api-r6-contract. Linux core evidence is under
|
Current native window1835476, process session37598. No output observed; runtime.log
|
||||||
D:/CodexBuilds/Capella2-data-linux-review/target/linux-verification/report.md.
|
has not been created by Tee-Object (do not claim an empty log file).
|
||||||
|
|
||||||
## Active checks and remaining gates
|
All4 actual R6 HTTP tests pass with strict server Clippy: published data example,
|
||||||
|
protocol/artifact identity, rejected defaults, qualified reused/inherited/recursive
|
||||||
|
Call/Reply paths, phase/path422 with unchanged ETag/model/source/disk, source edits,
|
||||||
|
hop/terminal/base reidentification, correlation and restart persistence. Source3
|
||||||
|
coupled/source bundle migrations report correctly and opening alone does not write.
|
||||||
|
Logs: r6-http-paths-final.log, r6-http-all-data.log, r6-http-paths-clippy.log.
|
||||||
|
Initial fixture errors (untyped Port and transaction-error envelope assumption)
|
||||||
|
were corrected before passing. Published example responses from server32033FFE
|
||||||
|
passed served/local OpenAPI validation; obsolete shapes rejected. Strict reST HTML
|
||||||
|
and81 OpenAPI schemas passed. One stale port compatibility paragraph was corrected.
|
||||||
|
|
||||||
The isolated Linux checkout was fast-forwarded to b403039; full workspace tests are
|
Optimized empty inheritance chains250/500/1000: Windows21.070/89.088/391.590ms;
|
||||||
running in exec98587 with its dedicated data-linux-target. Initial filesystem wait
|
Linux22.381/111.662/408.129ms. Single-run core validation timings, not UI latency.
|
||||||
was verified as a live Cargo process in p9_client_rpc; it subsequently compiled.
|
|
||||||
Do not restart from an observation timeout. Bounded scenario editor production and
|
|
||||||
regressions are being tested by the desktop worker. Independent review found stale
|
|
||||||
values hiding an alternative reply call; its fix is included in that worker pass.
|
|
||||||
|
|
||||||
Before acceptance: integrated Windows/Linux checks, native data/association/scenario
|
## Personally observed Windows native workflows
|
||||||
value workflows at normal/minimum sizes, source3 migration diagnostics, Save/reopen,
|
|
||||||
and closure of material review findings. No R6 visual or real-app acceptance claim.
|
Loaded the isolated Engineering data source fixture. Its architecture view shows
|
||||||
|
nested fields, association role/multiplicity labels, self-reference navigation and
|
||||||
|
selectable endpoint labels. No selection-induced dirty state observed.
|
||||||
|
|
||||||
|
Voltage maximum48→12 with default24 rejects when navigating; entered12 survives,
|
||||||
|
accepted summary remains0..48/default24, and a located default error is shown.
|
||||||
|
Repair default→12 applies together; Undo restores both old values, Redo restores
|
||||||
|
both new values. Created Cooling water with Material/Flow and contained Mass flow
|
||||||
|
rate Real1..1, unit kg/s, explicit default0.0. Save All wrote an included sa/types.arcadia
|
||||||
|
file with matching typed values, leaving13objects/4links/1 expected missing-System
|
||||||
|
warning. Navigation allocation changed Synchronous→Asynchronous; Monitoring's
|
||||||
|
separate Asynchronous/Read allocation is preserved. Removing Monitoring allocation
|
||||||
|
keeps Navigate; Undo restores the allocation and original checks. Followed inherited
|
||||||
|
Latitude from Located asset to Coordinate. Saved all edits. Data layout inspected
|
||||||
|
at1440x900 and1100x700; no overlapping controls observed.
|
||||||
|
|
||||||
|
Separate ui-r6-scenario-windows/model/project.arcadia was constructed with the core
|
||||||
|
API from the saved native data, adding functional ports/exchange, correlated
|
||||||
|
Call/Reply, reused/inherited/recursive records and30 extra fields.53objects/5links
|
||||||
|
validate and source round-trip exactly. fixture-generator.rs retained as evidence;
|
||||||
|
temporary project example removed. Initial generator compile/load errors were
|
||||||
|
fixture-only and fixed using the bundle-aware loader; final build has no warnings.
|
||||||
|
This preparation is not native scenario authoring evidence.
|
||||||
|
|
||||||
|
## Remaining acceptance gates
|
||||||
|
|
||||||
|
Native scenario focused/path/phase/draft behavior and paging; actual saved reopen;
|
||||||
|
source3 migration diagnostics; Linux native data/scenario workflows. Closure of
|
||||||
|
material findings before R6 acceptance. Independent semantic/correctness/spec
|
||||||
|
reviews already drove fixes; final native observations may require further review.
|
||||||
|
The broader release still needs semantic reconciliation, direct scenario/process
|
||||||
|
editing, lifecycle/hosted/help work and fresh cross-disciplinary end-state review.
|
||||||
|
|
||||||
|
## Native findings and follow-up fixes (2026-09-06)
|
||||||
|
|
||||||
|
At1100x700, the53-object recursive fixture exposed32/33 nested fields in two pages.
|
||||||
|
Native Call+Reply creation stored distinct Origin/Latitude and Destination/Latitude
|
||||||
|
expressions despite their shared terminal UUID. One Undo removed the pair; Redo
|
||||||
|
restored it. Save All wrote55objects/5links; source confirms both paths and reply
|
||||||
|
correlation. Three expected checks concern the absent SA System and two unallocated
|
||||||
|
functions. The saved evidence is ui-r6-scenario-windows/model/project.arcadia.
|
||||||
|
|
||||||
|
A third-hop popup exposed a material UI defect: repeated long path prefixes hid
|
||||||
|
all terminal field names. Fix74989b6 shows the terminal name first, sorts choices,
|
||||||
|
and retains the full address as wrapping detail text.262 desktop tests, strict
|
||||||
|
Clippy and release build passed; native retest is pending. Intermediate fixed hash
|
||||||
|
E592165A6FFFAEC64B67FF0A33FB4E78571527EE3C8F2CC457666DA6C277D5CB.
|
||||||
|
|
||||||
|
Native source3 import showed8semantic defaults,2generated allocations,1unresolved
|
||||||
|
value address and left the original hash305F19D1B79F99F8D4CF627BA2E9CA6836BB7959CC0070227815937A699EAFEA unchanged.
|
||||||
|
Its old desktop Save All path requested a new file and preserved the original, but
|
||||||
|
canonical regeneration discarded comments/nesting in the upgraded copy. This is a
|
||||||
|
material desktop/source parity defect, not a core bundle migration defect. The fix
|
||||||
|
keeps the historical single-document adapter only for source1/2; source3+ follows
|
||||||
|
bundle migration and propagates its report into Model checks. A regression passes
|
||||||
|
for actual disk single-file and included-file open, no write until explicit save,
|
||||||
|
comments, lexical ownership, report, exact saved source and clean reopened state.
|
||||||
|
Full desktop/Clippy/build and native retest are in progress; do not accept R6 yet.
|
||||||
|
|
||||||
|
The old Windows native window1835476 closed normally; exec37598 exited0 with no
|
||||||
|
runtime output beyond a newline. Re-inventory before opening the rebuilt binary.
|
||||||
|
|
||||||
|
## Migration retest and bounded property browsing
|
||||||
|
|
||||||
|
f5467e5 passed263 desktop tests, strict all-target desktop Clippy and optimized
|
||||||
|
release on Windows/Linux. Frozen Windows SHA256
|
||||||
|
33D1B2B6709BBC734E265568C7F93CB86A506497DEF3F4531669E5ABF8B2F039.
|
||||||
|
Native fresh legacy.arcadia import preserved original bytes until Save All,
|
||||||
|
displayed8defaults/2allocations/1unresolved address, and retained comments and
|
||||||
|
nested fields in its text editor. Save All kept the same file and source4 wording;
|
||||||
|
reopen was clean15objects/3links/4checks without a migration report. Saved SHA256
|
||||||
|
A8C0A689FEA6CC352007ACB127DE4A45E05290830DE815385CFFBAB777255543.
|
||||||
|
Evidence: ui-r6-final-windows/migration/legacy.arcadia.
|
||||||
|
|
||||||
|
The55-object recursive scenario reopened with both qualified expressions intact.
|
||||||
|
Leaf-first labels fixed the hidden terminal names, but existing-value properties
|
||||||
|
still flattened every representative path, causing repeated indistinguishable
|
||||||
|
leaf names. The follow-up browses one record at a time, with24-choice pages and
|
||||||
|
wrapping current address/location text. Up/root/page actions change only browser
|
||||||
|
state; draft field and expression are preserved.264 desktop tests and strict
|
||||||
|
Clippy passed, including wide-record page coverage and draft preservation across
|
||||||
|
recursive browsing. Optimized build and native retest remain pending. Frozen
|
||||||
|
f5467e5 window3476204 closed normally; exec93768 had no runtime output.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Direct scenario editing — bounded implementation plan
|
||||||
|
|
||||||
|
Status: implementation and automated Windows checks passed in the isolated
|
||||||
|
`codex/scenario-direct` checkout, based on ab661f2. Initial production is d1d8f0b;
|
||||||
|
independent-review fixes are in 7a66607 and e7e37d4. The focused independent
|
||||||
|
recheck reports no remaining medium/high finding. Root-owned Windows/Linux native
|
||||||
|
trials remain pending. DATA b35dede is integrated in dfd2e41 with 286 desktop
|
||||||
|
tests, strict Clippy and a fresh frozen Windows release artifact. This does not
|
||||||
|
complete the release or process/chain work.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Extend the existing `behavior::Scene`; retain its derived lifeline columns and
|
||||||
|
ordered occurrence rows. No model/schema/layout records are added. Lifeline and
|
||||||
|
occurrence UUIDs remain distinct from represented work and global exchanges.
|
||||||
|
|
||||||
|
Select mode selects headers, stems, messages and occurrence boxes. A selected
|
||||||
|
occurrence has a reorder handle; dragging it chooses a visible slot in its same
|
||||||
|
parent sequence. It never reparents or changes lifeline columns. A Message tool
|
||||||
|
uses the existing MessageStyle, with Call + correlated reply initially selected.
|
||||||
|
Click-click or drag between represented lifelines captures a highlighted explicit
|
||||||
|
insertion slot `(parent, before occurrence or append)` and opens the existing
|
||||||
|
typed draft with those endpoints. Exchanges are filtered by layer, viewpoint and
|
||||||
|
the core's represented-endpoint compatibility. Earlier calls retain complete
|
||||||
|
exact-slot validation, cached by document revision, owner, slot and endpoints.
|
||||||
|
Choices use search plus 24-item pages; every final creation still validates the
|
||||||
|
complete ordinary command at the captured insertion slot.
|
||||||
|
No compatible exchange produces an actionable retained draft, not a new global
|
||||||
|
exchange or a silently changed endpoint. Timer remains available as a local tool.
|
||||||
|
|
||||||
|
The canvas remains rendered while a bounded, independently scrollable form is
|
||||||
|
open. Values keep the existing qualified-path editor and lifecycle. Final creation
|
||||||
|
uses ordinary CreateElement plus SetSteps in one Batch; paired call/reply are
|
||||||
|
inserted together and undo together. Reordering uses SetSteps within the captured
|
||||||
|
parent. Existing core validation decides reply order, branch dominance, lifetime,
|
||||||
|
phase and endpoint compatibility. Failed preparation/source writes retain drafts.
|
||||||
|
|
||||||
|
Transient events capture document/scenario/revision/epoch, and the App validates
|
||||||
|
the current Behavior owner before handling them. Selection does not invalidate a
|
||||||
|
gesture within the same scenario. Escape, unfocus, context/tool change and pending
|
||||||
|
drafts cancel gestures. Scroll, bounds changes and actual window resize also
|
||||||
|
cancel the captured source/slot. Ordered raw pointer events are translated by the
|
||||||
|
actual combined inner/page Scrollable offsets. No geometry is persisted.
|
||||||
|
|
||||||
|
Execution bars derive their ends from actual correlated reply positions; separate
|
||||||
|
conditional reply branches render their corresponding spans. Calls without a
|
||||||
|
visible reply use a short explicitly open activation, not a fabricated reply.
|
||||||
|
|
||||||
|
## Acceptance checks
|
||||||
|
|
||||||
|
Production-event tests cover hit priority, click-click/drag, scroll-origin changes,
|
||||||
|
Escape/unfocus/stale contexts and exactly one result per gesture. Planner/App tests
|
||||||
|
cover middle/operand insertion, paired identities, complete SetSteps ordering,
|
||||||
|
same-parent reorder, rejected reply-before-call, unchanged global exchanges, exact
|
||||||
|
source/model Undo, draft retention and bounded exchange/call controls. Run focused
|
||||||
|
and full desktop tests, strict all-target desktop Clippy and optimized build with
|
||||||
|
the dedicated `D:/CodexBuilds/Capella2-scenario-target` target.
|
||||||
|
|
||||||
|
Root native tasks (not yet performed):
|
||||||
|
1. Draw an existing exchange again into the middle of a scenario as Call+Reply;
|
||||||
|
enter a qualified value, select both occurrences, Undo/Redo, save/reopen.
|
||||||
|
2. Insert inside an ALT/LOOP operand, reorder within it, reject a reply before its
|
||||||
|
call, and inspect correlated execution bars and preserved occurrence identities.
|
||||||
|
3. Cancel drawing after scrolling/layout change, retry, retain typed values while
|
||||||
|
navigation is blocked, and open/return from a referenced scenario. Verify at
|
||||||
|
1440x900 and 1100x700 on Windows and Linux before scoped acceptance.
|
||||||
|
|
||||||
|
## Initial automated evidence — 2026-09-06 (superseded by review fixes)
|
||||||
|
|
||||||
|
Source checkout: `D:/CodexBuilds/Capella2-scenario-direct`, production commit
|
||||||
|
`d1d8f0bbed5aa0112189138a1cc025affe26c09b`. Changes are confined to desktop
|
||||||
|
`app.rs`, `behavior.rs` and `behavior/scenario.rs`; no core model, persisted layout
|
||||||
|
or schema changes are included.
|
||||||
|
|
||||||
|
All commands used the dedicated `D:/CodexBuilds/Capella2-scenario-target` target
|
||||||
|
with `--locked --offline`:
|
||||||
|
|
||||||
|
- `cargo check -p oa-desktop --all-targets`: passed.
|
||||||
|
- `cargo test -p oa-desktop direct_`: 19 passed, including 13 new direct-scenario
|
||||||
|
tests and six existing matching tests.
|
||||||
|
- `cargo test -p oa-desktop`: final source passed all 274 tests, zero failures or
|
||||||
|
ignored tests. This includes exact source/model Undo, creation and reorder
|
||||||
|
selection, rejected stale contexts, retained failed-write drafts, bounded
|
||||||
|
contextual controls, scroll-origin cancellation and correlated activation spans.
|
||||||
|
- `cargo clippy -p oa-desktop --all-targets -- -D warnings`: passed.
|
||||||
|
- `cargo build -p oa-desktop --release`: passed.
|
||||||
|
- `git diff --check`: passed before the production commit.
|
||||||
|
|
||||||
|
Logs are under `D:/CodexBuilds/`: `Capella2-scenario-check.log`,
|
||||||
|
`Capella2-scenario-direct-tests.log`, `Capella2-scenario-desktop-final-tests.log`,
|
||||||
|
`Capella2-scenario-desktop-clippy.log` and `Capella2-scenario-desktop-release.log`.
|
||||||
|
|
||||||
|
Windows artifact:
|
||||||
|
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
|
||||||
|
20,345,856 bytes, SHA-256
|
||||||
|
`7CC6DCDB7ECC0C6B144137D31DB0BE9BC1555FEFAE6B465DAEABCF52DFE1A166`.
|
||||||
|
This artifact has not been native-tested. Widget-tree/event tests do not establish
|
||||||
|
visual usability, scrolling behavior or GPU rendering acceptance; the three native
|
||||||
|
tasks above remain required. Process/chain direct editing remains outside this
|
||||||
|
bounded implementation.
|
||||||
|
|
||||||
|
|
||||||
|
## Independent-review fixes and final automated evidence — 2026-09-06
|
||||||
|
|
||||||
|
Production commit: `7a666076bfcfd1f2258d2f5508328db5adf40825`, same isolated
|
||||||
|
checkout and dedicated target. Only the three previously owned desktop source
|
||||||
|
files changed. No core/source/schema or process/chain features were added.
|
||||||
|
|
||||||
|
The new event regressions failed on the prior implementation: a bounds shift kept
|
||||||
|
the first click's source/slot armed, and a held drag could still publish its old
|
||||||
|
slot after the bounds changed. Both now cancel. After scrolling, resizing or
|
||||||
|
clipping changes, the next click starts a fresh gesture. App tests also verify
|
||||||
|
unchanged-size notifications retain context, actual resize invalidates it, and
|
||||||
|
scrolling preserves property/source history and the pending behavior form.
|
||||||
|
|
||||||
|
Local Iced inspection established that Scrollable translates its supplied cursor
|
||||||
|
but leaves raw CursorMoved coordinates and the canvas layout unchanged. The
|
||||||
|
existing inner and outer scrollables now have a local, passive observer which
|
||||||
|
reads their actual translation through Widget::operate. Ordinary on_scroll cannot
|
||||||
|
report initial/remounted state or passive content-size clamping. The observer
|
||||||
|
forwards layout, drawing, overlays and event capture, and publishes only changed
|
||||||
|
translations or document/owner context. Reporting before child input makes a
|
||||||
|
result using an old slot stale before App processes it; reporting after input
|
||||||
|
captures wheel/scrollbar changes. The real Iced Scrollable test uses its debug
|
||||||
|
null renderer and verifies 0 → 180 wheel offset → 50 passive clamp → 180 on
|
||||||
|
expansion → 0 after remount, with no repeated messages on unchanged redraws.
|
||||||
|
|
||||||
|
The reported ALT fixture also failed before the fix: a root Call, Reply then
|
||||||
|
Action in operand one, another Reply in operand two, and a third operand without
|
||||||
|
a Reply. Derived execution geometry now splits at operand boundaries, stops each
|
||||||
|
returned operand at its own correlated Reply, leaves its following Action outside
|
||||||
|
that segment, and marks the reply-free operand open. It does not imply chronology
|
||||||
|
between alternative operands or add persistent execution records.
|
||||||
|
|
||||||
|
Picker measurement on the same Windows debug target:
|
||||||
|
|
||||||
|
| Probe | Before | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 500 compatible exchange choices | 7.87 s | 1.46 ms |
|
||||||
|
| Same choices after one name edit | 8.14 s | 1.48 ms |
|
||||||
|
| 100 exact-slot earlier calls, initial validation | not measured | 595 ms |
|
||||||
|
| Those calls after name/value/search/page edits | not measured | 34–39 µs |
|
||||||
|
|
||||||
|
The exchange list no longer clones and validates the entire project per candidate.
|
||||||
|
Final Apply still rejects a Delete inserted before a later Call on that lifeline;
|
||||||
|
the new regression confirms appending that same Delete succeeds. Reply choices
|
||||||
|
retain exact validation, invalidate for changed slot/document/revision/endpoints,
|
||||||
|
and do not lose their cached eligibility while expressions are being typed.
|
||||||
|
These are bounded picker measurements, not complete native-frame timings.
|
||||||
|
|
||||||
|
Final checks (`--locked --offline`): 27 focused matching tests passed; all 282
|
||||||
|
desktop tests passed, zero failures/ignored tests; strict desktop all-target
|
||||||
|
Clippy passed; optimized desktop build passed; git diff --check passed. Clippy
|
||||||
|
requested one test-only nested-if cleanup, which was applied before the final
|
||||||
|
clean Clippy/build.
|
||||||
|
|
||||||
|
Logs under `D:/CodexBuilds/`:
|
||||||
|
`Capella2-scenario-review-before-tests.log`, `Capella2-scenario-alt-before.log`,
|
||||||
|
`Capella2-scenario-scroll-observer-tests.log`,
|
||||||
|
`Capella2-scenario-review-desktop-tests.log`,
|
||||||
|
`Capella2-scenario-review-clippy.log`, `Capella2-scenario-review-release.log`.
|
||||||
|
|
||||||
|
Current Windows binary:
|
||||||
|
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
|
||||||
|
20,349,952 bytes, SHA-256
|
||||||
|
`1AE729097F267DE596AE0970D3A62AE06C0C89CE2FDE70DF980AD785A0E0A8E6`.
|
||||||
|
It supersedes the initial artifact above. Native acceptance is still pending.
|
||||||
|
Root should repeat the three primary native tasks, including inner/outer wheel
|
||||||
|
and scrollbar cancellation at a middle slot, resize/remount/clamp recovery, and
|
||||||
|
the three-operand ALT fixture. The broader release objective remains incomplete.
|
||||||
|
|
||||||
|
|
||||||
|
## Final passive-scroll save correction — 2026-09-06
|
||||||
|
|
||||||
|
Production `e7e37d485d842731d270781bd4e60a323fe4cdba` addresses the focused review's remaining App lifecycle
|
||||||
|
finding. During an async save, Iced can change its scroll position while App's busy
|
||||||
|
gate rejects ordinary messages. The observer had already recorded its report,
|
||||||
|
so dropping that notice left App's offset stale after save completion. The new
|
||||||
|
regression failed before the fix: App retained offset (0, 0) instead of (25, 140).
|
||||||
|
|
||||||
|
App now dispatches only the passive Scrolled event before interaction gates. Its
|
||||||
|
existing handler still validates document, scenario and revision. Mutation input
|
||||||
|
remains gated. The test checks current offsets and cancellation epoch while busy,
|
||||||
|
rejects an old-document observation, delivers a successful Saved receipt, then
|
||||||
|
uses production Canvas pointer events to create the intended sender/receiver/slot
|
||||||
|
draft without a model or source-history mutation.
|
||||||
|
|
||||||
|
The independent reviewer rechecked this exact production change and regression;
|
||||||
|
no remaining medium/high finding was reported in the focused review. Final full
|
||||||
|
desktop tests pass 283/283, strict all-target desktop Clippy passes, optimized
|
||||||
|
desktop build passes, and git diff --check passes. Logs under D:/CodexBuilds are
|
||||||
|
`Capella2-scenario-busy-scroll-before.log`,
|
||||||
|
`Capella2-scenario-busy-scroll-after.log`,
|
||||||
|
`Capella2-scenario-busy-scroll-desktop-tests.log`,
|
||||||
|
`Capella2-scenario-busy-scroll-clippy.log` and
|
||||||
|
`Capella2-scenario-busy-scroll-release.log`.
|
||||||
|
|
||||||
|
Latest Windows artifact, superseding both builds above:
|
||||||
|
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
|
||||||
|
20,349,952 bytes, SHA-256 `CEEB8E277DFC47C0D55DA43F903FCA45AFBF902AFA418B3924375910D699373A`.
|
||||||
|
Native acceptance is pending. Include scrolling while saving in the root's native
|
||||||
|
recovery task before accepting the direct scenario milestone.
|
||||||
|
|
||||||
|
|
||||||
|
## Combined DATA/scenario integration — 2026-09-06
|
||||||
|
|
||||||
|
Merge `dfd2e41259e73ef10a4e29a426569c08eaae215f` integrates `codex/data-contract-integration` at
|
||||||
|
`b35dede75fc8778ea695b9b2c023cfa2a1e49e09` into the isolated scenario branch.
|
||||||
|
The merge applied without conflicts and needed no manual code resolutions or new
|
||||||
|
semantic design. Direct scenario geometry/planners/tests and the existing
|
||||||
|
scenario evidence were unchanged by the merge. App's early passive-scroll handler
|
||||||
|
remains intact alongside DATA's source migration and property-browser handling.
|
||||||
|
The shared field-path API remains compatible: compact leaf-first labels and
|
||||||
|
deterministic sorting now feed the existing bounded scenario value picker, while
|
||||||
|
full qualified labels remain available for detail text.
|
||||||
|
|
||||||
|
Combined verification on the dedicated scenario target: all 286 desktop tests
|
||||||
|
passed, no failures or ignored tests; strict desktop all-target Clippy passed;
|
||||||
|
optimized desktop build passed; staged diff --check passed. All commands used
|
||||||
|
`--locked --offline`. DATA's three new regressions are present together with the
|
||||||
|
283-test scenario suite, including source3 save/reopen and direct gesture/save
|
||||||
|
lifecycle coverage. No DATA checkout, Linux checkout or native application was
|
||||||
|
modified or operated in this integration task.
|
||||||
|
|
||||||
|
Frozen artifact directory: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`.
|
||||||
|
It contains `arcadia-studio.exe`, the three combined verification logs and a
|
||||||
|
`manifest.json` identifying the source checkout, merge commit, target and hash.
|
||||||
|
The copied binary hash was checked against the release target binary.
|
||||||
|
Binary size: 20,411,392 bytes. SHA-256: `8BC031CF6124859D31082BB0FFB0335C406D54E12ECAD8493CE50A23923E5589`.
|
||||||
|
|
||||||
|
Root reports DATA b35dede's Windows native trial and independent focused review
|
||||||
|
passed. This does not transfer native acceptance to the combined direct-scenario
|
||||||
|
workflow. Its Windows/Linux native tasks remain pending, including scroll/resize
|
||||||
|
cancellation, middle/operand insertion, execution spans and save recovery. Root
|
||||||
|
owns final integration into main and native acceptance; process editing remains
|
||||||
|
outside this task.
|
||||||
Reference in New Issue
Block a user