Enable direct and menu bend editing in State diagrams
This commit is contained in:
@@ -54,6 +54,8 @@ async fn armed() -> (App, Mounted, Id) {
|
||||
object: relation,
|
||||
placement: None,
|
||||
anchor: iced::Point::new(450., 400.),
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
},
|
||||
);
|
||||
mounted.settle(&mut app).await;
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
|
||||
let (mut app, _, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let next = app.project().relations[&relation].target();
|
||||
let waypoints = vec![Position { x: 715., y: -425. }];
|
||||
let delayed = Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: waypoints.clone(),
|
||||
};
|
||||
send(&mut app, Message::Select(next));
|
||||
assert_eq!(app.selected, next);
|
||||
assert_eq!(
|
||||
app.state_context(),
|
||||
Some(context),
|
||||
"same-view selection does not retire the context stamp"
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let camera = (
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
);
|
||||
event(&mut app, delayed);
|
||||
assert_eq!(app.selected, next);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit
|
||||
),
|
||||
camera
|
||||
);
|
||||
send(&mut app, Message::Select(relation));
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: waypoints.clone(),
|
||||
},
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let route = expected
|
||||
.diagrams
|
||||
.get_mut(&context.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == relation)
|
||||
.unwrap();
|
||||
route.waypoints = waypoints;
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(&app);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"live drag remains a single transaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
|
||||
let (mut app, _, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let point = Position { x: 500., y: -400. };
|
||||
event(
|
||||
&mut app,
|
||||
Event::ContextMenu {
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
anchor: iced::Point::ORIGIN,
|
||||
bend: Some(1),
|
||||
route_segment: Some((2, point)),
|
||||
},
|
||||
);
|
||||
let menu = crate::model_menu::popup(&app).unwrap();
|
||||
let mut add = false;
|
||||
let mut remove = false;
|
||||
let mut delete = false;
|
||||
for entry in menu.entries {
|
||||
if let crate::context_menu::Entry::Action {
|
||||
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
match *message {
|
||||
Message::StateDiagram(Event::AddBend {
|
||||
relation: id,
|
||||
segment: Some((2, at)),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(id, relation);
|
||||
assert_eq!(at, point);
|
||||
add = true;
|
||||
}
|
||||
Message::StateDiagram(Event::RemoveBend {
|
||||
relation: id,
|
||||
index: 1,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(id, relation);
|
||||
remove = true;
|
||||
}
|
||||
Message::Delete => delete = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(add && remove && delete);
|
||||
}
|
||||
|
||||
fn setup() -> (App, Id, Id, Id) {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut project = f.app.project().clone();
|
||||
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
|
||||
let second = state_edit::create_view(&project, f.machine).unwrap();
|
||||
project = prepare_command(&project, &second.command).unwrap().project;
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: relation,
|
||||
waypoints: vec![
|
||||
Position { x: 650., y: -400. },
|
||||
Position { x: 300., y: -400. },
|
||||
Position { x: 650., y: -400. },
|
||||
],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: Position { x: 9., y: -23. },
|
||||
}),
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.234567,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.75,
|
||||
}),
|
||||
});
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
(app, diagram, relation, f.a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
|
||||
let (mut app, diagram, relation, _) = setup();
|
||||
let baseline = app.document.snapshot();
|
||||
let points = vec![Position { x: 715., y: 425. }];
|
||||
let mut expected = app.project().clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: points.clone(),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(&app);
|
||||
let changed = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: points,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&changed, &app.document.snapshot()),
|
||||
"equal bends must not add history"
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &changed.bundle);
|
||||
let context = app.state_context().unwrap();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.clear();
|
||||
assert_eq!(app.project(), &expected);
|
||||
let cleared = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
|
||||
let (app, diagram, relation, _) = setup();
|
||||
let mut project = app.project().clone();
|
||||
let RelationBody::StateTransition { source, target, .. } =
|
||||
&mut project.relations.get_mut(&relation).unwrap().body
|
||||
else {
|
||||
panic!("State transition fixture")
|
||||
};
|
||||
*target = *source;
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.clear();
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
let baseline = app.document.snapshot();
|
||||
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
|
||||
assert!(!geometry.custom_suspended);
|
||||
assert!(geometry.pins.is_empty());
|
||||
assert!(
|
||||
geometry.points.len() >= 4,
|
||||
"actual external self-loop routing"
|
||||
);
|
||||
let segment = 1;
|
||||
let point = Position {
|
||||
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
|
||||
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
|
||||
};
|
||||
|
||||
// Each operation must alter only the complete Project's waypoint list;
|
||||
// Undo and Redo must restore exact serialized source, not just geometry.
|
||||
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
|
||||
let before = app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
|
||||
event(app, mutation);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(app);
|
||||
let after = app.document.snapshot();
|
||||
assert_ne!(after.bundle, before.bundle);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
send(app, Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((segment, point)),
|
||||
},
|
||||
vec![point],
|
||||
);
|
||||
let moved = Position {
|
||||
x: point.x + 45.,
|
||||
y: point.y - 35.,
|
||||
};
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![moved],
|
||||
},
|
||||
vec![moved],
|
||||
);
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: 0,
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
send(&mut app, Message::Undo);
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::ClearBends { context, relation },
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
|
||||
let (app, diagram, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let p = app.project();
|
||||
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
|
||||
let original = &p.diagrams[&diagram].routes[0];
|
||||
let reverse = geometry
|
||||
.points
|
||||
.windows(2)
|
||||
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
|
||||
.unwrap();
|
||||
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
|
||||
let point = Position { x: 500., y: -400. };
|
||||
let planned = state_edit::plan(
|
||||
p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((segment, point)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let actual = prepare_command(p, &planned.command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.insert(expected_index, point);
|
||||
assert_eq!(actual, expected);
|
||||
let removed = state_edit::plan(
|
||||
&actual,
|
||||
context,
|
||||
&Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: expected_index,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepare_command(&actual, &removed.command).unwrap().project,
|
||||
*p
|
||||
);
|
||||
}
|
||||
let default = state_edit::plan(
|
||||
p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let Command::SetEdgeRoute { route, .. } = default.command else {
|
||||
panic!("route command")
|
||||
};
|
||||
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
|
||||
assert_eq!(route.source_attachment, original.source_attachment);
|
||||
assert_eq!(route.target_attachment, original.target_attachment);
|
||||
assert_eq!(route.label, original.label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
|
||||
let (mut app, diagram, relation, composite) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let baseline = app.document.snapshot();
|
||||
for mutation in [
|
||||
Event::SetBends {
|
||||
context: Context {
|
||||
epoch: context.epoch + 1,
|
||||
..context
|
||||
},
|
||||
relation,
|
||||
waypoints: vec![],
|
||||
},
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![Position { x: f32::NAN, y: 0. }],
|
||||
},
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![Position { x: 0., y: 0. }; 129],
|
||||
},
|
||||
Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
|
||||
},
|
||||
Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: usize::MAX,
|
||||
},
|
||||
] {
|
||||
event(&mut app, mutation);
|
||||
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
}
|
||||
let mut p = app.project().clone();
|
||||
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
|
||||
vec![Position { x: 500., y: -400. }; 128];
|
||||
assert!(
|
||||
state_edit::plan(
|
||||
&p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((1, Position { x: 500., y: -400. }))
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut hidden = app.project().clone();
|
||||
hidden
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(relation);
|
||||
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
|
||||
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
|
||||
let owner = node(&app, diagram, composite);
|
||||
let mut folded = app.project().clone();
|
||||
folded
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
|
||||
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
|
||||
let mut internal = app.project().clone();
|
||||
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
|
||||
if let RelationBody::StateTransition {
|
||||
source,
|
||||
target,
|
||||
transition_kind,
|
||||
..
|
||||
} = &mut internal.relations.get_mut(&relation).unwrap().body
|
||||
{
|
||||
*target = *source;
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied bend owner name".into()),
|
||||
);
|
||||
let draft = app.draft.as_ref().unwrap().value.clone();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
assert_eq!(app.draft.as_ref().unwrap().value, draft);
|
||||
}
|
||||
@@ -157,10 +157,14 @@ impl App {
|
||||
object,
|
||||
placement,
|
||||
anchor,
|
||||
bend,
|
||||
route_segment,
|
||||
} => {
|
||||
let mut request = crate::model_menu::Request::object(object, anchor);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
request.bend = bend;
|
||||
request.route_segment = route_segment;
|
||||
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
}
|
||||
Event::EditTransition(_, id) => {
|
||||
@@ -228,6 +232,11 @@ impl App {
|
||||
let Some(context) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
if let Event::SetBends { relation, .. } = &mutation
|
||||
&& *relation != self.selected
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Event::SetAttachment {
|
||||
relation,
|
||||
end,
|
||||
@@ -246,6 +255,14 @@ impl App {
|
||||
}
|
||||
match state_edit::plan(self.project(), context, &mutation) {
|
||||
Ok(plan) => {
|
||||
if let Command::SetEdgeRoute { diagram, route } = &plan.command
|
||||
&& crate::route_edit::stored_route(
|
||||
&self.project().diagrams[diagram],
|
||||
route.edge,
|
||||
) == *route
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.execute(plan.command) {
|
||||
self.behavior.diagram.diagram = Some(plan.diagram);
|
||||
self.behavior.diagram.placement = plan.placement;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
use super::*;
|
||||
#[path = "state_attachment_escape_tests.rs"]
|
||||
mod attachment_escape;
|
||||
#[path = "state_bend_tests.rs"]
|
||||
mod bends;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
// Included in state_canvas::tests: exercise the real canvas event path.
|
||||
|
||||
fn bend_fixture(authored: bool) -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let relation = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Standby to stopped",
|
||||
f.b,
|
||||
f.c,
|
||||
);
|
||||
if authored {
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: relation,
|
||||
waypoints: vec![Position {
|
||||
x: 650.25,
|
||||
y: 340.75,
|
||||
}],
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.5,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.43,
|
||||
offset: Position { x: 28., y: -16. },
|
||||
}),
|
||||
});
|
||||
}
|
||||
validate_candidate(&f.project).unwrap();
|
||||
(f, relation)
|
||||
}
|
||||
|
||||
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
|
||||
let edge = &scene.routed.edges[&relation];
|
||||
edge.points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
|
||||
.map(|(i, p)| {
|
||||
(
|
||||
i,
|
||||
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
|
||||
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
|
||||
)
|
||||
})
|
||||
.filter(|(_, p, length)| {
|
||||
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
|
||||
})
|
||||
.max_by(|a, b| a.2.total_cmp(&b.2))
|
||||
.map(|(i, p, _)| (i, p))
|
||||
.expect("fixture has an exposed interior route segment")
|
||||
}
|
||||
|
||||
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
|
||||
motion(canvas, state, at, at);
|
||||
let event = input(
|
||||
canvas,
|
||||
state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
at,
|
||||
);
|
||||
assert!(
|
||||
!matches!(event, Some(Event::SetBends { .. })),
|
||||
"press alone cannot commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let (f, relation) = bend_fixture(false);
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let (_, midpoint) = bend_segment(&baseline, relation);
|
||||
let start = screen(&canvas, midpoint);
|
||||
let destination = Position {
|
||||
x: ((midpoint.x + 45.) / 5.).round() * 5.,
|
||||
y: ((midpoint.y + 30.) / 5.).round() * 5.,
|
||||
};
|
||||
let release = screen(&canvas, point(destination));
|
||||
let later_batch_cursor = release + Vector::new(137., 83.);
|
||||
motion(&canvas, &mut state, start, later_batch_cursor);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
later_batch_cursor,
|
||||
);
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
motion(&canvas, &mut state, release, later_batch_cursor);
|
||||
assert_eq!(
|
||||
state.bend_preview.as_ref().unwrap().waypoints,
|
||||
vec![destination]
|
||||
);
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
|
||||
vec![destination]
|
||||
);
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
later_batch_cursor,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&event, Event::SetBends { context, relation: id, waypoints }
|
||||
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
|
||||
);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
later_batch_cursor
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let after = prepare_command(
|
||||
&f.project,
|
||||
&state_edit::plan(&f.project, canvas.context, &event)
|
||||
.unwrap()
|
||||
.command,
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let stored = &after.diagrams[&f.diagram].routes[0];
|
||||
assert_eq!(stored.waypoints, vec![destination]);
|
||||
assert!(
|
||||
stored.source_attachment.is_none()
|
||||
&& stored.target_attachment.is_none()
|
||||
&& stored.label.is_none()
|
||||
);
|
||||
assert_eq!(after.relations, f.project.relations);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let (mut f, relation) = bend_fixture(true);
|
||||
let mut other = f.project.diagrams[&f.diagram].clone();
|
||||
other.id = Id::new_v4();
|
||||
other.alias = "bend_reference_view".into();
|
||||
other.nodes = other
|
||||
.nodes
|
||||
.into_values()
|
||||
.map(|mut node| {
|
||||
node.id = Id::new_v4();
|
||||
node.alias = format!("bend_reference_{}", node.alias);
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
let other_id = other.id;
|
||||
f.project.diagrams.insert(other_id, other.clone());
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
let destination = Position { x: 690., y: 365. };
|
||||
let release = screen(&canvas, point(destination));
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let preview = state.bend_preview.as_ref().unwrap();
|
||||
let mut intended = original.clone();
|
||||
intended.waypoints[0] = destination;
|
||||
assert_eq!(preview, &intended);
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes[0], original,
|
||||
"preview is transient"
|
||||
);
|
||||
let preview_scene = canvas.scene(&state).unwrap();
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
|
||||
);
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
|
||||
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
let before = document.bundle().clone();
|
||||
document.execute(plan.command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.project().diagrams[&other_id], other);
|
||||
let committed_view = projection::project_view(document.project(), f.diagram);
|
||||
let committed = routing::route_diagram(
|
||||
document.project(),
|
||||
&document.project().diagrams[&f.diagram],
|
||||
&committed_view,
|
||||
);
|
||||
let preview_edge = &preview_scene.routed.edges[&relation];
|
||||
let committed_edge = &committed.edges[&relation];
|
||||
assert_eq!(preview_edge.points, committed_edge.points);
|
||||
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
|
||||
assert_eq!(preview_edge.warnings, committed_edge.warnings);
|
||||
let edited = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &f.project);
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert!(!document.undo(), "one drag adds one history operation");
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &edited);
|
||||
assert!(!document.redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
|
||||
for authored in [false, true] {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for travel in [false, true] {
|
||||
let (f, relation) = bend_fixture(authored);
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let world = if authored {
|
||||
point(scene.routed.edges[&relation].pins[0])
|
||||
} else {
|
||||
bend_segment(&scene, relation).1
|
||||
};
|
||||
let start = screen(&canvas, world);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
if travel {
|
||||
let away = start + Vector::new(45., 30.) * zoom;
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.bend_preview.is_some());
|
||||
motion(&canvas, &mut state, start, start);
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
start
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
scene.routed.edges[&relation].points
|
||||
);
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes.len(),
|
||||
usize::from(authored)
|
||||
);
|
||||
if authored {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes[0].waypoints,
|
||||
vec![Position {
|
||||
x: 650.25,
|
||||
y: 340.75
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
|
||||
for cancellation in 0..16 {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
let release = start + Vector::new(45., 30.);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
assert!(state.bend_preview.is_some(), "case {cancellation}");
|
||||
let mut release_bounds = bounds();
|
||||
match cancellation {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.x += 10.,
|
||||
2 => canvas.context.epoch += 1,
|
||||
3 => canvas.context.revision += 1,
|
||||
4 => canvas.context.document = Id::new_v4(),
|
||||
5 => canvas.tool = Tool::Transition,
|
||||
6 => canvas.editable = false,
|
||||
7 | 8 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(if cancellation == 7 {
|
||||
mouse::Button::Right
|
||||
} else {
|
||||
mouse::Button::Middle
|
||||
}),
|
||||
release,
|
||||
);
|
||||
}
|
||||
9 => {
|
||||
canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(release),
|
||||
);
|
||||
}
|
||||
10 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
},
|
||||
release,
|
||||
);
|
||||
}
|
||||
11 => {
|
||||
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
|
||||
}
|
||||
12 => {
|
||||
motion(&canvas, &mut state, Point::new(-100., -100.), release);
|
||||
}
|
||||
13 => release_bounds.x += 25.,
|
||||
15 => canvas.selected = f.b,
|
||||
_ => {
|
||||
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let event = canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&escape,
|
||||
bounds(),
|
||||
mouse::Cursor::Available(release),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0);
|
||||
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
|
||||
}
|
||||
}
|
||||
let event = input_at_bounds(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
release_bounds,
|
||||
);
|
||||
assert!(
|
||||
!matches!(event, Some(Event::SetBends { .. })),
|
||||
"late release committed case {cancellation}"
|
||||
);
|
||||
assert!(
|
||||
state.bend_preview.is_none(),
|
||||
"stale preview survived case {cancellation}"
|
||||
);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
|
||||
motion(&canvas, &mut state, pin, pin);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None })
|
||||
if context == canvas.context && object == relation && anchor == pin)
|
||||
);
|
||||
let (index, midpoint) = bend_segment(&scene, relation);
|
||||
let at = screen(&canvas, midpoint);
|
||||
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: None, anchor, bend: None, route_segment: Some((segment, world)) })
|
||||
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
|
||||
for internal in [false, true] {
|
||||
let mut f = fixture();
|
||||
let relation = if internal {
|
||||
let id = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
id
|
||||
} else {
|
||||
let id = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Hidden child to standby",
|
||||
f.leaf,
|
||||
f.b,
|
||||
);
|
||||
let parent = placement(&f, f.a);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
id
|
||||
};
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let world = if internal {
|
||||
assert!(!scene.routed.edges.contains_key(&relation));
|
||||
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
|
||||
internal_rows(&f.project, node)
|
||||
.into_iter()
|
||||
.find(|(r, _)| r.id == relation)
|
||||
.unwrap()
|
||||
.1
|
||||
.center()
|
||||
} else {
|
||||
assert!(scene.routed.edges[&relation].custom_suspended);
|
||||
bend_segment(&scene, relation).1
|
||||
};
|
||||
let start = screen(&canvas, world);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(matches!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
start
|
||||
),
|
||||
Some(Event::ContextMenu {
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
bend_press(&canvas, &mut state, start);
|
||||
let away = start + Vector::new(30., 20.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert!(!matches!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
away
|
||||
),
|
||||
Some(Event::SetBends { .. })
|
||||
));
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
|
||||
for click_pin in [true, false] {
|
||||
let (mut f, relation) = bend_fixture(true);
|
||||
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
|
||||
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let edge = &baseline.routed.edges[&relation];
|
||||
let candidates: Vec<_> = edge.points.windows(2).enumerate()
|
||||
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
|
||||
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
|
||||
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
|
||||
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
|
||||
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
|
||||
.collect();
|
||||
let (_, insertion_at) = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
|
||||
.expect("exposed segment before both authored pins");
|
||||
let menu_segment = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(_, p)| p.distance(insertion_at) > 30.)
|
||||
.expect("a distinct committed segment for the menu control");
|
||||
let start = screen(&canvas, insertion_at);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
let away = start + Vector::new(45., 30.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
let preview = state.bend_preview.as_ref().unwrap();
|
||||
assert_eq!(preview.waypoints.len(), 3);
|
||||
assert_eq!(
|
||||
&preview.waypoints[1..],
|
||||
original.waypoints.as_slice(),
|
||||
"provisional insertion shifts both stored indices"
|
||||
);
|
||||
|
||||
// With the left gesture still active, moving to the last stored pin also
|
||||
// moves provisional pin0 there. The preview's first hit is therefore
|
||||
// pin0, but a menu built after cancellation must identify stored pin1.
|
||||
let menu_world = if click_pin {
|
||||
point(original.waypoints[1])
|
||||
} else {
|
||||
menu_segment.1
|
||||
};
|
||||
let menu_window = screen(&canvas, menu_world);
|
||||
motion(&canvas, &mut state, menu_window, menu_window);
|
||||
assert!(state.bend_preview.is_some());
|
||||
let menu = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
menu_window,
|
||||
)
|
||||
.unwrap();
|
||||
let Event::ContextMenu {
|
||||
context,
|
||||
object,
|
||||
bend,
|
||||
route_segment,
|
||||
..
|
||||
} = menu
|
||||
else {
|
||||
panic!("expected context menu after cancelling drag")
|
||||
};
|
||||
assert_eq!(context, canvas.context);
|
||||
assert_eq!(object, relation);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
edge.points
|
||||
);
|
||||
if click_pin {
|
||||
assert_eq!(
|
||||
bend,
|
||||
Some(1),
|
||||
"menu index must address the stored route, not the provisional route"
|
||||
);
|
||||
assert!(route_segment.is_none());
|
||||
let remove = Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: bend.unwrap(),
|
||||
};
|
||||
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
|
||||
let after = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.remove(1);
|
||||
assert_eq!(
|
||||
after, expected,
|
||||
"remove menu action must delete exactly the addressed original pin"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
bend.is_none(),
|
||||
"provisional pin under cursor must not leak into menu"
|
||||
);
|
||||
let (segment, at) = route_segment.expect("committed route segment remains actionable");
|
||||
assert_eq!(segment, menu_segment.0);
|
||||
assert!(point(at).distance(menu_world) < 0.001);
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
menu_window
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
|
||||
for change in 0..4 {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
|
||||
let release = start + Vector::new(45., 30.);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
assert_ne!(
|
||||
displayed_preview.routed.edges[&relation].pins,
|
||||
baseline.routed.edges[&relation].pins
|
||||
);
|
||||
let mut changed_bounds = bounds();
|
||||
match change {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.y += 25.,
|
||||
2 => changed_bounds.x += 35.,
|
||||
_ => changed_bounds.width -= 90.,
|
||||
}
|
||||
// No update event between camera/layout change and this draw-time read.
|
||||
let shown = canvas
|
||||
.scene_with_bounds(&state, Some(changed_bounds))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
|
||||
"stale draw-time pins for case {change}"
|
||||
);
|
||||
assert_eq!(
|
||||
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
|
||||
"stale cached preview for case {change}"
|
||||
);
|
||||
let event = input_at_bounds(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
changed_bounds,
|
||||
);
|
||||
assert!(!matches!(event, Some(Event::SetBends { .. })));
|
||||
assert!(state.bend_preview.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum Event {
|
||||
object: Id,
|
||||
placement: Option<Id>,
|
||||
anchor: iced::Point,
|
||||
bend: Option<usize>,
|
||||
route_segment: Option<(usize, Position)>,
|
||||
},
|
||||
CreateAt {
|
||||
context: Context,
|
||||
@@ -74,6 +76,25 @@ pub enum Event {
|
||||
placement: Id,
|
||||
attachment: Option<EdgeAttachment>,
|
||||
},
|
||||
SetBends {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
waypoints: Vec<Position>,
|
||||
},
|
||||
AddBend {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
segment: Option<(usize, Position)>,
|
||||
},
|
||||
RemoveBend {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
index: usize,
|
||||
},
|
||||
ClearBends {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
@@ -93,7 +114,11 @@ impl Event {
|
||||
| Self::Move { context: c, .. }
|
||||
| Self::Connect { context: c, .. }
|
||||
| Self::Reconnect { context: c, .. }
|
||||
| Self::SetAttachment { context: c, .. } => Some(*c),
|
||||
| Self::SetAttachment { context: c, .. }
|
||||
| Self::SetBends { context: c, .. }
|
||||
| Self::AddBend { context: c, .. }
|
||||
| Self::RemoveBend { context: c, .. }
|
||||
| Self::ClearBends { context: c, .. } => Some(*c),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,6 +313,24 @@ pub fn attachment_node(
|
||||
view.nodes.iter().find(|node| node.id == owner).cloned()
|
||||
}
|
||||
|
||||
/// Actual visible State transition geometry, never an internal row or folded proxy.
|
||||
pub fn bend_route(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
relation: Id,
|
||||
) -> Option<oa_core::routing::RoutedEdge> {
|
||||
machine(project, diagram).ok()?;
|
||||
if project.relations.get(&relation)?.kind() != RelationKind::StateTransition {
|
||||
return None;
|
||||
}
|
||||
let stored = project.diagrams.get(&diagram)?;
|
||||
let view = projection::project_diagram(project, stored);
|
||||
let geometry = oa_core::routing::route_diagram(project, stored, &view)
|
||||
.edges
|
||||
.remove(&relation)?;
|
||||
(!geometry.custom_suspended).then_some(geometry)
|
||||
}
|
||||
|
||||
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
|
||||
prepare_command(project, &plan.command)?;
|
||||
Ok(plan)
|
||||
@@ -547,6 +590,66 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
|
||||
route.set_attachment(end, attachment);
|
||||
(Command::SetEdgeRoute { diagram, route }, relation, None)
|
||||
}
|
||||
Event::SetBends { relation, .. }
|
||||
| Event::AddBend { relation, .. }
|
||||
| Event::RemoveBend { relation, .. }
|
||||
| Event::ClearBends { relation, .. } => {
|
||||
let geometry = bend_route(project, diagram, relation)
|
||||
.ok_or_else(|| error("Choose an actual visible transition route in this view."))?;
|
||||
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
|
||||
match event {
|
||||
Event::SetBends { waypoints, .. } => {
|
||||
if waypoints.len() > 128 {
|
||||
return Err(error("A route supports at most 128 bends."));
|
||||
}
|
||||
for &point in waypoints {
|
||||
finite(point)?;
|
||||
}
|
||||
route.waypoints = waypoints.clone();
|
||||
}
|
||||
Event::AddBend { segment, .. } => {
|
||||
let (segment, point) = segment
|
||||
.or_else(|| {
|
||||
geometry
|
||||
.points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(index, _)| {
|
||||
*index > 0 && index + 2 < geometry.points.len()
|
||||
})
|
||||
.max_by(|(_, a), (_, b)| {
|
||||
let length = |s: &[Position]| {
|
||||
(s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs()
|
||||
};
|
||||
length(a).total_cmp(&length(b))
|
||||
})
|
||||
.map(|(index, span)| {
|
||||
(
|
||||
index,
|
||||
Position {
|
||||
x: (span[0].x + span[1].x) / 2.,
|
||||
y: (span[0].y + span[1].y) / 2.,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| error("This route has no interior segment for a bend."))?;
|
||||
crate::route_edit::insert_pin_on_segment(&mut route, &geometry, segment, point)
|
||||
.ok_or_else(|| {
|
||||
error("The bend segment is stale, invalid, or at capacity.")
|
||||
})?;
|
||||
}
|
||||
Event::RemoveBend { index, .. } => {
|
||||
if *index >= route.waypoints.len() {
|
||||
return Err(error("This bend no longer exists."));
|
||||
}
|
||||
route.waypoints.remove(*index);
|
||||
}
|
||||
Event::ClearBends { .. } => route.waypoints.clear(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
(Command::SetEdgeRoute { diagram, route }, relation, None)
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"Handle this navigation or tool request without a model command.",
|
||||
|
||||
@@ -161,6 +161,53 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
);
|
||||
entries.push(item("Edit transition", edit, true));
|
||||
if let Some(context) = context {
|
||||
let bend_geometry = state_edit::bend_route(project, context.diagram, id);
|
||||
let bend_count = bend_geometry.as_ref().map_or(0, |route| route.pins.len());
|
||||
let can_add = bend_geometry.as_ref().is_some_and(|route| {
|
||||
bend_count < 128
|
||||
&& request.route_segment.map_or_else(
|
||||
|| route.points.len() >= 4,
|
||||
|(index, point)| {
|
||||
index
|
||||
.checked_add(2)
|
||||
.is_some_and(|end| end <= route.points.len())
|
||||
&& point.x.is_finite()
|
||||
&& point.y.is_finite()
|
||||
},
|
||||
)
|
||||
});
|
||||
entries.push(item(
|
||||
if request.route_segment.is_some() {
|
||||
"Add bend here"
|
||||
} else {
|
||||
"Add bend"
|
||||
},
|
||||
diagram_event(Event::AddBend {
|
||||
context,
|
||||
relation: id,
|
||||
segment: request.route_segment,
|
||||
}),
|
||||
can_add,
|
||||
));
|
||||
if let Some(index) = request.bend {
|
||||
entries.push(item(
|
||||
"Remove bend",
|
||||
diagram_event(Event::RemoveBend {
|
||||
context,
|
||||
relation: id,
|
||||
index,
|
||||
}),
|
||||
bend_geometry.is_some() && index < bend_count,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Clear bends",
|
||||
diagram_event(Event::ClearBends {
|
||||
context,
|
||||
relation: id,
|
||||
}),
|
||||
bend_geometry.is_some() && bend_count > 0,
|
||||
));
|
||||
let visible = projection::project_view(project, context.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
|
||||
@@ -565,6 +565,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
|
||||
"To shape a selected transition, drag an exposed line segment to add a bend, or drag a square bend handle to move it. Right-click a square and choose Remove bend. The transition's Actions menu also offers Add bend and Clear bends; clearing bends keeps its attachment points and label placement. Bends belong to this saved view. A click alone changes nothing, Escape cancels a drag, and Undo restores the complete edit. Internal transition rows and folded connections do not expose bend editing while their route is hidden.",
|
||||
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Request {
|
||||
pub placement: Option<Id>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub route_segment: Option<(usize, Position)>,
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub source_file: Option<String>,
|
||||
@@ -26,6 +27,7 @@ impl Request {
|
||||
placement: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
route_segment: None,
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
source_file: None,
|
||||
|
||||
@@ -137,6 +137,34 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
|
||||
}
|
||||
|
||||
/// Insert after the pins preceding the clicked segment along the routed path.
|
||||
pub fn insert_pin_on_segment(
|
||||
route: &mut EdgeRoute,
|
||||
geometry: &RoutedEdge,
|
||||
segment: usize,
|
||||
point: Position,
|
||||
) -> Option<usize> {
|
||||
if route.edge != geometry.id
|
||||
|| geometry.custom_suspended
|
||||
|| route.waypoints != geometry.pins
|
||||
|| route.waypoints.len() >= 128
|
||||
|| !point.x.is_finite()
|
||||
|| !point.y.is_finite()
|
||||
|| geometry
|
||||
.points
|
||||
.get(segment..segment.checked_add(2)?)
|
||||
.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let index = pin_insertion_index(geometry, segment, point);
|
||||
if index > route.waypoints.len() {
|
||||
return None;
|
||||
}
|
||||
route.waypoints.insert(index, point);
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Find a segment when an ordinary caller has only a point, not a captured hit.
|
||||
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
|
||||
let segment = geometry
|
||||
.points
|
||||
|
||||
Reference in New Issue
Block a user