Compose saved Scenario authoring with current Text and input authority

This commit is contained in:
2026-09-23 00:25:20 -07:00
parent 5960563b23
commit 6f4dfc67b3
78 changed files with 14097 additions and 318 deletions
@@ -50,7 +50,7 @@ pub fn controls<'a>(
control("Details", "Details", Some(details)),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(format!("{:.0}%", scale * 100.)).size(12),
text(zoom_label(scale)).size(12),
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))),
]
@@ -65,6 +65,7 @@ pub fn controls<'a>(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>,
pub document: Id,
pub owner: Id,
pub revision: u64,
@@ -76,6 +77,7 @@ impl Scope {
Some(Self {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
})
@@ -84,6 +86,7 @@ impl Scope {
Some(Self {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
})
@@ -103,8 +106,8 @@ impl Scope {
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{name}",
self.document, self.owner, self.revision
"behavior-control:{}:{}:{}:{:?}:{name}",
self.document, self.owner, self.revision, self.scenario_view
))
}
}
@@ -137,6 +140,20 @@ pub enum Event {
pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
change_with_minimum(pan, zoom, fit, event, 0.05)
}
/// Scenario extents can require scales below the topology canvas's 5% floor.
pub(super) fn change_with_minimum(
pan: &mut Vector,
zoom: &mut Option<f32>,
fit: &mut bool,
event: Event,
minimum: f32,
) -> bool {
if !minimum.is_finite() || minimum <= 0. || minimum > 3. {
return false;
}
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
@@ -156,7 +173,10 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
let next = (old * factor).clamp(0.05, 3.);
if !old.is_finite() || old <= 0. {
return false;
}
let next = (old * factor).clamp(minimum, 3.);
if old == next {
return false;
}
@@ -174,6 +194,51 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
}
}
fn zoom_label(scale: f32) -> String {
let percent = f64::from(scale) * 100.;
if !percent.is_finite() || percent <= 0. {
return "—".into();
}
if percent >= 1. {
format!("{percent:.0}%")
} else if percent >= 0.01 {
format!("{percent:.3}%")
} else {
format!("{percent:.2e}%")
}
}
#[cfg(test)]
mod camera_policy_tests {
use super::*;
#[test]
fn topology_retains_five_percent_floor() {
let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false);
assert!(change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 0.1,
at: Point::ORIGIN,
base: 0.06,
}
));
assert_eq!(zoom, Some(0.05));
}
#[test]
fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() {
assert_eq!(zoom_label(1.), "100%");
assert_eq!(zoom_label(0.05), "5%");
assert_eq!(zoom_label(0.00034193), "0.034%");
assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%");
assert_eq!(zoom_label(1e-7), "1.00e-5%");
assert_ne!(zoom_label(f32::from_bits(1)), "0%");
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
@@ -371,6 +371,7 @@ fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_input
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
scenario_view: None,
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
@@ -430,6 +431,7 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
}
@@ -438,6 +440,7 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
}
@@ -893,11 +893,14 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
.add_element(Kind::Scenario, Layer::Oa, "Second scenario", None)
.unwrap();
let mut app = App::with_project(p.clone());
app.page = Page::Behavior;
app.layer = Layer::Oa;
app.behavior.active_owner = Some(a);
app.selected = a;
// Capture the context created by actual navigation, including its visit.
// Merely assigning active_owner/selected does not mount a Scenario anymore.
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(Page::Behavior));
let context = app.scenario_context().unwrap();
assert_eq!(context.scenario, a);
assert!(!context.visit.is_nil());
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
context,
@@ -908,23 +911,40 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
},
lifeline: None,
};
app.behavior.active_owner = Some(b);
app.selected = b;
let _ = app.update(Message::Select(b));
let second = app.scenario_context().unwrap();
assert_eq!(second.scenario, b);
assert_ne!(second.visit, context.visit);
let _ = app.update(Message::ScenarioCanvas(begin(context)));
assert!(!app.behavior.has_pending());
assert_eq!(app.project(), &p);
app.behavior.active_owner = Some(a);
app.selected = a;
app.layer = Layer::Sa;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
let _ = app.update(Message::Select(a));
let before_layer_change = app.scenario_context().unwrap();
assert_eq!(before_layer_change.scenario, a);
assert_ne!(before_layer_change.visit, context.visit);
let _ = app.update(Message::Layer(Layer::Sa));
assert_eq!(app.layer, Layer::Sa);
assert!(app.scenario_context().is_none());
let _ = app.update(Message::ScenarioCanvas(begin(before_layer_change)));
assert!(!app.behavior.has_pending());
app.layer = Layer::Oa;
let _ = app.update(Message::Layer(Layer::Oa));
let _ = app.update(Message::Select(a));
let current = app.scenario_context().unwrap();
assert_eq!(current.scenario, a);
assert_ne!(current.visit, before_layer_change.visit);
// Returning to the same owner/layer must not revive either retired packet.
for retired in [context, before_layer_change] {
let _ = app.update(Message::ScenarioCanvas(begin(retired)));
assert!(!app.behavior.has_pending());
}
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
let _ = app.update(Message::ScenarioCanvas(begin(current)));
assert_eq!(app.behavior.pending_owner(), Some(a));
assert_eq!(app.project(), &p);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
+183 -22
View File
@@ -4,12 +4,28 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod camera;
pub(crate) mod focus;
pub(crate) mod spacing;
use super::scenario_layout::{
Representation,
presentation::{self, ScenarioPresentation},
};
#[cfg(test)]
mod presentation_tests;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
// A picker may select the Saved diagram record itself. Another view of the
// same Scenario is not the property target of this displayed representation.
pub(crate) fn properties_target(p: &Project, context: Context, id: Id) -> bool {
super::selected_behavior(p, id) == Some(context.scenario)
|| matches!(context.representation, Representation::Saved { diagram } if diagram == id)
&& presentation::checked_owner(p, context.representation) == Some(context.scenario)
}
#[cfg(test)]
#[path = "scenario_operation_tests.rs"]
mod operation_tests;
@@ -99,6 +115,8 @@ pub(crate) fn removal_target(p: &Project, owner: Id, id: Id) -> bool {
pub struct Context {
pub document: Id,
pub scenario: Id,
pub representation: Representation,
pub visit: Id,
pub revision: u64,
pub epoch: u64,
}
@@ -125,6 +143,7 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Spacing(spacing::Event),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
@@ -132,9 +151,11 @@ pub enum CanvasEvent {
Zoom(Context, Option<f32>),
Camera(Context, u64, super::canvas_workspace::Event),
Shelf(Context, Shelf),
Properties(Context, Id),
OpenUse(Context, Id),
Back(Context),
Choose(Context, CanvasTool),
Representation(Context, Representation),
Select(Context, Id),
Remove(Context, Id),
Begin {
@@ -164,6 +185,7 @@ pub enum ScrollArea {
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Spacing(event) => event.stamp().context,
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
@@ -171,9 +193,11 @@ impl CanvasEvent {
| Self::Zoom(context, _)
| Self::Camera(context, ..)
| Self::Shelf(context, _)
| Self::Properties(context, _)
| Self::OpenUse(context, _)
| Self::Back(context)
| Self::Choose(context, _)
| Self::Representation(context, _)
| Self::Select(context, _)
| Self::Remove(context, _)
| Self::Begin { context, .. }
@@ -231,6 +255,7 @@ pub enum ValueSection {
const VALUE_PAGE_SIZE: usize = 24;
#[derive(Default)]
pub struct State {
spacing: Option<spacing::controller::Session>,
draft: Option<Draft>,
form_session: Id,
form_mount: Id,
@@ -246,6 +271,7 @@ const CALLER_LIMIT: usize = 32;
pub(crate) struct Caller {
document: Id,
pub owner: Id,
pub representation: Representation,
pub occurrence: Id,
target: Id,
tool: CanvasTool,
@@ -255,6 +281,8 @@ pub(crate) struct Caller {
struct DetailsRestore {
document: Id,
owner: Id,
representation: Representation,
visit: Id,
token: Id,
offset: iced::Vector,
}
@@ -262,6 +290,8 @@ struct DetailsRestore {
pub(super) struct ScrollRestore {
pub document: Id,
pub owner: Id,
pub representation: Representation,
pub visit: Id,
pub token: Id,
pub offset: iced::Vector,
}
@@ -354,46 +384,158 @@ impl State {
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.enter_representation(document, owner, Representation::Derived { owner });
}
/// App validates requested identity with checked_owner before entering.
pub(crate) fn enter_representation(
&mut self,
document: Id,
owner: Id,
representation: Representation,
) -> bool {
if matches!(representation, Representation::Derived { owner: requested } if requested != owner)
{
return false;
}
let changed = self.presentation.identity != Some((document, owner))
|| self.presentation.representation != Some(representation);
if changed {
self.presentation = workspace::Presentation {
identity: Some((document, owner)),
representation: Some(representation),
fit: matches!(representation, Representation::Saved { .. }),
..Default::default()
};
self.details_offset = iced::Vector::ZERO;
self.restore = None;
self.canvas_tool = Some((owner, CanvasTool::Select));
}
if changed || self.presentation.visit.is_nil() {
self.spacing = None;
self.presentation.visit = Id::new_v4();
self.restore = None;
return true;
}
false
}
/// Suspend captured events while preserving this destination's camera state.
pub(crate) fn leave(&mut self) {
self.spacing = None;
self.presentation.visit = Id::nil();
self.restore = None;
}
pub(crate) fn forget_view(&mut self) {
self.spacing = None;
self.presentation.identity = None;
self.presentation.representation = None;
self.presentation.visit = Id::nil();
self.restore = None;
}
pub(crate) fn representation(&self) -> Option<Representation> {
self.presentation.representation
}
pub(crate) fn owner(&self) -> Option<Id> {
self.presentation.identity.map(|(_, owner)| owner)
}
pub(crate) fn context(
&self,
document: Id,
owner: Id,
revision: u64,
epoch: u64,
) -> Option<Context> {
if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil()
{
return None;
}
Some(Context {
document,
scenario: owner,
representation: self.representation()?,
visit: self.presentation.visit,
revision,
epoch,
})
}
pub(crate) fn display(
&self,
p: &Project,
owner: Id,
selected: Id,
) -> Option<ScenarioPresentation> {
let (_, current_owner) = self.presentation.identity?;
let representation = self.representation()?;
(current_owner == owner).then(|| presentation::present(p, representation, selected))
}
pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool {
self.context(
context.document,
context.scenario,
context.revision,
context.epoch,
) == Some(context)
&& self
.display(p, context.scenario, context.scenario)
.is_some_and(|shown| shown.capabilities().coordinate_input)
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
pub(crate) fn camera(
&mut self,
project: &Project,
owner: Id,
event: super::canvas_workspace::Event,
) -> bool {
let Some(bounds) = self
.display(project, owner, owner)
.and_then(|shown| shown.bounds())
else {
return false;
};
let p = &mut self.presentation;
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
camera::change(
&mut p.pan,
&mut p.zoom,
&mut p.fit,
p.viewport,
bounds,
event,
)
}
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale)
}
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
let size = Scene::sequence(p, owner, owner).size;
let old = self.presentation.scale(size);
let Some(bounds) = self
.display(p, owner, owner)
.and_then(|shown| shown.bounds())
else {
return false;
};
if let Some(next) = zoom {
let current = self.presentation.camera(bounds);
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.camera(super::canvas_workspace::Event::Zoom {
factor: next / old,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: old,
})
self.camera(
p,
owner,
super::canvas_workspace::Event::Zoom {
factor: next / current.scale,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: current.scale,
},
)
} else {
self.zoom(None);
let fitted = self.presentation.scale(size);
self.zoom(Some(fitted));
self.presentation.pan = iced::Vector::ZERO;
true
self.zoom(None)
}
}
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
}
pub(crate) fn show_properties(&mut self) {
// Panels remain available. Dismiss the competing outline without
// replacing this view or its camera.
self.presentation.shelf = None;
}
pub(crate) fn open_use(
&mut self,
p: &Project,
@@ -410,6 +552,7 @@ impl State {
self.return_to.push(Caller {
document: context.document,
owner: context.scenario,
representation: context.representation,
occurrence,
target,
tool: self.canvas_tool(context.scenario),
@@ -424,6 +567,7 @@ impl State {
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
frame.document == context.document
&& frame.target == context.scenario
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
}
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
@@ -440,16 +584,23 @@ impl State {
None
}
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) {
if context.document != frame.document || context.scenario != frame.owner {
if context.document != frame.document
|| context.scenario != frame.owner
|| context.representation != frame.representation
{
return;
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.details_offset = frame.details_offset;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.presentation.representation = Some(frame.representation);
self.presentation.visit = context.visit;
self.restore = Some(DetailsRestore {
document: context.document,
owner: frame.owner,
representation: frame.representation,
visit: context.visit,
token: Id::new_v4(),
offset: frame.details_offset,
});
@@ -459,13 +610,17 @@ impl State {
context: Context,
ScrollArea::Page: ScrollArea,
) -> Option<ScrollRestore> {
let restore = self
.restore
.as_ref()
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
let restore = self.restore.as_ref().filter(|r| {
r.document == context.document
&& r.owner == context.scenario
&& r.representation == context.representation
&& r.visit == context.visit
})?;
Some(ScrollRestore {
document: restore.document,
owner: restore.owner,
representation: restore.representation,
visit: restore.visit,
token: restore.token,
offset: restore.offset,
})
@@ -478,7 +633,11 @@ impl State {
offset: iced::Vector,
) -> bool {
if !self.restore.as_ref().is_some_and(|r| {
r.document == context.document && r.owner == context.scenario && r.token == token
r.document == context.document
&& r.owner == context.scenario
&& r.representation == context.representation
&& r.visit == context.visit
&& r.token == token
}) {
return false;
}
@@ -1951,6 +2110,8 @@ pub(crate) mod direct_tests {
Context {
document: Id::new_v4(),
scenario: owner,
representation: Representation::Derived { owner },
visit: Id::new_v4(),
revision: 0,
epoch: 0,
}
@@ -0,0 +1,284 @@
//! Scenario camera policy. Bounds are from the exact displayed presentation,
//! including its origin and automatic fallback; no model geometry is authored.
use crate::behavior::canvas_workspace::{self, Event};
use iced::{Rectangle, Size, Vector};
pub(super) const MIN_SCALE: f32 = f32::from_bits(1);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Camera {
pub(super) scale: f32,
pub(super) pan: Vector,
}
/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin
/// and size cannot overflow while finding the center. Unrepresentable cameras
/// are refused rather than silently clamped to an unrelated minimum scale.
pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option<Camera> {
if [
bounds.x,
bounds.y,
bounds.width,
bounds.height,
viewport.width,
viewport.height,
]
.iter()
.any(|n| !n.is_finite())
|| bounds.width < 0.
|| bounds.height < 0.
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return None;
}
let width = f64::from(viewport.width);
let height = f64::from(viewport.height);
let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.))
.min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.))
.min(1.) as f32;
if !scale.is_finite() || scale <= 0. {
return None;
}
let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale);
let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale);
let pan = Vector::new(x as f32, y as f32);
(pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan })
}
pub(super) fn effective_camera(
pan: Vector,
zoom: Option<f32>,
fitting: bool,
viewport: Option<Size>,
bounds: Rectangle,
) -> Camera {
if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) {
return camera;
}
Camera {
scale: zoom.unwrap_or(1.),
pan,
}
}
/// None preserves Fit as a mode, including before the first viewport arrives.
pub(super) fn set_zoom(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
scale: Option<f32>,
) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.min(3.));
let next_pan = if scale.is_none() { Vector::ZERO } else { *pan };
let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan;
*zoom = next;
*fitting = scale.is_none();
*pan = next_pan;
changed
}
/// Materialize the visible fitted camera only when a camera gesture succeeds.
/// A zero-motion or rejected event preserves the original mode and fields.
pub(super) fn change(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
viewport: Option<Size>,
bounds: Rectangle,
event: Event,
) -> bool {
let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds);
let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false);
let event = match event {
Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false,
Event::Zoom { factor, at, .. } => Event::Zoom {
factor,
at,
base: camera.scale,
},
event => event,
};
if !canvas_workspace::change_with_minimum(
&mut next_pan,
&mut next_zoom,
&mut next_fit,
event,
MIN_SCALE,
) {
return false;
}
*pan = next_pan;
*zoom = next_zoom;
*fitting = next_fit;
true
}
#[cfg(test)]
mod tests {
use super::*;
use iced::Point;
fn bounds() -> Rectangle {
Rectangle {
x: -1_000_160.,
y: -200.,
width: 2_000_400.,
height: 1_000.,
}
}
fn near(actual: f32, expected: f32, tolerance: f32) {
assert!(
(actual - expected).abs() <= tolerance,
"{actual} != {expected}"
);
}
fn projected(camera: Camera, point: Point) -> Point {
Point::new(
point.x * camera.scale + camera.pan.x,
point.y * camera.scale + camera.pan.y,
)
}
#[test]
fn negative_origin_fit_contains_independently_known_extremes() {
let camera = fit(bounds(), Size::new(700., 500.)).unwrap();
near(camera.scale, 0.0003419316, 1e-10);
near(camera.pan.x, 349.98633, 0.0001);
near(
projected(camera, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(camera, Point::new(1_000_240., 800.)).x,
692.,
0.0001,
);
near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001);
}
#[test]
fn fit_remains_active_across_first_and_changed_viewport() {
let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false);
assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None));
let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds());
let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds());
assert!(actual.scale < retained.scale);
near(
projected(actual, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(actual, Point::new(1_000_240., 800.)).x,
492.,
0.0001,
);
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
#[test]
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let start = fit(bounds(), size).unwrap();
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
let at = Point::new(210., 180.);
let world = Point::new(
(at.x - start.pan.x) / start.scale,
(at.y - start.pan.y) / start.scale,
);
for factor in [1.25, 0.8] {
let prior_scale = zoom.unwrap_or(start.scale);
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Zoom {
factor,
at,
base: start.scale,
}
));
let camera = effective_camera(pan, zoom, fitting, viewport, bounds());
near(camera.scale, prior_scale * factor, 1e-10);
assert!(camera.scale < 0.001);
near(projected(camera, world).x, at.x, 0.0001);
near(projected(camera, world).y, at.y, 0.0001);
}
near(zoom.unwrap(), start.scale, 1e-10);
near(pan.x, start.pan.x, 0.0001);
assert!(!fitting);
}
#[test]
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
for event in [
Event::Pan(Vector::ZERO),
Event::Zoom {
factor: 1.,
at: Point::ORIGIN,
base: 1.,
},
] {
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
event
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
let start = fit(bounds(), size).unwrap();
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Pan(Vector::new(20., -30.))
));
assert_eq!(pan, start.pan + Vector::new(20., -30.));
assert_eq!(zoom, Some(start.scale));
assert!(!fitting);
}
#[test]
fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() {
assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none());
let extreme = Rectangle {
x: f32::MAX,
y: 0.,
width: f32::MAX,
height: 100.,
};
let camera = fit(extreme, Size::new(700., 500.)).unwrap();
assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite());
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN)));
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
Some(Size::new(700., 500.)),
bounds(),
Event::Zoom {
factor: 1.25,
at: Point::ORIGIN,
base: f32::NAN
}
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
}
@@ -11,6 +11,8 @@ use std::collections::BTreeMap;
pub(crate) struct Stamp {
document: ModelId,
owner: ModelId,
representation: super::Representation,
visit: ModelId,
revision: u64,
session: ModelId,
mount: ModelId,
@@ -20,6 +22,8 @@ impl Stamp {
Self {
document: context.document,
owner: context.scenario,
representation: context.representation,
visit: context.visit,
revision: context.revision,
session,
mount,
@@ -28,10 +32,12 @@ impl Stamp {
}
pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!(
"{}:{}:{}:{}:{}:{}",
"{}:{}:{}:{:?}:{}:{}:{}:{}",
super::NAME_INPUT,
stamp.document,
stamp.owner,
stamp.representation,
stamp.visit,
stamp.revision,
stamp.session,
stamp.mount
@@ -0,0 +1,348 @@
//! Controller identity and actual presentation input, independent of App routing.
use super::*;
use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout};
fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("saved_{}", p.diagrams.len()),
name: "Saved scenario".into(),
description: String::new(),
layer: p.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: lanes
.iter()
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
.collect(),
messages: vec![],
})),
},
);
let errors: Vec<_> = oa_core::validation::validate(p)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
Representation::Saved { diagram: id }
}
#[test]
fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() {
let mut f = direct_tests::fixture();
let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]);
let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]);
let original = f.project.clone();
let document = Id::new_v4();
let mut state = State::default();
assert!(state.enter_representation(document, f.owner, a));
let first = state.context(document, f.owner, 7, 9).unwrap();
assert!(!state.enter_representation(document, f.owner, a));
assert_eq!(state.context(document, f.owner, 7, 9), Some(first));
state.presentation.pan = iced::Vector::new(23., -17.);
state.leave();
assert_eq!(state.representation(), Some(a));
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 7, 9).is_none());
assert!(state.enter_representation(document, f.owner, a));
let returned = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(returned.visit, first.visit);
assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.));
assert!(state.enter_representation(document, f.owner, b));
let middle = state.context(document, f.owner, 7, 9).unwrap();
assert!(state.enter_representation(document, f.owner, a));
let last = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(last.visit, returned.visit);
assert_ne!(last.visit, middle.visit);
assert!(!state.coordinate_input(&f.project, first));
assert!(state.coordinate_input(&f.project, last));
let session = Id::new_v4();
let mount = Id::new_v4();
assert_ne!(
focus::Stamp::new(first, session, mount),
focus::Stamp::new(last, session, mount)
);
assert_eq!(f.project, original);
state.forget_view();
assert_eq!(state.representation(), None);
assert_eq!(state.owner(), None);
assert!(state.context(document, f.owner, 7, 9).is_none());
}
#[test]
fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() {
let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture();
let view = saved(&mut project, a, &[]);
let original = project.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter_representation(document, a, view);
let initial = state.context(document, a, 0, 0).unwrap();
state.presentation.pan = iced::Vector::new(11., 29.);
state.details_offset = iced::Vector::new(0., 71.);
assert_eq!(state.open_use(&project, initial, reference).unwrap(), b);
state.enter(document, b);
let callee = state.context(document, b, 0, 0).unwrap();
let frame = state.back(&project, callee).unwrap();
assert_eq!(frame.representation, view);
state.enter_representation(document, a, frame.representation);
let returned = state.context(document, a, 0, 0).unwrap();
state.restore_caller(returned, frame);
assert_ne!(returned.visit, initial.visit);
assert_eq!(state.representation(), Some(view));
assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.));
assert!(state.scroll_restore(initial, ScrollArea::Page).is_none());
assert_eq!(
state
.scroll_restore(returned, ScrollArea::Page)
.unwrap()
.offset,
iced::Vector::new(0., 71.)
);
assert_eq!(project, original);
state.open_use(&project, returned, reference).unwrap();
state.enter(document, b);
let Representation::Saved { diagram } = view else {
unreachable!()
};
project.diagrams.remove(&diagram);
let callee = state.context(document, b, 0, 0).unwrap();
assert!(state.back(&project, callee).is_none());
}
#[test]
fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() {
let mut f = direct_tests::fixture();
// Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call.
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, 900.), (f.receiver, 900.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
let document = state.presentation.identity.unwrap().0;
let context = state.context(document, f.owner, 0, 0).unwrap();
let shown = state.display(&f.project, f.owner, f.call).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Fallback { .. }
));
assert!(!state.coordinate_input(&f.project, context));
let automatic = presentation::present(
&f.project,
Representation::Derived { owner: f.owner },
f.call,
);
let automatic = automatic.scene().unwrap();
assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes);
assert_eq!(shown.scene().unwrap().activations, automatic.activations);
assert!(!automatic.activations.is_empty());
let mut scene = shown.into_scene().unwrap();
scene.coordinate_input = false;
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
assert_eq!(scene.hit(point), Some(f.call));
let bounds = Rectangle::with_size(Size::new(1600., 1000.));
for tool in [
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Add(Tool::Reuse),
] {
scene.interaction = Some((context, tool, true));
let mut input = SequenceInput::default();
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: point },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved {
position: Point::new(point.x, point.y + 100.),
},
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
if let Some(message) = canvas::Program::update(
&scene,
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point),
)
.and_then(|action| action.into_inner().0)
{
messages.push(message);
}
}
assert_eq!(messages.len(), 1);
assert!(
matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call)
);
assert!(input.press.is_none() && input.source.is_none());
}
// Capture from an actual Ready presentation, then revoke coordinate input
// with the complete fallback frame of the same Saved representation.
let Representation::Saved { diagram } = rep else {
unreachable!()
};
let mut ready_project = f.project.clone();
ready_project.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let ready_original = ready_project.clone();
let ready = state.display(&ready_project, f.owner, f.call).unwrap();
assert!(matches!(
ready.status(),
presentation::PresentationStatus::Ready
));
let mut ready_scene = ready.into_scene().unwrap();
let line = ready_scene
.lines
.iter()
.find(|line| line.id == f.call)
.unwrap();
let ready_point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
let hit = ready_scene.hit_item(ready_point).unwrap();
assert_eq!(hit.id, f.call);
assert_eq!(hit.role, SceneHitRole::MessageBody);
let stamp = spacing::Stamp {
context,
camera_epoch: 17,
};
ready_scene.coordinate_input = true;
ready_scene.interaction = Some((context, CanvasTool::Select, true));
ready_scene.spacing_stamp = Some(stamp);
let mut input = SequenceInput::default();
let begin = canvas::Program::update(
&ready_scene,
&mut input,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.unwrap();
let Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin {
stamp: captured_stamp,
token,
target: spacing::Target::Message(target),
..
})) = begin
else {
panic!("Ready Saved message must begin a stamped spacing gesture");
};
assert_eq!(captured_stamp, stamp);
assert_eq!(target, f.call);
assert!(matches!(input.press, Some(SequencePress::Spacing {
stamp: held_stamp, token: held_token
}) if held_stamp == stamp && held_token == token));
scene.interaction = Some((context, CanvasTool::Select, true));
assert!(!scene.coordinate_input && scene.spacing_stamp.is_none());
let release = canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
let result = canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0);
assert!(
matches!(result, Some(Message::ScenarioCanvas(CanvasEvent::Spacing(
spacing::Event::Cancel { stamp: cancelled_stamp, token: cancelled_token }
))) if cancelled_stamp == stamp && cancelled_token == token)
);
assert!(input.press.is_none() && input.source.is_none());
assert!(
canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.is_none()
);
assert_eq!(ready_project, ready_original);
assert_eq!(f.project, original);
}
#[test]
fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() {
let mut f = direct_tests::fixture();
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
state.viewport(Size::new(700., 500.));
assert!(
state.presentation.fit,
"new saved views await their real viewport in Fit mode"
);
assert!(
!state.camera_zoom(&f.project, f.owner, None),
"already-active Fit is idempotent"
);
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Ready
));
let bounds = shown.bounds().unwrap();
assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.);
let fitted = state.presentation.camera(bounds);
assert!(fitted.scale > 0. && fitted.scale < 0.001);
let left = bounds.x * fitted.scale + fitted.pan.x;
let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x;
assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001);
assert!(state.camera(
&f.project,
f.owner,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.))
));
let moved = state.presentation.camera(bounds);
assert_eq!(moved.scale, fitted.scale);
assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.));
assert!(!state.presentation.fit);
assert_eq!(f.project, original);
}
#[test]
fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() {
let mut f = direct_tests::fixture();
let rep = saved(&mut f.project, f.owner, &[]);
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, f.owner, rep);
let Representation::Saved { diagram } = rep else {
unreachable!()
};
f.project.diagrams.remove(&diagram);
let original = f.project.clone();
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert_eq!(shown.requested(), rep);
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Unavailable { .. }
));
assert!(shown.scene().is_none() && shown.bounds().is_none());
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 0, 0).is_some());
assert!(!state.camera_zoom(&f.project, f.owner, None));
assert_eq!(f.project, original);
}
@@ -0,0 +1,160 @@
//! 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,
};
pub(crate) mod controller;
#[cfg(test)]
mod tests;
pub(crate) use controller::{Event, Stamp};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Lane(Id),
Message(Id),
}
impl Target {
pub(crate) fn id(self) -> Id {
match self {
Self::Lane(id) | Self::Message(id) => id,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Refusal {
InvalidSavedView(Id),
TargetNotRendered(Target),
InvalidDelta,
InvalidGesture,
CameraChanged,
TargetNotHit(Target),
Command(String),
Geometry(Vec<GeometryIssue>),
}
pub(super) struct Drag {
original: Project,
diagram: Id,
target: Target,
origin: f32,
original_geometry: ResolvedScenarioPlan,
}
pub(super) struct Preview {
pub(super) command: Command,
#[cfg(test)]
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,
original_geometry: geometry,
})
}
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,
#[cfg(test)]
project,
geometry,
}))
}
}
@@ -0,0 +1,300 @@
//! One press owns one immutable accepted Project and one frozen display camera.
use super::*;
use crate::behavior::{
Scene, SceneHitRole,
scenario::{CanvasTool, Context, State},
scenario_layout::Diagnostic,
};
use iced::{Point, Rectangle, Vector};
#[cfg(test)]
pub(crate) mod tests;
#[cfg(test)]
mod timing_tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Stamp {
pub(crate) context: Context,
pub(crate) camera_epoch: u64,
}
#[derive(Clone, Copy, Debug)]
pub enum Event {
Begin {
stamp: Stamp,
token: Id,
target: Target,
press_screen: Point,
canvas_bounds: Rectangle,
scale: f32,
pan: Vector,
},
Move {
stamp: Stamp,
token: Id,
screen: Point,
},
Finish {
stamp: Stamp,
token: Id,
screen: Point,
},
Cancel {
stamp: Stamp,
token: Id,
},
}
impl Event {
pub(crate) fn stamp(self) -> Stamp {
match self {
Self::Begin { stamp, .. }
| Self::Move { stamp, .. }
| Self::Finish { stamp, .. }
| Self::Cancel { stamp, .. } => stamp,
}
}
pub(crate) fn token(self) -> Id {
match self {
Self::Begin { token, .. }
| Self::Move { token, .. }
| Self::Finish { token, .. }
| Self::Cancel { token, .. } => token,
}
}
}
pub(crate) struct Session {
stamp: Stamp,
token: Id,
press_screen: Point,
scale: f32,
pan: Vector,
drag: Drag,
preview: Option<Preview>,
refusal: Option<Refusal>,
ever_crossed: bool,
}
pub(in crate::behavior) struct Paint<'a> {
pub(in crate::behavior) scene: &'a Scene,
#[cfg(test)]
pub(in crate::behavior) bounds: Rectangle,
pub(in crate::behavior) refusal: Option<&'a Refusal>,
pub(in crate::behavior) original_diagnostics: &'a [Diagnostic],
pub(in crate::behavior) original_latent: &'a [Id],
pub(in crate::behavior) diagnostics: &'a [Diagnostic],
}
pub(crate) enum Finish {
Click(Id),
Noop,
Commit {
command: Box<Command>,
selected: Id,
scale: f32,
pan: Vector,
},
}
impl Session {
fn configure(&self, scene: &mut Scene) {
scene.selected = self.drag.target.id();
scene.scale = self.scale;
scene.camera_pan = self.pan;
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
scene.spacing_stamp = Some(self.stamp);
scene.coordinate_input = true;
scene.pending_placement = None;
}
fn update(&mut self, screen: Point) {
self.preview = None;
self.refusal = None;
if !screen.x.is_finite() || !screen.y.is_finite() {
self.refusal = Some(Refusal::InvalidDelta);
return;
}
let distance = match self.drag.target {
Target::Lane(_) => screen.x - self.press_screen.x,
Target::Message(_) => screen.y - self.press_screen.y,
};
self.ever_crossed |= distance.abs() >= 3.;
if !self.ever_crossed {
return;
}
match self.drag.prepare(distance / self.scale) {
Ok(Some(mut preview)) => {
self.configure(&mut preview.geometry.scene);
self.preview = Some(preview);
}
Ok(None) => {}
Err(error) => self.refusal = Some(error),
}
}
}
impl State {
pub(crate) fn spacing_token(&self) -> Option<Id> {
self.spacing.as_ref().map(|s| s.token)
}
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
self.spacing.as_ref().map(|s| s.stamp)
}
pub(crate) fn cancel_spacing(&mut self) -> bool {
self.spacing.take().is_some()
}
pub(in crate::behavior) fn spacing_paint(
&self,
context: Context,
camera_epoch: u64,
) -> Option<Paint<'_>> {
let session = self.spacing.as_ref()?;
if session.stamp
!= (Stamp {
context,
camera_epoch,
})
{
return None;
}
let original = &session.drag.original_geometry;
let shown = session.preview.as_ref().map_or(original, |p| &p.geometry);
Some(Paint {
scene: &shown.scene,
#[cfg(test)]
bounds: shown.bounds,
refusal: session.refusal.as_ref(),
original_diagnostics: &original.plan.diagnostics,
original_latent: &original.latent_messages,
diagnostics: &shown.plan.diagnostics,
})
}
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = event
else {
return Err(Refusal::InvalidGesture);
};
let context = stamp.context;
if self.spacing.is_some()
|| token.is_nil()
|| self.context(
context.document,
context.scenario,
context.revision,
context.epoch,
) != Some(context)
|| self.canvas_tool(context.scenario) != CanvasTool::Select
|| ![
press_screen.x,
press_screen.y,
canvas_bounds.x,
canvas_bounds.y,
canvas_bounds.width,
canvas_bounds.height,
scale,
pan.x,
pan.y,
]
.into_iter()
.all(f32::is_finite)
|| scale <= 0.
|| canvas_bounds.width <= 0.
|| canvas_bounds.height <= 0.
|| !canvas_bounds.contains(press_screen)
{
return Err(Refusal::InvalidGesture);
}
let Representation::Saved { diagram } = context.representation else {
return Err(Refusal::InvalidGesture);
};
let mut drag = capture(project, diagram, target)?;
let camera = self.presentation.camera(drag.original_geometry.bounds);
if self.presentation.viewport != Some(canvas_bounds.size())
|| camera.scale != scale
|| camera.pan != pan
{
return Err(Refusal::CameraChanged);
}
let world = Point::new(
(press_screen.x - canvas_bounds.x - pan.x) / scale,
(press_screen.y - canvas_bounds.y - pan.y) / scale,
);
// Hit tolerance is measured in displayed pixels, just like the widget.
drag.original_geometry.scene.scale = scale;
drag.original_geometry.scene.camera_pan = pan;
let hit = drag.original_geometry.scene.hit_item(world);
if !hit.is_some_and(|hit| {
hit.id == target.id()
&& match target {
Target::Lane(_) => hit.role == SceneHitRole::LifelineHeader,
Target::Message(_) => matches!(
hit.role,
SceneHitRole::MessageBody | SceneHitRole::MessageLabel
),
}
}) {
return Err(Refusal::TargetNotHit(target));
}
// Configure baseline without changing the persistent camera or selection.
let scene = &mut drag.original_geometry.scene;
scene.selected = target.id();
scene.scale = scale;
scene.camera_pan = pan;
scene.interaction = Some((context, CanvasTool::Select, true));
scene.spacing_stamp = Some(stamp);
scene.coordinate_input = true;
scene.pending_placement = None;
self.spacing = Some(Session {
stamp,
token,
press_screen,
scale,
pan,
drag,
preview: None,
refusal: None,
ever_crossed: false,
});
Ok(())
}
pub(crate) fn move_spacing(&mut self, screen: Point) {
if let Some(session) = &mut self.spacing {
session.update(screen);
}
}
pub(crate) fn finish_spacing(&mut self, screen: Point) -> Result<Finish, Refusal> {
let Some(mut session) = self.spacing.take() else {
return Err(Refusal::InvalidGesture);
};
session.update(screen);
if let Some(error) = session.refusal {
return Err(error);
}
if !session.ever_crossed {
return Ok(Finish::Click(session.drag.target.id()));
}
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
command: Box::new(preview.command),
selected: session.drag.target.id(),
scale: session.scale,
pan: session.pan,
},
})
}
pub(crate) fn accept_spacing_camera(&mut self, scale: f32, pan: Vector) {
self.presentation.zoom = Some(scale);
self.presentation.pan = pan;
self.presentation.fit = false;
}
}
@@ -0,0 +1,421 @@
use super::*;
use crate::document::Document;
use iced::Size;
use oa_core::{Diagram, Layer, ProjectionKind, language, source_project::ProjectLimits};
pub(crate) fn fixture() -> (Document, Id, Id, Id, Id) {
let mut p = language::parse(r#"arcadia 7;
project spacing "Spacing" {
oa_activity work "Work" {}
oa_activity response "Response" {}
oa_scenario incident "Incident" { viewpoint OperationalActivity; steps [@first, @second]; }
oa_lifeline reporter "Reporter" { parent @incident; represented @work; }
oa_lifeline operator "Operator" { parent @incident; represented @response; }
oa_occurrence first "First" { parent @incident; body { Exchange { exchange none; sender @reporter; receiver @operator; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" { parent @incident; body { Exchange { exchange none; sender @operator; receiver @reporter; message_kind Asynchronous; }; }; }
}"#).unwrap();
let id = |name: &str| {
p.identities()
.into_iter()
.find(|(_, alias)| *alias == name)
.unwrap()
.0
};
let (owner, lane, message) = (id("incident"), id("reporter"), id("first"));
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "saved".into(),
name: "Saved".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: None,
},
);
(
Document::from_project(p, ProjectLimits::default()).unwrap(),
owner,
diagram,
lane,
message,
)
}
pub(crate) fn expected(p: &Project, diagram: Id, target: Target, delta: f32) -> Project {
capture(p, diagram, target)
.unwrap()
.prepare(delta)
.unwrap()
.unwrap()
.project
}
pub(crate) fn packet(
p: &Project,
state: &State,
context: Context,
camera_epoch: u64,
target: Target,
) -> Event {
let geometry = resolve(p, context.representation, target.id()).unwrap();
let world = match target {
Target::Lane(id) => geometry
.scene
.boxes
.iter()
.find(|item| item.id == id && item.role == SceneHitRole::LifelineHeader)
.unwrap()
.rect
.center(),
Target::Message(id) => {
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap();
Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y)
}
};
assert!(
geometry
.scene
.hit_item(world)
.is_some_and(|hit| hit.id == target.id())
);
let camera = state.presentation.camera(geometry.bounds);
let canvas_bounds = Rectangle::new(Point::new(17., 23.), state.presentation.viewport.unwrap());
Event::Begin {
stamp: Stamp {
context,
camera_epoch,
},
token: Id::new_v4(),
target,
press_screen: Point::new(
canvas_bounds.x + world.x * camera.scale + camera.pan.x,
canvas_bounds.y + world.y * camera.scale + camera.pan.y,
),
canvas_bounds,
scale: camera.scale,
pan: camera.pan,
}
}
fn setup(target_message: bool) -> (Document, State, Event) {
let (doc, owner, diagram, lane, message) = fixture();
let mut state = State::default();
state.enter_representation(Id::new_v4(), owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
let context = state
.context(
state.presentation.identity.unwrap().0,
owner,
doc.revision(),
4,
)
.unwrap();
let target = if target_message {
Target::Message(message)
} else {
Target::Lane(lane)
};
let event = packet(doc.project(), &state, context, 9, target);
(doc, state, event)
}
fn press(event: Event) -> Point {
let Event::Begin { press_screen, .. } = event else {
unreachable!()
};
press_screen
}
#[test]
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
for message in [false, true] {
let (doc, mut state, begin) = setup(message);
let original = doc.snapshot();
let camera = state.camera_boundary();
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
let first = if message {
Vector::new(500., 30.)
} else {
Vector::new(30., 500.)
};
state.move_spacing(start + first);
let session = state.spacing.as_ref().unwrap();
let candidate = session.preview.as_ref().unwrap();
let delta = 30. / session.scale;
let actual_anchor = match session.drag.target {
Target::Lane(id) => candidate.geometry.scene.render_lanes[&id],
Target::Message(id) => {
candidate
.geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap()
.points[0]
.y
}
};
let wanted = (f64::from(session.drag.origin) + f64::from(delta)) as f32;
assert_eq!(actual_anchor.to_bits(), wanted.to_bits());
let expected = session.drag.prepare(delta).unwrap().unwrap();
assert_eq!(candidate.project, expected.project);
assert_eq!(candidate.geometry.scene.rows, expected.geometry.scene.rows);
assert_eq!(
candidate.geometry.scene.render_lanes,
expected.geometry.scene.render_lanes
);
state.move_spacing(start + first * 2.);
let session = state.spacing.as_ref().unwrap();
assert_eq!(
session.preview.as_ref().unwrap().project,
session.drag.prepare(delta * 2.).unwrap().unwrap().project
);
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
}
#[test]
fn click_threshold_is_active_axis_and_return_to_zero_is_not_a_click() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(2., 100.));
assert!(matches!(
state.finish_spacing(start).unwrap(),
Finish::Click(_)
));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(4., 0.));
assert!(matches!(state.finish_spacing(start).unwrap(), Finish::Noop));
assert!(!doc.can_undo());
}
#[test]
fn invalid_move_discards_prior_candidate_and_finish_rechecks_final_pointer() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(20., 0.));
state.move_spacing(Point::new(f32::NAN, start.y));
let paint = state.spacing_paint(begin.stamp().context, 9).unwrap();
assert!(matches!(paint.refusal, Some(Refusal::InvalidDelta)));
assert_eq!(
paint.scene.render_lanes,
state
.spacing
.as_ref()
.unwrap()
.drag
.original_geometry
.scene
.render_lanes
);
assert!(matches!(
state.finish_spacing(Point::new(f32::NAN, start.y)),
Err(Refusal::InvalidDelta)
));
assert!(state.spacing_token().is_none());
}
#[test]
fn forged_camera_hit_role_and_retired_visit_cannot_capture() {
let (doc, mut state, begin) = setup(false);
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = begin
else {
unreachable!()
};
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale: scale * 2.,
pan
}
),
Err(Refusal::CameraChanged)
));
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen: canvas_bounds.center(),
canvas_bounds,
scale,
pan
}
),
Err(Refusal::TargetNotHit(_))
));
state.leave();
assert!(matches!(
state.begin_spacing(doc.project(), begin),
Err(Refusal::InvalidGesture)
));
}
#[test]
fn zoomed_out_message_tolerance_matches_actual_canvas_hit() {
let (doc, mut state, _) = setup(true);
state.presentation.fit = false;
state.presentation.zoom = Some(0.25);
state.presentation.pan = Vector::ZERO;
let context = state
.context(
state.presentation.identity.unwrap().0,
state.owner().unwrap(),
doc.revision(),
4,
)
.unwrap();
let message = doc
.project()
.identities()
.into_iter()
.find(|(_, alias)| *alias == "first")
.map(|(id, _)| id)
.unwrap();
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = packet(doc.project(), &state, context, 9, Target::Message(message))
else {
unreachable!()
};
let event = Event::Begin {
stamp,
token,
target,
press_screen: press_screen + Vector::new(0., 3.),
canvas_bounds,
scale,
pan,
};
state.begin_spacing(doc.project(), event).unwrap();
assert_eq!(state.spacing_token(), Some(token));
}
#[test]
fn fractional_screen_packet_preserves_subpixel_motion_in_preview_and_finish() {
let (doc, owner, diagram, _, message) = fixture();
let original = doc.snapshot();
assert!(doc.project().diagrams[&diagram].layout.is_none());
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
state.presentation.fit = false;
state.presentation.zoom = Some(0.5);
// 406 + 2^-15 puts the press just below the 512 screen-coordinate boundary.
state.presentation.pan = Vector::new(0., f32::from_bits(0x43cb_0001));
let camera = state.camera_boundary();
let context = state.context(document, owner, doc.revision(), 4).unwrap();
let baseline = resolve(doc.project(), context.representation, message).unwrap();
let line = baseline
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y, 148.);
let begin = packet(doc.project(), &state, context, 9, Target::Message(message));
let Event::Begin {
press_screen,
canvas_bounds,
scale,
pan,
..
} = begin
else {
unreachable!()
};
assert_eq!(canvas_bounds.y, 23.);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
assert_eq!(press_screen.y.to_bits(), 0x43fb_8001); // 503 + 2^-15.
let end = press_screen + Vector::new(0., 10.);
assert_eq!(end.y, 513.);
assert!(canvas_bounds.contains(press_screen) && canvas_bounds.contains(end));
// The submitted f32 displacement is 10 - 2^-15, not exactly 10.
assert_eq!((end.y - press_screen.y).to_bits(), 0x411f_ffe0);
assert_eq!(((end.y - press_screen.y) / scale).to_bits(), 0x419f_ffe0);
// Independent oracle: 148 + (10 - 2^-15) / .5 = 168 - 2^-14.
let expected_y = f32::from_bits(0x4327_fffc);
let mut expected = original.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(oa_core::DiagramLayout::Scenario(oa_core::ScenarioLayout {
lanes: vec![],
messages: vec![oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: message,
},
send_y: expected_y,
}],
}));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(end);
let preview = state.spacing.as_ref().unwrap().preview.as_ref().unwrap();
assert_eq!(preview.project, expected);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y.to_bits(), expected_y.to_bits());
let Finish::Commit {
command,
selected,
scale,
pan,
} = state.finish_spacing(end).unwrap()
else {
panic!("fractional motion must remain a commit")
};
assert_eq!(selected, message);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
let committed = oa_core::execute_batch(doc.project(), &[*command]).unwrap();
assert_eq!(committed, expected);
assert!(state.spacing_token().is_none());
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
@@ -0,0 +1,304 @@
//! Diagnostic only: production capture/prepare, excluding fixture construction,
//! oracle work, App source writes, widget rebuilds, text shaping and GPU paint.
use super::*;
use crate::document::Document;
use oa_core::{
DiagramLayout, Layer, MessageKind, RelationKind, ScenarioLayout, source_project::ProjectLimits,
};
use serde_json::{Value, json};
use std::{collections::BTreeSet, sync::Arc, time::Instant};
const MESSAGE_COUNT: usize = 1000;
const TARGET_INDEX: usize = 500;
fn large_fixture() -> (Document, Id, [Id; 3], Vec<Id>, Vec<Id>) {
let (seed, owner, a, reporter, first) = super::tests::fixture();
let mut p = seed.project().clone();
let old_steps = p.elements[&owner].steps().unwrap().clone();
let template = p.elements[&first].clone();
let work = |alias: &str| {
p.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
};
let (source, target) = (work("work"), work("response"));
let operator = work("operator");
let relation = p.relate(
RelationKind::Interaction,
"Repeated incident report",
source,
target,
);
let mut lanes = vec![];
for index in 0..20 {
let original = if index < 10 { reporter } else { operator };
let mut lane = p.elements[&original].clone();
lane.id = Id::new_v4();
lane.alias = format!("timing_lane_{index:02}");
lane.name = format!("Review participant {index:02}");
lanes.push(lane.id);
p.elements.insert(lane.id, lane);
}
p.elements.remove(&reporter);
p.elements.remove(&operator);
for id in old_steps {
p.elements.remove(&id);
}
let mut messages = vec![];
for index in 0..MESSAGE_COUNT {
let mut occurrence = template.clone();
occurrence.id = Id::new_v4();
occurrence.alias = format!("timing_message_{index:04}");
occurrence.name = format!("Validate incident report {index:04}");
*occurrence.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(relation),
sender: Some(lanes[index % 10]),
receiver: Some(lanes[10 + index % 10]),
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
messages.push(occurrence.id);
p.elements.insert(occurrence.id, occurrence);
}
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = messages.clone();
p.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: lanes
.iter()
.enumerate()
.map(|(i, &lifeline)| ScenarioLanePosition {
lifeline,
x: 160. + i as f32 * 240.,
})
.collect(),
messages: vec![],
}));
let mut views = [a, Id::nil(), Id::nil()];
for (index, x, offset) in [(1, -500., 12.), (2, 6500., -12.)] {
let mut view = p.diagrams[&a].clone();
view.id = Id::new_v4();
view.alias = format!("timing_saved_{index}");
view.name = format!("Review arrangement {index}");
view.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: lanes[0],
x,
}],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: messages[TARGET_INDEX],
},
send_y: 148. + TARGET_INDEX as f32 * 92. + offset,
}],
}));
views[index] = view.id;
p.diagrams.insert(view.id, view);
}
let document = Document::from_project(p, ProjectLimits::default()).unwrap();
assert_eq!(
document
.project()
.children(owner)
.filter(|e| e.kind() == Kind::Lifeline)
.count(),
20
);
assert_eq!(document.project().elements[&owner].layer(), Layer::Oa);
assert_eq!(document.project().diagrams.len(), 3);
(document, owner, views, lanes, messages)
}
fn assert_scene(
scene: &Scene,
lanes: &[Id],
messages: &[Id],
moved_message: Option<(Id, f32)>,
moved_lane: Option<(Id, f32)>,
) {
assert_eq!(scene.render_lanes.len(), 20);
assert_eq!(
scene
.boxes
.iter()
.filter(|b| b.role == SceneHitRole::LifelineHeader)
.count(),
20
);
assert_eq!(scene.lines.len(), MESSAGE_COUNT);
assert_eq!(
scene
.lines
.iter()
.map(|line| line.id)
.collect::<BTreeSet<_>>(),
messages.iter().copied().collect::<BTreeSet<_>>()
);
assert!(scene.activations.is_empty() && scene.open_calls.is_empty());
let x = |index: usize| {
160. + index as f32 * 240.
+ moved_lane
.filter(|(id, _)| *id == lanes[index])
.map_or(0., |(_, delta)| delta)
};
for (index, &lane) in lanes.iter().enumerate() {
assert_eq!(scene.render_lanes[&lane], x(index));
}
for (index, (line, &id)) in scene.lines.iter().zip(messages).enumerate() {
let y = 148.
+ index as f32 * 92.
+ moved_message
.filter(|(target, _)| *target == id)
.map_or(0., |(_, delta)| delta);
assert_eq!(line.id, id);
assert_eq!(
line.points,
vec![
Point::new(x(index % 10), y),
Point::new(x(10 + index % 10), y)
]
);
}
}
fn summary(samples: &[Value], phase: &str) -> Value {
let mut times: Vec<u64> = samples
.iter()
.filter(|sample| sample["phase"] == phase)
.map(|sample| sample["elapsed_ns"].as_u64().unwrap())
.collect();
times.sort_unstable();
let n = times.len();
let median = if n.is_multiple_of(2) {
((u128::from(times[n / 2 - 1]) + u128::from(times[n / 2])) / 2) as u64
} else {
times[n / 2]
};
json!({"samples": n, "min_ns": times[0], "median_ns": median, "max_ns": times[n-1]})
}
#[test]
#[ignore = "Diagnostic timing only; run alone with --exact --ignored --nocapture after correctness gates; no latency acceptance threshold"]
fn saved_spacing_capture_prepare_20_lanes_1000_messages_3_views() {
let (document, owner, views, lanes, messages) = large_fixture();
let original = document.snapshot();
let target = messages[TARGET_INDEX];
let mut samples = vec![];
let mut retained = None;
for iteration in 0..4 {
let start = Instant::now();
let result = capture(document.project(), views[0], Target::Message(target));
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let drag = result.expect("complete asynchronous fixture must resolve");
samples.push(json!({"phase":"capture_message", "iteration":iteration,
"first_in_probe":iteration==0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
assert_scene(&drag.original_geometry.scene, &lanes, &messages, None, None);
if iteration == 0 {
retained = Some(drag);
}
}
let drag = retained.unwrap();
// Every sample is relative to the original press, including duplicate,
// reverse and return-to-start samples. Oracle work is outside each timer.
let deltas = [
4., 8., 12., 16., 20., 24., 28., 32., 32., 28., 24., 20., 16., 12., 8., 4., -4., -8., -4.,
0.,
];
for (iteration, delta) in deltas.into_iter().enumerate() {
let start = Instant::now();
let result = drag.prepare(delta);
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let candidate = result.expect("these isolated asynchronous offsets are feasible");
samples.push(json!({"phase":"prepare_message", "iteration":iteration,
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":if candidate.is_some(){"preview"}else{"noop"}}));
if delta == 0. {
assert!(candidate.is_none());
} else {
let candidate = candidate.unwrap();
let mut expected = original.decoded.project.clone();
let Some(DiagramLayout::Scenario(layout)) =
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
else {
panic!()
};
layout.messages.push(ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: target,
},
send_y: 148. + TARGET_INDEX as f32 * 92. + delta,
});
assert_eq!(candidate.project, expected);
assert_scene(
&candidate.geometry.scene,
&lanes,
&messages,
Some((target, delta)),
None,
);
}
assert_eq!(
document.project().elements[&owner].steps().unwrap(),
&messages
);
assert_eq!(document.project(), &original.decoded.project);
assert!(Arc::ptr_eq(&original, &document.snapshot()));
}
let lane = lanes[10];
let start = Instant::now();
let result = capture(document.project(), views[0], Target::Lane(lane));
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let drag = result.unwrap();
samples.push(json!({"phase":"capture_lane", "iteration":0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
for (iteration, delta) in [20., 40., -20.].into_iter().enumerate() {
let start = Instant::now();
let result = drag.prepare(delta);
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let candidate = result.unwrap().unwrap();
samples.push(json!({"phase":"prepare_lane", "iteration":iteration,
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":"preview"}));
let mut expected = original.decoded.project.clone();
let Some(DiagramLayout::Scenario(layout)) =
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
else {
panic!()
};
layout.lanes[10].x = 2560. + delta;
assert_eq!(candidate.project, expected);
assert_scene(
&candidate.geometry.scene,
&lanes,
&messages,
None,
Some((lane, delta)),
);
}
assert_eq!(document.project(), &original.decoded.project);
assert_eq!(document.bundle(), &original.bundle);
for view in views {
assert_eq!(
document.project().diagrams[&view],
original.decoded.project.diagrams[&view]
);
}
assert_eq!(document.revision(), 0);
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
let report = json!({
"schema":"scenario_spacing_timing_v1",
"fixture":{"lanes":20,"messages":MESSAGE_COUNT,"saved_views":3,"target_index":TARGET_INDEX,"distinct_message_ids":messages.iter().copied().collect::<BTreeSet<_>>().len()},
"profile_context":{"debug_assertions":cfg!(debug_assertions),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,
"source_pin_from_runner":std::env::var("SCENARIO_SPACING_SOURCE_PIN").ok(),"source_binary_toolchain_binding":"external gate receipt required"},
"summaries":{"capture_message":summary(&samples,"capture_message"),"prepare_message":summary(&samples,"prepare_message"),"capture_lane":summary(&samples,"capture_lane"),"prepare_lane":summary(&samples,"prepare_lane")},
"samples":samples,
"invariant_status":"passed",
"limits":"First capture is first in this probe, not cold OS caches. Timers exclude construction/oracles, prior-preview disposal, source write/history, widget rebuild, label shaping, GPU paint and queued input; no responsiveness acceptance or latency threshold. All messages asynchronous; no nested frame/lifecycle stress."
});
println!("SPACING_TIMING_JSON={report}");
}
@@ -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; }; }; }
}
"#;
@@ -1,5 +1,6 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
@@ -22,7 +23,14 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
start < retained
}
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
fn usages(
p: &Project,
owner: Id,
subject: Id,
surface: Surface,
bounds: Rectangle,
scene: &Scene,
) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
@@ -33,7 +41,7 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
{
result.push(Usage {
subject,
surface: Surface::Scenario(owner),
surface,
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
@@ -137,15 +145,15 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
}
}
}
if subject == owner {
if subject == owner || subject == surface.id() {
result.insert(
0,
Usage {
subject,
surface: Surface::Scenario(owner),
surface,
target: Target::Whole,
role: "scenario".into(),
bounds: Rectangle::new(Point::ORIGIN, scene.size),
bounds,
},
);
}
@@ -153,15 +161,29 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
}
impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
/// Membership comes from the complete presentation chosen for this exact view,
/// including automatic fallback geometry while retaining its Saved identity.
pub(crate) fn representation_usages(
&self,
p: &Project,
representation: Representation,
subject: Id,
) -> Vec<Usage> {
let shown = presentation::present(p, representation, subject);
let (Some(owner), Some(bounds), Some(scene)) =
(shown.owner(), shown.bounds(), shown.scene())
else {
return vec![];
}
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
};
let surface = match representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
};
usages(p, owner, subject, surface, bounds, scene)
}
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
self.representation_usages(p, Representation::Derived { owner }, subject)
}
pub(crate) fn reveal_usage(
@@ -171,12 +193,24 @@ impl State {
owner: Id,
target: &Target,
) -> bool {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
self.reveal_representation_usage(
p,
document,
owner,
Representation::Derived { owner },
target,
)
}
pub(crate) fn reveal_representation_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
representation: Representation,
target: &Target,
) -> bool {
if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) {
return false;
}
let id = match target {
@@ -185,7 +219,7 @@ impl State {
_ => return false,
};
let Some(usage) = self
.diagram_usages(p, owner, id)
.representation_usages(p, representation, id)
.into_iter()
.find(|u| &u.target == target)
else {
@@ -193,11 +227,29 @@ impl State {
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
if !viewport.width.is_finite()
|| !viewport.height.is_finite()
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return false;
}
let (zoom, pan) = if matches!(target, Target::Whole) {
let Some(camera) = super::camera::fit(usage.bounds, viewport) else {
return false;
};
(None, camera.pan)
} else {
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() {
return false;
}
(Some(zoom), pan)
};
self.enter_representation(document, owner, representation);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.zoom(zoom);
self.presentation.pan = pan;
true
}
@@ -13,13 +13,14 @@ pub enum Shelf {
#[derive(Clone, Default)]
pub(super) struct Presentation {
pub(super) identity: Option<(Id, Id)>,
pub(super) representation: Option<Representation>,
pub(super) visit: Id,
pub(super) viewport: Option<Size>,
pub(super) shelf: Option<Shelf>,
pub(super) pan: iced::Vector,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
const MIN_SCALE: f32 = 0.05;
impl Presentation {
pub(super) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
@@ -34,29 +35,26 @@ impl Presentation {
true
}
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
let changed = self.zoom != next || self.fit != scale.is_none();
self.zoom = next;
self.fit = scale.is_none();
changed
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
}
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// Fit uses the actual mounted viewport with a small visual gutter.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
}
pub(super) fn scale(&self, scene: Size) -> f32 {
if self.fit {
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
} else {
self.zoom.unwrap_or(1.)
}
// Legacy tests call size-only scale; actual display always passes full bounds.
#[cfg(test)]
pub(super) fn scale(&self, size: Size) -> f32 {
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ArrangementChoice {
representation: Representation,
label: String,
}
impl std::fmt::Display for ArrangementChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
@@ -68,43 +66,136 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.draft
.as_ref()
.is_some_and(|draft| draft.owner == owner);
let context = app.scenario_context().expect("visible scenario context");
let Some(context) = app.scenario_context() else {
return column![
out,
text("Scenario view is unavailable."),
button("Open selected source").on_press(Message::SourceForSelection)
]
.spacing(8)
.into();
};
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
scene.scale = scale;
scene.camera_pan = presentation.pan;
scene.interaction = Some((context, tool, clean));
scene.pending_placement = state.pending_placement(owner);
// Resolve once per accepted view. An active spacing session owns its exact
// prepared geometry; borrowing it also retains the canvas input state.
let preview = state.spacing_paint(context, app.camera_epoch);
let shown = preview
.is_none()
.then(|| presentation::present(p, context.representation, app.selected));
let capabilities = shown.as_ref().map_or(
presentation::PresentationCapabilities {
geometry_navigation: true,
coordinate_input: true,
},
ScenarioPresentation::capabilities,
);
let coordinate_input = clean && capabilities.coordinate_input;
let notice = if let Some(paint) = &preview {
display_notice_parts(
p,
context.representation,
&presentation::PresentationStatus::Ready,
paint.original_diagnostics,
paint.original_latent,
)
} else {
display_notice(p, shown.as_ref().expect("normal presentation"))
};
let camera_state = preview
.as_ref()
.map(|paint| camera::Camera {
scale: paint.scene.scale,
pan: paint.scene.camera_pan,
})
.or_else(|| {
shown
.as_ref()
.and_then(ScenarioPresentation::bounds)
.map(|bounds| presentation.camera(bounds))
});
let scale = camera_state.map_or(1., |camera| camera.scale);
let scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
scenario_view: Some((context.representation, context.visit)),
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let keyboard = clean
&& capabilities.geometry_navigation
&& super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container(
sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(scene).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.pan_background(tool == CanvasTool::Select),
let raw_canvas: Option<Element<'a, Message>> = if let Some(paint) = &preview {
Some(
canvas::Canvas::new(paint.scene)
.width(Fill)
.height(Fill)
.into(),
)
.key((context.document, owner))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
} else if let Some(mut scene) = shown.and_then(ScenarioPresentation::into_scene) {
let camera_state = camera_state.expect("displayed Scene has complete bounds");
scene.scale = camera_state.scale;
scene.camera_pan = camera_state.pan;
scene.interaction = Some((context, tool, clean));
scene.spacing_stamp = (coordinate_input
&& !app.source_rename.open()
&& keyboard
&& matches!(context.representation, Representation::Saved { .. }))
.then_some(spacing::Stamp {
context,
camera_epoch: app.camera_epoch,
});
scene.coordinate_input = capabilities.coordinate_input;
scene.pending_placement = coordinate_input
.then(|| state.pending_placement(owner))
.flatten();
Some(canvas::Canvas::new(scene).width(Fill).height(Fill).into())
} else {
None
};
let canvas: Element<'a, Message> = if let Some(raw_canvas) = raw_canvas {
// Keep this Stack in both branches: replacing the Canvas with a wrapper
// on Begin would discard its pointer capture before Move/Finish.
let mut layers = iced::widget::stack![raw_canvas];
if let Some(paint) = &preview {
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
layers = layers.push(
container(
container(text(feedback).size(12))
.padding(6)
.width(Fill)
.style(style::card),
)
.width(Fill)
.align_bottom(Fill)
.padding(8),
);
}
container(
sensor(
super::super::canvas_workspace::Viewport::new(
layers, scope, scale, keyboard, camera,
)
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
)
.key((
context.document,
format!("{:?}", context.representation),
context.visit,
))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into()
} else {
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
};
let mut navigation = row![out.width(Fill)].spacing(6);
if state.can_back(p, context) {
navigation =
@@ -112,20 +203,72 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
let header = column![
navigation,
super::super::canvas_workspace::controls(
let mut header = column![navigation, notice].spacing(4);
let saved_views = p
.diagrams
.values()
.filter(|diagram| {
presentation::checked_owner(
p,
Representation::Saved {
diagram: diagram.id,
},
) == Some(owner)
})
.map(|diagram| ArrangementChoice {
representation: Representation::Saved {
diagram: diagram.id,
},
label: short(&diagram.name, 64),
})
.collect::<Vec<_>>();
if !saved_views.is_empty() {
let mut choices = vec![ArrangementChoice {
representation: Representation::Derived { owner },
label: "Automatic arrangement".into(),
}];
choices.extend(saved_views);
let selected = choices
.iter()
.find(|choice| choice.representation == context.representation)
.cloned();
header = header.push(
container(
pick_list(choices, selected, move |choice: ArrangementChoice| {
Message::ScenarioCanvas(CanvasEvent::Representation(
context,
choice.representation,
))
})
.placeholder("Choose arrangement")
.text_size(12)
.padding(5)
.width(Fill),
)
.width(Fill)
.id("scenario-arrangement-picker"),
);
}
if capabilities.geometry_navigation {
header = header.push(super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
]
.spacing(4);
camera,
));
}
let entries = [
("S", "Select / reorder occurrences", CanvasTool::Select),
(
"S",
if matches!(context.representation, Representation::Saved { .. }) {
"Select / move messages and lifelines"
} else {
"Select / reorder occurrences"
},
CanvasTool::Select,
),
(
"L",
"Lifeline: click to choose represented work or participant",
@@ -192,16 +335,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
glyph,
label,
choice == tool,
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
(clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
button(text("Properties").size(12))
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
container(button(text("Properties").size(12)).on_press_maybe(
(clean && properties_target(p, context, app.selected)).then_some(
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
),
))
.id("scenario-properties")
]
.spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
@@ -212,6 +360,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
))),
));
}
details = details.push(text("Scenario order").size(13));
if matches!(context.representation, Representation::Saved { .. }) {
details = details.push(text("↑ and ↓ change the shared Scenario order. Saved message positions are retained; automatically placed items may move.").size(11));
}
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
@@ -541,6 +693,153 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
display_notice_parts(
p,
shown.requested(),
shown.status(),
shown.diagnostics(),
shown.latent_messages(),
)
}
fn display_notice_parts<'a>(
p: &Project,
requested: Representation,
status: &presentation::PresentationStatus,
diagnostics: &[super::super::scenario_layout::Diagnostic],
latent_messages: &[Id],
) -> Element<'a, Message> {
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
let title = match requested {
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|| "Unavailable saved Scenario view".into(),
|view| format!("Saved view · {}", view.name),
),
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
};
let mut notice = column![text(title).size(14)].spacing(4);
match status {
PresentationStatus::Ready => {
for diagnostic in diagnostics.iter().take(3) {
let explanation = match diagnostic {
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
p.named(*before),
p.named(*after)
),
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
p.named(*frame),
p.named(*following)
),
};
notice = notice.push(text(explanation).size(12));
}
if diagnostics.len() > 3 {
notice = notice.push(
text(format!(
"{} additional spacing notices are retained.",
diagnostics.len() - 3
))
.size(12),
);
}
}
PresentationStatus::Fallback { requested_issues } => {
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
if let Some(issue) = requested_issues.first() {
notice = notice.push(text(geometry_problem(p, issue)).size(12));
}
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
}
PresentationStatus::Unavailable { .. } => {
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
}
}
if !latent_messages.is_empty() {
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", latent_messages.len())).size(12));
}
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
container(notice)
.id("scenario-display-notice")
.padding(6)
.width(Fill)
.into()
}
fn spacing_feedback(
p: &Project,
refusal: Option<&spacing::Refusal>,
diagnostics: &[super::super::scenario_layout::Diagnostic],
) -> String {
if let Some(refusal) = refusal {
let reason = match refusal {
spacing::Refusal::Geometry(issues) => issues
.first()
.map(|issue| geometry_problem(p, issue))
.unwrap_or_else(|| "This position cannot be displayed.".into()),
spacing::Refusal::Command(error) => error.clone(),
_ => "This position is unavailable in the current view.".into(),
};
return format!(
"Position unavailable · {reason} Move back to a supported position, or release to cancel."
);
}
let explanation = match diagnostics.first() {
Some(super::super::scenario_layout::Diagnostic::SourceOrderCrossing {
before,
after,
..
}) => format!(
" '{}' is below '{}'; Scenario order stays unchanged.",
p.named(*before),
p.named(*after)
),
Some(super::super::scenario_layout::Diagnostic::FrameExtendsPastFollowingAnchor {
frame,
following,
}) => format!(" '{}' overlaps '{}'.", p.named(*frame), p.named(*following)),
None => " Scenario order stays unchanged.".into(),
};
format!("Spacing preview · Release to finish; Escape cancels.{explanation}")
}
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
use super::super::scenario_layout::GeometryIssue;
match issue {
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
"The position of '{}' conflicts with the execution of '{}'.",
p.named(*event),
p.named(*call)
),
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
"The distinct endpoints of '{}' have the same horizontal position.",
p.named(*message)
),
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
"The position of '{}' falls outside its supported execution interval.",
p.named(*call)
),
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
"The saved spacing would move the fixed header of '{}'.",
p.named(*frame)
),
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
"The spacing of '{}' extends outside its enclosing frame.",
p.named(*operand)
),
GeometryIssue::LifecycleGeometryNotSupported { .. }
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
"The saved spacing cannot preserve the participant lifetime geometry.".into()
}
_ => "These saved positions are outside the geometry currently supported by the canvas."
.into(),
}
}
fn sequence_action<'a>(
title: &str,
message: Message,
@@ -597,6 +896,12 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
#[cfg(all(test, debug_assertions))]
mod tests;
#[cfg(all(test, debug_assertions))]
mod spacing_tests;
#[cfg(all(test, debug_assertions))]
mod details_tests;
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
@@ -0,0 +1,437 @@
//! Mounted Scenario Details → inspector regression and retained-intent guards.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario_layout::resolve;
use iced::advanced::widget;
use iced::{Vector, keyboard};
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn setup() -> (App, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let message = id(&app, "initial_report");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A".into(),
}));
send(&mut app, |c| CanvasEvent::Select(c, message));
app.window_size = Size::new(1440., 1000.);
(app, diagram, message)
}
fn open_details(app: &mut App) {
if app.behavior.scenario.presentation.shelf.is_none() {
send(app, |c| CanvasEvent::Shelf(c, Shelf::Selection));
}
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
let p = &app.behavior.scenario.presentation;
(p.pan, p.zoom, p.fit)
}
#[tokio::test]
async fn saved_details_opens_real_editable_inspector_and_returns_to_same_view() {
let (mut app, diagram, message) = setup();
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let before = mounted.geometry(&app);
assert!(before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let button = before.bounds("scenario-properties").center() - offset;
assert!(
details.contains(button),
"Properties must be actually visible"
);
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let original = app.document.snapshot();
let bundle = app.document.bundle().clone();
let messages = mounted.click(&mut app, button).await;
assert!(messages.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == message)));
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.presentation.shelf.is_none());
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, message);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.page, crate::app::Page::Behavior);
let current = app.scenario_context().unwrap();
assert_eq!(current.representation, context.representation);
assert_eq!(current.visit, context.visit);
assert_eq!(
stored_camera(&app),
camera,
"restoring panels retains camera intent"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(inspector.width > 250. && inspector.height > 500.);
// Production Properties places Name before the other scalar text inputs.
// Account for the retained inspector scroll, rather than clicking content coordinates.
let input = shown
.text_inputs
.iter()
.map(|bounds| bounds.center() - offset)
.filter(|point| inspector.contains(*point))
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("visible editable property");
mounted.click(&mut app, input).await;
let typed = mounted
.batch(
&mut app,
&[iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character("x".into()),
modified_key: keyboard::Key::Character("x".into()),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some("x".into()),
repeat: false,
})],
mouse::Cursor::Available(input),
)
.await;
assert!(
typed
.iter()
.any(|message| matches!(message, Message::Field(path, _) if path == "/name")),
"real Name editor must publish its edit: {typed:?}"
);
assert!(app.draft_dirty());
assert!(
Arc::ptr_eq(&original, &app.document.snapshot()),
"typing is only a draft"
);
let _ = app.update(Message::RevertProperties);
assert!(!app.draft_dirty());
mounted.settle(&mut app).await;
let returned = mounted.geometry(&app);
assert!(returned.scrolls.contains_key(&widget::Id::new("inspector")));
let viewport = returned.bounds("scenario-viewport");
assert_eq!(app.selected, message);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(stored_camera(&app), camera);
let geometry = resolve(app.project(), context.representation, message).unwrap();
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = viewport.position()
+ effective.pan
+ Vector::new(line.points[0].x, line.points[0].y) * effective.scale;
assert!(
viewport.contains(point),
"the same selected message remains in fitted view"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_pending_property_source_and_behavior_drafts_without_apply() {
for kind in 0..3 {
let (mut app, _, message) = setup();
open_details(&mut app);
match kind {
0 => {
let _ = app.update(Message::Field(
"/name".into(),
"Retained property draft".into(),
));
assert!(app.draft_dirty());
}
1 => {
let _ = app.update(Message::SourceEdit(
iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Insert('x'),
),
));
assert!(app.source_dirty());
}
_ => {
let owner = app.scenario_context().unwrap().scenario;
let _ = app.update(Message::Behavior(super::super::super::Event::Scenario(
Event::Start(owner, Tool::Lifeline),
)));
assert!(app.behavior.has_pending());
}
}
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let property = app.draft.as_ref().unwrap().materialize();
let source = app.source_editor.text();
let behavior = app.behavior.recovery_snapshot();
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context().unwrap();
let epochs = (app.canvas_epoch, app.camera_epoch);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
context, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.draft.as_ref().unwrap().materialize(), property);
assert_eq!(app.source_editor.text(), source);
assert_eq!(app.behavior.recovery_snapshot(), behavior);
assert_eq!(app.selected, message);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn details_opens_current_saved_view_properties_after_following_diagram_picker() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
// Selecting the current view record makes the real picker follow that
// record selection to B. Do not seed selected=B or its property draft.
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let before = mounted.geometry(&app);
assert!(before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let point = before.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == b)));
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(
shown
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current Saved diagram has a visible editable inspector"
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.page, crate::app::Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(stored_camera(&app), camera);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_another_saved_view_even_with_the_same_scenario_owner() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
open_details(&mut app);
let context = app.scenario_context().unwrap();
assert_eq!(context.representation, Representation::Saved { diagram: b });
assert_eq!(
presentation::checked_owner(app.project(), Representation::Saved { diagram: a }),
Some(context.scenario)
);
assert!(!properties_target(app.project(), context, a));
let camera = app.behavior.scenario.camera_boundary();
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(context, a)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!(app.selected, a);
assert_eq!(app.draft.as_ref().unwrap().original, a);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_rejects_retired_view_selection_and_epoch_without_changing_panels() {
for kind in 0..3 {
let (mut app, diagram, message) = setup();
open_details(&mut app);
let captured = app.scenario_context().unwrap();
match kind {
0 => {
let other = id(&app, "incident_wide");
for view in [other, diagram] {
let _ = app.update(Message::Diagram(crate::app::Choice {
id: view,
label: "view".into(),
}));
}
open_details(&mut app);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
}
1 => {
let other = id(&app, "updated_report");
send(&mut app, |c| CanvasEvent::Select(c, other));
}
_ => {
send(&mut app, |c| CanvasEvent::Zoom(c, Some(0.5)));
}
}
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context();
let selected = app.selected;
let tab = app.inspector_tab;
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
captured, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), context);
assert_eq!(app.selected, selected);
assert_eq!(app.inspector_tab, tab);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
}
}
#[test]
fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish() {
use super::super::spacing::{Event as Spacing, Target, controller::tests::packet};
let (mut app, _, message) = setup();
send(&mut app, |c| {
CanvasEvent::Viewport(c, Size::new(900., 600.))
});
open_details(&mut app);
let begin = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
Target::Message(message),
);
let Spacing::Begin { press_screen, .. } = begin else {
panic!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
let snapshot = app.document.snapshot();
let end = press_screen + Vector::new(0., 12.);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Move {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
let paint = app
.behavior
.scenario
.spacing_paint(begin.stamp().context, begin.stamp().camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert!(
line.points[0].y > 160.,
"a real proposed scene moved the first message"
);
assert!(
Arc::ptr_eq(&snapshot, &app.document.snapshot()),
"preview is not authored"
);
let camera = stored_camera(&app);
send(&mut app, |c| CanvasEvent::Properties(c, message));
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(stored_camera(&app), camera);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
@@ -0,0 +1,438 @@
//! Real mounted widgets dispatch the gesture; expectations name authored fields
//! and actual displayed anchors independently of controller command results.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use crate::behavior::scenario_layout::{ResolvedScenarioPlan, resolve};
use crate::document::Document;
use iced::Vector;
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
async fn setup(fit: bool) -> (App, Mounted, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A - Incident review readable".into(),
}));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
if !fit {
let context = app.scenario_context().unwrap();
let task = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(0.5),
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
}
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram }
);
assert!(!app.document.can_undo() && !app.document.can_redo());
(app, mounted, diagram)
}
fn resolved(app: &App) -> ResolvedScenarioPlan {
resolve(
app.project(),
app.scenario_context().unwrap().representation,
app.selected,
)
.unwrap()
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
let p = &app.behavior.scenario.presentation;
(p.pan, p.zoom, p.fit)
}
fn screen(mounted: &mut Mounted, app: &App, world: Point) -> Point {
let bounds = mounted.geometry(app).bounds("scenario-viewport");
let geometry = resolved(app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale;
assert!(
bounds.contains(point),
"actual visible world {world:?}: {point:?} in {bounds:?}"
);
point
}
fn mouse_event(event: mouse::Event) -> iced::Event {
iced::Event::Mouse(event)
}
async fn press(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[
mouse_event(mouse::Event::CursorMoved { position: point }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
async fn move_to(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[mouse_event(mouse::Event::CursorMoved { position: point })],
mouse::Cursor::Available(point),
)
.await
}
async fn release(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[mouse_event(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(point),
)
.await
}
fn set_expected_message(project: &mut oa_core::Project, diagram: Id, occurrence: Id, y: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.messages
.iter_mut()
.find(|m| m.address.occurrence == occurrence)
.unwrap()
.send_y = y;
}
fn set_expected_lane(project: &mut oa_core::Project, diagram: Id, lifeline: Id, x: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.lanes
.iter_mut()
.find(|lane| lane.lifeline == lifeline)
.unwrap()
.x = x;
}
fn assert_reopens_exactly(app: &App) {
let reopened = Document::from_bundle(
app.document.bundle().clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), app.document.bundle());
}
#[tokio::test]
async fn saved_message_same_batch_pointer_events_commit_once_and_one_undo_redo_restores_exact_source()
{
let (mut app, mut mounted, diagram) = setup(false).await;
let target = id(&app, "updated_report");
let before = app.document.snapshot();
let revision = app.document.revision();
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
// This fixture asks for exactly +20 world units. A fractional start near a
// screen-coordinate exponent boundary need not retain an exact +10 delta.
let start = Point::new(projected.x.round(), projected.y.round());
let mut geometry = resolved(&app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
assert_eq!(camera.scale, 0.5);
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let world = Point::new(
(start.x - bounds.x - camera.pan.x) / camera.scale,
(start.y - bounds.y - camera.pan.y) / camera.scale,
);
geometry.scene.scale = camera.scale;
geometry.scene.camera_pan = camera.pan;
let hit = geometry.scene.hit_item(world).unwrap();
assert_eq!(hit.id, target);
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
let end = start + Vector::new(0., 10.); // .5 display scale: +20 authored y.
assert_eq!(end.y - start.y, 10.);
assert!(bounds.contains(start) && bounds.contains(end));
let messages = mounted
.batch(
&mut app,
&[
mouse_event(mouse::Event::CursorMoved { position: start }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
mouse_event(mouse::Event::CursorMoved { position: end }),
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(end),
)
.await;
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
))
.count(),
1
);
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
))
.count(),
1
);
let mut expected = before.decoded.project.clone();
set_expected_message(&mut expected, diagram, target, 272.);
assert_eq!(
app.project(),
&expected,
"Only Saved A's addressed y changes; steps, other occurrences and B/C are exact"
);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_reopens_exactly(&app);
let after = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(app.document.bundle(), &after.bundle);
}
#[tokio::test]
async fn saved_header_preview_survives_frames_and_freezes_fit_while_actual_bounds_expand() {
let (mut app, mut mounted, diagram) = setup(true).await;
let assessor = id(&app, "assessor");
let before = app.document.snapshot();
let original = resolved(&app);
let original_camera = app.behavior.scenario.presentation.camera(original.bounds);
let stored = stored_camera(&app);
assert!(stored.2, "test must exercise original Fit");
let start = screen(&mut mounted, &app, Point::new(640., 45.));
press(&mut mounted, &mut app, start).await;
let token = app
.behavior
.scenario
.spacing_token()
.expect("actual header press begins");
for x in [670., 700.] {
let end = start + Vector::new((x - 640.) * original_camera.scale, 0.);
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await; // Actual borrowed Scene/Stack tree is rebuilt.
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), stored);
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
assert!((paint.scene.render_lanes[&assessor] - x).abs() < 0.001);
assert!(paint.bounds.width > original.bounds.width);
assert_eq!(paint.scene.scale, original_camera.scale);
assert_eq!(paint.scene.camera_pan, original_camera.pan);
let header = paint.scene.boxes.iter().find(|b| b.id == assessor).unwrap();
let displayed = Vector::new(header.rect.center().x, header.rect.center().y)
* paint.scene.scale
+ paint.scene.camera_pan;
let baseline = Vector::new(640., 50.) * original_camera.scale + original_camera.pan;
assert!((displayed.x - baseline.x - (x - 640.) * original_camera.scale).abs() < 0.001);
assert_eq!(displayed.y, baseline.y);
}
let end = start + Vector::new(60. * original_camera.scale, 0.);
release(&mut mounted, &mut app, end).await;
let final_geometry = resolved(&app);
let actual_x = final_geometry.scene.render_lanes[&assessor];
assert!((actual_x - 700.).abs() < 0.001);
let mut expected = before.decoded.project.clone();
set_expected_lane(&mut expected, diagram, assessor, actual_x);
assert_eq!(app.project(), &expected);
let final_camera = app
.behavior
.scenario
.presentation
.camera(final_geometry.bounds);
assert_eq!(
final_camera, original_camera,
"commit must not refit underneath pointer"
);
assert!(!app.behavior.scenario.presentation.fit);
assert_eq!(app.selected, assessor);
assert_reopens_exactly(&app);
}
#[tokio::test]
async fn saved_body_and_header_clicks_select_without_materializing_spacing() {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let original_camera = stored_camera(&app);
for (alias, world) in [
("updated_report", Point::new(400., 252.)),
("assessor", Point::new(640., 45.)),
] {
let target = id(&app, alias);
let point = screen(&mut mounted, &app, world);
mounted.click(&mut app, point).await;
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), original_camera);
}
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_valid_preview_then_refused_self_call_never_commits_last_valid_candidate() {
let (mut app, mut mounted, _) = setup(false).await;
let target = id(&app, "assess_call");
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(680., 344.));
press(&mut mounted, &mut app, start).await;
let valid = start + Vector::new(0., -10.); // send324, receive348, reply436 unchanged.
move_to(&mut mounted, &mut app, valid).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint.scene.lines.iter().find(|l| l.id == target).unwrap();
assert_eq!(line.points[0].y, 324.);
assert_eq!(line.points.last().unwrap().y, 348.);
let refused = start + Vector::new(0., 53.); // send450, receive474 would follow reply436.
move_to(&mut mounted, &mut app, refused).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_some());
assert_eq!(
paint
.scene
.lines
.iter()
.find(|l| l.id == target)
.unwrap()
.points[0]
.y,
344.
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
release(&mut mounted, &mut app, refused).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_escape_and_outside_pointer_cancel_actual_held_preview() {
for escape in [true, false] {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(400., 252.));
let end = start + Vector::new(0., 10.);
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
let release_at = if escape {
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::Escape)],
mouse::Cursor::Available(end),
)
.await;
end
} else {
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let outside = Point::new(bounds.x - 5., bounds.y + 100.);
move_to(&mut mounted, &mut app, outside).await;
outside
};
assert!(app.behavior.scenario.spacing_token().is_none());
release(&mut mounted, &mut app, release_at).await;
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_camera() {
let (mut app, mut mounted, _) = setup(true).await;
let owner = id(&app, "incident_response");
app.document
.execute(oa_core::Command::Rename(
owner,
"Previously edited owner".into(),
))
.unwrap();
let redo = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert!(app.document.can_redo());
let before = app.document.snapshot();
let camera = stored_camera(&app);
let selected = app.selected;
let revision = app.document.revision();
let start = screen(&mut mounted, &app, Point::new(400., 252.));
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, start + Vector::new(0., 12.)).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
move_to(&mut mounted, &mut app, start).await;
mounted.settle(&mut app).await;
release(&mut mounted, &mut app, start).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(stored_camera(&app), camera);
assert_eq!(app.selected, selected);
assert!(app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &redo.decoded.project);
assert_eq!(app.document.bundle(), &redo.bundle);
}
@@ -57,8 +57,28 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
mounted.pan(&mut app, start, start + delta).await;
let diff = app.behavior.scenario.presentation.pan - next_pan;
assert!(diff.x.hypot(diff.y) < 0.01);
let tool = mounted.geometry(&app).bounds("behavior-tool:A").center();
mounted.click(&mut app, tool).await;
// Iced operations report palette children in content coordinates.
// The preceding Action focus may have scrolled this retained palette.
let geometry = mounted.geometry(&app);
let scope = canvas_workspace::Scope::current(&app).unwrap();
let (palette, offset) = geometry.scrolls[&scope.control_id("palette-scroll")];
let tool = geometry.bounds("behavior-tool:A").center() - offset;
assert!(
palette.contains(tool),
"visible Action at {tool:?}: {palette:?}"
);
let chosen = mounted.click(&mut app, tool).await;
assert!(
chosen.iter().any(|message| matches!(
canvas_workspace::tests::intent_action(message),
Message::ScenarioCanvas(CanvasEvent::Choose(_, CanvasTool::Add(Tool::Action)))
)),
"Action palette click at scale {scale}, offset {offset:?}: {chosen:?}"
);
assert_eq!(
app.behavior.scenario.canvas_tool(f.owner),
CanvasTool::Add(Tool::Action)
);
let messages = mounted.click(&mut app, bounds.position() + desired).await;
assert!(
messages.iter().any(
@@ -66,7 +86,7 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
tool: Tool::Action, slot, lifeline: Some(line), ..
}) if *slot == expected && *line == f.sender)
),
"scale {scale}: {messages:?}"
"scale {scale}, expected slot {expected:?}: {messages:?}"
);
let session = app.behavior.scenario.form_session().unwrap();
let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation;
@@ -1348,7 +1368,18 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let point = bounds.position() + iced::Vector::new(95., 90.);
let lifetime = app.camera_epoch;
let pan = app.behavior.scenario.presentation.pan;
// Fit stores no pan offset. A real drag starts from the displayed fitted
// camera, then materializes it before applying its ordered screen deltas.
let shown = app
.behavior
.scenario
.display(app.project(), f.owner, app.selected)
.unwrap();
let before_camera = app
.behavior
.scenario
.presentation
.camera(shown.bounds().unwrap());
let first = point + iced::Vector::new(13., 9.);
let last = first + iced::Vector::new(17., -3.);
let emitted = mounted
@@ -1363,7 +1394,21 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
mouse::Cursor::Available(last),
)
.await;
assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point));
let expected_pan = before_camera.pan + (last - point);
let actual_pan = app.behavior.scenario.presentation.pan;
let difference = actual_pan - expected_pan;
assert!(
difference.x.hypot(difference.y) < 0.001,
"{boundary}: displayed pan {actual_pan:?} must follow {expected_pan:?}"
);
assert_eq!(
app.behavior.scenario.presentation.zoom,
Some(before_camera.scale)
);
assert!(
!app.behavior.scenario.presentation.fit,
"drag materializes the visible Fit camera"
);
assert_eq!(
app.camera_epoch, lifetime,
"own ordered deltas retain their lifetime"
@@ -1768,14 +1813,19 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
assert!(!presentation.viewport(Size::new(484., 204.)));
let scale = presentation.scale(graph);
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
assert!(scale > MIN_SCALE);
assert!(scale > camera::MIN_SCALE);
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
assert!(!presentation.zoom(Some(bad)));
assert!(!presentation.viewport(Size::new(bad, 100.)));
assert!(!presentation.viewport(Size::new(100., bad)));
assert_eq!(presentation.scale(graph), scale);
}
assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE);
let tall = Rectangle::with_size(Size::new(900., 10000.));
let camera = presentation.camera(tall);
assert!(camera.scale < 0.05 && camera.scale > 0.);
assert!((camera.scale - 188. / 10000.).abs() < 1e-8);
assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4);
assert!((camera.pan.y - 8.).abs() < 1e-4);
assert!(presentation.zoom(Some(1.25)));
presentation.viewport(Size::new(200., 100.));
assert_eq!(
@@ -0,0 +1,386 @@
//! Actual Scene primitives, with independently chosen points and semantic IDs.
//! These preserve the existing label hit band; they do not establish ink bounds
//! or authoring eligibility, and do not dispatch a spacing gesture.
use super::*;
use crate::document::Document;
struct Fixture {
project: Project,
owner: Id,
sender: Id,
receiver: Id,
call: Id,
reply: Id,
exchange: Id,
}
fn fixture() -> Fixture {
let project = language::parse(r#"arcadia 7;
project hits "Hit roles" {
oa_activity source "Source" {}
oa_activity target "Target" {}
interaction exchange "Contract" { source @source; target @target; }
oa_scenario conversation "Conversation" { viewpoint OperationalActivity; steps [@call, @reply]; }
oa_lifeline left "Left" { parent @conversation; represented @source; }
oa_lifeline right "Right" { parent @conversation; represented @target; }
oa_occurrence call "Call" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" { parent @conversation; body { Exchange { exchange @exchange; sender @right; receiver @left; message_kind Reply; reply_to @call; }; }; }
}
"#).unwrap();
let id = |alias: &str| {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
};
Fixture {
owner: id("conversation"),
sender: id("left"),
receiver: id("right"),
call: id("call"),
reply: id("reply"),
exchange: id("exchange"),
project,
}
}
fn scene(f: &Fixture) -> Scene {
let document =
Document::from_project(f.project.clone(), source_project::ProjectLimits::default())
.unwrap();
let before = document.snapshot();
let result = Scene::sequence_geometry(
document.project(),
f.owner,
f.call,
Some(&BTreeMap::from([(f.sender, 160.), (f.receiver, 400.)])),
None,
);
assert_eq!(document.project(), &before.decoded.project);
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(document.revision(), 0);
assert!(!document.can_undo() && !document.can_redo() && !document.dirty());
result
}
fn hit(scene: &Scene, point: Point, id: Id, role: SceneHitRole) {
assert_eq!(scene.hit_item(point), Some(SceneHit { id, role }));
assert_eq!(scene.hit(point), Some(id));
}
fn occurrence(p: &mut Project, parent: Id, body: OccurrenceBody) -> Id {
let mut element = p
.make_element(Kind::Occurrence, Layer::Oa, "Hit target", Some(parent))
.unwrap();
let id = element.id;
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
p.elements.insert(id, element);
p.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn exchange_mut(p: &mut Project, id: Id) -> &mut OccurrenceBody {
let SharedBody::Occurrence { body, .. } =
p.elements.get_mut(&id).unwrap().shared_mut().unwrap()
else {
panic!("expected occurrence")
};
body
}
#[test]
fn exact_headers_include_unassigned_lifelines_but_guides_are_not_headers() {
let mut f = fixture();
*f.project
.elements
.get_mut(&f.receiver)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline { represented: None };
let s = scene(&f);
assert!(!s.lanes.contains_key(&f.receiver));
assert_eq!(s.render_lanes[&f.receiver], 400.);
for (id, x) in [(f.sender, 160.), (f.receiver, 400.)] {
// The actual header is x-102..x+102, y15..85, including its padding.
for point in [Point::new(x - 101., 16.), Point::new(x + 101., 84.)] {
hit(&s, point, id, SceneHitRole::LifelineHeader);
}
hit(&s, Point::new(x, 95.), id, SceneHitRole::LifelineGuide);
}
}
#[test]
fn repeated_exchange_occurrences_keep_distinct_message_body_and_label_hits() {
let f = fixture();
let s = scene(&f);
assert_eq!(s.rows[&f.call].1, 148.);
assert_eq!(s.rows[&f.reply].1, 240.);
for (id, y) in [(f.call, 148.), (f.reply, 240.)] {
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., y - 16.),
id,
SceneHitRole::MessageLabel,
);
assert_ne!(id, f.exchange);
}
}
#[test]
fn self_call_elbow_and_receive_endpoint_are_messages_not_execution_hits() {
let mut f = fixture();
for id in [f.call, f.reply] {
let OccurrenceBody::Exchange {
sender,
receiver,
exchange,
..
} = exchange_mut(&mut f.project, id)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.sender);
*exchange = None;
}
let s = scene(&f);
let call = s.lines.iter().find(|l| l.id == f.call).unwrap();
assert_eq!(call.points.len(), 4);
assert_eq!(call.points[0].y, 148.);
assert_eq!(call.points[3].y, 172.);
hit(
&s,
Point::new(call.points[1].x, 160.),
f.call,
SceneHitRole::MessageBody,
);
hit(&s, call.points[3], f.call, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., 132.),
f.call,
SceneHitRole::MessageLabel,
);
let bar = s.activations.iter().find(|a| a.call == f.call).unwrap();
hit(
&s,
Point::new(bar.rect.x + 2., 190.),
f.call,
SceneHitRole::Activation,
);
}
#[test]
fn activation_shoulder_beats_another_calls_label_and_broad_message_tolerance() {
let mut f = fixture();
let mut inner = f.project.elements[&f.call].clone();
inner.id = Id::new_v4();
inner.alias = "inner_call_hit".into();
inner.name = "Pressure verification with a descriptive operation label".into();
let inner_id = inner.id;
f.project.elements.insert(inner_id, inner);
let mut inner_reply = f.project.elements[&f.reply].clone();
inner_reply.id = Id::new_v4();
inner_reply.alias = "inner_reply_hit".into();
let reply_id = inner_reply.id;
f.project.elements.insert(reply_id, inner_reply);
let OccurrenceBody::Exchange { reply_to, .. } = exchange_mut(&mut f.project, reply_id) else {
unreachable!()
};
*reply_to = Some(inner_id);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, inner_id, reply_id, f.reply];
let mut s = scene(&f);
let inner = s.lines.iter().find(|l| l.id == inner_id).unwrap();
let outer = s.activations.iter().find(|a| a.call == f.call).unwrap();
let shoulder = Point::new(outer.rect.x + 1., 225.);
assert!(outer.rect.contains(shoulder));
assert!(inner.label_bounds().contains(shoulder));
let endpoint = *inner.points.last().unwrap();
for scale in [1., 0.05, 0.0003] {
s.scale = scale;
hit(&s, shoulder, f.call, SceneHitRole::Activation);
hit(&s, endpoint, inner_id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., 225.),
inner_id,
SceneHitRole::MessageLabel,
);
}
}
#[test]
fn create_delete_markers_are_distinct_from_their_own_message_body_and_label() {
let mut f = fixture();
for (id, kind) in [
(f.call, MessageKind::Create),
(f.reply, MessageKind::Delete),
] {
let OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
reply_to,
..
} = exchange_mut(&mut f.project, id)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.receiver);
*message_kind = kind;
*reply_to = None;
}
let s = scene(&f);
for (id, y, label_y) in [(f.call, 148., 118.), (f.reply, 240., 224.)] {
hit(&s, Point::new(400., y), id, SceneHitRole::LifecycleMarker);
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., label_y),
id,
SceneHitRole::MessageLabel,
);
}
// Capsule boundary owns its contact with the incoming Create arrow.
hit(
&s,
Point::new(330., 148.),
f.call,
SceneHitRole::LifecycleMarker,
);
}
#[test]
fn tiny_scale_nearest_hit_retains_the_winning_marker_or_message_role() {
let mut f = fixture();
let OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
reply_to,
..
} = exchange_mut(&mut f.project, f.reply)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.receiver);
*message_kind = MessageKind::Delete;
*reply_to = None;
let mut s = scene(&f);
s.scale = 0.0003;
// Both are outside exact strokes/labels. Comparing all broad candidates
// chooses the actual nearer shape, even when their UUID is identical.
hit(
&s,
Point::new(280., 246.),
f.reply,
SceneHitRole::MessageBody,
);
hit(
&s,
Point::new(400., 265.),
f.reply,
SceneHitRole::LifecycleMarker,
);
hit(
&s,
Point::new(280., 154.),
f.call,
SceneHitRole::MessageBody,
);
}
#[test]
fn action_frame_and_use_boxes_do_not_advertise_message_or_header_roles() {
for body in [
OccurrenceBody::Action {
action: None,
lifeline: None,
},
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
OccurrenceBody::ScenarioUse {
scenario: None,
bindings: vec![],
},
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
] {
let mut f = fixture();
let id = occurrence(&mut f.project, f.owner, body);
let s = scene(&f);
let item = s.boxes.iter().find(|b| b.id == id).unwrap();
hit(
&s,
item.rect.position() + iced::Vector::new(15., 15.),
id,
SceneHitRole::Box,
);
}
}
#[test]
fn winning_primitive_role_does_not_come_from_its_shared_uuid() {
let f = fixture();
let mut s = scene(&f);
// A controlled overlapping primitive verifies reverse box order without
// inventing a core alias. Its ID intentionally equals a real header's ID.
s.boxes.push(BoxItem {
id: f.sender,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(150., 20.), Size::new(20., 20.)),
title: "Overlay control".into(),
detail: String::new(),
marker: None,
});
hit(&s, Point::new(160., 30.), f.sender, SceneHitRole::Box);
hit(
&s,
Point::new(180., 30.),
f.sender,
SceneHitRole::LifelineHeader,
);
}
#[test]
fn invalid_points_never_produce_a_semantic_or_spacing_hit() {
let f = fixture();
let s = scene(&f);
for point in [
Point::new(f32::NAN, 148.),
Point::new(280., f32::NAN),
Point::new(f32::INFINITY, 148.),
Point::new(280., f32::NEG_INFINITY),
] {
assert_eq!(s.hit_item(point), None);
assert_eq!(s.hit(point), None);
}
}
@@ -0,0 +1,430 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//! Experimental parity requires settled fonts and a fresh renderer/cache after
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
//! No fonts may be loaded between measurement, guarded access and actual draw.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{
graphics::text as graphics_text,
text::{Alignment, LineHeight, Shaping},
};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self {
font: text.font,
size: Pixels(12.),
line_height: text.line_height,
shaping: text.shaping,
}
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
/// Explicit experimental contract: the caller finishes loading fonts and starts
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
/// This module cannot inspect or reset the actual renderer's text cache.
#[derive(Clone, Debug)]
pub(super) struct RenderSession {
version: graphics_text::Version,
identity: Arc<()>,
}
impl RenderSession {
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
Ok(Self {
version: fonts.version(),
identity: Arc::new(()),
})
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
bounds: Rectangle,
style: Style,
measured_session: RenderSession,
measured_zoom: f32,
}
impl Label {
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
return Err(Error::StaleRenderSession);
}
if !zoom.is_finite() || zoom != self.measured_zoom {
return Err(Error::StaleZoom);
}
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
return Err(Error::FontSetChanged);
}
Ok(())
}
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
self.check(session, zoom)?;
Ok(&self.displayed)
}
pub fn retained_source(
&self,
session: &RenderSession,
zoom: f32,
) -> Result<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(
&self,
session: &RenderSession,
zoom: f32,
field: Range<usize>,
) -> Result<bool, Error> {
self.check(session, zoom)?;
if field.start > field.end || self.source.get(field.clone()).is_none() {
return Ok(false);
}
let end = field.end.min(self.retained_source.end);
Ok(field.start < end
&& self
.source
.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
}
/// Validate finite world position and rectangle endpoints only. Camera
/// transforms and raster bounds remain a separate integration contract.
pub fn bounds_at(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
translated_bounds(position, self.bounds.size())
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
/// The returned Text must be consumed immediately in this session/zoom;
/// retaining raw Text past another font load/zoom change bypasses the guard.
pub fn canvas_text(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
color: Color,
) -> Result<Text, Error> {
self.bounds_at(session, zoom, position)?;
Ok(Text {
content: self.displayed.clone(),
position,
color,
size: self.style.size,
line_height: self.style.line_height,
font: self.style.font,
shaping: self.style.shaping,
max_width: f32::INFINITY,
..Text::default()
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error {
InvalidMetrics,
SourceBudget,
GraphemeBudget,
FontSystemUnavailable,
InvalidMeasurement,
InvalidPosition,
StaleZoom,
StaleRenderSession,
FontSetChanged,
}
struct Entry {
source: String,
style: Style,
zoom: f32,
width: f32,
label: Arc<Label>,
}
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(
&mut self,
session: &RenderSession,
source: &str,
width: f32,
zoom: f32,
style: Style,
) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite()
|| width < 0.
|| !zoom.is_finite()
|| zoom <= 0.
|| !size.is_finite()
|| size <= 0.
|| !line_height.is_finite()
|| line_height <= 0.
|| !(width * zoom).is_finite()
|| !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite()
{
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES {
return Err(Error::SourceBudget);
}
let mut fonts = graphics_text::font_system()
.write()
.map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() {
return Err(Error::FontSetChanged);
}
if self.version != Some(fonts.version())
|| self
.session
.as_ref()
.is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity))
{
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry| {
entry.source == source
&& entry.style == style
&& entry.zoom == zoom
&& entry.width == width
}) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized
.text
.grapheme_indices(true)
.map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len()))
.collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
return Err(Error::GraphemeBudget);
}
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() {
return Ok(Size::ZERO);
}
#[cfg(test)]
{
self.allocations += 1;
}
let (_, entry) = shapes.allocate(
fonts.raw(),
graphics_text::cache::Key {
content,
size,
line_height,
font: style.font,
bounds: Size::INFINITE,
shaping: style.shaping,
align_x: Alignment::Default,
},
);
if entry.buffer.layout_runs().count() != 1
|| !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite()
{
return Err(Error::InvalidMeasurement);
}
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else {
high = middle;
}
}
best
}
};
let label = Arc::new(Label {
source: source.into(),
displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured),
style,
measured_session: session.clone(),
measured_zoom: zoom,
});
if self.entries.len() == MAX_ENTRIES {
self.entries.pop_front();
}
self.entries.push_back(Entry {
source: source.into(),
style,
zoom,
width,
label: label.clone(),
});
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite()
|| !world.height.is_finite()
|| world.width < 0.
|| world.height < 0.
{
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
if !position.x.is_finite()
|| !position.y.is_finite()
|| !(position.x + size.width).is_finite()
|| !(position.y + size.height).is_finite()
{
return Err(Error::InvalidPosition);
}
Ok(Rectangle::new(position, size))
}
struct Normalized {
text: String,
ends: Vec<(usize, usize)>,
}
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self {
text: String::new(),
ends: vec![(0, 0)],
};
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() {
separator = !result.text.is_empty();
continue;
}
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends
.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1)
.expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
#[path = "scenario_label/tests.rs"]
mod tests;
@@ -0,0 +1,231 @@
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
//!
//! The caller supplies a finite structural label band AFTER endpoint/activation
//! alignment, with padding already removed from its edges. Blockers include
//! padded activation/card/header bounds over the complete text band, including
//! stroke/ink clearance. This function does not infer a self-message corridor,
//! alter scene extents, change zoom, or establish raster-ink containment.
use iced::Rectangle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct InvalidGeometry;
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
let right = rect.x + rect.width;
let bottom = rect.y + rect.height;
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
.into_iter()
.any(|value| !value.is_finite())
|| rect.width < 0.
|| rect.height < 0.
{
return Err(InvalidGeometry);
}
Ok((rect.x, right, rect.y, bottom))
}
/// Select one connected clear interval; never assemble text across blockers.
/// Prefer greater width, then proximity to the previous label anchor, then the
/// leftmost interval. Input order has no influence. A zero-area band has no
/// corridor. Touching the padded vertical band is conservatively obstructing;
/// a zero-area blocker has no area and must receive stroke padding upstream.
pub(super) fn widest_clear(
band: Rectangle,
blockers: &[Rectangle],
preferred_anchor: f32,
) -> Result<Option<Rectangle>, InvalidGeometry> {
let (left, right, top, bottom) = edges(band)?;
if !preferred_anchor.is_finite() {
return Err(InvalidGeometry);
}
let mut blocked = Vec::new();
for &blocker in blockers {
let (x0, x1, y0, y1) = edges(blocker)?;
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
let interval = (x0.max(left), x1.min(right));
if interval.0 < interval.1 {
blocked.push(interval);
}
}
}
if band.width == 0. || band.height == 0. {
return Ok(None);
}
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut best: Option<Rectangle> = None;
let mut consider = |start: f32, end: f32| {
if start >= end {
return;
}
let candidate = Rectangle {
x: start,
width: contained_width(start, end),
..band
};
// Use f64 only for comparison distance: subtracting two admitted f32
// world coordinates can overflow even though each rectangle is finite.
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
if best.is_none_or(|old| {
candidate.width > old.width
|| (candidate.width == old.width
&& (distance(candidate.x) < distance(old.x)
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
}) {
best = Some(candidate);
}
};
let mut cursor = left;
for (start, end) in blocked {
consider(cursor, start);
cursor = cursor.max(end);
}
consider(cursor, right);
Ok(best)
}
/// Finite f32 endpoints can subtract to infinity or round across a blocker.
/// Compute in f64, cap to finite f32, and round inward if nearest rounding
/// overshot the admitted interval's end.
fn contained_width(start: f32, end: f32) -> f32 {
let exact = f64::from(end) - f64::from(start);
let mut width = exact.min(f64::from(f32::MAX)) as f32;
if f64::from(start) + f64::from(width) > f64::from(end) {
width = width.next_down();
}
width
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
Rectangle {
x,
y,
width,
height,
}
}
#[test]
fn long_message_does_not_cross_an_intermediate_execution() {
let band = rect(20., 100., 700., 22.);
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
assert_eq!(result, rect(20., 100., 230., 22.));
for blocker in blockers {
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
}
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
#[test]
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
let band = rect(-100., 10., 600., 20.);
let blockers = [
rect(50., 0., 80., 60.),
rect(0., 5., 70., 30.),
rect(450., 0., 100., 60.),
];
let expected = Some(rect(130., 10., 320., 20.));
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
let mut reversed = blockers;
reversed.reverse();
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
}
#[test]
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
let band = rect(0., 100., 200., 20.);
// The first blocker ends at the band top: checking only a baseline
// would miss it. The second starts at the band bottom.
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
assert_eq!(
widest_clear(band, &blockers, 0.).unwrap(),
Some(rect(60., 100., 90., 20.))
);
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
}
#[test]
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
let band = rect(0., 0., 300., 20.);
let blockers = [rect(100., 0., 100., 20.)];
assert_eq!(
widest_clear(band, &blockers, 190.).unwrap(),
Some(rect(200., 0., 100., 20.))
);
assert_eq!(
widest_clear(band, &blockers, 100.).unwrap(),
Some(rect(0., 0., 100., 20.))
);
}
#[test]
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
// Direction and self-loop policy are explicit caller inputs. Neither
// a zero endpoint gap nor screen width can silently supply a corridor.
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
assert_eq!(
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
None
);
}
#[test]
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
let band = rect(0., 0., 100., 20.);
for invalid in [
rect(f32::NAN, 0., 10., 20.),
rect(0., 0., -1., 20.),
rect(f32::MAX, 0., f32::MAX, 20.),
rect(0., f32::MAX, 10., f32::MAX),
] {
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
}
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
}
#[test]
fn finite_band_cannot_reconstruct_an_infinite_width() {
let band = rect(f32::from_bits(0xfe79636e), 0., f32::MAX, 20.);
let right = band.x + band.width;
assert!(right.is_finite());
assert!(
(right - band.x).is_infinite(),
"counterexample must exercise f32 subtraction overflow"
);
let result = widest_clear(band, &[], 0.).unwrap().unwrap();
assert!(result.width.is_finite() && result.width > 0.);
assert!(result.x + result.width <= right);
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(right));
}
#[test]
fn reconstructed_width_rounds_inward_at_a_blocker() {
let band = rect(-999_948.562_5, 0., 1_000_000., 20.);
let blocker = rect(2.1, 0., 10., 20.);
assert!(
band.x + (blocker.x - band.x) > blocker.x,
"counterexample must cross the blocker under naive reconstruction"
);
let result = widest_clear(band, &[blocker], 0.).unwrap().unwrap();
assert_eq!(result.x, band.x);
assert!(result.width > 999_000.);
assert!(result.x + result.width <= blocker.x);
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(blocker.x));
}
}
@@ -0,0 +1,487 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{
graphics::text::Paragraph,
text::{Paragraph as _, Text as ParagraphText, Wrapping},
};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText {
content,
bounds: Size::INFINITE,
size,
line_height,
font: style.font,
align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top,
shaping: style.shaping,
wrapping: Wrapping::default(),
});
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(
narrow.bounds.size(),
paragraph_size(&narrow.displayed, 1., style)
);
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let wide = cache
.resolve(&session, raw, 1600., 1., Style::default())
.unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
assert!(
!wide.retains_any(&session, 1., 0..2).unwrap(),
"normalized whitespace is not a visible field"
);
let small = cache
.resolve(&session, raw, 70., 1., Style::default())
.unwrap();
assert!(!small.retains_any(&session, 1., field).unwrap());
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for zoom in [0.66, 1., 1.75] {
let label = cache
.resolve(&session, text, 105., zoom, Style::default())
.unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(
label.bounds.size(),
paragraph_size(&label.displayed, zoom, Style::default())
);
assert!(
text.grapheme_indices(true)
.any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len()
);
let painted = label
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
.unwrap();
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(
label
.bounds_at(&session, zoom, painted.position)
.unwrap()
.size(),
label.bounds.size()
);
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache
.resolve(&session, text, 0., 1., Style::default())
.unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [
(120., 1., style),
(100., 0.66, style),
(
100.,
1.,
Style {
size: Pixels(14.),
..style
},
),
] {
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
}
#[test]
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
let mut cache = Cache::default();
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification";
let a = cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(
&a,
&cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap()
));
assert_eq!(cache.allocations, measured);
// A fresh renderer session invalidates a still-resident entry. This does
// not mutate global fonts or purport to test dynamic WGPU font loading.
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let refreshed = cache
.resolve(&new_session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Resident pressure label";
let a = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
// Exercise the local memo epoch branch while a is still resident. No
// intervening width/zoom/style resolution can cause incidental eviction.
// This is not an actual font-load or renderer-cache acceptance test.
cache.version = None;
let refreshed = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
.unwrap();
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (context, zoom, expected) in [
(&session, 0.66, Error::StaleZoom),
(&other, 1., Error::StaleRenderSession),
] {
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
assert_eq!(
label.retains_any(context, zoom, 0..9).unwrap_err(),
expected
);
assert_eq!(
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
expected
);
assert_eq!(
label
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
.unwrap_err(),
expected
);
}
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
}
#[test]
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style {
size: Pixels(2.),
line_height: LineHeight::Relative(f32::MAX),
..Style::default()
};
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
// required world line height is not; reject before passing it to shaping.
let scaled = style.scaled(0.5);
assert!(scaled.0.is_finite() && scaled.1.is_finite());
assert_eq!(
cache
.resolve(&session, "call", 100., 0.5, style)
.unwrap_err(),
Error::InvalidMetrics
);
assert_eq!(cache.allocations, 0);
assert!(cache.entries.is_empty());
// Independently exercise validation after division for actual measured
// dimensions: finite scaled width/height do not imply finite world values.
assert_eq!(
world_size(Size::new(f32::MAX, 1.), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(1., f32::MAX), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(20., 8.), 0.5).unwrap(),
Size::new(40., 16.)
);
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(
cache
.resolve(&session, "call", width, zoom, Style::default())
.unwrap_err(),
Error::InvalidMetrics
);
}
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_SOURCE_BYTES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::SourceBudget
);
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_GRAPHEMES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::GraphemeBudget
);
assert!(cache.entries.is_empty());
}
#[test]
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "call", 100., 1., Style::default())
.unwrap();
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
assert_eq!(
label.bounds_at(&session, 1., position).unwrap_err(),
Error::InvalidPosition
);
assert_eq!(
label
.canvas_text(&session, 1., position, Color::BLACK)
.unwrap_err(),
Error::InvalidPosition
);
}
assert_eq!(
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
Err(Error::InvalidPosition)
);
assert_eq!(
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
Err(Error::InvalidPosition)
);
assert_eq!(
label
.bounds_at(&session, 1., Point::new(-20., -10.))
.unwrap()
.position(),
Point::new(-20., -10.)
);
}
/// Must run as the ONLY selected test in a dedicated, fresh test process.
/// The ignored marker keeps this global-font mutation out of normal parallel
/// desktop tests. See the evidence directory's exact executable runner.
#[test]
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
fn actual_font_load_rejects_retained_label_and_old_session() {
use graphics_text::cosmic_text::fontdb;
use std::borrow::Cow;
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let source = "Operation: Pressure";
let label = cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap();
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
let (version_before, version_after, faces_before, faces_after) = {
let mut fonts = graphics_text::font_system().write().unwrap();
let face = fonts
.raw()
.db()
.faces()
.find(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
.id;
let bytes = fonts
.raw()
.db()
.with_face_data(face, |data, _| data.to_vec())
.expect("the built-in binary font has accessible bytes");
assert!(!bytes.is_empty());
// load_font increments Version even for invalid bytes. Independently
// establish actual face admission so this cannot pass with invalid data.
let mut probe = fontdb::Database::new();
probe.load_font_data(bytes.clone());
let admitted = probe
.faces()
.filter(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.count();
assert!(
admitted > 0,
"the extracted bytes parse as the expected real font"
);
let before = fonts.version();
assert_eq!(before, session.version);
let count = fonts.raw().db().faces().count();
// Owned bytes intentionally bypass Borrowed-address deduplication.
// This is an actual valid duplicate-face load, not a claim the chosen
// face or rendered metrics change; the stale-epoch rule is the oracle.
fonts.load_font(Cow::Owned(bytes));
(
before,
fonts.version(),
count,
fonts.raw().db().faces().count(),
)
};
assert_ne!(version_before, version_after);
assert!(
faces_after > faces_before,
"actual font admission must accompany the version increment"
);
assert_eq!(
label.displayed(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retained_source(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retains_any(&session, 1., 0..9).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache.allocations, measured,
"an invalid session must not remeasure and imply renderer repair"
);
println!(
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
);
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
// Process exit is the isolation boundary; no other test may run afterward.
}
@@ -0,0 +1,816 @@
//! Pure complete Scenario geometry. No persistence, gesture or fallback policy.
//! The only renderer is Scene::sequence_geometry; this module resolves its inputs.
use super::*;
use oa_core::{DiagramLayout, ScenarioLayout};
#[cfg(test)]
mod tests;
pub(crate) mod presentation;
type Candidate = BTreeMap<Id, f32>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AnchorRole {
MessageSend,
MessageReceive,
Observation,
FrameStart,
OperandStart,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Anchor {
pub(super) y: f32,
role: AnchorRole,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct MessageAnchors {
send_y: f32,
receive_y: f32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Diagnostic {
SourceOrderCrossing { scope: Id, before: Id, after: Id },
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum GeometryIssue {
UnknownAnchor(Id),
LifecycleGeometryNotSupported { lifeline: Id, scope: Id },
InvalidRepresentation(Id),
InvalidCoordinate(Id),
DuplicateCoordinate(Id),
ExpandedAddressNotSupported(Id),
CoincidentDistinctEndpoints(Id),
TypedActivationOutsideScope { call: Id, scope: Id },
TypedActivationOutsideLifetime { call: Id, receiver: Id },
TypedActivationCrossingNotSupported { call: Id, event: Id },
NonFinite(Id),
CompoundMoveNotImplemented(Id),
FixedFrameHeaderWouldMove { frame: Id },
OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id },
LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id },
LifetimeInversionNotSupported { create: Id, delete: Id },
}
#[derive(Clone, Debug)]
pub(super) struct Plan {
pub(super) anchors: BTreeMap<Id, Anchor>,
messages: BTreeMap<Id, MessageAnchors>,
pub(super) frames: BTreeMap<Id, Rectangle>,
pub(super) scopes: BTreeMap<Id, (f32, f32)>,
pub(super) height: f32,
pub(super) diagnostics: Vec<Diagnostic>,
}
pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
match p.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. } => Some(body),
_ => None,
}
}
fn role(body: &OccurrenceBody) -> AnchorRole {
match body {
OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
} => AnchorRole::MessageReceive,
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
_ => AnchorRole::Observation,
}
}
fn inside(p: &Project, id: Id, owner: Id) -> bool {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
for _ in 0..p.elements.len() {
match current {
Some(parent) if parent == owner => return true,
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
None => return false,
}
}
false
}
fn depth(p: &Project, id: Id) -> usize {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
let mut result = 0;
while let Some(parent) = current {
result += 1;
if result > p.elements.len() {
break;
}
current = p.elements.get(&parent).and_then(|e| e.parent);
}
result
}
pub(in crate::behavior) fn union(a: Rectangle, b: Rectangle) -> Rectangle {
let x = a.x.min(b.x);
let y = a.y.min(b.y);
Rectangle::new(
Point::new(x, y),
Size::new(
(a.x + a.width).max(b.x + b.width) - x,
(a.y + a.height).max(b.y + b.height) - y,
),
)
}
/// Actual named shapes/message paths supply footprint measurements; activation
/// bars are intentionally excluded because current Scene derives them later.
fn footprints(
scene: &Scene,
anchors: &BTreeMap<Id, Anchor>,
seed: &BTreeMap<Id, Anchor>,
) -> BTreeMap<Id, Rectangle> {
let mut out = BTreeMap::new();
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(anchor) = anchors.get(&item.id) else {
continue;
};
let mut rect = item.rect;
rect.y += anchor.y - seed[&item.id].y;
out.insert(item.id, rect);
}
for line in &scene.lines {
let Some(anchor) = anchors.get(&line.id) else {
continue;
};
let delta = anchor.y - seed[&line.id].y;
let mut rect = line.label_bounds();
rect.y += delta;
for point in &line.points {
rect = union(
rect,
Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO),
);
}
out.entry(line.id)
.and_modify(|old| *old = union(*old, rect))
.or_insert(rect);
}
out
}
fn include_ancestors(p: &Project, id: Id, rect: Rectangle, covered: &mut BTreeMap<Id, Rectangle>) {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
for _ in 0..p.elements.len() {
let Some(parent) = current else {
break;
};
covered
.entry(parent)
.and_modify(|old| *old = union(*old, rect))
.or_insert(rect);
current = p.elements.get(&parent).and_then(|e| e.parent);
}
}
fn coverage(p: &Project, footprints: &BTreeMap<Id, Rectangle>) -> BTreeMap<Id, Rectangle> {
let mut covered = BTreeMap::new();
for (&id, &rect) in footprints {
include_ancestors(p, id, rect, &mut covered);
}
covered
}
impl Plan {
/// Authored endpoint values are primary inputs, not deltas reconstructed from
/// rounded rows. Lifecycle alignment owns x and marker attachment; this final
/// y alignment precedes typed activation derivation and preserves send bits.
pub(super) fn align_message_anchors(&self, lines: &mut [Line]) {
for line in lines {
let Some(message) = self.messages.get(&line.id) else {
continue;
};
let previous_send = line.points.first().map_or(message.send_y, |point| point.y);
line.label_at.y += message.send_y - previous_send;
let last = line.points.len().saturating_sub(1);
for (index, point) in line.points.iter_mut().enumerate() {
point.y = if index == last || (last == 3 && index == 2) {
message.receive_y
} else {
message.send_y
};
}
}
}
fn build(
p: &Project,
owner: Id,
baseline: &Scene,
desired: &Candidate,
) -> Result<Self, GeometryIssue> {
let mut seed: BTreeMap<_, _> = baseline
.rows
.iter()
.map(|(id, (_, y))| {
(
*id,
Anchor {
y: *y,
role: role(occurrence(p, *id).expect("Scene occurrence")),
},
)
})
.collect();
// Operand headers are real Scene boxes, but are not entries in rows.
for item in &baseline.boxes {
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
seed.insert(
item.id,
Anchor {
y: item.rect.y,
role: AnchorRole::OperandStart,
},
);
}
}
let mut anchors = seed.clone();
for (&id, &y) in desired {
let anchor = anchors
.get_mut(&id)
.ok_or(GeometryIssue::UnknownAnchor(id))?;
if !y.is_finite() {
return Err(GeometryIssue::NonFinite(id));
}
if matches!(
anchor.role,
AnchorRole::FrameStart | AnchorRole::OperandStart
) && y != anchor.y
{
return Err(GeometryIssue::CompoundMoveNotImplemented(id));
}
anchor.y = y;
}
let mut messages = BTreeMap::new();
for line in &baseline.lines {
if let (Some(anchor), Some(first), Some(last)) = (
anchors.get(&line.id),
line.points.first(),
line.points.last(),
) {
let message = if desired.contains_key(&line.id) {
let self_message = matches!(occurrence(p, line.id), Some(OccurrenceBody::Exchange {
sender: Some(sender), receiver, message_kind, ..
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
let lifecycle = matches!(
occurrence(p, line.id),
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
})
);
if self_message && lifecycle {
MessageAnchors {
send_y: anchor.y - 24.,
receive_y: anchor.y,
}
} else {
MessageAnchors {
send_y: anchor.y,
receive_y: if self_message {
anchor.y + 24.
} else {
anchor.y
},
}
}
} else {
MessageAnchors {
send_y: first.y,
receive_y: last.y,
}
};
messages.insert(line.id, message);
}
}
// Track actual semantic lifecycle order per receiver and scope. In particular,
// a later recreation must not be compared with an earlier deletion.
for element in p
.elements
.values()
.filter(|e| e.id == owner || inside(p, e.id, owner))
{
let Some(steps) = element.steps() else {
continue;
};
let mut last = BTreeMap::new();
for id in steps {
if let (
Some(message),
Some(OccurrenceBody::Exchange {
receiver: Some(lane),
message_kind: MessageKind::Create | MessageKind::Delete,
..
}),
) = (messages.get(id), occurrence(p, *id))
&& let Some((previous, previous_y)) =
last.insert(*lane, (*id, message.receive_y))
&& previous_y > message.receive_y
{
return Err(GeometryIssue::LifetimeInversionNotSupported {
create: previous,
delete: *id,
});
}
}
}
let old_footprints = footprints(baseline, &seed, &seed);
let mut drawn = footprints(baseline, &anchors, &seed);
let old_coverage = coverage(p, &old_footprints);
let mut new_coverage = coverage(p, &drawn);
let mut frames = BTreeMap::new();
let mut containers: Vec<_> = seed
.keys()
.copied()
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. })))
.collect();
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
for id in containers {
let original = old_footprints[&id];
let mut rect = original;
if let Some(covered) = new_coverage.get(&id).copied() {
// Preserve automatic extents and their existing descendant padding.
// Upward envelope growth would move the painted frame header
// while its fixed FrameStart/row/slot remained at the seed.
// Reject that unsupported compound change, not mere overlap.
if covered.y < rect.y {
return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id });
}
let old_covered = old_coverage[&id];
let padding =
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
// Preserve padding before the footprint itself reaches the old
// frame edge: operand scopes already retain their own padding.
rect.height = rect
.height
.max(covered.y + covered.height + padding - rect.y);
}
frames.insert(id, rect);
drawn.insert(id, rect);
include_ancestors(p, id, rect, &mut new_coverage);
}
let mut scopes = baseline.sequence_bounds.clone();
for (&scope, bounds) in &mut scopes {
if let (Some(old), Some(new)) = (
old_coverage.get(&scope).copied(),
new_coverage.get(&scope).copied(),
) {
let top_padding = (old.y - bounds.0).max(0.);
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
if new.y < bounds.0 {
bounds.0 = new.y - top_padding;
}
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
}
}
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
let height = baseline.size.height.max(scopes[&owner].1 + tail);
for (&operand, &(start, end)) in &scopes {
if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) {
continue;
}
let fragment = p.elements[&operand].parent.expect("operand fragment");
let caller = p.elements[&fragment]
.parent
.expect("fragment caller sequence");
let enclosing_frame = frames[&fragment];
let (caller_start, caller_end) = if caller == owner {
(85., height - 20.)
} else {
let (a, b) = scopes[&caller];
(a, if a == b { a + 26. } else { b })
};
let enclosing_start = anchors[&fragment].y.max(caller_start);
let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end);
let effective_end = if start == end { start + 26. } else { end };
// Actual lifecycle traversal cannot enter an operand that escapes
// this enclosing interval. This guards bridge feasibility only;
// it is not a generic event-spacing or sibling-ordering policy.
if start < enclosing_start || effective_end > enclosing_end {
return Err(GeometryIssue::OperandScopeOutsideEnclosingInterval {
operand,
fragment,
});
}
}
for (&id, message) in &messages {
if matches!(
occurrence(p, id),
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
})
) {
let scope = p.elements[&id].parent.expect("lifecycle sequence owner");
// Match Rendering::new's effective scope, not the insertion
// scope: it overrides the root span to 85..height-20 and gives
// empty non-root scopes the existing 26px lifetime allowance.
let (start, end) = if scope == owner {
(85., height - 20.)
} else if let Some(&(start, end)) = scopes.get(&scope) {
(start, if start == end { start + 26. } else { end })
} else {
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
occurrence: id,
scope,
});
};
if message.receive_y < start || message.receive_y > end {
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
occurrence: id,
scope,
});
}
}
}
let mut diagnostics = Vec::new();
for element in p
.elements
.values()
.filter(|e| e.id == owner || inside(p, e.id, owner))
{
if let Some(steps) = element.steps() {
for pair in steps.windows(2) {
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
if a.y > b.y {
diagnostics.push(Diagnostic::SourceOrderCrossing {
scope: element.id,
before: pair[0],
after: pair[1],
});
}
if let Some(frame) = frames.get(&pair[0])
&& frame.y + frame.height > b.y
{
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
frame: pair[0],
following: pair[1],
});
}
}
}
}
}
Ok(Self {
anchors,
messages,
frames,
scopes,
height,
diagnostics,
})
}
}
/// An appearance identity, never inferred from selection or from the owner alone.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Representation {
Derived { owner: Id },
Saved { diagram: Id },
}
/// Complete immutable geometry produced from one Project borrow. Scene is retained
/// with its plan so a caller cannot accidentally render this plan against another
/// Project or representation. Document/revision freshness belongs to that caller.
pub(super) struct ResolvedScenarioPlan {
pub(super) representation: Representation,
pub(super) owner: Id,
pub(super) lanes: BTreeMap<Id, f32>,
pub(super) plan: Plan,
pub(super) latent_messages: Vec<Id>,
pub(super) bounds: Rectangle,
pub(super) scene: Scene,
}
pub(super) fn resolve(
p: &Project,
representation: Representation,
selected: Id,
) -> Result<ResolvedScenarioPlan, Vec<GeometryIssue>> {
let (owner, layout) = match representation {
Representation::Derived { owner } => (owner, None),
Representation::Saved { diagram } => {
let view = p
.diagrams
.get(&diagram)
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
let owner = view
.projection
.scenario()
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
if p.elements
.get(&owner)
.is_none_or(|e| e.layer() != view.layer)
{
return Err(vec![GeometryIssue::InvalidRepresentation(diagram)]);
}
let layout = view
.layout
.as_ref()
.map(|DiagramLayout::Scenario(layout)| layout);
(owner, layout)
}
};
if p.elements
.get(&owner)
.is_none_or(|e| e.kind() != Kind::Scenario)
{
return Err(vec![GeometryIssue::InvalidRepresentation(owner)]);
}
let empty = ScenarioLayout::default();
let layout = layout.unwrap_or(&empty);
let mut lanes = BTreeMap::new();
let mut issues = Vec::new();
for lane in &layout.lanes {
if p.elements
.get(&lane.lifeline)
.is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(owner))
{
issues.push(GeometryIssue::UnknownAnchor(lane.lifeline));
}
if !lane.x.is_finite() || lane.x.abs() > oa_core::MAX_COORDINATE {
issues.push(GeometryIssue::InvalidCoordinate(lane.lifeline));
}
if lanes.insert(lane.lifeline, lane.x).is_some() {
issues.push(GeometryIssue::DuplicateCoordinate(lane.lifeline));
}
}
if !issues.is_empty() {
return Err(issues);
}
// The actual automatic traversal runs first, with authored lane centers already
// applied. It supplies every untouched anchor, shape, insertion scope and label.
let baseline = Scene::sequence_geometry(
p,
owner,
selected,
(!lanes.is_empty()).then_some(&lanes),
None,
);
let mut desired = BTreeMap::new();
let mut seen = std::collections::BTreeSet::new();
let mut latent_messages = Vec::new();
let line_ids: std::collections::BTreeSet<_> =
baseline.lines.iter().map(|line| line.id).collect();
for message in &layout.messages {
let id = message.address.occurrence;
if !seen.insert(id) {
issues.push(GeometryIssue::DuplicateCoordinate(id));
}
if !message.address.references.is_empty() {
issues.push(GeometryIssue::ExpandedAddressNotSupported(id));
}
if !message.send_y.is_finite() || message.send_y.abs() > oa_core::MAX_COORDINATE {
issues.push(GeometryIssue::InvalidCoordinate(id));
}
let Some(OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
..
}) = occurrence(p, id)
else {
issues.push(GeometryIssue::UnknownAnchor(id));
continue;
};
if !baseline.rows.contains_key(&id) {
issues.push(GeometryIssue::UnknownAnchor(id));
continue;
}
if !line_ids.contains(&id) {
latent_messages.push(id);
continue;
}
// Legacy lifecycle rows denote receipt, whereas the saved field denotes send.
let row = if sender.is_some()
&& sender == receiver
&& matches!(message_kind, MessageKind::Create | MessageKind::Delete)
{
message.send_y + 24.
} else {
message.send_y
};
desired.insert(id, row);
}
let actual_lanes = &baseline.render_lanes;
// A coincident pair is not a self message. Do not invent an arrow or lane order.
for line in &baseline.lines {
if let Some(OccurrenceBody::Exchange {
sender: Some(sender),
receiver,
message_kind,
..
}) = occurrence(p, line.id)
{
let target = if matches!(message_kind, MessageKind::Timer { .. }) {
Some(*sender)
} else {
*receiver
};
if target.is_some_and(|target| target != *sender)
&& target
.is_some_and(|target| actual_lanes.get(sender) == actual_lanes.get(&target))
{
issues.push(GeometryIssue::CoincidentDistinctEndpoints(line.id));
}
}
}
if !issues.is_empty() {
return Err(issues);
}
let mut plan = Plan::build(p, owner, &baseline, &desired).map_err(|issue| vec![issue])?;
// Keep the schema's exact send value; send+24 is performed once. In particular,
// a self Create/Delete receive row must never round-trip back into saved send.
for message in &layout.messages {
let id = message.address.occurrence;
if let Some(anchors) = plan.messages.get_mut(&id) {
let self_message = matches!(occurrence(p, id), Some(OccurrenceBody::Exchange {
sender: Some(sender), receiver, message_kind, ..
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
*anchors = MessageAnchors {
send_y: message.send_y,
receive_y: if self_message {
message.send_y + 24.
} else {
message.send_y
},
};
}
}
// Typed bars require a positive receiver interval. This is a narrow renderer
// limitation, not a global ban on reordered/negative asynchronous anchors.
for (&id, message) in &plan.messages {
match occurrence(p, id) {
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
}) => {
let scope = baseline.rows[&id].0;
let (start, end) = if scope == owner {
(85., plan.height - 20.)
} else {
plan.scopes[&scope]
};
if desired.contains_key(&id) && (message.send_y < start || message.receive_y >= end)
{
issues.push(GeometryIssue::TypedActivationOutsideScope { call: id, scope });
}
}
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(call),
..
}) => {
if let Some(start) = plan.messages.get(call)
&& (desired.contains_key(&id) || desired.contains_key(call))
&& message.send_y <= start.receive_y
{
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
call: *call,
event: id,
});
}
}
_ => {}
}
}
if !issues.is_empty() {
return Err(issues);
}
for segment in &baseline.activations {
if baseline
.rows
.get(&segment.call)
.is_none_or(|(scope, _)| *scope != segment.scope)
{
continue;
}
let Some(start) = plan.messages.get(&segment.call) else {
continue;
};
let event = match segment.finish {
scenario_activation::Finish::Reply(event)
| scenario_activation::Finish::Terminated(event) => Some(event),
_ => None,
};
if let Some(event) = event
&& (desired.contains_key(&event) || desired.contains_key(&segment.call))
&& plan.anchors[&event].y <= start.receive_y
{
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
call: segment.call,
event,
});
}
}
if !issues.is_empty() {
return Err(issues);
}
let scene = Scene::sequence_geometry(
p,
owner,
selected,
(!lanes.is_empty()).then_some(&lanes),
Some(&plan),
);
// Compare the actual projector, whose cursor rules include sibling scopes and
// lifecycle/fragment events. Retain existing incomplete-model uncertainty;
// reject only newly missing geometry introduced by this presentation.
let previous_missing = baseline.lifecycle.missing_geometry();
for (lifeline, scope) in scene
.lifecycle
.missing_geometry()
.difference(&previous_missing)
{
issues.push(GeometryIssue::LifecycleGeometryNotSupported {
lifeline: *lifeline,
scope: *scope,
});
}
if !issues.is_empty() {
return Err(issues);
}
for segment in &baseline.activations {
let Some(seed) = baseline
.lines
.iter()
.find(|line| line.id == segment.call)
.and_then(|line| line.points.last())
else {
continue;
};
if segment.rect.y != seed.y || baseline.rows[&segment.call].0 != segment.scope {
continue;
}
let receive = plan.messages[&segment.call].receive_y;
if !desired.is_empty()
&& !scene.activations.iter().any(|current| {
current.call == segment.call
&& current.scope == segment.scope
&& current.rect.y == receive
})
{
issues.push(GeometryIssue::TypedActivationOutsideLifetime {
call: segment.call,
receiver: segment.receiver,
});
}
}
if !issues.is_empty() {
return Err(issues);
}
let bounds = structural_bounds(&scene);
let all_lanes = scene.render_lanes.clone();
Ok(ResolvedScenarioPlan {
representation,
owner,
lanes: all_lanes,
plan,
latent_messages,
bounds,
scene,
})
}
fn structural_bounds(scene: &Scene) -> Rectangle {
let mut bounds = Rectangle::new(scene.layout_origin, scene.size);
for item in &scene.boxes {
bounds = union(bounds, item.rect);
}
for line in &scene.lines {
if !line.label.is_empty() {
bounds = union(bounds, line.label_bounds());
}
for point in &line.points {
// Arrow wings extend beyond the centerline in either direction.
bounds = union(
bounds,
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
);
}
}
for segment in &scene.activations {
bounds = union(bounds, segment.rect);
}
for point in &scene.open_calls {
bounds = union(
bounds,
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
);
}
if let Some(lifetime) = scene.lifecycle.bounds() {
bounds = union(bounds, lifetime);
}
bounds
}
@@ -0,0 +1,234 @@
//! Display policy for one requested Scenario representation, without authoring.
//! The resolver remains the sole renderer. Freshness and editor admission belong
//! to the caller; a capability here never bypasses those guards.
use super::{Diagnostic, GeometryIssue, Representation, ResolvedScenarioPlan, resolve};
use crate::behavior::Scene;
use iced::Rectangle;
use oa_core::{DiagramLayout, Id, Kind, OccurrenceBody, Project, SharedBody};
use std::collections::BTreeSet;
#[cfg(test)]
mod tests;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PresentationStatus {
Ready,
Fallback {
requested_issues: Vec<GeometryIssue>,
},
Unavailable {
requested_issues: Vec<GeometryIssue>,
automatic_issues: Vec<GeometryIssue>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PresentationCapabilities {
/// Actual geometry exists for inspection, Fit and geometry-based navigation.
pub(crate) geometry_navigation: bool,
/// Still subject to document, gesture, editor and view-visit admission.
pub(crate) coordinate_input: bool,
}
pub(crate) struct ScenarioPresentation {
requested: Representation,
owner: Option<Id>,
status: PresentationStatus,
geometry: Option<ResolvedScenarioPlan>,
// Saved metadata must survive a complete automatic fallback.
latent_messages: Vec<Id>,
}
impl ScenarioPresentation {
pub(crate) fn requested(&self) -> Representation {
self.requested
}
pub(crate) fn owner(&self) -> Option<Id> {
self.owner
}
pub(crate) fn status(&self) -> &PresentationStatus {
&self.status
}
pub(crate) fn geometry_representation(&self) -> Option<Representation> {
self.geometry
.as_ref()
.map(|geometry| geometry.representation)
}
pub(crate) fn bounds(&self) -> Option<Rectangle> {
self.geometry.as_ref().map(|geometry| geometry.bounds)
}
/// Diagnostics describe the actual displayed geometry. Requested hard failures
/// are retained separately in status(), never presented as automatic warnings.
pub(crate) fn diagnostics(&self) -> &[Diagnostic] {
self.geometry
.as_ref()
.map_or(&[], |geometry| &geometry.plan.diagnostics)
}
pub(crate) fn latent_messages(&self) -> &[Id] {
&self.latent_messages
}
pub(crate) fn capabilities(&self) -> PresentationCapabilities {
PresentationCapabilities {
geometry_navigation: self.geometry.is_some(),
coordinate_input: matches!(self.status, PresentationStatus::Ready),
}
}
pub(in crate::behavior) fn scene(&self) -> Option<&Scene> {
self.geometry.as_ref().map(|geometry| &geometry.scene)
}
pub(in crate::behavior) fn into_scene(self) -> Option<Scene> {
self.geometry.map(|geometry| geometry.scene)
}
}
/// Resolve a structurally accepted Project for passive display. This is not a
/// second model validator: identity and resolver-reported structural failures
/// cannot be converted into a more permissive automatic representation.
pub(crate) fn present(
project: &Project,
requested: Representation,
selected: Id,
) -> ScenarioPresentation {
let owner = checked_owner(project, requested);
let Some(owner_id) = owner else {
let id = match requested {
Representation::Derived { owner } => owner,
Representation::Saved { diagram } => diagram,
};
return ScenarioPresentation {
requested,
owner: None,
geometry: None,
latent_messages: vec![],
status: PresentationStatus::Unavailable {
requested_issues: vec![GeometryIssue::InvalidRepresentation(id)],
automatic_issues: vec![],
},
};
};
match resolve(project, requested, selected) {
Ok(geometry) => ScenarioPresentation {
requested,
owner,
status: PresentationStatus::Ready,
latent_messages: geometry.latent_messages.clone(),
geometry: Some(geometry),
},
Err(requested_issues) => {
if matches!(requested, Representation::Derived { .. })
|| requested_issues.iter().any(structural_failure)
{
return ScenarioPresentation {
requested,
owner,
geometry: None,
latent_messages: vec![],
status: PresentationStatus::Unavailable {
requested_issues,
automatic_issues: vec![],
},
};
}
match resolve(
project,
Representation::Derived { owner: owner_id },
selected,
) {
Ok(geometry) => {
let latent_messages =
saved_latent_messages(project, requested, &geometry.scene);
ScenarioPresentation {
requested,
owner,
status: PresentationStatus::Fallback { requested_issues },
geometry: Some(geometry),
latent_messages,
}
}
Err(automatic_issues) => ScenarioPresentation {
requested,
owner,
geometry: None,
latent_messages: vec![],
status: PresentationStatus::Unavailable {
requested_issues,
automatic_issues,
},
},
}
}
}
}
pub(crate) fn checked_owner(project: &Project, requested: Representation) -> Option<Id> {
let owner = match requested {
Representation::Derived { owner } => owner,
Representation::Saved { diagram } => {
let view = project.diagrams.get(&diagram)?;
let owner = view.projection.scenario()?;
if view.id != diagram || project.elements.get(&owner)?.layer() != view.layer {
return None;
}
owner
}
};
project
.elements
.get(&owner)
.is_some_and(|element| element.kind() == Kind::Scenario)
.then_some(owner)
}
fn structural_failure(issue: &GeometryIssue) -> bool {
// These violate the existing identity/address/finite-coordinate admission
// contract. Geometry support failures below do not invalidate stored intent.
match issue {
GeometryIssue::InvalidRepresentation(_)
| GeometryIssue::UnknownAnchor(_)
| GeometryIssue::InvalidCoordinate(_)
| GeometryIssue::DuplicateCoordinate(_)
| GeometryIssue::ExpandedAddressNotSupported(_)
| GeometryIssue::NonFinite(_) => true,
GeometryIssue::LifecycleGeometryNotSupported { .. }
| GeometryIssue::CoincidentDistinctEndpoints(_)
| GeometryIssue::TypedActivationOutsideScope { .. }
| GeometryIssue::TypedActivationOutsideLifetime { .. }
| GeometryIssue::TypedActivationCrossingNotSupported { .. }
| GeometryIssue::CompoundMoveNotImplemented(_)
| GeometryIssue::FixedFrameHeaderWouldMove { .. }
| GeometryIssue::OperandScopeOutsideEnclosingInterval { .. }
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
| GeometryIssue::LifetimeInversionNotSupported { .. } => false,
}
}
fn saved_latent_messages(project: &Project, requested: Representation, scene: &Scene) -> Vec<Id> {
let Representation::Saved { diagram } = requested else {
return vec![];
};
let Some(DiagramLayout::Scenario(layout)) = project.diagrams[&diagram].layout.as_ref() else {
return vec![];
};
let lines: BTreeSet<_> = scene.lines.iter().map(|line| line.id).collect();
layout
.messages
.iter()
.filter_map(|message| {
let id = message.address.occurrence;
(message.address.references.is_empty()
&& scene.rows.contains_key(&id)
&& !lines.contains(&id)
&& matches!(
project
.elements
.get(&id)
.and_then(|element| element.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
))
.then_some(id)
})
.collect()
}
@@ -0,0 +1,351 @@
use super::*;
use crate::document::Document;
use oa_core::{
Command, Diagram, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition, ScenarioLayout,
ScenarioMessagePosition, language, validation,
};
fn fixture() -> (Project, Id) {
let project = language::parse(FIXTURE).unwrap();
assert_admitted(&project);
let owner = named(&project, "conversation");
(project, owner)
}
fn named(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_admitted(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn save(project: &mut Project, owner: Id, layout: Option<ScenarioLayout>) -> Id {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: format!("view_{}", project.diagrams.len()),
name: "Saved conversation".into(),
description: String::new(),
layer: project.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: layout.map(DiagramLayout::Scenario),
},
);
id
}
fn layout(messages: &[(Id, f32)], lanes: &[(Id, f32)]) -> ScenarioLayout {
ScenarioLayout {
lanes: lanes
.iter()
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
.collect(),
messages: messages
.iter()
.map(|&(occurrence, send_y)| ScenarioMessagePosition {
address: OccurrenceEndpoint {
occurrence,
references: vec![],
},
send_y,
})
.collect(),
}
}
fn same_geometry(a: &Scene, b: &Scene) {
assert!(
!a.activations.is_empty(),
"fallback must include real typed executions"
);
assert_eq!(a.activations, b.activations);
assert_eq!(a.lifecycle, b.lifecycle);
assert_eq!(a.rows, b.rows);
assert_eq!(a.sequence_bounds, b.sequence_bounds);
assert_eq!(a.render_lanes, b.render_lanes);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.open_calls, b.open_calls);
assert_eq!((a.size, a.layout_origin), (b.size, b.layout_origin));
let boxes = |scene: &Scene| {
scene
.boxes
.iter()
.map(|item| {
(
item.id,
item.rect,
item.title.clone(),
item.detail.clone(),
item.marker,
)
})
.collect::<Vec<_>>()
};
let lines = |scene: &Scene| {
scene
.lines
.iter()
.map(|line| {
(
line.id,
line.points.clone(),
line.label.clone(),
line.label_at,
line.dashed,
)
})
.collect::<Vec<_>>()
};
let slots = |scene: &Scene| {
scene
.slots
.iter()
.map(|slot| {
(
slot.slot.parent,
slot.slot.before,
slot.y,
slot.label.clone(),
)
})
.collect::<Vec<_>>()
};
assert_eq!(boxes(a), boxes(b));
assert_eq!(lines(a), lines(b));
assert_eq!(slots(a), slots(b));
}
#[test]
fn absent_and_empty_saved_layouts_remain_saved_ready_representations() {
let (mut project, owner) = fixture();
let absent = save(&mut project, owner, None);
let empty = save(&mut project, owner, Some(ScenarioLayout::default()));
assert_admitted(&project);
let automatic = present(&project, Representation::Derived { owner }, owner);
assert_eq!(automatic.status(), &PresentationStatus::Ready);
for diagram in [absent, empty] {
let requested = Representation::Saved { diagram };
let displayed = present(&project, requested, owner);
assert_eq!(displayed.requested(), requested);
assert_eq!(displayed.geometry_representation(), Some(requested));
assert_eq!(displayed.owner(), Some(owner));
assert_eq!(displayed.status(), &PresentationStatus::Ready);
assert!(displayed.capabilities().coordinate_input);
assert_eq!(displayed.bounds(), automatic.bounds());
same_geometry(displayed.scene().unwrap(), automatic.scene().unwrap());
}
}
#[test]
fn successful_crossing_diagnostic_keeps_authored_geometry_and_latent_identity() {
let (mut project, owner) = fixture();
let first = named(&project, "first");
let second = named(&project, "second");
let left = named(&project, "left");
let unfinished = named(&project, "unfinished");
let diagram = save(
&mut project,
owner,
Some(layout(
&[(first, 1000.), (unfinished, 2000.)],
&[(left, 900.)],
)),
);
assert_admitted(&project);
let shown = present(&project, Representation::Saved { diagram }, first);
assert_eq!(shown.status(), &PresentationStatus::Ready);
assert_eq!(shown.scene().unwrap().rows[&first].1, 1000.);
assert_eq!(shown.scene().unwrap().rows[&second].1, 240.);
assert_eq!(shown.scene().unwrap().render_lanes[&left], 900.);
assert!(
shown
.diagnostics()
.contains(&Diagnostic::SourceOrderCrossing {
scope: owner,
before: first,
after: second
})
);
assert_eq!(shown.latent_messages(), &[unfinished]);
assert_eq!(shown.scene().unwrap().rows[&unfinished].1, 516.);
}
#[test]
fn hard_failure_uses_complete_automatic_geometry_but_retains_each_saved_request_and_latent_notice()
{
let (mut project, owner) = fixture();
let call = named(&project, "call");
let reply = named(&project, "reply");
let unfinished = named(&project, "unfinished");
let left = named(&project, "left");
let right = named(&project, "right");
let authored = layout(
&[(reply, 300.), (unfinished, 2000.)],
&[(left, 900.), (right, -500.)],
);
let a = save(&mut project, owner, Some(authored.clone()));
let b = save(&mut project, owner, Some(authored));
assert_admitted(&project);
let before = project.clone();
let automatic = present(&project, Representation::Derived { owner }, call);
let control = automatic.scene().unwrap();
assert_eq!(control.render_lanes[&left], 160.);
assert_eq!(control.render_lanes[&right], 400.);
assert_eq!(control.rows[&reply].1, 424.);
assert_eq!(control.activations.len(), 1);
assert_eq!(
(
control.activations[0].rect.y,
control.activations[0].rect.height
),
(356., 68.)
);
for diagram in [a, b] {
let request = Representation::Saved { diagram };
let shown = present(&project, request, call);
assert_eq!(shown.requested(), request);
assert_eq!(
shown.geometry_representation(),
Some(Representation::Derived { owner })
);
assert_eq!(
shown.status(),
&PresentationStatus::Fallback {
requested_issues: vec![GeometryIssue::TypedActivationCrossingNotSupported {
call,
event: reply
}],
}
);
assert_eq!(
shown.capabilities(),
PresentationCapabilities {
geometry_navigation: true,
coordinate_input: false
}
);
assert_eq!(shown.latent_messages(), &[unfinished]);
assert!(shown.diagnostics().is_empty());
assert_eq!(shown.bounds(), automatic.bounds());
same_geometry(shown.scene().unwrap(), control);
}
assert_eq!(project, before);
}
#[test]
fn missing_or_incompatible_identity_and_structural_coordinate_errors_are_unavailable() {
let (mut project, owner) = fixture();
let left = named(&project, "left");
let diagram = save(&mut project, owner, None);
let missing = Id::new_v4();
for request in [
Representation::Saved { diagram: missing },
Representation::Derived { owner: left },
] {
let shown = present(&project, request, owner);
assert_eq!(shown.requested(), request);
assert_eq!(shown.owner(), None);
assert!(
matches!(shown.status(), PresentationStatus::Unavailable { automatic_issues, .. } if automatic_issues.is_empty())
);
assert!(shown.scene().is_none() && shown.bounds().is_none());
assert_eq!(
shown.capabilities(),
PresentationCapabilities {
geometry_navigation: false,
coordinate_input: false
}
);
}
project.diagrams.get_mut(&diagram).unwrap().projection =
ProjectionKind::Scenario { scenario: missing };
assert!(matches!(
present(&project, Representation::Saved { diagram }, owner).status(),
PresentationStatus::Unavailable { .. }
));
// Deliberately invalid-model control, not a warning-admitted geometry failure.
project.diagrams.get_mut(&diagram).unwrap().projection =
ProjectionKind::Scenario { scenario: owner };
project.diagrams.get_mut(&diagram).unwrap().layout = Some(DiagramLayout::Scenario(layout(
&[],
&[(left, oa_core::MAX_COORDINATE + 1.)],
)));
assert!(
validation::validate(&project)
.iter()
.any(|issue| issue.severity == validation::Severity::Error)
);
let before = project.clone();
let shown = present(&project, Representation::Saved { diagram }, owner);
assert!(
matches!(shown.status(), PresentationStatus::Unavailable { requested_issues, automatic_issues }
if requested_issues == &[GeometryIssue::InvalidCoordinate(left)] && automatic_issues.is_empty())
);
assert!(shown.scene().is_none());
assert_eq!(project, before);
}
#[test]
fn passive_fallback_preserves_exact_document_source_and_one_step_undo_redo() {
let (mut project, owner) = fixture();
let first = named(&project, "first");
let reply = named(&project, "reply");
let view = save(&mut project, owner, Some(layout(&[(reply, 300.)], &[])));
assert_admitted(&project);
let mut document = Document::from_project(project, Default::default()).unwrap();
let initial_project = document.project().clone();
let initial_source = document.bundle().clone();
document
.execute(Command::Rename(first, "An intentional prior edit".into()))
.unwrap();
let accepted_project = document.project().clone();
let accepted_source = document.bundle().clone();
let revision = document.revision();
{
let shown = present(
document.project(),
Representation::Saved { diagram: view },
first,
);
assert!(matches!(
shown.status(),
PresentationStatus::Fallback { .. }
));
assert!(shown.scene().is_some());
}
assert_eq!(document.project(), &accepted_project);
assert_eq!(document.bundle(), &accepted_source);
assert_eq!(document.revision(), revision);
assert!(document.undo());
assert_eq!(document.project(), &initial_project);
assert_eq!(document.bundle(), &initial_source);
assert!(document.redo());
assert_eq!(document.project(), &accepted_project);
assert_eq!(document.bundle(), &accepted_source);
}
const FIXTURE: &str = r#"arcadia 7;
project display_probe "Scenario display probe" id "70000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "70000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "70000000-0000-4000-8000-000000000003" {}
interaction exchange "Repeated exchange" id "70000000-0000-4000-8000-000000000004" { source @source; target @target; }
oa_scenario conversation "Conversation" id "70000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
oa_lifeline left "Left" id "70000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "70000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
oa_occurrence first "First" id "70000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" id "70000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence call "Call" id "70000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" id "70000000-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 "70000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
}
"#;
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,7 @@ struct EventMarker {
capsule: Rectangle,
name: String,
sender_x: Option<f32>,
self_message: bool,
}
#[derive(Debug, Default, Clone, PartialEq)]
@@ -89,7 +90,7 @@ impl Rendering {
continue;
};
for event in &lane.markers {
let sender_x = project
let sender_id = project
.elements
.get(&event.occurrence)
.and_then(|e| e.shared())
@@ -101,7 +102,7 @@ impl Rendering {
..
},
..
} => lanes.get(sender).copied(),
} => Some(*sender),
_ => None,
});
markers.push(EventMarker {
@@ -113,7 +114,8 @@ impl Rendering {
Size::new(140., 28.),
),
name: project.named(lane.lifeline).to_owned(),
sender_x,
sender_x: sender_id.and_then(|id| lanes.get(&id).copied()),
self_message: sender_id == Some(lane.lifeline),
});
}
}
@@ -138,7 +140,7 @@ impl Rendering {
};
let x = marker.center.x;
let y = marker.center.y;
let self_message = sender == x;
let self_message = marker.self_message;
let end_x = match marker.event.kind {
MarkerKind::Birth if sender < x => marker.capsule.x,
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
@@ -166,6 +168,48 @@ impl Rendering {
}
}
/// Geometry loss is distinct from warning-admitted semantic uncertainty.
/// The saved bridge compares these exact projector diagnostics with baseline.
pub(in crate::behavior) fn missing_geometry(&self) -> std::collections::BTreeSet<(Id, Id)> {
self.lifetimes
.iter()
.flat_map(|lane| {
lane.uncertainties
.iter()
.filter(|issue| issue.reason == super::UncertaintyReason::MissingGeometry)
.map(|issue| (lane.lifeline, issue.scope))
})
.collect()
}
/// Complete structural lifetime bounds, including the birth capsule and death mark.
pub(in crate::behavior) fn bounds(&self) -> Option<Rectangle> {
let mut bounds = self.span.and_then(|span| {
self.lanes
.values()
.map(|x| {
Rectangle::new(
Point::new(*x, span.start),
Size::new(0., span.end - span.start),
)
})
.reduce(crate::behavior::scenario_layout::union)
});
for marker in &self.markers {
let rect = match marker.event.kind {
MarkerKind::Birth => marker.capsule,
MarkerKind::Death => Rectangle::new(
Point::new(marker.center.x - 10., marker.center.y - 10.),
Size::new(20., 20.),
),
};
bounds = Some(bounds.map_or(rect, |old| {
crate::behavior::scenario_layout::union(old, rect)
}));
}
bounds
}
pub(in crate::behavior) fn alive_spans(
&self,
lifeline: Id,
@@ -443,8 +443,8 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
let birth = f.message(f.owner, MessageKind::Create);
let c1 = f.message(f.owner, MessageKind::Call);
let c2 = f.message(f.owner, MessageKind::Call);
f.reply(f.owner, c2);
f.reply(f.owner, c1);
let r2 = f.reply(f.owner, c2);
let r1 = f.reply(f.owner, c1);
let death = f.message(f.owner, MessageKind::Delete);
let document = f.document();
let before = document.snapshot();
@@ -454,6 +454,14 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
});
assert_eq!(moved.rows[&birth].1, baseline.rows[&birth].1 + 60.);
assert_eq!(moved.rows[&death].1, baseline.rows[&death].1 + 60.);
for scene in [&baseline, &moved] {
assert_eq!(scene.activations.len(), 2);
for (call, reply) in [(c1, r1), (c2, r2)] {
assert!(scene.activations.iter().any(|segment| {
segment.call == call && segment.finish == Finish::Reply(reply)
}));
}
}
for (a, b) in baseline.activations.iter().zip(&moved.activations) {
assert_eq!(
(a.call, a.receiver, a.scope, a.column, a.finish),
@@ -1796,6 +1796,7 @@ mod tests {
layer: Layer::Sa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
@@ -294,6 +294,7 @@ pub fn create_view(project: &Project, machine: Id) -> Result<Plan, ModelError> {
layer: owner.layer(),
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
@@ -219,6 +219,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
graph.interaction = Some((context, state.tool, clean));
let scale = graph.scale;
let scope = super::super::canvas_workspace::Scope {
scenario_view: None,
document: context.document,
owner,
revision: context.revision,