Integrate typed Scenario activations before saved geometry resolution

This commit is contained in:
2026-09-12 20:52:51 -07:00
parent e4e87951d7
commit 695685a2b1
6 changed files with 1958 additions and 199 deletions
+100 -197
View File
@@ -20,6 +20,7 @@ mod contextual_source_tests;
pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
@@ -1179,9 +1180,10 @@ struct Scene {
lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
lifecycle: scenario_lifecycle::render::Rendering,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
lifecycle: scenario_lifecycle::render::Rendering,
}
struct SequenceSlot {
slot: scenario::Slot,
@@ -1238,9 +1240,10 @@ impl Scene {
lanes: BTreeMap::new(),
slots: vec![],
rows: BTreeMap::new(),
activations: vec![],
lifecycle: scenario_lifecycle::render::Rendering::default(),
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
lifecycle: scenario_lifecycle::render::Rendering::default(),
};
let mut lanes = p
.children(owner)
@@ -1338,29 +1341,7 @@ impl Scene {
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
let calls: Vec<_> = scene
.rows
.keys()
.filter_map(|&id| {
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
receiver: Some(receiver),
..
},
..
}) = p.elements[&id].shared()
{
Some((id, *lane_x.get(receiver)?))
} else {
None
}
})
.collect();
for (call, x) in calls {
scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None);
}
scene.layout_activations(p, &lane_x);
}
for (id, name, detail) in lanes {
let x = lane_x[&id];
@@ -1427,14 +1408,16 @@ impl Scene {
};
if scenario
&& matches!(body, OccurrenceBody::Exchange {
sender: Some(sender), receiver: Some(receiver),
message_kind: MessageKind::Create | MessageKind::Delete, ..
} if sender == receiver)
sender: Some(sender), receiver: Some(receiver),
message_kind: MessageKind::Create | MessageKind::Delete, ..
} if sender == receiver)
{
// Self lifecycle elbows and their caption need clearance from
// the preceding action/state/reference box, before recording rows.
// Record the lifecycle receive row only after making room for
// the self-message elbow and caption above its marker.
*y += 16.;
}
#[cfg(test)]
scenario_lifecycle::render::apply_test_row_offset(*id, y);
if ordered {
let slot = scenario::Slot {
parent: owner,
@@ -1654,148 +1637,6 @@ impl Scene {
});
}
}
fn activation_segment(
&mut self,
call: Id,
scope: Id,
receiver: Id,
x: f32,
top: f32,
bottom: f32,
) {
for span in self.lifecycle.alive_spans(receiver, scope, top, bottom) {
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(
Point::new(x - 4., span.start),
Size::new(8., span.end - span.start),
),
title: String::new(),
detail: String::new(),
marker: None,
});
}
}
/// Follow a single correlation through the ordered tree. Branches are drawn
/// as independent segments, never as one chronological stretch through ALT
/// operands. The result says whether a path can leave this sequence open.
fn activation_sequence(
&mut self,
p: &Project,
owner: Id,
call: Id,
x: f32,
mut start: Option<f32>,
) -> bool {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
receiver: Some(receiver),
..
},
..
}) = p.elements[&call].shared()
else {
return false;
};
let receiver = *receiver;
let entered_open = start.is_some();
let mut split = false;
for id in p.elements[&owner].steps().into_iter().flatten() {
let Some(&(_, y)) = self.rows.get(id) else {
continue;
};
if *id == call {
start = Some(y);
}
let Some(top) = start else {
continue;
};
match p.elements[id].shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Delete,
receiver: Some(deleted),
..
},
..
}) if *deleted == receiver => {
// This path cannot show activation beyond its local death.
// Keep the unresolved-call cue; a Delete is not a Reply.
self.activation_segment(call, owner, receiver, x, top, y);
self.open_calls.push(Point::new(x + 12., y - 16.));
start = None;
}
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(correlation),
..
},
..
}) if *correlation == call => {
self.activation_segment(call, owner, receiver, x, top, y);
for span in self.lifecycle.alive_spans(receiver, owner, y - 1., y + 1.) {
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(
Point::new(x - 7., span.start),
Size::new(14., span.end - span.start),
),
title: String::new(),
detail: String::new(),
marker: None,
});
}
start = None;
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { operator, branches },
..
}) => {
split = true;
self.activation_segment(call, owner, receiver, x, top, y);
let bottom = self
.boxes
.iter()
.find(|b| b.id == *id && !b.title.is_empty())
.map(|b| b.rect.y + b.rect.height)
.unwrap_or(y);
let outcomes: Vec<_> = branches
.iter()
.filter_map(|branch| {
let &(top, _) = self.sequence_bounds.get(branch)?;
Some(self.activation_sequence(p, *branch, call, x, Some(top)))
})
.collect();
let open = match operator {
FragmentOperator::Alternative => {
outcomes.is_empty() || outcomes.iter().any(|open| *open)
}
FragmentOperator::Parallel => outcomes.iter().all(|open| *open),
// Optional/repeated bodies do not guarantee execution.
FragmentOperator::Optional | FragmentOperator::Loop => true,
};
start = open.then_some(bottom);
}
_ => {}
}
}
if let Some(top) = start {
let bottom = if entered_open || split {
self.sequence_bounds
.get(&owner)
.map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.))
} else {
top + 26.
};
self.activation_segment(call, owner, receiver, x, top, bottom);
self.open_calls.push(Point::new(x + 8., bottom - 7.));
}
start.is_some()
}
fn slot_at(&self, point: Point, parent: Option<Id>) -> Option<&SequenceSlot> {
self.slots
.iter()
@@ -1842,17 +1683,40 @@ impl Scene {
.or_else(|| {
self.lines
.iter()
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.find(|line| {
!line.label.is_empty()
&& line.points.windows(2).any(|p| {
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
})
})
.map(|line| line.id)
})
.or_else(|| self.near_sequence_item(point))
.or_else(|| {
self.boxes
self.activations
.iter()
.rev()
.find(|b| b.rect.contains(point) && b.title.is_empty())
.map(|b| b.id)
.find(|b| b.rect.contains(point))
.map(|b| b.call)
})
.or_else(|| {
self.lines
.iter()
.find(|line| {
!line.label.is_empty()
&& Rectangle::new(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
25.,
),
)
.contains(point)
})
.map(|line| line.id)
})
// The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out.
.or_else(|| self.near_sequence_item(point))
.or_else(|| self.lifecycle.lane_at(point, self.scale))
.or_else(|| self.lane_at(point))
.or_else(|| {
@@ -1885,9 +1749,8 @@ impl Scene {
.fold(f32::INFINITY, f32::min);
(line.id, distance)
});
// One distance comparison for both kinds of screen-sized hit tolerance.
// Dense minimum-Fit rows must not give the first message or last marker
// a larger hit priority than the visibly nearer item. UUID breaks ties.
// Compare broad message and lifecycle-marker tolerance together after
// exact shapes, so dense minimum-Fit rows choose the nearer item.
self.lifecycle
.nearest_marker(point)
.into_iter()
@@ -2158,6 +2021,55 @@ impl canvas::Program<Message> for Scene {
f.translate(self.camera_pan);
f.scale(self.scale);
self.lifecycle.draw_lanes(&mut f, self.selected);
// Guides belong behind execution fills and cards. In particular, a
// lifeline must not cut through the middle of its activation rectangle.
for guide in self.lines.iter().filter(|line| line.label.is_empty()) {
f.stroke(
&Path::new(|path| {
path.move_to(guide.points[0]);
for point in &guide.points[1..] {
path.line_to(*point);
}
}),
Stroke::default()
.with_color(if guide.id == self.selected {
style::TEAL
} else {
style::MUTED
})
.with_width(if guide.id == self.selected { 2.5 } else { 1.2 }),
);
}
// Label backplates clear guides, but must not erase execution borders
// or message geometry. Their text remains in the foreground below.
for line in self.lines.iter().filter(|line| !line.label.is_empty()) {
f.fill_rectangle(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
22.,
),
Color::WHITE,
);
}
for activation in &self.activations {
let path = Path::rectangle(activation.rect.position(), activation.rect.size());
f.fill(&path, Color::from_rgb8(243, 248, 248));
f.stroke(
&path,
Stroke::default()
.with_color(if activation.call == self.selected {
style::TEAL
} else {
style::LINE
})
.with_width(if activation.call == self.selected {
2.5
} else {
1.
}),
);
}
for b in &self.boxes {
let outline = matches!(
b.marker,
@@ -2184,7 +2096,7 @@ impl canvas::Program<Message> for Scene {
);
}
}
for l in &self.lines {
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
let color = if l.id == self.selected {
style::TEAL
} else {
@@ -2221,11 +2133,6 @@ impl canvas::Program<Message> for Scene {
}),
Stroke::default().with_color(color),
);
f.fill_rectangle(
l.label_at,
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.),
Color::WHITE,
);
f.fill_text(Text {
content: short(&l.label, 32),
position: l.label_at,
@@ -2237,8 +2144,8 @@ impl canvas::Program<Message> for Scene {
}
for b in &self.boxes {
if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
// Clipped child frames start with an identity transform. Match
// the card's pan and scale, as for lifecycle Create labels.
// WGPU child clips start with an identity transform, as in
// the lifecycle capsule label painter.
f.with_clip(clip, |label| {
label.translate(self.camera_pan);
label.scale(self.scale);
@@ -3025,9 +2932,9 @@ mod direct_canvas_tests {
p = execute_batch(&p, &commands).unwrap();
let scene = Scene::sequence(&p, f.owner, f.call);
let bars: Vec<_> = scene
.boxes
.activations
.iter()
.filter(|b| b.id == f.call && b.rect.width == 8.)
.filter(|b| b.call == f.call)
.map(|b| b.rect)
.collect();
for reply in &replies {
@@ -3065,11 +2972,7 @@ mod direct_canvas_tests {
#[test]
fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() {
let (f, scene) = scene();
let bar = scene
.boxes
.iter()
.find(|b| b.id == f.call && b.rect.width == 8.)
.unwrap();
let bar = scene.activations.iter().find(|b| b.call == f.call).unwrap();
assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1);
assert!(scene.open_calls.is_empty());
let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
@@ -3077,9 +2980,9 @@ mod direct_canvas_tests {
assert_eq!(scene.open_calls.len(), 1);
assert_eq!(
scene
.boxes
.activations
.iter()
.find(|b| b.id == f.call && b.rect.width == 8.)
.find(|b| b.call == f.call)
.unwrap()
.rect
.height,