Enable direct and menu bend editing in State diagrams

This commit is contained in:
2026-09-22 21:01:35 -07:00
parent 53d01a3d44
commit 26979c1093
13 changed files with 1765 additions and 21 deletions
+253 -20
View File
@@ -44,6 +44,8 @@ pub struct State {
source: Option<Id>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend: Option<BendGesture>,
bend_preview: Option<EdgeRoute>,
pan_from: Option<(mouse::Button, Point)>,
viewport: Option<Size>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
@@ -84,6 +86,16 @@ enum Press {
placement: Id,
start: Point,
},
Bend {
relation: Id,
},
}
struct BendGesture {
start: Point,
route: EdgeRoute,
index: usize,
baseline: Arc<Scene>,
}
#[derive(Clone, Copy, PartialEq)]
@@ -103,6 +115,7 @@ struct CachedScene {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
scene: Arc<Scene>,
}
#[derive(PartialEq)]
@@ -110,6 +123,7 @@ struct PaintKey {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
selected: Id,
zoom: f32,
pan: Vector,
@@ -126,6 +140,8 @@ impl State {
self.press = None;
self.preview = None;
self.attachment_preview = None;
self.bend = None;
self.bend_preview = None;
self.attachment_viewport = None;
self.pan_from = None;
}
@@ -669,25 +685,31 @@ impl StateCanvas<'_> {
&& state_edit::validate_context(self.project, self.context, self.context).is_ok()
}
fn scene(&self, state: &State) -> Option<Arc<Scene>> {
self.scene_with_bounds(state, None)
}
fn scene_with_bounds(&self, state: &State, bounds: Option<Rectangle>) -> Option<Arc<Scene>> {
if !self.valid() {
return None;
}
let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds);
let preview = self
.gesture_active(state)
.then_some(state.preview)
.flatten();
let attachment_preview = self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten();
let attachment_preview = route_active.then_some(state.attachment_preview).flatten();
let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten();
if let Some(cached) = state.scene.borrow().as_ref()
&& cached.context == self.context
&& cached.preview == preview
&& cached.attachment_preview == attachment_preview
&& cached.bend_preview == bend_preview
{
return Some(cached.scene.clone());
}
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
if let Some(route) = &bend_preview {
crate::route_edit::with_route(&mut diagram, route.clone());
}
if let Some(preview) = attachment_preview {
let mut route = crate::route_edit::stored_route(&diagram, preview.relation);
route.set_attachment(preview.end, Some(preview.attachment));
@@ -717,7 +739,17 @@ impl StateCanvas<'_> {
}
}
let view = projection::project_diagram(self.project, &diagram);
let routed = routing::route_diagram(self.project, &diagram, &view);
let routed = if let Some(gesture) = state.bend.as_ref().filter(|_| bend_preview.is_some()) {
routing::route_edge_preview(
self.project,
&diagram,
&view,
&gesture.baseline.routed,
gesture.route.edge,
)
} else {
routing::route_diagram(self.project, &diagram, &view)
};
let symbol_labels = symbol_labels(self.project, &view, &routed);
let scene = Arc::new(Scene {
view,
@@ -728,6 +760,7 @@ impl StateCanvas<'_> {
context: self.context,
preview,
attachment_preview,
bend_preview,
scene: scene.clone(),
});
Some(scene)
@@ -859,6 +892,84 @@ impl StateCanvas<'_> {
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(endpoint, _)| (route.id, endpoint))
}
fn bend_at(&self, scene: &Scene, world: Point) -> Option<usize> {
let edge = scene.routed.edges.get(&self.selected)?;
if edge.custom_suspended {
return None;
}
edge.pins
.iter()
.enumerate()
.filter_map(|(index, pin)| {
let distance = world.distance(point(*pin));
(distance <= 8. / self.scale()).then_some((index, distance))
})
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
.map(|(index, _)| index)
}
/// Preserve the rendered traversal index; overlapping runs can have the
/// same world point but different positions among the authored pins.
fn segment_at(&self, scene: &Scene, relation: Id, world: Point) -> Option<(usize, Position)> {
let edge = scene.routed.edges.get(&relation)?;
if edge.custom_suspended {
return None;
}
edge.points
.windows(2)
.enumerate()
.filter(|(index, _)| *index > 0 && index + 2 < edge.points.len())
.filter_map(|(index, span)| {
let projection = routing::project_onto_polyline(span, position(world))?;
(projection.distance <= 7. / self.scale()).then_some((index, projection.distance))
})
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
.map(|(index, _)| (index, position(world)))
}
fn begin_bend(
&self,
state: &mut State,
scene: Arc<Scene>,
window: Point,
route: EdgeRoute,
index: usize,
bounds: Rectangle,
) {
state.press = Some(Press::Bend {
relation: route.edge,
});
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
state.bend = Some(BendGesture {
start: window,
route,
index,
baseline: scene,
});
state.bend_preview = None;
}
fn move_bend_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(gesture) = &state.bend else {
return;
};
if window.distance(gesture.start) < 3. {
// Also discard a provisional insertion. The exact original route
// comes from the model, so an off-grid pin is never quantized here.
state.bend_preview = None;
return;
}
let world = self.world(local);
if !world.x.is_finite() || !world.y.is_finite() {
state.cancel_gesture();
return;
}
let mut changed = gesture.route.clone();
changed.waypoints[gesture.index] = Position {
x: (world.x / 5.).round() * 5.,
y: (world.y / 5.).round() * 5.,
};
let original =
crate::route_edit::stored_route(&self.project.diagrams[&self.diagram], changed.edge);
state.bend_preview = (changed != original).then_some(changed);
}
fn owner_at(&self, scene: &Scene, world: Point, kind: Kind) -> Option<Id> {
self.node_at(scene, world)
.filter(|n| state_edit::eligible_owner(self.project, self.diagram, n.element, kind))
@@ -887,7 +998,22 @@ impl StateCanvas<'_> {
}
}
fn gesture_active(&self, state: &State) -> bool {
self.editable && state.context == Some(self.context) && state.tool == Some(self.tool)
self.editable
&& state.context == Some(self.context)
&& state.tool == Some(self.tool)
&& state
.bend
.as_ref()
.is_none_or(|g| g.route.edge == self.selected)
}
fn route_viewport_current(&self, state: &State, bounds: Option<Rectangle>) -> bool {
state
.attachment_viewport
.is_none_or(|(zoom, pan, captured)| {
zoom == self.zoom
&& pan == self.pan
&& bounds.is_none_or(|current| current == captured)
})
}
fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(Press::Attachment {
@@ -941,6 +1067,23 @@ impl StateCanvas<'_> {
if !self.editable {
return "Pending edits · apply or cancel them before editing the diagram".into();
}
if state.bend.is_some()
&& self.gesture_active(state)
&& self.route_viewport_current(state, None)
{
return "Move bend · release to keep · Esc cancels".into();
}
if self.tool == Tool::Select
&& state.press.is_none()
&& scene
.routed
.edges
.get(&self.selected)
.is_some_and(|edge| !edge.custom_suspended)
{
return "Drag line to add a bend · squares move bends · right-click a square to remove"
.into();
}
if let Tool::AdjustAttachment { relation, end } = self.tool {
return format!(
"Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged",
@@ -1035,11 +1178,16 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.tool = Some(self.tool);
}
if !self.editable {
state.press = None;
state.preview = None;
state.attachment_preview = None;
state.cancel_gesture();
state.source = None;
}
if state
.bend
.as_ref()
.is_some_and(|g| g.route.edge != self.selected)
{
state.cancel_gesture();
}
if state
.attachment_viewport
.is_some_and(|view| view != (self.zoom, self.pan, bounds))
@@ -1055,6 +1203,9 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
canvas::Event::Mouse(mouse::Event::CursorLeft) => {
state.pointer = None;
if state.bend.is_some() {
state.cancel_gesture();
}
cursor
}
canvas::Event::Mouse(
@@ -1150,10 +1301,23 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
if *button == mouse::Button::Right {
state.reset();
let (object, placement) = match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
// A canceled provisional pin shifts indices in its preview.
// Menu commands must target the committed route instead.
let scene = self.scene(state)?;
let bend = self.bend_at(&scene, world);
let (object, placement) = if bend.is_some() {
(self.selected, None)
} else {
match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
}
};
let route_segment = if bend.is_none() {
self.segment_at(&scene, object, world)
} else {
None
};
return Some(
Action::publish(Event::ContextMenu {
@@ -1161,6 +1325,8 @@ impl canvas::Program<Event> for StateCanvas<'_> {
object,
placement,
anchor: window,
bend,
route_segment,
})
.and_capture(),
);
@@ -1178,6 +1344,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
});
return Some(Action::capture());
}
if let Some(index) = self.bend_at(&scene, world) {
let route = crate::route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
);
self.begin_bend(state, scene.clone(), window, route, index, bounds);
return Some(Action::capture());
}
match self.hit(&scene, world) {
Some(Hit::Node { placement, element }) => {
let original = self
@@ -1198,6 +1372,36 @@ impl canvas::Program<Event> for StateCanvas<'_> {
)
}
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
if id == self.selected
&& let Some(edge) = scene.routed.edges.get(&id)
&& !Rectangle::new(
point(edge.label.bounds.position),
Size::new(
edge.label.bounds.size.width,
edge.label.bounds.size.height,
),
)
.contains(world)
&& let Some((segment, at)) = self.segment_at(&scene, id, world)
{
let mut route = crate::route_edit::stored_route(
&self.project.diagrams[&self.diagram],
id,
);
if let Some(index) = crate::route_edit::insert_pin_on_segment(
&mut route, edge, segment, at,
) {
self.begin_bend(
state,
scene.clone(),
window,
route,
index,
bounds,
);
return Some(Action::capture());
}
}
Some(Action::publish(Event::Select(self.context, id)).and_capture())
}
None => {
@@ -1275,6 +1479,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
self.move_bend_pointer(state, window, local);
}
Some(Action::request_redraw())
}
@@ -1298,14 +1503,24 @@ impl canvas::Program<Event> for StateCanvas<'_> {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
self.move_bend_pointer(state, window, local);
}
}
let press = state.press.take()?;
state.attachment_viewport = None;
let preview = state.preview.take();
let attachment_preview = state.attachment_preview.take();
let bend_preview = state.bend_preview.take();
state.bend = None;
let world = local.map(|p| self.world(p));
let event = match press {
Press::Bend { relation } if inside => bend_preview
.filter(|route| route.edge == relation)
.map(|route| Event::SetBends {
context: self.context,
relation,
waypoints: route.waypoints,
}),
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
context: self.context,
node,
@@ -1416,19 +1631,19 @@ impl canvas::Program<Event> for StateCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let Some(scene) = self.scene(state) else {
let Some(scene) = self.scene_with_bounds(state, Some(bounds)) else {
return vec![];
};
let route_active =
self.gesture_active(state) && self.route_viewport_current(state, Some(bounds));
let key = PaintKey {
context: self.context,
preview: self
.gesture_active(state)
.then_some(state.preview)
.flatten(),
attachment_preview: self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten(),
attachment_preview: route_active.then_some(state.attachment_preview).flatten(),
bend_preview: route_active.then(|| state.bend_preview.clone()).flatten(),
selected: self.selected,
zoom: self.scale(),
pan: self.pan,
@@ -1508,6 +1723,22 @@ impl canvas::Program<Event> for StateCanvas<'_> {
style::MUTED,
);
if active {
if !edge.custom_suspended && self.tool == Tool::Select {
for pin in &edge.pins {
let radius = 4. / self.scale();
let handle = Path::rectangle(
point(*pin) - Vector::new(radius, radius),
Size::new(radius * 2., radius * 2.),
);
frame.fill(&handle, Color::WHITE);
frame.stroke(
&handle,
Stroke::default()
.with_color(style::TEAL)
.with_width(1.6 / self.scale()),
);
}
}
for end in [edge.points.first(), edge.points.last()]
.into_iter()
.flatten()
@@ -1922,6 +2153,8 @@ mod tests {
use super::*;
use crate::document::Document;
include!("state_bend_tests.rs");
struct Fixture {
project: Project,
diagram: Id,
@@ -2377,7 +2610,7 @@ mod tests {
motion(&canvas, &mut state, at, at);
assert!(matches!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: Some(node), anchor })
Some(Event::ContextMenu { context, object, placement: Some(node), anchor, .. })
if context == canvas.context && object == f.b
&& node == placement(&f, f.b) && anchor == at));
let background = Point::new(
@@ -2387,7 +2620,7 @@ mod tests {
motion(&canvas, &mut state, background, background);
assert!(matches!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Right), background),
Some(Event::ContextMenu { context, object, placement: None, anchor })
Some(Event::ContextMenu { context, object, placement: None, anchor, .. })
if context == canvas.context && object == f.diagram && anchor == background));
}