Integrate typed Scenario activations before saved geometry resolution
This commit is contained in:
+100
-197
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user