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 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)]
|
||||
mod behavior_lifecycle_tests;
|
||||
mod process_canvas;
|
||||
mod state_diagram;
|
||||
#[cfg(test)]
|
||||
mod state_diagram_tests;
|
||||
@@ -130,6 +341,7 @@ pub enum Message {
|
||||
FieldShape(String, Value),
|
||||
FieldAppend(String, Value),
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -147,6 +359,8 @@ pub enum Message {
|
||||
BehaviorOwner(Choice),
|
||||
BehaviorDiagram(Choice),
|
||||
StateDiagram(crate::behavior::state_edit::Event),
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
ToggleCollapse,
|
||||
@@ -579,9 +793,14 @@ impl App {
|
||||
}
|
||||
fn execute(&mut self, command: Command) -> bool {
|
||||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(id) = selected {
|
||||
if let Some(id) = scenario_selection.or(selected) {
|
||||
self.selected = id;
|
||||
}
|
||||
self.sync();
|
||||
@@ -737,6 +956,19 @@ impl App {
|
||||
])
|
||||
}
|
||||
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.
|
||||
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
||||
self.model_menu = None;
|
||||
@@ -894,6 +1126,11 @@ impl App {
|
||||
let entering_catalog =
|
||||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||||
let mut task = self.handle(message);
|
||||
if let Some(context) = self.topology_context() {
|
||||
self.behavior
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
}
|
||||
if self.connection.as_ref().is_some_and(|session| {
|
||||
Some(session.context) != self.route_context()
|
||||
|| self.page != Page::Diagram
|
||||
@@ -936,8 +1173,116 @@ impl App {
|
||||
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> {
|
||||
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::BehaviorOwner(choice) => {
|
||||
if self.apply()
|
||||
@@ -1139,6 +1484,18 @@ impl App {
|
||||
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) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.value_path(&path, key)
|
||||
@@ -1513,6 +1870,9 @@ impl App {
|
||||
Ok(Some(command)) => {
|
||||
if self.execute(command) {
|
||||
self.behavior.acknowledge_commit();
|
||||
if self.topology_context().is_some() {
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
@@ -2238,7 +2598,16 @@ impl App {
|
||||
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) => {
|
||||
let first_measurement = self.canvas_size.is_none();
|
||||
let size_changed = self.canvas_size != Some(size);
|
||||
@@ -2612,9 +2981,15 @@ impl App {
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
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 =
|
||||
"Source project opened. Included files and comments are preserved.".into();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.canvas_document = Id::new_v4();
|
||||
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)) =
|
||||
disk.expectations.get(&disk.bundle.entry)
|
||||
&& 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(
|
||||
path,
|
||||
@@ -3328,6 +3705,97 @@ mod source_project_tests {
|
||||
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) {
|
||||
let path = directory.0.join("project.arcadia");
|
||||
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()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
let context = behavior::topology::Context {
|
||||
document: app.canvas_document,
|
||||
owner: fixture.machine,
|
||||
revision: app.document.revision(),
|
||||
epoch: app.canvas_epoch,
|
||||
};
|
||||
for event in [
|
||||
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
|
||||
900., 600.,
|
||||
))),
|
||||
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
|
||||
behavior::Event::Topology(behavior::topology::Event::Fit),
|
||||
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, None),
|
||||
] {
|
||||
send(app, Message::Behavior(event));
|
||||
send(app, Message::TopologyCanvas(event));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(!app.behavior.has_pending());
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::{self, CanvasEvent};
|
||||
impl App {
|
||||
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
|
||||
let owner = crate::behavior::selected_owner(self)?;
|
||||
(self.page == Page::Behavior
|
||||
&& matches!(
|
||||
self.project().elements.get(&owner)?.kind(),
|
||||
Kind::Process | Kind::FunctionalChain
|
||||
))
|
||||
.then_some(topology::Context {
|
||||
document: self.canvas_document,
|
||||
owner,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(offset) =
|
||||
self.behavior
|
||||
.topology
|
||||
.selection_offset(self.project(), context.owner, self.selected)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
iced::widget::operation::scroll_to("process-canvas", offset)
|
||||
}
|
||||
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.scrolled(area, offset)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.viewport(size)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
|
||||
{
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
self.behavior.topology.zoom(zoom);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand(self.project(), context.owner, id);
|
||||
self.behavior.topology = state;
|
||||
if changed {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Open(_, occurrence) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(target) =
|
||||
topology::referenced(self.project(), context.owner, occurrence)
|
||||
{
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.select(target);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Back(_) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let back = state.back(self.project());
|
||||
self.behavior.topology = state;
|
||||
if let Some((owner, occurrence)) = back {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.select(occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the process.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::Begin {
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.begin(self.project(), context, tool, control, insertion);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status="Choose the contextual contribution and apply at the captured position.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::AppendExchange(_) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.append_exchange(self.project(), context.owner);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Connect { source, target, .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.connect(self.project(), context.owner, source, target);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Describe the path condition, then Create.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reconnect {
|
||||
link, end, target, ..
|
||||
} => {
|
||||
match topology::reconnect(self.project(), context.owner, link, end, target)
|
||||
{
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = link;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reorder {
|
||||
occurrence, before, ..
|
||||
} => {
|
||||
match topology::reorder(self.project(), context.owner, occurrence, before) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = occurrence;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
topology::{
|
||||
CanvasTool, Event, Insertion, Tool,
|
||||
direct_tests::{self, Fixture},
|
||||
},
|
||||
};
|
||||
fn setup() -> (App, Fixture) {
|
||||
let f = direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
(app, f)
|
||||
}
|
||||
fn form(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
|
||||
}
|
||||
fn begin(app: &App) -> CanvasEvent {
|
||||
CanvasEvent::Begin {
|
||||
context: app.topology_context().unwrap(),
|
||||
tool: Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = direct_tests::empty_exchange_fixture(layer);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let empty = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: Tool::Exchange,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}));
|
||||
assert!(app.behavior.has_pending());
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.exchange,
|
||||
label: "Assessment".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("First contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let first_id = app.selected;
|
||||
let first = app.document.snapshot();
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id]
|
||||
);
|
||||
assert_eq!(app.project().relations, empty.decoded.project.relations);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
|
||||
context,
|
||||
)));
|
||||
assert!(
|
||||
app.behavior.has_pending(),
|
||||
"reachable single-exchange continuation: {}",
|
||||
app.status
|
||||
);
|
||||
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: next,
|
||||
label: "Response".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Second contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let second_id = app.selected;
|
||||
let second = app.document.snapshot();
|
||||
assert_ne!(first_id, second_id);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id, second_id]
|
||||
);
|
||||
let links = app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::SequenceLink)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(links.len(), 1);
|
||||
assert_eq!(
|
||||
(links[0].source(), links[0].target()),
|
||||
(first_id, second_id)
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements.len(),
|
||||
empty.decoded.project.elements.len() + 2
|
||||
);
|
||||
for (id, relation) in &empty.decoded.project.relations {
|
||||
assert_eq!(app.project().relations[id], *relation);
|
||||
}
|
||||
for work in f.work {
|
||||
assert_eq!(
|
||||
app.project().elements[&work],
|
||||
empty.decoded.project.elements[&work]
|
||||
);
|
||||
}
|
||||
let reopened = Document::from_bundle(
|
||||
second.bundle.clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.project(), app.project());
|
||||
assert_eq!(reopened.bundle(), &second.bundle);
|
||||
for expected in [&first, &empty] {
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"each Create must add exactly one history entry"
|
||||
);
|
||||
for expected in [&first, &second] {
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
|
||||
let (mut app, _) = setup();
|
||||
let snapshot = app.document.snapshot();
|
||||
for field in 0..4 {
|
||||
let mut event = begin(&app);
|
||||
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||||
unreachable!()
|
||||
};
|
||||
match field {
|
||||
0 => context.document = Id::new_v4(),
|
||||
1 => context.owner = Id::new_v4(),
|
||||
2 => context.revision += 1,
|
||||
_ => context.epoch += 1,
|
||||
};
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.draft_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
for event in [
|
||||
CanvasEvent::Zoom(context, Some(0.7)),
|
||||
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
|
||||
let (mut app, f) = setup();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes =
|
||||
bundle.files.values().map(String::len).sum::<usize>() + 3000;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
app.sync();
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.work[0],
|
||||
label: "Assess".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
form(&mut app, Event::Name("Recovered contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_ne!(app.selected, f.owner);
|
||||
assert_eq!(app.project().named(app.selected), "Recovered contribution");
|
||||
let created = app.selected;
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert!(app.project().contains(created));
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Connect,
|
||||
)));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
context, f.nodes[0],
|
||||
)));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let other = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let other_id = other.id;
|
||||
assert!(app.execute(Command::CreateElement(other)));
|
||||
let _ = app.update(Message::BehaviorOwner(Choice {
|
||||
id: other_id,
|
||||
label: "Other".into(),
|
||||
}));
|
||||
assert_eq!(app.topology_context().unwrap().owner, other_id);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
|
||||
context,
|
||||
source: f.nodes[0],
|
||||
target: f.nodes[2],
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
for (area, offset) in [
|
||||
(
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(25., 100.),
|
||||
),
|
||||
(
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(500., 300.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
assert!(app.busy);
|
||||
let mut stale = context;
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = PathBuf::from("process-scroll-save-fixture");
|
||||
let receipt = FileSnapshot {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
snapshot: original.clone(),
|
||||
path: root.join(&original.bundle.entry),
|
||||
disk: Some(source_files::DiskProject {
|
||||
root,
|
||||
bundle: original.bundle.clone(),
|
||||
expectations: Default::default(),
|
||||
reachable: original.bundle.files.keys().cloned().collect(),
|
||||
diagnostics: vec![],
|
||||
}),
|
||||
};
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(app.project(), &f.p);
|
||||
let context = app.topology_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||||
size.width + 20.,
|
||||
size.height,
|
||||
)));
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(&mut app, Event::Name("Keep this form".into()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 80.),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
|
||||
let (mut app, f) = setup();
|
||||
let source = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
assert!(app.execute(Command::CreateElement(source)));
|
||||
let mut occurrence = app
|
||||
.project()
|
||||
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
|
||||
.unwrap();
|
||||
let occurrence_id = occurrence.id;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
|
||||
*body = OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
};
|
||||
}
|
||||
assert!(app.execute(Command::CreateElement(occurrence)));
|
||||
let snapshot = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
|
||||
context,
|
||||
occurrence_id,
|
||||
)));
|
||||
assert_eq!(app.selected, source_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, source_id);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(app.selected, occurrence_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let original = app.document.snapshot();
|
||||
let edited = format!(
|
||||
"{}\n// Retained source draft\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(edited.clone().into()),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.source_dirty());
|
||||
assert_eq!(app.source_workspace.text(), edited);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
}
|
||||
+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
|
||||
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
mod direct;
|
||||
mod view;
|
||||
use direct::Input;
|
||||
pub(crate) use direct::{
|
||||
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) mod direct_tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -40,9 +48,8 @@ pub enum Event {
|
||||
Guard(String),
|
||||
AlternateGuard(String),
|
||||
Control(ControlChoice),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
Search(usize, String),
|
||||
Page(usize, usize),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ControlChoice(pub ControlKind);
|
||||
@@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id)>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
struct Draft {
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
browsers: [Browser; 4],
|
||||
owner: Id,
|
||||
tool: Tool,
|
||||
name: String,
|
||||
@@ -99,9 +120,6 @@ impl State {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
Event::Start(owner, tool, selected) => {
|
||||
let e = p
|
||||
.elements
|
||||
@@ -127,6 +145,9 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool,
|
||||
name: String::new(),
|
||||
@@ -154,6 +175,11 @@ impl State {
|
||||
Event::Guard(value) => d.guard = value,
|
||||
Event::AlternateGuard(value) => d.alternate_guard = value,
|
||||
Event::Control(value) => d.control = value.0,
|
||||
Event::Search(index, value) if index < 4 => {
|
||||
d.browsers[index].search = value;
|
||||
d.browsers[index].page = 0;
|
||||
}
|
||||
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -163,12 +189,13 @@ impl State {
|
||||
}
|
||||
|
||||
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
|
||||
self.zoom.unwrap_or_else(|| {
|
||||
if !self.fit {
|
||||
return self.zoom.unwrap_or(1.);
|
||||
}
|
||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||
((width - 24.).max(1.) / graph.width.max(1.))
|
||||
.min(450. / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
})
|
||||
}
|
||||
}
|
||||
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
|
||||
@@ -247,13 +274,24 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
|
||||
})
|
||||
.map(|e| Choice {
|
||||
id: e.id,
|
||||
label: format!("{} · {}", e.name, e.alias),
|
||||
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
fn semantic_path(p: &Project, id: Id) -> String {
|
||||
let mut path = Vec::new();
|
||||
let mut current = Some(id);
|
||||
while let Some(id) = current {
|
||||
let Some(e) = p.elements.get(&id) else { break };
|
||||
path.push(e.alias.as_str());
|
||||
current = e.parent;
|
||||
}
|
||||
path.reverse();
|
||||
path.join(" / ")
|
||||
}
|
||||
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
|
||||
p.elements
|
||||
.get(&owner)
|
||||
@@ -394,6 +432,40 @@ impl Draft {
|
||||
if self.before.is_some() && self.before == self.after {
|
||||
return Err("Choose different preceding and following contributions.".into());
|
||||
}
|
||||
let captured = self
|
||||
.insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, self.owner, r.source())
|
||||
&& is_contribution(p, self.owner, r.target())
|
||||
})
|
||||
.ok_or("The captured path no longer exists in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if captured
|
||||
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
|
||||
{
|
||||
return Err("The captured path changed. Choose its position again.".into());
|
||||
}
|
||||
let matches: Vec<_> = sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
|
||||
.collect();
|
||||
let split_link = if self.tool == Tool::Connect {
|
||||
None
|
||||
} else if captured.is_some() {
|
||||
captured
|
||||
} else if !self.fixed && matches.len() == 1 {
|
||||
Some(matches[0])
|
||||
} else if !self.fixed && matches.len() > 1 {
|
||||
return Err("Select the specific sequence link to insert on.".into());
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut commands = preserved_order(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -418,7 +490,6 @@ impl Draft {
|
||||
{
|
||||
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
|
||||
}
|
||||
require_guard(p, source, &self.guard)?;
|
||||
commands.push(link(p, source, target, &self.guard));
|
||||
return Ok(unique_batch(p, commands));
|
||||
}
|
||||
@@ -459,12 +530,6 @@ impl Draft {
|
||||
return Err("Choose same-layer work for the second path.".into());
|
||||
}
|
||||
let parallel = self.control == ControlKind::Fork;
|
||||
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"Describe both alternative guards; use 'else' for the fallback path.".into(),
|
||||
);
|
||||
}
|
||||
let split = occurrence(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -552,30 +617,47 @@ impl Draft {
|
||||
commands.push(Command::CreateElement(e));
|
||||
(id, id)
|
||||
};
|
||||
if let Some(before) = self.before {
|
||||
// Branch guards belong to the split's two outgoing paths. A branch
|
||||
// inserted after another choice must first use an explicit link.
|
||||
let guard = if self.tool == Tool::Branch {
|
||||
if let Some(existing) = split_link {
|
||||
let mut updated = existing.clone();
|
||||
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
|
||||
*target = first;
|
||||
}
|
||||
commands.push(Command::EditRelation(existing.id, updated));
|
||||
} else if let Some(before) = self.before {
|
||||
commands.push(link(
|
||||
p,
|
||||
before,
|
||||
first,
|
||||
if self.tool == Tool::Branch {
|
||||
""
|
||||
} else {
|
||||
&self.guard
|
||||
};
|
||||
require_guard(p, before, guard)?;
|
||||
commands.push(link(p, before, first, guard));
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(after) = self.after {
|
||||
commands.push(link(p, last, after, ""));
|
||||
}
|
||||
// Inserting between two selected contributions replaces their direct
|
||||
// precedence while preserving other links and their semantic identities.
|
||||
if let (Some(before), Some(after)) = (self.before, self.after) {
|
||||
commands.extend(
|
||||
sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| r.source() == before && r.target() == after)
|
||||
.map(|r| Command::Delete(r.id)),
|
||||
);
|
||||
}
|
||||
let new_ids: Vec<_> = commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut order = owner.steps().expect("process steps").clone();
|
||||
let slot = self.insertion.before.or(self.after);
|
||||
let index = slot
|
||||
.map(|id| {
|
||||
order
|
||||
.iter()
|
||||
.position(|v| *v == id)
|
||||
.ok_or("The presentation slot changed.")
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(order.len());
|
||||
order.splice(index..index, new_ids);
|
||||
commands.push(Command::SetSteps(self.owner, order));
|
||||
Ok(unique_batch(p, commands))
|
||||
}
|
||||
}
|
||||
@@ -598,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
|
||||
}
|
||||
Command::Batch(commands)
|
||||
}
|
||||
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
|
||||
if guard.trim().is_empty()
|
||||
&& matches!(
|
||||
p.elements.get(&source).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice | ControlKind::Loop
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
Err("Describe this choice / repeat condition, or enter 'else'.".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(e: Event) -> Message {
|
||||
Message::Behavior(if matches!(e, Event::Submit) {
|
||||
super::Event::Submit
|
||||
@@ -622,307 +687,9 @@ fn event(e: Event) -> Message {
|
||||
super::Event::Topology(e)
|
||||
})
|
||||
}
|
||||
fn picker<'a>(
|
||||
choices: Vec<Choice>,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
pick_list(choices, selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill)
|
||||
.into()
|
||||
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
view::view(app, owner, out)
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
if !p.elements[&owner].description.is_empty() {
|
||||
out = out.push(
|
||||
text(&p.elements[&owner].description)
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
|
||||
action(
|
||||
"+ Exchange",
|
||||
event(Event::Start(owner, Tool::Exchange, selected))
|
||||
),
|
||||
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
action(
|
||||
"+ Control",
|
||||
event(Event::Start(owner, Tool::Control, selected))
|
||||
),
|
||||
action(
|
||||
"Branch / parallel…",
|
||||
event(Event::Start(owner, Tool::Branch, selected))
|
||||
),
|
||||
action(
|
||||
"Connect…",
|
||||
event(Event::Start(owner, Tool::Connect, selected))
|
||||
),
|
||||
action("Properties", Message::EditProperties(owner)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if let Some(d) = app
|
||||
.behavior
|
||||
.topology
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
|
||||
let legacy = legacy_order(p, owner);
|
||||
if legacy.len() > 1 {
|
||||
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices: Vec<_> = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|choice| compatible_source(p, d, choice.id))
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d.target,
|
||||
"Choose an existing source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source name)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d.alternate,
|
||||
"Work on the second path…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form.push(
|
||||
row![
|
||||
text_input("First path condition", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
text_input("Second path condition", &d.alternate_guard)
|
||||
.on_input(|s| event(Event::AlternateGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.before,
|
||||
if d.tool == Tool::Connect {
|
||||
"From contribution…"
|
||||
} else {
|
||||
"After contribution (optional)…"
|
||||
},
|
||||
Event::Before
|
||||
),
|
||||
action("Clear", event(Event::ClearBefore)),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.after,
|
||||
if d.tool == Tool::Connect {
|
||||
"To contribution…"
|
||||
} else {
|
||||
"Before contribution (optional)…"
|
||||
},
|
||||
Event::After
|
||||
),
|
||||
action("Clear", event(Event::ClearAfter)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if d.tool != Tool::Branch {
|
||||
form = form.push(
|
||||
text_input("Sequence guard (for a choice or loop)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if let Err(problem) = d.command(p) {
|
||||
form = form.push(label(problem));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
out = out.push(container(form).padding(14).style(style::card));
|
||||
}
|
||||
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|
||||
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
|
||||
{
|
||||
let mut graph = Graph::new(p, owner, app.selected);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(container(column![
|
||||
text("Start this process").size(18),
|
||||
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
|
||||
].spacing(12)).padding(24).width(Fill).style(style::card));
|
||||
return scrollable(out.padding(24)).into();
|
||||
} else if graph.edges.is_empty() {
|
||||
out = out.push(label(
|
||||
"No explicit sequence links. Connect contributions to describe topology.",
|
||||
));
|
||||
}
|
||||
graph.scale = app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
let scale = graph.scale;
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
out = out.push(
|
||||
row![
|
||||
action("Fit", event(Event::Fit)),
|
||||
action("−", event(Event::Zoom(scale / 1.25))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
action("+", event(Event::Zoom(scale * 1.25))),
|
||||
action("100%", event(Event::Zoom(1.))),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
} else {
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
let conversion = preserved_order(p, owner, None);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · the stored contribution order defines this sequence.",
|
||||
));
|
||||
if !conversion.is_empty() {
|
||||
out = out.push(action(
|
||||
"Convert ordered path to explicit links",
|
||||
Message::Command(unique_batch(p, conversion)),
|
||||
));
|
||||
}
|
||||
(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(
|
||||
scrollable(canvas)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(size.height.clamp(200., 470.)),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(|size| event(Event::Viewport(size)))
|
||||
.on_resize(|size| event(Event::Viewport(size))),
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference {
|
||||
sequence: Some(target),
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
|
||||
{
|
||||
out = out.push(action(
|
||||
&format!("Open source: {} →", p.named(*target)),
|
||||
Message::Select(*target),
|
||||
));
|
||||
}
|
||||
for r in sequence_links(p, owner) {
|
||||
let guard = match &r.body {
|
||||
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
|
||||
_ => String::new(),
|
||||
};
|
||||
out = out.push(
|
||||
action(
|
||||
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
|
||||
Message::Select(r.id),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||
p.relations
|
||||
.values()
|
||||
@@ -942,6 +709,7 @@ struct GraphNode {
|
||||
source: Option<Id>,
|
||||
preview: Vec<(Id, String)>,
|
||||
preview_links: Vec<(Id, Id)>,
|
||||
preview_total: usize,
|
||||
}
|
||||
struct GraphEdge {
|
||||
id: Id,
|
||||
@@ -951,15 +719,21 @@ struct GraphEdge {
|
||||
points: Vec<Point>,
|
||||
label: Rectangle,
|
||||
}
|
||||
struct Graph {
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
scroll_offset: iced::Vector,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
}
|
||||
impl Graph {
|
||||
fn new(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
impl<'a> Graph<'a> {
|
||||
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
|
||||
Self::expanded(p, owner, selected, &BTreeSet::new())
|
||||
}
|
||||
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
@@ -1000,17 +774,17 @@ impl Graph {
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||
let mut nodes = vec![];
|
||||
let mut size: Size = Size::new(640., 220.);
|
||||
for c in choices {
|
||||
let e = &p.elements[&c.id];
|
||||
let rank = ranks[&c.id];
|
||||
let slot = rows.entry(rank).or_default();
|
||||
let slot = rows.entry(rank).or_insert(24.);
|
||||
let mut node = GraphNode {
|
||||
id: e.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
|
||||
Point::new(24. + rank as f32 * 420., *slot),
|
||||
Size::new(270., 104.),
|
||||
),
|
||||
title: e.name.clone(),
|
||||
@@ -1019,8 +793,8 @@ impl Graph {
|
||||
source: None,
|
||||
preview: vec![],
|
||||
preview_links: vec![],
|
||||
preview_total: 0,
|
||||
};
|
||||
*slot += 1;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
||||
match body {
|
||||
OccurrenceBody::Action { action, .. } => {
|
||||
@@ -1051,11 +825,19 @@ impl Graph {
|
||||
.map(|id| format!("ref · {}", p.named(id)))
|
||||
.unwrap_or("Choose source in Properties".into());
|
||||
if let Some(source) = sequence {
|
||||
node.preview = contributions(p, *source)
|
||||
let contributions = contributions(p, *source);
|
||||
node.preview_total = contributions.len();
|
||||
node.preview = contributions
|
||||
.into_iter()
|
||||
.take(4)
|
||||
.take(if expanded.contains(&e.id) {
|
||||
usize::MAX
|
||||
} else {
|
||||
4
|
||||
})
|
||||
.map(|c| (c.id, p.named(c.id).to_owned()))
|
||||
.collect();
|
||||
node.rect.height =
|
||||
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
|
||||
let preview_ids: BTreeSet<_> =
|
||||
node.preview.iter().map(|(id, _)| *id).collect();
|
||||
node.preview_links = sequence_links(p, *source)
|
||||
@@ -1071,11 +853,13 @@ impl Graph {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
*slot += node.rect.height + 36.;
|
||||
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
|
||||
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
||||
nodes.push(node);
|
||||
}
|
||||
let mut edges = vec![];
|
||||
let mut routed_pairs = BTreeSet::new();
|
||||
for (i, r) in links.into_iter().enumerate() {
|
||||
let a = nodes
|
||||
.iter()
|
||||
@@ -1089,11 +873,14 @@ impl Graph {
|
||||
.rect;
|
||||
let start = Point::new(a.x + a.width, a.center_y());
|
||||
let end = Point::new(b.x, b.center_y());
|
||||
let points = if end.x > start.x {
|
||||
// Parallel records keep distinct visible paths and guard hit targets.
|
||||
// Their semantic endpoints and IDs remain unchanged.
|
||||
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
|
||||
let points = if forward {
|
||||
let mid = (start.x + end.x) / 2.;
|
||||
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
|
||||
} else {
|
||||
let below = size.height + 24. + i as f32 * 30.;
|
||||
let below = size.height + 24. + i as f32 * 42.;
|
||||
vec![
|
||||
start,
|
||||
Point::new(start.x + 18., start.y),
|
||||
@@ -1107,7 +894,7 @@ impl Graph {
|
||||
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let label_at = if end.x > start.x {
|
||||
let label_at = if forward {
|
||||
Point::new(end.x - 138., end.y - 43.)
|
||||
} else {
|
||||
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
||||
@@ -1128,6 +915,9 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
interaction: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
size,
|
||||
@@ -1204,47 +994,24 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
impl canvas::Program<Message> for Graph {
|
||||
type State = ();
|
||||
impl canvas::Program<Message> for Graph<'_> {
|
||||
type State = Input;
|
||||
fn update(
|
||||
&self,
|
||||
_: &mut (),
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) && let Some(point) = cursor.position_in(bounds)
|
||||
{
|
||||
let point = Point::new(point.x / self.scale, point.y / self.scale);
|
||||
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
|
||||
let id = if point.y > n.rect.y + 75. {
|
||||
n.source.unwrap_or(n.id)
|
||||
} else {
|
||||
n.id
|
||||
};
|
||||
return Some(Action::publish(Message::Select(id)).and_capture());
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points
|
||||
.windows(2)
|
||||
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
|
||||
}) {
|
||||
return Some(Action::publish(Message::Select(e.id)).and_capture());
|
||||
}
|
||||
}
|
||||
None
|
||||
self.input(state, event, bounds, cursor)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
_: &(),
|
||||
state: &Input,
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
@@ -1327,7 +1094,7 @@ impl canvas::Program<Message> for Graph {
|
||||
if n.source.is_some() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
"Open source →".into(),
|
||||
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||
11.,
|
||||
style::TEAL,
|
||||
@@ -1400,11 +1167,12 @@ impl canvas::Program<Message> for Graph {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.overlay(state, &mut f, cursor, bounds);
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_: &(),
|
||||
_: &Input,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
@@ -1478,6 +1246,9 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool: Tool::Branch,
|
||||
name: String::new(),
|
||||
@@ -1666,7 +1437,7 @@ mod tests {
|
||||
let command = branch(owner, a, b, before, after).command(&p).unwrap();
|
||||
session.execute(command).unwrap();
|
||||
let result = session.project();
|
||||
assert!(!result.relations.contains_key(&old_link));
|
||||
assert!(result.relations.contains_key(&old_link));
|
||||
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||
assert_eq!(sequence_links(result, owner).len(), 6);
|
||||
assert_eq!(
|
||||
@@ -1777,10 +1548,26 @@ mod tests {
|
||||
draft: Some(branch(owner, a, b, before, after)),
|
||||
..State::default()
|
||||
};
|
||||
state.update(&p, Event::Guard(String::new())).unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: Id::nil(),
|
||||
label: "Missing".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.update(&p, Event::Submit).is_err());
|
||||
assert!(state.draft.is_some());
|
||||
state.update(&p, Event::Guard("ready".into())).unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: a,
|
||||
label: "Work".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.draft.is_some());
|
||||
assert!(matches!(command, Command::Batch(_)));
|
||||
@@ -1836,9 +1623,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(486., 460.)))
|
||||
.unwrap();
|
||||
state.viewport(Size::new(486., 460.));
|
||||
state.zoom(None);
|
||||
graph.scale = state.scale(graph.size, 500.);
|
||||
assert!(graph.size.width * graph.scale <= 462.1);
|
||||
assert!(graph.size.height * graph.scale <= 450.1);
|
||||
@@ -1849,7 +1635,7 @@ mod tests {
|
||||
);
|
||||
let result = canvas::Program::update(
|
||||
&graph,
|
||||
&mut (),
|
||||
&mut Input::default(),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
||||
mouse::Cursor::Available(click),
|
||||
@@ -1858,12 +1644,10 @@ mod tests {
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
||||
state.update(&p, Event::Zoom(1.)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(800., 460.)))
|
||||
.unwrap();
|
||||
state.zoom(Some(1.));
|
||||
state.viewport(Size::new(800., 460.));
|
||||
assert_eq!(state.scale(graph.size, 500.), 1.);
|
||||
state.update(&p, Event::Fit).unwrap();
|
||||
state.zoom(None);
|
||||
assert!(state.scale(graph.size, 500.) < 1.);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
//! Local pointer state and ordinary command proposals for the derived process graph.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Insertion {
|
||||
pub link: Option<Id>,
|
||||
pub before: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CanvasTool {
|
||||
#[default]
|
||||
Select,
|
||||
Add(Tool, ControlKind),
|
||||
Connect,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum End {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
AppendExchange(Context),
|
||||
Begin {
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
},
|
||||
Connect {
|
||||
context: Context,
|
||||
source: Id,
|
||||
target: Id,
|
||||
},
|
||||
Reconnect {
|
||||
context: Context,
|
||||
link: Id,
|
||||
end: End,
|
||||
target: Id,
|
||||
},
|
||||
Reorder {
|
||||
context: Context,
|
||||
occurrence: Id,
|
||||
before: Id,
|
||||
},
|
||||
Expand(Context, Id),
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::Expand(c, ..)
|
||||
| Self::Open(c, ..)
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
| Self::Reconnect { context, .. }
|
||||
| Self::Reorder { context, .. } => *context,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
self.tool = CanvasTool::Select;
|
||||
self.zoom = Some(1.);
|
||||
self.fit = false;
|
||||
self.viewport = None;
|
||||
self.expanded.clear();
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
|
||||
let index = match area {
|
||||
scenario::ScrollArea::Canvas => 0,
|
||||
scenario::ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
||||
self.tool = tool;
|
||||
if tool != CanvasTool::Select && self.fit {
|
||||
self.fit = false;
|
||||
self.zoom = Some(1.);
|
||||
}
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
||||
self.fit = value.is_none();
|
||||
self.zoom = value.map(|v| v.clamp(0.5, 2.));
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
let changed = self.viewport != Some(size);
|
||||
self.viewport = Some(size);
|
||||
changed
|
||||
}
|
||||
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
if !is_contribution(p, owner, id)
|
||||
|| !matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.expanded.remove(&id) {
|
||||
self.expanded.insert(id);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to.push((owner, occurrence));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
let Command::Batch(commands) = command else {
|
||||
return None;
|
||||
};
|
||||
commands.iter().find_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub(crate) fn begin(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !matches!(
|
||||
tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
|
||||
) {
|
||||
return Err("Choose a contribution tool.".into());
|
||||
}
|
||||
if !legacy_order(p, context.owner).is_empty() {
|
||||
return Err("Convert the ordered path to explicit links before drawing.".into());
|
||||
}
|
||||
let link = insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, context.owner, r.source())
|
||||
&& is_contribution(p, context.owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if insertion
|
||||
.before
|
||||
.is_some_and(|id| !is_contribution(p, context.owner, id))
|
||||
{
|
||||
return Err("Choose a presentation slot in this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(context.owner, tool, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.insertion = insertion;
|
||||
d.control = control;
|
||||
if let Some(r) = link {
|
||||
d.before = Some(r.source());
|
||||
d.after = Some(r.target());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
|
||||
let last = legacy_order(p, owner)
|
||||
.last()
|
||||
.copied()
|
||||
.ok_or("Choose an ordered exchange path to append to.")?;
|
||||
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
|
||||
self.draft.as_mut().expect("started form").fixed = true;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn connect(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
source: Id,
|
||||
target: Id,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !is_contribution(p, owner, source)
|
||||
|| !is_contribution(p, owner, target)
|
||||
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
|
||||
{
|
||||
return Err("Choose compatible contributions directly owned by this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(owner, Tool::Connect, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.before = Some(source);
|
||||
d.after = Some(target);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
|
||||
if !is_contribution(p, owner, occurrence) {
|
||||
return None;
|
||||
}
|
||||
match p.elements[&occurrence].shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action { action, .. },
|
||||
..
|
||||
} => *action,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
} => *exchange,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => *sequence,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn reconnect(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
end: End,
|
||||
endpoint: Id,
|
||||
) -> Result<Command, String> {
|
||||
let mut r = p
|
||||
.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, owner, r.source())
|
||||
&& is_contribution(p, owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")?
|
||||
.clone();
|
||||
if !is_contribution(p, owner, endpoint) {
|
||||
return Err("Choose a local contribution endpoint.".into());
|
||||
}
|
||||
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
|
||||
match end {
|
||||
End::Source => *source = endpoint,
|
||||
End::Target => *target = endpoint,
|
||||
}
|
||||
}
|
||||
let command = Command::EditRelation(id, r);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
|
||||
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
|
||||
return Err("Reorder contributions in this process only.".into());
|
||||
}
|
||||
let graph = Graph::new(p, owner, id);
|
||||
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
|
||||
if position(id) != position(before) {
|
||||
return Err("Reorder within this column; links determine the path columns.".into());
|
||||
}
|
||||
let mut steps = p.elements[&owner].steps().unwrap().clone();
|
||||
if id != before {
|
||||
steps.retain(|v| *v != id);
|
||||
let index = steps
|
||||
.iter()
|
||||
.position(|v| *v == before)
|
||||
.ok_or("This slot no longer exists.")?;
|
||||
steps.insert(index, id);
|
||||
}
|
||||
let command = Command::SetSteps(owner, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Input {
|
||||
context: Option<Context>,
|
||||
tool: CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
bounds: Option<Rectangle>,
|
||||
source: Option<Id>,
|
||||
press: Option<Press>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Press {
|
||||
Connect {
|
||||
source: Id,
|
||||
first: bool,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reconnect {
|
||||
link: Id,
|
||||
end: End,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reorder {
|
||||
occurrence: Id,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Add {
|
||||
insertion: Insertion,
|
||||
},
|
||||
}
|
||||
impl Input {
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
fn node_at(&self, point: Point) -> Option<Id> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|n| n.rect.contains(point))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
fn edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
.find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points.windows(2).any(|v| {
|
||||
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|e| e.id)
|
||||
}
|
||||
fn handle(&self, id: Id) -> Option<Rectangle> {
|
||||
let n = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(Rectangle::new(
|
||||
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
|
||||
Size::new(23., 24.),
|
||||
))
|
||||
}
|
||||
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
||||
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
||||
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
||||
.into_iter()
|
||||
.find(|(p, _)| p.distance(point) < 10. / self.scale)
|
||||
.map(|(_, end)| (e.id, end))
|
||||
}
|
||||
fn connectable(&self, source: Id, target: Id) -> bool {
|
||||
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
|
||||
}
|
||||
pub(super) fn input(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
(Some(c), t, e)
|
||||
});
|
||||
if state.context != context
|
||||
|| state.tool != tool
|
||||
|| state.bounds.is_some_and(|b| b != bounds)
|
||||
{
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
state.context = context;
|
||||
state.tool = tool;
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
&& matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
)
|
||||
{
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
let cursor = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
if let Some(
|
||||
Press::Connect { start, moved, .. }
|
||||
| Press::Reconnect { start, moved, .. }
|
||||
| Press::Reorder { start, moved, .. },
|
||||
) = &mut state.press
|
||||
{
|
||||
*moved |= position.distance(*start) >= 3.;
|
||||
}
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(
|
||||
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
|
||||
) => {
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) => {
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
) => state
|
||||
.pointer
|
||||
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||
cursor
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
||||
let point = world?;
|
||||
let Some(context) = context else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.map(|id| Action::publish(Message::Select(id)).and_capture());
|
||||
};
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let start = cursor.position()? - self.scroll_offset;
|
||||
match tool {
|
||||
CanvasTool::Add(..) => {
|
||||
state.press = Some(Press::Add {
|
||||
insertion: Insertion {
|
||||
link: self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none()),
|
||||
before: self.node_at(point),
|
||||
},
|
||||
});
|
||||
}
|
||||
CanvasTool::Connect => {
|
||||
let first = state.source.is_none();
|
||||
let source = state.source.or_else(|| self.node_at(point))?;
|
||||
state.source = Some(source);
|
||||
state.press = Some(Press::Connect {
|
||||
source,
|
||||
first,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
}
|
||||
CanvasTool::Select => {
|
||||
if let Some((link, end)) = self.endpoint_at(point) {
|
||||
state.press = Some(Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else if self
|
||||
.handle(self.selected)
|
||||
.is_some_and(|r| r.contains(point))
|
||||
{
|
||||
state.press = Some(Press::Reorder {
|
||||
occurrence: self.selected,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.and_then(|id| publish(CanvasEvent::Select(context, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
Press::Add { insertion } if world.is_some() => {
|
||||
if let CanvasTool::Add(tool, control) = tool {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Begin {
|
||||
context,
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Connect {
|
||||
source,
|
||||
first,
|
||||
moved,
|
||||
..
|
||||
} if moved || !first => {
|
||||
if let Some(target) = world
|
||||
.and_then(|p| self.node_at(p))
|
||||
.filter(|target| self.connectable(source, *target))
|
||||
{
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Connect {
|
||||
context,
|
||||
source,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(target) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reconnect {
|
||||
context,
|
||||
link,
|
||||
end,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reorder {
|
||||
occurrence,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(before) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence,
|
||||
before,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
Some(Action::request_redraw())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn overlay(
|
||||
&self,
|
||||
state: &Input,
|
||||
f: &mut Frame,
|
||||
cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
if !editable {
|
||||
return;
|
||||
}
|
||||
let point = state
|
||||
.pointer
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
for n in &self.nodes {
|
||||
if n.id == source || self.connectable(source, n.id) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
|
||||
draw_arrow(
|
||||
f,
|
||||
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
|
||||
style::TEAL,
|
||||
2.,
|
||||
);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reconnect { link, end, .. }) = state.press
|
||||
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
|
||||
{
|
||||
for node in &self.nodes {
|
||||
let (source, target) = match end {
|
||||
End::Source => (node.id, edge.target),
|
||||
End::Target => (edge.source, node.id),
|
||||
};
|
||||
if self.connectable(source, target) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(point) = point {
|
||||
let (from, to) = match end {
|
||||
End::Source => (point, *edge.points.last().unwrap()),
|
||||
End::Target => (edge.points[0], point),
|
||||
};
|
||||
draw_arrow(f, &[from, to], style::TEAL, 2.5);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reorder { occurrence, .. }) = state.press
|
||||
&& let Some(point) = point
|
||||
&& let Some(target) = self.node_at(point)
|
||||
{
|
||||
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
|
||||
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
|
||||
if source.rect.x == target.rect.x {
|
||||
f.stroke(
|
||||
&Path::line(
|
||||
Point::new(target.rect.x, target.rect.y - 7.),
|
||||
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
|
||||
),
|
||||
Stroke::default().with_color(style::TEAL).with_width(3.),
|
||||
);
|
||||
draw_text(
|
||||
f,
|
||||
"Order before · paths unchanged".into(),
|
||||
Point::new(target.rect.x, target.rect.y - 24.),
|
||||
12.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if let Some(r) = self.handle(self.selected) {
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
|
||||
for p in [e.points[0], *e.points.last().unwrap()] {
|
||||
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let CanvasTool::Add(..) = tool
|
||||
&& let Some(point) = point
|
||||
{
|
||||
let at = self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none());
|
||||
let caption = if let Some(id) = at {
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
|
||||
draw_arrow(f, &e.points, style::TEAL, 3.);
|
||||
}
|
||||
"Insert on this path · preserve its guard"
|
||||
} else if self.node_at(point).is_some() {
|
||||
"Add before this contribution · unconnected"
|
||||
} else {
|
||||
"Append contribution · unconnected"
|
||||
};
|
||||
draw_text(
|
||||
f,
|
||||
caption.into(),
|
||||
Point::new(point.x + 12., point.y + 16.),
|
||||
12. / self.scale,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph = Graph::expanded(p, owner, id, &self.expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
let rect = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.map(|n| n.rect)
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
y: (rect.y * scale - 12.).max(0.),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::{ProjectLimits, SourceBundle};
|
||||
|
||||
pub(crate) struct Fixture {
|
||||
pub p: Project,
|
||||
pub owner: Id,
|
||||
pub work: [Id; 3],
|
||||
pub nodes: [Id; 3],
|
||||
pub exchange: Id,
|
||||
pub link: Id,
|
||||
}
|
||||
pub(crate) fn fixture(layer: Layer) -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.make_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
"Respond safely",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let owner_id = owner.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
|
||||
let mut work = Vec::new();
|
||||
let mut nodes = Vec::new();
|
||||
for name in ["Assess", "Respond", "Recover"] {
|
||||
let e = p
|
||||
.make_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
name,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let id = e.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
|
||||
work.push(id);
|
||||
let e = occurrence(
|
||||
&p,
|
||||
owner_id,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(id),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let id = e.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
|
||||
nodes.push(id);
|
||||
}
|
||||
let kind = if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
};
|
||||
p = execute_batch(
|
||||
&p,
|
||||
&[Command::Connect(
|
||||
kind,
|
||||
"Assessment".into(),
|
||||
work[0],
|
||||
work[1],
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let exchange = p.relations.values().find(|r| r.kind() == kind).unwrap().id;
|
||||
let Command::CreateRelation(mut r) = link(&p, nodes[0], nodes[1], "approved") else {
|
||||
unreachable!()
|
||||
};
|
||||
r.alias = "assessed_path".into();
|
||||
r.description = "Reviewed safety rationale".into();
|
||||
let link_id = r.id;
|
||||
p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap();
|
||||
Fixture {
|
||||
p,
|
||||
owner: owner_id,
|
||||
work: work.try_into().unwrap(),
|
||||
nodes: nodes.try_into().unwrap(),
|
||||
exchange,
|
||||
link: link_id,
|
||||
}
|
||||
}
|
||||
pub(crate) fn context(owner: Id) -> Context {
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
owner,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
fn command(f: &Fixture, tool: Tool, target: Option<Id>, insertion: Insertion) -> Command {
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(&f.p, context(f.owner), tool, ControlKind::Choice, insertion)
|
||||
.unwrap();
|
||||
if let Some(id) = target {
|
||||
state
|
||||
.update(
|
||||
&f.p,
|
||||
Event::Target(Choice {
|
||||
id,
|
||||
label: "Source".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
state.prepare_pending(&f.p).unwrap()
|
||||
}
|
||||
fn created(p: &Project, after: &Project, owner: Id) -> Id {
|
||||
after
|
||||
.children(owner)
|
||||
.find(|e| !p.contains(e.id))
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_oa_sa_repeated_occurrences_share_sources_and_split_one_guarded_link() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let original = f.p.clone();
|
||||
let add = command(&f, Tool::Work, Some(f.work[0]), Insertion::default());
|
||||
let result = execute_batch(&f.p, &[add]).unwrap();
|
||||
let repeated = created(&f.p, &result, f.owner);
|
||||
assert_ne!(repeated, f.nodes[0]);
|
||||
assert_eq!(result.elements[&f.work[0]], f.p.elements[&f.work[0]]);
|
||||
assert!(
|
||||
matches!(result.elements[&repeated].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Action{action:Some(id),..},..})if *id==f.work[0])
|
||||
);
|
||||
f.p = result;
|
||||
let plan = command(
|
||||
&f,
|
||||
Tool::Exchange,
|
||||
Some(f.exchange),
|
||||
Insertion {
|
||||
link: Some(f.link),
|
||||
before: None,
|
||||
},
|
||||
);
|
||||
let serialized = serde_json::to_string(&plan).unwrap();
|
||||
let api: Command = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(api, plan);
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
doc.execute(plan).unwrap();
|
||||
let inserted = created(&f.p, doc.project(), f.owner);
|
||||
let r = &doc.project().relations[&f.link];
|
||||
let expected = &f.p.relations[&f.link];
|
||||
assert_eq!(r.id, expected.id);
|
||||
assert_eq!(r.alias, expected.alias);
|
||||
assert_eq!(r.description, expected.description);
|
||||
assert_eq!(r.source(), expected.source());
|
||||
assert_eq!(r.target(), inserted);
|
||||
assert!(matches!(&r.body,RelationBody::SequenceLink{guard,..}if guard=="approved"));
|
||||
assert!(
|
||||
sequence_links(doc.project(), f.owner)
|
||||
.iter()
|
||||
.any(|r| r.source() == inserted && r.target() == f.nodes[1])
|
||||
);
|
||||
let steps = doc.project().elements[&f.owner].steps().unwrap();
|
||||
assert_eq!(&steps[..3], &[f.nodes[0], inserted, f.nodes[1]]);
|
||||
assert_eq!(
|
||||
doc.project().relations[&f.exchange],
|
||||
original.relations[&f.exchange]
|
||||
);
|
||||
assert_eq!(doc.project(), &execute_batch(&f.p, &[api]).unwrap());
|
||||
let after = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), doc.project());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_specific_parallel_link_and_incompatible_adjacency() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let Command::CreateRelation(mut second) = link(&f.p, f.nodes[0], f.nodes[1], "urgent") else {
|
||||
unreachable!()
|
||||
};
|
||||
second.alias = "urgent_path".into();
|
||||
let second_id = second.id;
|
||||
f.p = execute_batch(&f.p, &[Command::CreateRelation(second)]).unwrap();
|
||||
let graph = Graph::new(&f.p, f.owner, f.owner);
|
||||
for edge in &graph.edges {
|
||||
let clicked = send(
|
||||
&graph,
|
||||
&mut Input::default(),
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
edge.label.center(),
|
||||
Rectangle::new(Point::ORIGIN, graph.size),
|
||||
);
|
||||
assert!(
|
||||
matches!(clicked,Some(Message::Select(id))if id==edge.id),
|
||||
"Each guarded parallel path must be individually selectable"
|
||||
);
|
||||
}
|
||||
|
||||
let plan = command(
|
||||
&f,
|
||||
Tool::Control,
|
||||
None,
|
||||
Insertion {
|
||||
link: Some(f.link),
|
||||
before: None,
|
||||
},
|
||||
);
|
||||
let after = execute_batch(&f.p, &[plan]).unwrap();
|
||||
assert_eq!(after.relations[&second_id], f.p.relations[&second_id]);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
&f.p,
|
||||
context(f.owner),
|
||||
Tool::Exchange,
|
||||
ControlKind::Choice,
|
||||
Insertion {
|
||||
link: Some(f.link),
|
||||
before: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let wrong = execute_batch(
|
||||
&f.p,
|
||||
&[Command::Connect(
|
||||
RelationKind::Interaction,
|
||||
"Reverse".into(),
|
||||
f.work[2],
|
||||
f.work[0],
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let wrong_id = wrong
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.name == "Reverse")
|
||||
.unwrap()
|
||||
.id;
|
||||
state
|
||||
.update(
|
||||
&wrong,
|
||||
Event::Target(Choice {
|
||||
id: wrong_id,
|
||||
label: "Wrong".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.prepare_pending(&wrong).is_err());
|
||||
assert!(state.draft.is_some());
|
||||
assert!(direct::reconnect(&f.p, f.owner, f.link, direct::End::Target, f.nodes[0]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_controls_allow_incomplete_work_and_preserve_branch_semantics() {
|
||||
let f = fixture(Layer::Oa);
|
||||
for kind in [
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
] {
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
&f.p,
|
||||
context(f.owner),
|
||||
Tool::Control,
|
||||
kind,
|
||||
Insertion::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
|
||||
let id = created(&f.p, &result, f.owner);
|
||||
assert!(
|
||||
matches!(result.elements[&id].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Control{kind:k},..})if *k==kind)
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&result)
|
||||
.iter()
|
||||
.any(|d| d.element == Some(id)
|
||||
&& d.code == "control_cardinality"
|
||||
&& d.severity == validation::Severity::Warning)
|
||||
);
|
||||
}
|
||||
for kind in [ControlKind::Choice, ControlKind::Fork] {
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(&f.p, Event::Start(f.owner, Tool::Branch, Some(f.nodes[0])))
|
||||
.unwrap();
|
||||
for event in [
|
||||
Event::Target(Choice {
|
||||
id: f.work[0],
|
||||
label: "A".into(),
|
||||
}),
|
||||
Event::Alternate(Choice {
|
||||
id: f.work[2],
|
||||
label: "B".into(),
|
||||
}),
|
||||
Event::Control(ControlChoice(kind)),
|
||||
Event::Guard("ready".into()),
|
||||
] {
|
||||
state.update(&f.p, event).unwrap();
|
||||
}
|
||||
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
|
||||
assert!(
|
||||
matches!(&result.relations[&f.link].body,RelationBody::SequenceLink{guard,..}if guard=="approved")
|
||||
);
|
||||
let branch_guards: Vec<_> = sequence_links(&result, f.owner)
|
||||
.into_iter()
|
||||
.filter(|r| !f.p.relations.contains_key(&r.id))
|
||||
.filter_map(|r| {
|
||||
if let RelationBody::SequenceLink { guard, .. } = &r.body {
|
||||
Some(guard.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
branch_guards.contains(&"ready"),
|
||||
kind == ControlKind::Choice
|
||||
);
|
||||
assert_eq!(branch_guards.contains(&"else"), kind == ControlKind::Choice);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
doc.execute(
|
||||
direct::reconnect(
|
||||
doc.project(),
|
||||
f.owner,
|
||||
f.link,
|
||||
direct::End::Target,
|
||||
f.nodes[2],
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc.project().relations[&f.link].target(), f.nodes[2]);
|
||||
assert_eq!(doc.project().relations[&f.link].alias, "assessed_path");
|
||||
let reconnected = doc.bundle().clone();
|
||||
doc.execute(Command::Delete(f.nodes[0])).unwrap();
|
||||
assert!(doc.project().contains(f.work[0]));
|
||||
assert!(doc.project().contains(f.exchange));
|
||||
assert!(doc.project().contains(f.nodes[1]));
|
||||
assert!(!doc.project().contains(f.link));
|
||||
doc.undo();
|
||||
assert_eq!(doc.bundle(), &reconnected);
|
||||
doc.undo();
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
let reordered = execute_batch(
|
||||
&f.p,
|
||||
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
|
||||
assert_eq!(reordered.relations, f.p.relations);
|
||||
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_reuse_preview_is_local_expandable_and_recursive_sources_are_rejected() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let source =
|
||||
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
|
||||
for i in 0..8 {
|
||||
let e = occurrence(
|
||||
&f.p,
|
||||
source_id,
|
||||
&format!("Shared step {i}"),
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
|
||||
}
|
||||
let shared = f.p.clone();
|
||||
let mut references = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let plan = command(&f, Tool::Reuse, Some(source_id), Insertion::default());
|
||||
let result = execute_batch(&f.p, &[plan]).unwrap();
|
||||
references.push(created(&f.p, &result, f.owner));
|
||||
f.p = result;
|
||||
}
|
||||
let graph = Graph::new(&f.p, f.owner, references[0]);
|
||||
let node = graph.nodes.iter().find(|n| n.id == references[0]).unwrap();
|
||||
assert_eq!(node.preview.len(), 4);
|
||||
assert_eq!(node.preview_total, 8);
|
||||
let mut input = Input::default();
|
||||
let point = Point::new(node.rect.x + 20., node.rect.y + 140.);
|
||||
let clicked = send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point,
|
||||
Rectangle::new(Point::ORIGIN, graph.size),
|
||||
);
|
||||
assert!(
|
||||
matches!(clicked,Some(Message::Select(id))if id==references[0]),
|
||||
"preview click must select the local reference"
|
||||
);
|
||||
let expanded = Graph::expanded(
|
||||
&f.p,
|
||||
f.owner,
|
||||
references[0],
|
||||
&BTreeSet::from([references[0]]),
|
||||
);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == references[0])
|
||||
.unwrap()
|
||||
.preview
|
||||
.len(),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == references[1])
|
||||
.unwrap()
|
||||
.preview
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
for e in shared.children(source_id) {
|
||||
assert_eq!(f.p.elements[&e.id], *e);
|
||||
}
|
||||
assert!(
|
||||
!targets(&f.p, source_id, Tool::Reuse)
|
||||
.iter()
|
||||
.any(|c| c.id == f.owner)
|
||||
);
|
||||
let inside = f.p.elements[&source_id].steps().unwrap()[0];
|
||||
assert!(!validation::can_connect(
|
||||
&f.p,
|
||||
RelationKind::SequenceLink,
|
||||
references[0],
|
||||
inside
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_nested_source_reopen_and_api_command_use_one_transaction() {
|
||||
let bundle=SourceBundle{entry:"project.arcadia".into(),files:[
|
||||
("project.arcadia".into(),"arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; include \"oa/process.arcadia\"; }\n".into()),
|
||||
("oa/work.arcadia".into(),"// Shared work\noa_activity assess \"Assess\" {}\n".into()),
|
||||
("oa/process.arcadia".into(),"// Contextual process\noa_process respond \"Respond\" {}\n".into()),
|
||||
].into()};
|
||||
let mut doc = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
let owner = doc
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "respond")
|
||||
.unwrap()
|
||||
.id;
|
||||
let work = doc
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "assess")
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
doc.project(),
|
||||
context(owner),
|
||||
Tool::Work,
|
||||
ControlKind::Choice,
|
||||
Insertion::default(),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Target(Choice {
|
||||
id: work,
|
||||
label: "Assess".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let plan = state.prepare_pending(doc.project()).unwrap();
|
||||
let expected = execute_batch(
|
||||
doc.project(),
|
||||
&[serde_json::from_value(serde_json::to_value(&plan).unwrap()).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
doc.execute(plan).unwrap();
|
||||
let id = doc.project().elements[&owner].steps().unwrap()[0];
|
||||
assert_eq!(
|
||||
doc.compiled().index.declarations[&id].path,
|
||||
"oa/process.arcadia"
|
||||
);
|
||||
assert_eq!(
|
||||
doc.bundle().files["oa/work.arcadia"],
|
||||
original.files["oa/work.arcadia"]
|
||||
);
|
||||
assert_eq!(doc.project(), &expected);
|
||||
let after = doc.bundle().clone();
|
||||
doc.undo();
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
doc.redo();
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
}
|
||||
fn graph(f: &Fixture, tool: CanvasTool) -> Graph<'_> {
|
||||
let mut graph = Graph::new(&f.p, f.owner, f.owner);
|
||||
graph.interaction = Some((context(f.owner), tool, true));
|
||||
graph
|
||||
}
|
||||
fn send(
|
||||
graph: &Graph<'_>,
|
||||
input: &mut Input,
|
||||
event: mouse::Event,
|
||||
point: Point,
|
||||
bounds: Rectangle,
|
||||
) -> Option<Message> {
|
||||
canvas::Program::update(
|
||||
graph,
|
||||
input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(point + graph.scroll_offset),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn position(graph: &Graph<'_>, id: Id) -> Point {
|
||||
graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.unwrap()
|
||||
.rect
|
||||
.center()
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() {
|
||||
let f = fixture(Layer::Oa);
|
||||
for drag in [false, true] {
|
||||
let mut graph = graph(&f, CanvasTool::Connect);
|
||||
graph.scroll_offset = iced::Vector::new(35., 90.);
|
||||
let bounds = Rectangle::new(Point::new(10., 20.), graph.size);
|
||||
let screen =
|
||||
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset;
|
||||
let a = screen(f.nodes[0]);
|
||||
let b = screen(f.nodes[2]);
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: a },
|
||||
a,
|
||||
bounds,
|
||||
);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
a,
|
||||
bounds,
|
||||
);
|
||||
if !drag {
|
||||
assert!(
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
a,
|
||||
bounds
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
b,
|
||||
bounds,
|
||||
);
|
||||
if !drag {
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
b,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Connect{source,target,..}))if source==f.nodes[0]&&target==f.nodes[2])
|
||||
);
|
||||
assert!(
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
b,
|
||||
bounds
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let graph = graph(&f, CanvasTool::Add(Tool::Exchange, ControlKind::Choice));
|
||||
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
|
||||
let e = &graph.edges[0];
|
||||
let point = Point::new((e.points[0].x + e.points[1].x) / 2., e.points[0].y);
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),point,bounds),Some(Message::TopologyCanvas(CanvasEvent::Begin{insertion:Insertion{link:Some(id),..},..}))if id==f.link)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_production_stale_focus_scroll_bounds_and_pending_cancel() {
|
||||
let f = fixture(Layer::Oa);
|
||||
for change in 0..8 {
|
||||
let mut graph = graph(&f, CanvasTool::Connect);
|
||||
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
|
||||
let a = position(&graph, f.nodes[0]);
|
||||
let b = position(&graph, f.nodes[2]);
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
a,
|
||||
bounds,
|
||||
);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
b,
|
||||
bounds,
|
||||
);
|
||||
let mut end_bounds = bounds;
|
||||
match change {
|
||||
0 => graph.interaction.as_mut().unwrap().0.epoch += 1,
|
||||
1 => graph.interaction.as_mut().unwrap().2 = false,
|
||||
2 => end_bounds.y -= 40.,
|
||||
3 => {
|
||||
canvas::Program::update(
|
||||
&graph,
|
||||
&mut input,
|
||||
&canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
bounds,
|
||||
mouse::Cursor::Available(b),
|
||||
);
|
||||
}
|
||||
4 => {
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
},
|
||||
b,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
5 => graph.interaction.as_mut().unwrap().0.document = Id::new_v4(),
|
||||
6 => graph.interaction.as_mut().unwrap().1 = CanvasTool::Select,
|
||||
_ => {
|
||||
canvas::Program::update(
|
||||
&graph,
|
||||
&mut input,
|
||||
&canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
modified_key: iced::keyboard::Key::Named(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
physical_key: iced::keyboard::key::Physical::Code(
|
||||
iced::keyboard::key::Code::Escape,
|
||||
),
|
||||
location: iced::keyboard::Location::Standard,
|
||||
modifiers: iced::keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
}),
|
||||
bounds,
|
||||
mouse::Cursor::Available(b),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
b,
|
||||
end_bounds
|
||||
)
|
||||
.is_none(),
|
||||
"change {change}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_readable_default_fit_expansion_and_bounded_choices() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let mut state = State::default();
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
state.viewport(Size::new(500., 400.));
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
|
||||
state.zoom(None);
|
||||
assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2);
|
||||
state.zoom(Some(0.1));
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5);
|
||||
state.enter(Id::new_v4(), f.owner);
|
||||
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
|
||||
let mut all = Vec::new();
|
||||
for page in 0..500usize.div_ceil(24) {
|
||||
let range = view::choice_page(500, page);
|
||||
assert!(range.len() <= 24);
|
||||
all.extend(range);
|
||||
}
|
||||
assert_eq!(all, (0..500).collect::<Vec<_>>());
|
||||
assert_eq!(view::choice_page(0, 90), 0..0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let mut graph = graph(&f, CanvasTool::Select);
|
||||
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
|
||||
graph.selected = f.link;
|
||||
let mut input = Input::default();
|
||||
let a = *graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|e| e.id == f.link)
|
||||
.unwrap()
|
||||
.points
|
||||
.last()
|
||||
.unwrap();
|
||||
let b = position(&graph, f.nodes[2]);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
a,
|
||||
bounds,
|
||||
);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
b,
|
||||
bounds,
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reconnect{link,end:direct::End::Target,target,..}))if link==f.link&&target==f.nodes[2])
|
||||
);
|
||||
graph.selected = f.nodes[2];
|
||||
let n = graph.nodes.iter().find(|n| n.id == f.nodes[2]).unwrap();
|
||||
let a = Point::new(n.rect.x + n.rect.width - 20., n.rect.y + 18.);
|
||||
let b = position(&graph, f.nodes[0]);
|
||||
let mut input = Input::default();
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
a,
|
||||
bounds,
|
||||
);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
b,
|
||||
bounds,
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reorder{occurrence,before,..}))if occurrence==f.nodes[2]&&before==f.nodes[0])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
&f.p,
|
||||
context(f.owner),
|
||||
Tool::Work,
|
||||
ControlKind::Choice,
|
||||
Insertion::default(),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
&f.p,
|
||||
Event::Target(Choice {
|
||||
id: f.work[0],
|
||||
label: "Keep".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut choices: Vec<_> = (0..500)
|
||||
.map(|i| Choice {
|
||||
id: Id::new_v4(),
|
||||
label: format!("Activity {i}"),
|
||||
})
|
||||
.collect();
|
||||
choices.push(Choice {
|
||||
id: f.work[0],
|
||||
label: "Selected source".into(),
|
||||
});
|
||||
fn nodes(tree: &iced::advanced::widget::Tree) -> usize {
|
||||
1 + tree.children.iter().map(nodes).sum::<usize>()
|
||||
}
|
||||
for page in 0..21 {
|
||||
state.update(&f.p, Event::Page(0, page)).unwrap();
|
||||
let d = state.draft.as_ref().unwrap();
|
||||
let view = view::picker(choices.clone(), d, 0, d.target, "Choose", Event::Target);
|
||||
assert!(nodes(&iced::advanced::widget::Tree::new(view.as_widget())) < 40);
|
||||
}
|
||||
state
|
||||
.update(&f.p, Event::Search(0, "Activity 499".into()))
|
||||
.unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0]));
|
||||
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
|
||||
}
|
||||
|
||||
pub(crate) fn empty_exchange_fixture(layer: Layer) -> (Fixture, Id) {
|
||||
let mut f = fixture(layer);
|
||||
let mut commands = f.nodes.into_iter().map(Command::Delete).collect::<Vec<_>>();
|
||||
commands.push(Command::Connect(
|
||||
f.p.relations[&f.exchange].kind(),
|
||||
"Response".into(),
|
||||
f.work[1],
|
||||
f.work[2],
|
||||
));
|
||||
f.p = execute_batch(&f.p, &commands).unwrap();
|
||||
let next =
|
||||
f.p.relations
|
||||
.values()
|
||||
.find(|r| r.name == "Response")
|
||||
.unwrap()
|
||||
.id;
|
||||
assert!(f.p.elements[&f.owner].steps().unwrap().is_empty());
|
||||
(f, next)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, next) = empty_exchange_fixture(layer);
|
||||
let kind = f.p.relations[&f.exchange].kind();
|
||||
f.p = execute_batch(
|
||||
&f.p,
|
||||
&[Command::Connect(
|
||||
kind,
|
||||
"Recovery".into(),
|
||||
f.work[2],
|
||||
f.work[0],
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let final_exchange =
|
||||
f.p.relations
|
||||
.values()
|
||||
.find(|r| r.name == "Recovery")
|
||||
.unwrap()
|
||||
.id;
|
||||
for exchange in [f.exchange, next] {
|
||||
let e = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
"Ordered contribution",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
|
||||
}
|
||||
let before = f.p.elements[&f.owner].steps().unwrap().clone();
|
||||
assert_eq!(legacy_order(&f.p, f.owner), before);
|
||||
assert!(sequence_links(&f.p, f.owner).is_empty());
|
||||
let mut state = State::default();
|
||||
state.append_exchange(&f.p, f.owner).unwrap();
|
||||
let draft = state.draft.as_ref().unwrap();
|
||||
assert!(draft.fixed);
|
||||
assert_eq!(draft.before, before.last().copied());
|
||||
assert_eq!(draft.after, None);
|
||||
assert!(!compatible_source(&f.p, draft, f.exchange));
|
||||
assert!(compatible_source(&f.p, draft, final_exchange));
|
||||
state
|
||||
.update(
|
||||
&f.p,
|
||||
Event::Target(Choice {
|
||||
id: f.exchange,
|
||||
label: "Incompatible".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.prepare_pending(&f.p).is_err());
|
||||
assert!(state.draft.is_some());
|
||||
state
|
||||
.update(
|
||||
&f.p,
|
||||
Event::Target(Choice {
|
||||
id: final_exchange,
|
||||
label: "Recovery".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let command = state.prepare_pending(&f.p).unwrap();
|
||||
let Command::Batch(commands) = &command else {
|
||||
panic!("one ordinary batch")
|
||||
};
|
||||
assert_eq!(
|
||||
commands.len(),
|
||||
4,
|
||||
"materialize old path + occurrence + append link + order"
|
||||
);
|
||||
let serialized = serde_json::to_value(command).unwrap();
|
||||
let result = execute_batch(&f.p, &[serde_json::from_value(serialized).unwrap()]).unwrap();
|
||||
let after = result.elements[&f.owner].steps().unwrap();
|
||||
assert_eq!(&after[..2], &before);
|
||||
assert_eq!(after.len(), 3);
|
||||
let links = sequence_links(&result, f.owner);
|
||||
assert_eq!(links.len(), 2);
|
||||
for pair in after.windows(2) {
|
||||
assert_eq!(
|
||||
links
|
||||
.iter()
|
||||
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert!(uses_explicit_topology(&result, f.owner));
|
||||
for (id, relation) in &f.p.relations {
|
||||
assert_eq!(result.relations[id], *relation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
pub(super) fn picker<'a>(
|
||||
mut choices: Vec<Choice>,
|
||||
d: &'a Draft,
|
||||
index: usize,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
let browser = &d.browsers[index];
|
||||
let query = browser.search.trim().to_lowercase();
|
||||
choices.retain(|c| c.label.to_lowercase().contains(&query));
|
||||
let range = choice_page(choices.len(), browser.page);
|
||||
let page = range.start / 24;
|
||||
let mut out = column![
|
||||
text_input("Search compatible choices…", &browser.search)
|
||||
.on_input(move |v| event(Event::Search(index, v)))
|
||||
.padding(7)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(selected) = selected
|
||||
.as_ref()
|
||||
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
|
||||
{
|
||||
out = out.push(label(format!("Selected: {}", selected.label)));
|
||||
}
|
||||
out = out.push(
|
||||
row![
|
||||
button("Previous")
|
||||
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
|
||||
text(format!(
|
||||
"{} choices · {}/{}",
|
||||
choices.len(),
|
||||
page + 1,
|
||||
choices.len().div_ceil(24).max(1)
|
||||
))
|
||||
.size(11),
|
||||
button("Next").on_press_maybe(
|
||||
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
out.push(
|
||||
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
|
||||
let start = requested.min(len.saturating_sub(1) / 24) * 24;
|
||||
start..(start + 24).min(len)
|
||||
}
|
||||
pub(super) fn view<'a>(
|
||||
app: &'a App,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let context = app.topology_context().expect("visible process context");
|
||||
let state = &app.behavior.topology;
|
||||
let pending = state.draft.is_some();
|
||||
let explicit =
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
if !pending && explicit {
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
button("Select / reorder")
|
||||
.style(if state.tool == CanvasTool::Select {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Select)),
|
||||
button("Work")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
|
||||
button("Exchange")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
|
||||
button("Reuse")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
pick_list(
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop
|
||||
]
|
||||
.into_iter()
|
||||
.map(ControlChoice)
|
||||
.collect::<Vec<_>>(),
|
||||
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
|
||||
Some(ControlChoice(kind))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Add(Tool::Control, c.0)
|
||||
))
|
||||
)
|
||||
.placeholder("Draw control…")
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
button("Sequence link")
|
||||
.style(if state.tool == CanvasTool::Connect {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Connect)),
|
||||
button("Two paths…").on_press(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
if explicit {
|
||||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
graph.scroll_offset = state.scroll_offset();
|
||||
let scale = graph.scale;
|
||||
out = out.push(
|
||||
row![
|
||||
button("Fit overview")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
let canvas = scrollable(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(if pending {
|
||||
(app.window_size.height - 510.).clamp(160., 240.)
|
||||
} else {
|
||||
(app.window_size.height - 420.).clamp(190., 420.)
|
||||
});
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
);
|
||||
} else {
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
if !pending {
|
||||
out = out.push(
|
||||
button("Append exchange…")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
|
||||
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
|
||||
}
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
out = out.push(
|
||||
button("Convert ordered path to explicit links")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
} else {
|
||||
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
|
||||
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
|
||||
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
|
||||
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
|
||||
}
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
out = out.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
out = out.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
"Choose existing source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
out = out.push(
|
||||
container(
|
||||
column![
|
||||
actions,
|
||||
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
);
|
||||
}
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
)
|
||||
}
|
||||
@@ -11,10 +11,11 @@ pub struct FieldPathKey {
|
||||
pub struct FieldPathChoice {
|
||||
pub key: FieldPathKey,
|
||||
pub label: String,
|
||||
pub picker_label: String,
|
||||
}
|
||||
impl std::fmt::Display for FieldPathChoice {
|
||||
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))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ");
|
||||
.collect::<Vec<_>>();
|
||||
let direction =
|
||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||
match entry.direction {
|
||||
@@ -63,31 +63,60 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
|
||||
} 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 {
|
||||
key: FieldPathKey {
|
||||
path: entry.path,
|
||||
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(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
Ok(data::try_carried_field_paths(project, exchange)?
|
||||
Ok(sorted_choices(
|
||||
data::try_carried_field_paths(project, exchange)?
|
||||
.into_iter()
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect())
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct carried fields only: an unrelated deep branch must not prevent
|
||||
/// browsing a root field. Preserve the core resolver's first carried owner.
|
||||
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
sorted_choices(
|
||||
exchange
|
||||
.body
|
||||
.items()
|
||||
@@ -110,7 +139,21 @@ pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec
|
||||
.filter(|entry| seen.insert(entry.field))
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.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.
|
||||
@@ -166,7 +209,8 @@ pub fn children(
|
||||
}
|
||||
let mut path = key.path.clone();
|
||||
path.push(key.field);
|
||||
Ok(data::effective_fields(project, *record)
|
||||
Ok(sorted_choices(
|
||||
data::effective_fields(project, *record)
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
named_choice(
|
||||
@@ -179,7 +223,8 @@ pub fn children(
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -482,4 +527,33 @@ pub(crate) mod tests {
|
||||
.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 editor: text_editor::Content,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValueBrowser {
|
||||
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
pub original: Id,
|
||||
@@ -40,6 +46,7 @@ pub struct Draft {
|
||||
pub inputs: BTreeMap<String, Input>,
|
||||
pub exact_strings: bool,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
@@ -77,11 +84,14 @@ impl Draft {
|
||||
inputs: BTreeMap::new(),
|
||||
exact_strings: false,
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
|
||||
match value {
|
||||
@@ -710,10 +720,15 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
..
|
||||
},
|
||||
..
|
||||
} => project
|
||||
.relations
|
||||
.get(id)
|
||||
.and_then(|r| crate::data_values::choices(project, r, message_kind).ok()),
|
||||
} => project.relations.get(id).and_then(|r| {
|
||||
let scope = address_path.split_last().map(|(&field, path)| {
|
||||
crate::data_values::FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
}
|
||||
});
|
||||
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -330,97 +330,152 @@ fn message_value<'a>(
|
||||
} => project.relations.get(id).map(|r| (r, message_kind)),
|
||||
_ => None,
|
||||
});
|
||||
let choices = context
|
||||
.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
|
||||
let current_key = current
|
||||
.as_ref()
|
||||
.and_then(|key| choices.iter().find(|c| &c.key == key))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
context.and_then(|(r, phase)| {
|
||||
.map(|value| crate::data_values::FieldPathKey {
|
||||
path: value.path.clone(),
|
||||
field: value.field,
|
||||
});
|
||||
let selected = context.and_then(|(exchange, phase)| {
|
||||
current_key
|
||||
.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
|
||||
&& !choices.iter().any(|c| c.key == choice.key)
|
||||
{
|
||||
choices.push(choice.clone());
|
||||
}
|
||||
if selected.is_none()
|
||||
&& let Some(key) = current_key.clone()
|
||||
let browser = draft
|
||||
.value_browsers
|
||||
.get(path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ValueBrowser {
|
||||
scope: selected
|
||||
.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()
|
||||
{
|
||||
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));
|
||||
let retained = crate::data_values::FieldPathChoice {
|
||||
label: format!(
|
||||
"Current address · {} / {}",
|
||||
out = out.push(
|
||||
text(format!(
|
||||
"Current address needs review: {} / {}. Choose an explicit field.",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| project.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
project.named(key.field)
|
||||
),
|
||||
key,
|
||||
};
|
||||
selected = Some(retained.clone());
|
||||
choices.push(retained);
|
||||
))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
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();
|
||||
out = out.push(
|
||||
pick_list(choices, selected, move |choice| {
|
||||
pick_list(visible, visible_selection, move |choice| {
|
||||
Message::FieldValuePath(owned.clone(), choice.key)
|
||||
})
|
||||
.placeholder("Choose a carried field path…")
|
||||
.placeholder("Choose a field here…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if pages > 1 {
|
||||
out = out.push(
|
||||
button(text("Up to enclosing field").size(11))
|
||||
row![
|
||||
button(text("Previous").size(11))
|
||||
.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)),
|
||||
_ => {}
|
||||
}
|
||||
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") {
|
||||
out = out.push(super::property(
|
||||
@@ -435,6 +490,25 @@ fn message_value<'a>(
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -490,6 +564,25 @@ mod tests {
|
||||
format!("{path}/expression"),
|
||||
"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(
|
||||
path.into(),
|
||||
crate::data_values::FieldPathKey {
|
||||
@@ -553,6 +646,43 @@ mod tests {
|
||||
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]
|
||||
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
|
||||
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
|
||||
actual context change; preserve same-view return. Independent review reproduced
|
||||
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
|
||||
|
||||
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
|
||||
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
|
||||
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.
|
||||
|
||||
Direct state-machine editing is accepted through production316dc55. The main
|
||||
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts are now
|
||||
being implemented in a separate checkout; they are not accepted or released.
|
||||
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts
|
||||
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.
|
||||
|
||||
## 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.
|
||||
See release-data-implementation.md and release-data-contract.md in that checkout.
|
||||
|
||||
Core is committed0b58107; source/migration1230ab8. Root API/docs are integrated
|
||||
on the same working tree; desktop remains in flight and R6 is not accepted.
|
||||
Core15 R6 +28workflow tests passed again after final allocation-arrow suppression.
|
||||
Linux0b58107 full core307 tests passed (one explicit probe ignored by default),
|
||||
with strict all-target core Clippy. Earlier13ports/14scenarios and115 source/lib
|
||||
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.
|
||||
R6 core/source/desktop are frozen at ab661f2. Full Windows607 tests and Linux610
|
||||
tests pass (one explicit benchmark ignored on each platform); strict workspace
|
||||
all-target Clippy passes on both. Optimized desktop/server Linux build also passed.
|
||||
Windows desktop261 tests include the bounded scenario value editor. Exact logs,
|
||||
binaries and runtime evidence: release-data-verification.md.
|
||||
|
||||
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
|
||||
data example, protocol/artifact identity, atomic rejected defaults, query/export,
|
||||
reidentify/delete/restart, source3 bundle migration reports and old coupled3
|
||||
envelope upgrade without write/revision. Tests exposed dropped bundle reports and
|
||||
rejected legitimate old source/model pairs; both root fixes passed retest.
|
||||
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.
|
||||
All4 R6 actual HTTP workflows pass, covering data protocols/artifact identity,
|
||||
rejected defaults, qualified Call/Reply paths, validation locations, source edits,
|
||||
reidentification, source3 migration and restart persistence. The published example's
|
||||
actual server responses passed served/local OpenAPI validation; obsolete shapes
|
||||
reject. Strict reST rendering and81 OpenAPI schemas passed.
|
||||
|
||||
Independent core review found association transition closure pulling unrelated
|
||||
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
|
||||
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.
|
||||
Root found another unresolved scale issue: scenario creation still enumerates every
|
||||
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.
|
||||
The bounded scenario editor replaces whole-recursion enumeration and per-field
|
||||
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
|
||||
the dedicated Windows Cargo target. state_architecture_review independently reviews
|
||||
R6 spec/API consistency; its data_view.rs ownership has ended. data_core runs an
|
||||
isolated R3 typed-baseline experiment; Linux R6 core checkout is idle and verified.
|
||||
Root owns API/OpenAPI/reST, docs, integration and native input. No concurrent writers.
|
||||
Root exclusively operates native R6. The Windows13-object data model has saved
|
||||
native bounds/default repair, a typed physical flow/field, allocation protocol
|
||||
edit, allocation removal/Undo, and inherited-owner navigation. Association endpoint
|
||||
labels/selectability were exercised. The1100x700 data layout has been inspected.
|
||||
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
|
||||
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
|
||||
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
|
||||
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
|
||||
baselines/reconciliation only cover prose. Prefer historical typed-body snapshots
|
||||
and finite proposals; avoid a duplicate recursive semantic schema without evidence.
|
||||
baselines/reconciliation only cover prose. Use the experimentally selected typed facet review design C; the semantic
|
||||
integration checkout records the full scope and source5 migration plan.
|
||||
4. Complete direct scenario/process gestures, persistent coverage/filters,
|
||||
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
|
||||
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
|
||||
|
||||
Native input is paused after physical Escape; no R6 native trial has run.
|
||||
Root exclusively owns native input. Latest saved Windows trial is window1706782,
|
||||
ui-state-navigation-windows,49objects/28links/1 expected missing-owner warning.
|
||||
Linux navigation trial3148598 closed normally, exec46107 exit0; baseline Linux
|
||||
trial721472 also closed normally, exec72680 exit0. Independent Windows3672536 is
|
||||
saved at1100x700. Older root drawing windows are saved. Re-inventory before input.
|
||||
Protected user7210312/6228978 were absent; do not reset/save/close if they return.
|
||||
The user explicitly reauthorized native testing after Escape. Root's f5467e5 R6
|
||||
window3476204 and exec93768 closed normally after verified source migration and
|
||||
55-object recursive scenario reopen; no runtime output. The property-browser
|
||||
replacement build b35dede has passed the root's Windows native retest. The
|
||||
combined direct-scenario binary is frozen for its separate pending native trial.
|
||||
Original Capella is present. Older protected user7210312/6228978 were absent;
|
||||
do not reset/save/close them if they return. Re-inventory before switching apps.
|
||||
|
||||
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/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.
|
||||
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.
|
||||
|
||||
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.
|
||||
- R3 existing-target reconciliation remains name/description only. R6 data shape
|
||||
expansion is active implementation, not accepted functionality.
|
||||
- R6 core/source and actual HTTP semantics pass focused Windows and full Linux
|
||||
core checks; desktop257 tests pass after independent draft/path/paging fixes.
|
||||
Native R6 acceptance and bounded scenario-value authoring remain outstanding.
|
||||
- R6 ab661f2 full suites passed Windows607/Linux610; f5467e5 desktop263 and
|
||||
strict Clippy/release passed on both platforms. Native Windows data editing,
|
||||
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
|
||||
|
||||
@@ -51,11 +55,11 @@ requires native workflows, semantic evaluators and fresh independent review.
|
||||
gestures still need their own occurrence/order semantics and task verification.
|
||||
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
|
||||
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
|
||||
- R3 typed historical bodies plus finite proposals appear simpler than a separate
|
||||
recursive semantic-value schema. An isolated probe supports scalar partial review
|
||||
and deleted historical references, but ordered membership/order acknowledgements
|
||||
need separate typed order checkpoints. Historical source reference parsing is
|
||||
still unimplemented; this remains a candidate awaiting independent review.
|
||||
- R3 selected design C uses finite self-tagged typed facets, separate membership
|
||||
and order acknowledgements, and transient constructor groups. Source5 historical
|
||||
references are implemented in the isolated semantic checkout. Independent review
|
||||
found narrow-acknowledgement/canonical-agreement/rollup defects; the worker has
|
||||
added regressions and fixes. Full catalog, GUI/API/spec and acceptance remain.
|
||||
|
||||
## 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
|
||||
objective10134df9-bea2-4c02-a820-67b53d6b86f7 was read fully on2026-09-06; product
|
||||
scope is unchanged and reasoning effort must match the delegated task. The full
|
||||
first release remains incomplete. Native input is paused after physical Escape.
|
||||
R6 is not accepted. Main remains at a8f451e. The full first-release goal remains
|
||||
active and incomplete. User explicitly reauthorized native testing after Escape.
|
||||
|
||||
## Current evidence
|
||||
## Frozen production and automated evidence
|
||||
|
||||
- Core0b58107: Linux307 tests pass, one explicitly ignored benchmark; strict
|
||||
all-target core Clippy passes. Windows15 R6+28 workflows pass after final
|
||||
allocation-edge filtering. Source migration/compatibility evidence is retained.
|
||||
- Desktopb403039: Windows257 tests, strict all-target desktop Clippy and release
|
||||
build pass. Binary D:/CodexBuilds/Capella2-data-target/release/arcadia-studio.exe,
|
||||
SHA256 d4aa6ae7d36f429c0cb639cdf57e33cb8b99d158a9629c95a1510d24e406fab5.
|
||||
The later bounded scenario editor is not covered by this binary.
|
||||
- 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.
|
||||
Data integration ab661f2: Windows607 and Linux610 workspace tests pass, one explicit
|
||||
benchmark ignored each; strict workspace all-target Clippy passes on both.
|
||||
Windows261 desktop tests include the focused bounded scenario editor; Linux release
|
||||
desktop/server build passed. Logs under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||
r6-workspace-windows-ab661f2.log and r6-workspace-windows-clippy-ab661f2.log.
|
||||
Linux logs in Capella2-data-linux-review/target/linux-verification:
|
||||
workspace-ab661f2.log, workspace-clippy-ab661f2.log, release-ab661f2.log.
|
||||
|
||||
Logs are under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||
r6-http-paths-final.log, r6-http-all-data.log, r6-http-paths-clippy.log,
|
||||
r6-server-tests-after.log and api-r6-contract. Linux core evidence is under
|
||||
D:/CodexBuilds/Capella2-data-linux-review/target/linux-verification/report.md.
|
||||
Windows frozen executable ui-r6-windows/arcadia-studio.exe SHA256
|
||||
C3ABD9E550E1AD6824DFE273EB27A3ABB35C5C0DA97C819DCB0AB2586AF5450C.
|
||||
Current native window1835476, process session37598. No output observed; runtime.log
|
||||
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
|
||||
running in exec98587 with its dedicated data-linux-target. Initial filesystem wait
|
||||
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.
|
||||
Optimized empty inheritance chains250/500/1000: Windows21.070/89.088/391.590ms;
|
||||
Linux22.381/111.662/408.129ms. Single-run core validation timings, not UI latency.
|
||||
|
||||
Before acceptance: integrated Windows/Linux checks, native data/association/scenario
|
||||
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.
|
||||
## Personally observed Windows native workflows
|
||||
|
||||
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