Render nested call activations with correlation-aware anchors
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user