Render explicit scenario lifetimes and preserve marker selection

This commit is contained in:
2026-09-11 05:25:04 -07:00
parent b853a1eca3
commit 52f6e176cd
15 changed files with 2231 additions and 97 deletions
+140 -39
View File
@@ -16,8 +16,6 @@ use oa_core::*;
use std::collections::BTreeMap;
pub(crate) mod creation;
pub(crate) mod scenario;
// Projection acceptance precedes the coordinated scenario renderer integration.
#[cfg(test)]
mod scenario_lifecycle;
pub(crate) mod state_canvas;
pub(crate) mod state_edit;
@@ -1092,6 +1090,7 @@ struct Scene {
rows: BTreeMap<Id, (Id, f32)>,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
lifecycle: scenario_lifecycle::render::Rendering,
}
struct SequenceSlot {
slot: scenario::Slot,
@@ -1150,6 +1149,7 @@ impl Scene {
rows: BTreeMap::new(),
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
lifecycle: scenario_lifecycle::render::Rendering::default(),
};
let mut lanes = p
.children(owner)
@@ -1236,7 +1236,17 @@ impl Scene {
}
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
scene.size.height = (y + 45.).max(210.);
if scenario {
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
p,
owner,
&lane_x,
&scene.rows,
&scene.sequence_bounds,
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
let calls: Vec<_> = scene
.rows
.keys()
@@ -1261,23 +1271,28 @@ impl Scene {
scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None);
}
}
scene.size.height = (y + 45.).max(210.);
for (id, name, detail) in lanes {
let x = lane_x[&id];
scene.lines.insert(
0,
Line {
id,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
dashed: false,
},
);
if !scenario {
scene.lines.insert(
0,
Line {
id,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
dashed: false,
},
);
}
scene.boxes.push(BoxItem {
id,
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: name,
title: if scenario {
format!("Lane · {name}")
} else {
name
},
detail,
marker: None,
});
@@ -1319,6 +1334,16 @@ impl Scene {
let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
continue;
};
if scenario
&& matches!(body, OccurrenceBody::Exchange {
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.
*y += 16.;
}
if ordered {
let slot = scenario::Slot {
parent: owner,
@@ -1538,17 +1563,27 @@ impl Scene {
});
}
}
fn activation_segment(&mut self, call: Id, x: f32, top: f32, bottom: f32) {
if bottom <= top {
return;
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,
});
}
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
@@ -1561,6 +1596,18 @@ impl Scene {
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() {
@@ -1574,6 +1621,21 @@ impl Scene {
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 {
@@ -1583,14 +1645,19 @@ impl Scene {
},
..
}) 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,
});
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 {
@@ -1598,7 +1665,7 @@ impl Scene {
..
}) => {
split = true;
self.activation_segment(call, x, top, y);
self.activation_segment(call, owner, receiver, x, top, y);
let bottom = self
.boxes
.iter()
@@ -1633,7 +1700,7 @@ impl Scene {
} else {
top + 26.
};
self.activation_segment(call, x, top, bottom);
self.activation_segment(call, owner, receiver, x, top, bottom);
self.open_calls.push(Point::new(x + 8., bottom - 7.));
}
start.is_some()
@@ -1666,6 +1733,12 @@ impl Scene {
))
}
fn hit(&self, point: Point) -> Option<Id> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
if let Some(id) = self.lifecycle.marker_at(point) {
return Some(id);
}
self.boxes
.iter()
.rev()
@@ -1680,7 +1753,7 @@ impl Scene {
.iter()
.find(|line| {
!line.label.is_empty()
&& (Rectangle::new(
&& Rectangle::new(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
@@ -1688,13 +1761,10 @@ impl Scene {
),
)
.contains(point)
|| 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.near_sequence_item(point))
.or_else(|| {
self.boxes
.iter()
@@ -1702,6 +1772,7 @@ impl Scene {
.find(|b| b.rect.contains(point) && b.title.is_empty())
.map(|b| b.id)
})
.or_else(|| self.lifecycle.lane_at(point, self.scale))
.or_else(|| self.lane_at(point))
.or_else(|| {
self.lines
@@ -1716,6 +1787,34 @@ impl Scene {
.map(|line| line.id)
})
}
fn near_sequence_item(&self, point: Point) -> Option<Id> {
if !self.scale.is_finite() || self.scale <= 0. {
return None;
}
let messages = self
.lines
.iter()
.filter(|line| !line.label.is_empty())
.map(|line| {
let distance = line
.points
.windows(2)
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
.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.
self.lifecycle
.nearest_marker(point)
.into_iter()
.chain(messages)
.filter(|(_, distance)| *distance <= 8. / self.scale)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
.map(|(id, _)| id)
}
}
impl canvas::Program<Message> for Scene {
type State = SequenceInput;
@@ -1956,6 +2055,7 @@ impl canvas::Program<Message> for Scene {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.scale(self.scale);
self.lifecycle.draw_lanes(&mut f, self.selected);
for b in &self.boxes {
let outline = matches!(
b.marker,
@@ -2064,6 +2164,7 @@ impl canvas::Program<Message> for Scene {
..Default::default()
});
}
self.lifecycle.draw_markers(&mut f, self.selected);
if let Some((context, tool, editable)) = self.interaction {
let active = editable && state.context == Some(context) && state.tool == tool;
let point = cursor