feat: preview and commit Saved Scenario spacing gestures
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user