Prototype pure saved Scenario spacing command previews

This commit is contained in:
2026-09-12 23:46:57 -07:00
parent 2c9cd2b3e6
commit 457a2c28db
3 changed files with 676 additions and 0 deletions
@@ -5,7 +5,10 @@ use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod camera;
// Pure spacing experiment; no production gesture or command dispatch is wired.
pub(crate) mod focus;
#[cfg(test)]
mod spacing;
use super::scenario_layout::{
Representation,
presentation::{self, ScenarioPresentation},
@@ -0,0 +1,151 @@
//! Pure Saved-only spacing preparation. Delta is measured from the original
//! pointer in the captured camera's world coordinates, never from the last
//! preview. The controller must own document/revision/visit, camera and hit-role
//! admission; this module neither dispatches commands nor changes selection.
use crate::behavior::scenario_layout::{
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
};
use oa_core::{
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
ScenarioMessagePosition, SharedBody, execute_batch,
};
#[cfg(test)]
mod tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Target {
Lane(Id),
Message(Id),
}
impl Target {
fn id(self) -> Id {
match self {
Self::Lane(id) | Self::Message(id) => id,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Refusal {
InvalidSavedView(Id),
TargetNotRendered(Target),
InvalidDelta,
Command(String),
Geometry(Vec<GeometryIssue>),
}
pub(super) struct Drag {
original: Project,
diagram: Id,
target: Target,
origin: f32,
}
pub(super) struct Preview {
pub(super) command: Command,
pub(super) project: Project,
pub(super) geometry: ResolvedScenarioPlan,
}
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
/// fallback is not an editable original, even if the target appears in fallback.
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
let element = project.elements.get(&target.id());
let origin = match target {
Target::Lane(id)
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
{
// Unassigned lifelines still have drawn headers. `scene.lanes`
// excludes them only from semantic message-endpoint hit testing.
geometry.scene.render_lanes.get(&id).copied()
}
Target::Message(id)
if element.is_some_and(|e| {
matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
}) =>
{
// A lifecycle row can denote receive y; the public position means
// send y. Read the actual outgoing endpoint, including self calls.
geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.and_then(|line| line.points.first())
.map(|point| point.y)
}
_ => None,
}
.ok_or(Refusal::TargetNotRendered(target))?;
if !origin.is_finite() {
return Err(Refusal::TargetNotRendered(target));
}
Ok(Drag {
original: project.clone(),
diagram,
target,
origin,
})
}
impl Drag {
/// None means there is no command to commit. In particular +0/-0, return to
/// start, and sub-precision motion never materialize a missing override or
/// normalize an existing explicit value. Document/redo are never touched.
pub(super) fn prepare(&self, original_delta: f32) -> Result<Option<Preview>, Refusal> {
if !original_delta.is_finite() {
return Err(Refusal::InvalidDelta);
}
if original_delta == 0. {
return Ok(None);
}
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
if position == self.origin {
return Ok(None);
}
let command = match self.target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
};
let project = execute_batch(&self.original, std::slice::from_ref(&command))
.map_err(|error| Refusal::Command(error.to_string()))?;
let geometry = resolve(
&project,
Representation::Saved {
diagram: self.diagram,
},
self.target.id(),
)
.map_err(Refusal::Geometry)?;
Ok(Some(Preview {
command,
project,
geometry,
}))
}
}
@@ -0,0 +1,522 @@
use super::*;
use crate::behavior::scenario_layout::Diagnostic;
use crate::document::Document;
use oa_core::{
Diagram, DiagramLayout, ProjectionKind, ScenarioLayout, language, source_project::ProjectLimits,
};
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn fixture(layout: Option<ScenarioLayout>) -> (Document, Id, Id) {
let mut project = language::parse(FIXTURE).unwrap();
let owner = id(&project, "conversation");
let mut add = |alias: &str, layout: Option<ScenarioLayout>| {
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: oa_core::Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: layout.map(DiagramLayout::Scenario),
},
);
diagram
};
let a = add("saved_a", layout);
let b = add("saved_b", Some(ScenarioLayout::default()));
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
(document, a, b)
}
fn resolved(project: &Project, diagram: Id, selected: Id) -> ResolvedScenarioPlan {
resolve(project, Representation::Saved { diagram }, selected).unwrap()
}
fn same_geometry(a: &ResolvedScenarioPlan, b: &ResolvedScenarioPlan) {
assert_eq!(a.bounds, b.bounds);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.plan.frames, b.plan.frames);
assert_eq!(a.plan.scopes, b.plan.scopes);
let (a, b) = (&a.scene, &b.scene);
assert_eq!(a.rows, b.rows);
assert_eq!(a.sequence_bounds, b.sequence_bounds);
assert_eq!(a.size, b.size);
assert_eq!(a.layout_origin, b.layout_origin);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.render_lanes, b.render_lanes);
assert_eq!(a.activations, b.activations);
assert_eq!(a.lifecycle, b.lifecycle);
assert_eq!(a.open_calls, b.open_calls);
let slots = |scene: &crate::behavior::Scene| {
scene
.slots
.iter()
.map(|item| {
(
item.slot.parent,
item.slot.before,
item.y,
item.label.clone(),
)
})
.collect::<Vec<_>>()
};
assert_eq!(slots(a), slots(b));
let lines = |scene: &crate::behavior::Scene| {
scene
.lines
.iter()
.map(|line| {
(
line.id,
line.points.clone(),
line.label_at,
line.label.clone(),
line.dashed,
)
})
.collect::<Vec<_>>()
};
let boxes = |scene: &crate::behavior::Scene| {
scene
.boxes
.iter()
.map(|item| {
(
item.id,
item.rect,
item.title.clone(),
item.detail.clone(),
item.marker,
)
})
.collect::<Vec<_>>()
};
assert_eq!(lines(a), lines(b));
assert_eq!(boxes(a), boxes(b));
}
fn expected_message(project: &Project, diagram: Id, occurrence: Id, y: f32) -> Project {
// Independent expected mutation: tests begin with no message overrides.
let mut expected = project.clone();
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: y,
}],
}));
expected
}
#[test]
fn one_repeated_message_moves_in_one_view_with_exact_document_undo_redo_and_reopen() {
let (mut document, a, b) = fixture(None);
let original = document.snapshot();
let before = document.project().clone();
let first = id(&before, "first");
let second = id(&before, "second");
let baseline = resolved(&before, a, second);
assert_eq!(baseline.scene.rows[&first].1, 148.);
assert_eq!(baseline.scene.rows[&second].1, 240.);
let untouched = resolved(&before, b, second);
let drag = capture(&before, a, Target::Message(second)).unwrap();
let preview = drag.prepare(-40.).unwrap().unwrap();
let expected = expected_message(&before, a, second, 200.);
assert_eq!(preview.project, expected);
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
assert_eq!(preview.geometry.scene.rows[&second].1, 200.);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == second)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 200.);
assert_eq!(line.points.last().unwrap().y, 200.);
assert_eq!(
preview.geometry.scene.activations,
baseline.scene.activations
);
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
same_geometry(&resolved(&preview.project, b, second), &untouched);
assert_eq!(drag.original, before);
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
assert!(!document.can_undo());
let returned_selection = document.execute(preview.command).unwrap();
assert_eq!(
returned_selection,
Some(a),
"controller must preserve the dragged semantic selection separately"
);
assert_eq!(document.project(), &expected);
assert_eq!(document.revision(), 1);
let accepted = document.bundle().clone();
same_geometry(&resolved(document.project(), a, second), &preview.geometry);
assert!(document.undo());
assert_eq!(document.project(), &before);
assert_eq!(document.bundle(), &original.bundle);
assert!(!document.can_undo());
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &accepted);
let reopened = Document::from_bundle(accepted, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
same_geometry(&resolved(reopened.project(), a, second), &preview.geometry);
assert!(!reopened.dirty() && !reopened.can_undo());
}
#[test]
fn horizontal_lane_move_changes_actual_header_and_attached_endpoints_only_in_selected_view() {
let (document, a, b) = fixture(None);
let before = document.project();
let right = id(before, "right");
let first = id(before, "first");
let other = resolved(before, b, right);
let preview = capture(before, a, Target::Lane(right))
.unwrap()
.prepare(100.)
.unwrap()
.unwrap();
let mut expected = before.clone();
expected.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: right,
x: 500.,
}],
messages: vec![],
}));
assert_eq!(preview.project, expected);
assert_eq!(preview.geometry.scene.lanes[&right], 500.);
let header = preview
.geometry
.scene
.boxes
.iter()
.find(|item| item.id == right)
.unwrap();
assert_eq!(header.rect.center_x(), 500.);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == first)
.unwrap();
assert_eq!(line.points.first().unwrap().x, 160.);
assert_eq!(line.points.last().unwrap().x, 500.);
assert!(line.points.iter().all(|point| point.y == 148.));
same_geometry(&resolved(&preview.project, b, right), &other);
assert_eq!(document.project(), before);
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn drawn_unassigned_lifeline_can_move_without_assigning_a_semantic_participant() {
let (document, a, _) = fixture(None);
let mut project = document.project().clone();
let owner = id(&project, "conversation");
let lane = project
.add_element(
Kind::Lifeline,
oa_core::Layer::Oa,
"Unassigned header",
Some(owner),
)
.unwrap();
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
let baseline = resolved(document.project(), a, lane);
assert!(!baseline.scene.lanes.contains_key(&lane));
let before_header = baseline
.scene
.boxes
.iter()
.find(|item| item.id == lane)
.unwrap()
.rect;
let preview = capture(document.project(), a, Target::Lane(lane))
.unwrap()
.prepare(70.)
.unwrap()
.unwrap();
let after_header = preview
.geometry
.scene
.boxes
.iter()
.find(|item| item.id == lane)
.unwrap()
.rect;
assert_eq!(after_header.x, before_header.x + 70.);
assert_eq!(after_header.y, before_header.y);
assert_eq!(after_header.size(), before_header.size());
assert_eq!(preview.project.elements, document.project().elements);
assert_eq!(preview.project.relations, document.project().relations);
assert!(!document.can_undo());
}
#[test]
fn self_call_spacing_uses_send_y_and_preserves_reply_and_actual_activation_finish() {
let (document, a, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let reply = id(before, "reply");
let baseline = resolved(before, a, call);
let preview = capture(before, a, Target::Message(call))
.unwrap()
.prepare(-22.)
.unwrap()
.unwrap();
assert_eq!(preview.project, expected_message(before, a, call, 310.));
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == call)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 310.);
assert_eq!(line.points.last().unwrap().y, 334.);
assert_eq!(preview.geometry.scene.rows[&reply].1, 424.);
assert_eq!(preview.geometry.scene.activations.len(), 1);
let mut expected_activation = baseline.scene.activations.clone();
expected_activation[0].rect.y = 334.;
expected_activation[0].rect.height = 90.;
assert_eq!(preview.geometry.scene.activations, expected_activation);
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
}
#[test]
fn feasible_spacing_crossing_remains_authored_with_diagnostic_and_unchanged_steps() {
let (document, a, _) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let owner = id(before, "conversation");
let preview = capture(before, a, Target::Message(second))
.unwrap()
.prepare(-120.)
.unwrap()
.unwrap();
assert_eq!(preview.project, expected_message(before, a, second, 120.));
assert_eq!(preview.geometry.scene.rows[&second].1, 120.);
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
assert_eq!(
preview.project.elements[&owner].steps(),
before.elements[&owner].steps()
);
let shown = crate::behavior::scenario_layout::presentation::present(
&preview.project,
Representation::Saved { diagram: a },
second,
);
assert!(shown.capabilities().coordinate_input);
assert!(
shown
.diagnostics()
.contains(&Diagnostic::SourceOrderCrossing {
scope: owner,
before: first,
after: second
})
);
}
#[test]
fn unsupported_self_call_move_is_refused_and_automatic_fallback_is_not_editable_capture() {
let (document, a, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let drag = capture(before, a, Target::Message(call)).unwrap();
let Err(Refusal::Geometry(issues)) = drag.prepare(118.) else {
panic!("send450 / receive474 after reply424 must be refused")
};
assert!(issues.iter().any(|issue| matches!(issue, GeometryIssue::TypedActivationCrossingNotSupported { call: id, .. } if *id == call)));
assert_eq!(&drag.original, before);
let invalid_geometry = expected_message(before, a, call, 450.);
let shown = crate::behavior::scenario_layout::presentation::present(
&invalid_geometry,
Representation::Saved { diagram: a },
call,
);
assert!(shown.capabilities().geometry_navigation && !shown.capabilities().coordinate_input);
assert!(matches!(
capture(&invalid_geometry, a, Target::Message(call)),
Err(Refusal::Geometry(_))
));
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn invalid_view_foreign_identity_and_latent_message_are_not_spacing_targets() {
let (document, a, b) = fixture(None);
let p = document.project();
let unfinished = id(p, "unfinished");
let source = id(p, "source");
assert!(matches!(
capture(p, Id::nil(), Target::Lane(source)),
Err(Refusal::InvalidSavedView(_))
));
for target in [
Target::Lane(source),
Target::Lane(b),
Target::Message(source),
Target::Message(unfinished),
] {
assert!(
matches!(capture(p, a, target), Err(Refusal::TargetNotRendered(found)) if found == target)
);
}
// A stored latent coordinate is not permission to fabricate a painted arrow.
let mut latent = expected_message(p, a, unfinished, 2000.);
let foreign_owner = latent
.add_element(Kind::Scenario, oa_core::Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = latent
.add_element(
Kind::Lifeline,
oa_core::Layer::Oa,
"Foreign lane",
Some(foreign_owner),
)
.unwrap();
*latent
.elements
.get_mut(&foreign_lane)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline {
represented: Some(source),
};
let latent_document = Document::from_project(latent, ProjectLimits::default()).unwrap();
assert!(matches!(
capture(latent_document.project(), a, Target::Message(unfinished)),
Err(Refusal::TargetNotRendered(_))
));
assert!(matches!(
capture(latent_document.project(), a, Target::Lane(foreign_lane)),
Err(Refusal::TargetNotRendered(_))
));
}
#[test]
fn zero_motion_return_to_start_and_subprecision_preserve_sparse_intent_and_real_redo() {
let parsed = language::parse(FIXTURE).unwrap();
let left = id(&parsed, "left");
let second = id(&parsed, "second");
let layouts = [
None,
Some(ScenarioLayout::default()),
Some(ScenarioLayout {
lanes: vec![],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: second,
},
send_y: 240.,
}],
}),
Some(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: left,
x: -0.,
}],
messages: vec![],
}),
];
for (index, layout) in layouts.into_iter().enumerate() {
let (mut document, a, _) = fixture(layout);
let owner = id(document.project(), "conversation");
document
.execute(Command::Rename(owner, "A real redo entry".into()))
.unwrap();
let redo = document.snapshot();
assert!(document.undo());
let before = document.snapshot();
let revision = document.revision();
let target = if index == 3 {
Target::Lane(left)
} else {
Target::Message(second)
};
let drag = capture(document.project(), a, target).unwrap();
if index == 3 {
let Some(DiagramLayout::Scenario(layout)) = &drag.original.diagrams[&a].layout else {
panic!()
};
assert_eq!(layout.lanes[0].x.to_bits(), (-0_f32).to_bits());
}
assert!(drag.prepare(10.).unwrap().is_some());
for delta in [0., -0.] {
assert!(drag.prepare(delta).unwrap().is_none());
}
if index != 3 {
assert!(drag.prepare(f32::from_bits(1)).unwrap().is_none());
}
assert_eq!(
serde_json::to_vec(&drag.original).unwrap(),
serde_json::to_vec(document.project()).unwrap()
);
assert!(std::sync::Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(document.revision(), revision);
assert!(document.can_redo());
assert!(document.redo());
assert_eq!(document.project(), &redo.decoded.project);
assert_eq!(document.bundle(), &redo.bundle);
}
}
#[test]
fn malformed_or_out_of_range_motion_cannot_mutate_original() {
let (document, a, _) = fixture(None);
let drag = capture(
document.project(),
a,
Target::Message(id(document.project(), "second")),
)
.unwrap();
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
}
assert!(matches!(drag.prepare(f32::MAX), Err(Refusal::Command(_))));
assert_eq!(&drag.original, document.project());
assert!(!document.dirty() && !document.can_undo());
}
const FIXTURE: &str = r#"arcadia 7;
project spacing_probe "Saved spacing probe" id "71000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; }
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
}
"#;