feat: preview and commit Saved Scenario spacing gestures

This commit is contained in:
2026-09-13 01:32:42 -07:00
parent 46ff127373
commit 07e611fb94
13 changed files with 2516 additions and 55 deletions
+224 -30
View File
@@ -21,6 +21,8 @@ pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
#[cfg(test)]
mod scenario_hit_tests;
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
#[allow(dead_code)]
pub(crate) mod scenario_layout;
@@ -1161,8 +1163,27 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
false
}
/// Identifies the drawn primitive that won selection, not an authoring permission.
/// In particular an activation and its message can share an occurrence UUID.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SceneHitRole {
LifelineHeader,
MessageBody,
MessageLabel,
Activation,
LifecycleMarker,
LifelineGuide,
Box,
OtherLine,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SceneHit {
id: Id,
role: SceneHitRole,
}
struct BoxItem {
id: Id,
role: SceneHitRole,
rect: Rectangle,
title: String,
detail: String,
@@ -1195,6 +1216,7 @@ fn box_text_clip(
struct Line {
id: Id,
role: SceneHitRole,
points: Vec<Point>,
label: String,
label_at: Point,
@@ -1220,6 +1242,7 @@ struct Scene {
scale: f32,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
spacing_stamp: Option<scenario::spacing::Stamp>,
coordinate_input: bool,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
@@ -1248,6 +1271,10 @@ struct SequenceInput {
}
#[derive(Clone, Copy)]
enum SequencePress {
Spacing {
stamp: scenario::spacing::Stamp,
token: Id,
},
Occurrence {
tool: scenario::Tool,
slot: scenario::Slot,
@@ -1268,6 +1295,19 @@ enum SequencePress {
},
}
impl SequenceInput {
fn cancel_spacing(&mut self) -> Option<Action<Message>> {
let cancel = match self.press {
Some(SequencePress::Spacing { stamp, token }) => Some(
Action::publish(Message::ScenarioCanvas(scenario::CanvasEvent::Spacing(
scenario::spacing::Event::Cancel { stamp, token },
)))
.and_capture(),
),
_ => None,
};
self.cancel();
cancel
}
fn cancel(&mut self) {
self.source = None;
self.press = None;
@@ -1293,6 +1333,7 @@ impl Scene {
layout_origin: Point::ORIGIN,
selected,
interaction: None,
spacing_stamp: None,
coordinate_input: true,
pending_placement: None,
lanes: BTreeMap::new(),
@@ -1450,6 +1491,7 @@ impl Scene {
0,
Line {
id,
role: SceneHitRole::LifelineGuide,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
@@ -1459,6 +1501,11 @@ impl Scene {
}
scene.boxes.push(BoxItem {
id,
role: if scenario {
SceneHitRole::LifelineHeader
} else {
SceneHitRole::Box
},
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: if scenario {
format!("Lane · {name}")
@@ -1552,6 +1599,7 @@ impl Scene {
0,
BoxItem {
id: *id,
role: SceneHitRole::Box,
rect,
title: format!("{operator:?} · {}", e.name),
detail: String::new(),
@@ -1563,6 +1611,7 @@ impl Scene {
OccurrenceBody::Branch { guard, .. } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(36. + depth as f32 * 10., *y),
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
@@ -1646,6 +1695,7 @@ impl Scene {
}
self.lines.push(Line {
id: *id,
role: SceneHitRole::MessageBody,
points,
label: format!("{}. {kind}{}", index + 1, message_label),
label_at: Point::new(a.min(*b) + 12., *y - 26.),
@@ -1654,6 +1704,7 @@ impl Scene {
} else {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 65.),
@@ -1676,6 +1727,7 @@ impl Scene {
.unwrap_or(180.);
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
title: e.name.clone(),
detail: action
@@ -1688,6 +1740,7 @@ impl Scene {
OccurrenceBody::Control { kind } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
title: format!("{kind:?} · {}", e.name),
detail: "Contextual process control".into(),
@@ -1698,6 +1751,7 @@ impl Scene {
OccurrenceBody::SequenceReference { sequence } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
title: e.name.clone(),
detail: sequence
@@ -1710,6 +1764,7 @@ impl Scene {
OccurrenceBody::ScenarioUse { scenario, bindings } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 70.),
@@ -1775,11 +1830,21 @@ impl Scene {
))
}
fn hit(&self, point: Point) -> Option<Id> {
self.hit_item(point).map(|hit| hit.id)
}
/// Preserve the semantic selection order and tolerance while retaining the
/// primitive's identity. Future spacing admission must also validate the
/// saved view, owner and core occurrence kind; a hit alone grants no edit.
fn hit_item(&self, point: Point) -> Option<SceneHit> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
if let Some(id) = self.lifecycle.marker_at(point) {
return Some(id);
return Some(SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
});
}
self.boxes
.iter()
@@ -1789,7 +1854,10 @@ impl Scene {
&& b.rect.contains(point)
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
})
.map(|b| b.id)
.map(|b| SceneHit {
id: b.id,
role: b.role,
})
.or_else(|| {
self.lines
.iter()
@@ -1799,36 +1867,51 @@ impl Scene {
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
})
})
.map(|line| line.id)
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
})
.or_else(|| {
self.activations
.iter()
.rev()
.find(|b| b.rect.contains(point))
.map(|b| b.call)
.map(|b| SceneHit {
id: b.call,
role: SceneHitRole::Activation,
})
})
.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)
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.map(|line| SceneHit {
id: line.id,
role: if line.role == SceneHitRole::MessageBody {
SceneHitRole::MessageLabel
} else {
SceneHitRole::OtherLine
},
})
.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(|| {
self.lifecycle
.lane_at(point, self.scale)
.map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| {
self.lane_at(point).map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| {
self.lines
.iter()
@@ -1839,11 +1922,14 @@ impl Scene {
< 8. / self.scale
})
})
.map(|line| line.id)
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
})
}
fn near_sequence_item(&self, point: Point) -> Option<Id> {
fn near_sequence_item(&self, point: Point) -> Option<SceneHit> {
if !self.scale.is_finite() || self.scale <= 0. {
return None;
}
@@ -1857,17 +1943,32 @@ impl Scene {
.windows(2)
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
.fold(f32::INFINITY, f32::min);
(line.id, distance)
(
SceneHit {
id: line.id,
role: line.role,
},
distance,
)
});
// 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)
.map(|(id, distance)| {
(
SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
},
distance,
)
})
.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)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.id.cmp(&b.0.id)))
.map(|(hit, _)| hit)
}
}
impl canvas::Program<Message> for Scene {
@@ -1895,15 +1996,33 @@ impl canvas::Program<Message> for Scene {
// captured insertion slot belongs to the previous view, even if the
// pointer itself did not move and the semantic context is unchanged.
if state.bounds.is_some_and(|previous| previous != bounds) {
state.cancel();
let cancelled = state.cancel_spacing();
state.pointer = None;
state.bounds = Some(bounds);
if cancelled.is_some() {
return cancelled;
}
}
state.bounds = Some(bounds);
if !editable || !self.coordinate_input {
state.cancel();
if (!editable || !self.coordinate_input)
&& let Some(cancelled) = state.cancel_spacing()
{
return Some(cancelled);
}
// The viewport masks input outside its visible canvas bounds.
let visible = cursor.position().is_some();
if !visible
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
&& matches!(
event,
canvas::Event::Mouse(
mouse::Event::CursorMoved { .. }
| mouse::Event::ButtonReleased(mouse::Button::Left)
)
)
{
return state.cancel_spacing();
}
if !visible
&& matches!(
event,
@@ -1922,7 +2041,7 @@ impl canvas::Program<Message> for Scene {
| SequencePress::Reorder { start, moved, .. } => {
*moved |= position.distance(*start) >= 3.
}
SequencePress::Occurrence { .. } => {}
SequencePress::Occurrence { .. } | SequencePress::Spacing { .. } => {}
}
}
mouse::Cursor::Available(*position)
@@ -1931,9 +2050,9 @@ impl canvas::Program<Message> for Scene {
| canvas::Event::Window(iced::window::Event::Resized(_))
| canvas::Event::Mouse(mouse::Event::CursorLeft)
| canvas::Event::Window(iced::window::Event::Unfocused) => {
state.cancel();
let cancelled = state.cancel_spacing();
state.pointer = None;
return Some(Action::request_redraw());
return cancelled.or_else(|| Some(Action::request_redraw()));
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
@@ -1946,8 +2065,9 @@ impl canvas::Program<Message> for Scene {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) if state.source.is_some() || state.press.is_some() => {
state.cancel();
return Some(Action::request_redraw().and_capture());
return state
.cancel_spacing()
.or_else(|| Some(Action::request_redraw().and_capture()));
}
_ => cursor,
};
@@ -2001,6 +2121,48 @@ impl canvas::Program<Message> for Scene {
});
return Some(Action::request_redraw().and_capture());
}
if matches!(
context.representation,
scenario_layout::Representation::Saved { .. }
) {
let hit = self.hit_item(point)?;
let target = match hit.role {
SceneHitRole::LifelineHeader => {
Some(scenario::spacing::Target::Lane(hit.id))
}
SceneHitRole::MessageBody | SceneHitRole::MessageLabel => {
Some(scenario::spacing::Target::Message(hit.id))
}
_ => None,
};
if let Some((target, stamp)) = target.zip(self.spacing_stamp)
&& stamp.context == context
{
let token = Id::new_v4();
state.press = Some(SequencePress::Spacing { stamp, token });
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Begin {
stamp,
token,
target,
press_screen: cursor.position()?,
canvas_bounds: bounds,
scale: self.scale,
pan: self.camera_pan,
},
)))
.and_capture(),
);
}
// Saved spacing never silently changes the shared Scenario order.
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, hit.id,
)))
.and_capture(),
);
}
if let Some(occurrence) = self
.hit(point)
.filter(|id| self.rows.contains_key(id))
@@ -2030,6 +2192,22 @@ impl canvas::Program<Message> for Scene {
let press = state.press.take()?;
let context = context?;
match press {
SequencePress::Spacing { stamp, token } => {
state.source = None;
let event = if world.is_some() {
scenario::spacing::Event::Finish {
stamp,
token,
screen: cursor.position()?,
}
} else {
scenario::spacing::Event::Cancel { stamp, token }
};
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(event)))
.and_capture(),
);
}
SequencePress::Occurrence {
tool: captured,
slot,
@@ -2121,6 +2299,18 @@ impl canvas::Program<Message> for Scene {
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => {
if let Some(SequencePress::Spacing { stamp, token }) = state.press {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Move {
stamp,
token,
screen: cursor.position()?,
},
)))
.and_capture(),
);
}
Some(Action::request_redraw())
}
_ => None,
@@ -2323,6 +2513,10 @@ impl canvas::Program<Message> for Scene {
if editable
&& self.coordinate_input
&& tool == scenario::CanvasTool::Select
&& matches!(
context.representation,
scenario_layout::Representation::Derived { .. }
)
&& let Some(handle) = self.reorder_handle()
{
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);