Render nested call activations with correlation-aware anchors

This commit is contained in:
2026-09-12 07:05:36 -07:00
parent e57c390d49
commit 956a584132
3 changed files with 1203 additions and 138 deletions
+65 -138
View File
@@ -20,6 +20,7 @@ mod contextual_source_tests;
pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
// Projection acceptance precedes the coordinated scenario renderer integration.
#[cfg(test)]
mod scenario_lifecycle;
@@ -1145,6 +1146,7 @@ struct Scene {
lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
}
@@ -1208,6 +1210,7 @@ impl Scene {
lanes: BTreeMap::new(),
slots: vec![],
rows: BTreeMap::new(),
activations: vec![],
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
};
@@ -1297,29 +1300,7 @@ impl Scene {
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
if scenario {
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);
}
scene.size.height = (y + 45.).max(210.);
for (id, name, detail) in lanes {
@@ -1598,106 +1579,6 @@ impl Scene {
});
}
}
fn activation_segment(&mut self, call: Id, x: f32, top: f32, bottom: f32) {
if bottom <= top {
return;
}
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(Point::new(x - 4., top), Size::new(8., bottom - top)),
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 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::Reply,
reply_to: Some(correlation),
..
},
..
}) if *correlation == call => {
self.activation_segment(call, x, top, y);
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(Point::new(x - 7., y - 1.), Size::new(14., 2.)),
title: String::new(),
detail: String::new(),
marker: None,
});
start = None;
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { operator, branches },
..
}) => {
split = true;
self.activation_segment(call, 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, 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()
@@ -1749,18 +1630,31 @@ impl Scene {
)
.contains(point)
|| line.points.windows(2).any(|p| {
crate::diagram::distance_to_segment(point, p[0], p[1])
< 8. / self.scale
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
}))
})
.map(|line| line.id)
})
.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)
})
// The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out.
.or_else(|| {
self.lines
.iter()
.find(|line| {
!line.label.is_empty()
&& line.points.windows(2).any(|p| {
crate::diagram::distance_to_segment(point, p[0], p[1])
< 8. / self.scale
})
})
.map(|line| line.id)
})
.or_else(|| self.lane_at(point))
.or_else(|| {
@@ -2060,6 +1954,43 @@ impl canvas::Program<Message> for Scene {
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.translate(self.camera_pan);
f.scale(self.scale);
// 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 }),
);
}
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,
@@ -2086,7 +2017,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 {
@@ -2917,9 +2848,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 {
@@ -2957,11 +2888,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();
@@ -2969,9 +2896,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,