Compose reviewed attachments and State bends with saved Scenario
This commit is contained in:
@@ -43,8 +43,12 @@ pub struct State {
|
||||
press: Option<Press>,
|
||||
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)>,
|
||||
scene: RefCell<Option<CachedScene>>,
|
||||
geometry: canvas::Cache,
|
||||
paint_key: RefCell<Option<PaintKey>>,
|
||||
@@ -76,6 +80,30 @@ enum Press {
|
||||
/// Handles require a drag; an explicitly chosen reconnect tool accepts a click.
|
||||
start: Option<Point>,
|
||||
},
|
||||
Attachment {
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
placement: Id,
|
||||
start: Point,
|
||||
},
|
||||
Bend {
|
||||
relation: Id,
|
||||
},
|
||||
}
|
||||
|
||||
struct BendGesture {
|
||||
start: Point,
|
||||
route: EdgeRoute,
|
||||
index: usize,
|
||||
baseline: Arc<Scene>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct AttachmentPreview {
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
placement: Id,
|
||||
attachment: EdgeAttachment,
|
||||
}
|
||||
|
||||
struct Scene {
|
||||
@@ -86,12 +114,16 @@ struct Scene {
|
||||
struct CachedScene {
|
||||
context: Context,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
scene: Arc<Scene>,
|
||||
}
|
||||
#[derive(PartialEq)]
|
||||
struct PaintKey {
|
||||
context: Context,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
selected: Id,
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
@@ -104,9 +136,16 @@ enum Hit {
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn cancel_gesture(&mut self) {
|
||||
fn cancel_edit(&mut self) {
|
||||
self.press = None;
|
||||
self.preview = None;
|
||||
self.attachment_preview = None;
|
||||
self.bend = None;
|
||||
self.bend_preview = None;
|
||||
self.attachment_viewport = None;
|
||||
}
|
||||
fn cancel_gesture(&mut self) {
|
||||
self.cancel_edit();
|
||||
self.pan_from = None;
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
@@ -649,20 +688,36 @@ 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 = 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));
|
||||
crate::route_edit::with_route(&mut diagram, route);
|
||||
}
|
||||
if let Some((id, at)) = preview
|
||||
&& let Some(node) = diagram.nodes.get(&id)
|
||||
{
|
||||
@@ -687,7 +742,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,
|
||||
@@ -697,6 +762,8 @@ impl StateCanvas<'_> {
|
||||
*state.scene.borrow_mut() = Some(CachedScene {
|
||||
context: self.context,
|
||||
preview,
|
||||
attachment_preview,
|
||||
bend_preview,
|
||||
scene: scene.clone(),
|
||||
});
|
||||
Some(scene)
|
||||
@@ -828,6 +895,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))
|
||||
@@ -856,12 +1001,98 @@ 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 {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
start,
|
||||
}) = state.press
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if window.distance(start) < 3. {
|
||||
// Returning to the press restores the exact persisted optional value,
|
||||
// without quantizing an existing attachment or pinning an automatic one.
|
||||
state.attachment_preview = None;
|
||||
return;
|
||||
}
|
||||
let Some(owner) = state_edit::attachment_node(self.project, self.diagram, relation, end)
|
||||
.filter(|owner| owner.id == placement)
|
||||
else {
|
||||
state.cancel_gesture();
|
||||
return;
|
||||
};
|
||||
let diagram = &self.project.diagrams[&self.diagram];
|
||||
let previous = state
|
||||
.attachment_preview
|
||||
.map(|preview| preview.attachment.side)
|
||||
.or_else(|| {
|
||||
diagram
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.edge == relation)
|
||||
.and_then(|route| route.attachment(end))
|
||||
.map(|attachment| attachment.side)
|
||||
});
|
||||
let world = self.world(local);
|
||||
if !world.x.is_finite() || !world.y.is_finite() {
|
||||
state.cancel_gesture();
|
||||
return;
|
||||
}
|
||||
let attachment =
|
||||
routing::attachment_from_point(&owner, position(world), previous, 6. / self.scale());
|
||||
state.attachment_preview = Some(AttachmentPreview {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
attachment,
|
||||
});
|
||||
}
|
||||
fn guidance(&self, state: &State, scene: &Scene, world: Option<Point>) -> String {
|
||||
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",
|
||||
self.project.named(relation)
|
||||
);
|
||||
}
|
||||
let hovered = world.and_then(|p| self.vertex_at(scene, p));
|
||||
let press = self.gesture_active(state).then_some(state.press).flatten();
|
||||
let source = self.gesture_active(state).then_some(state.source).flatten();
|
||||
@@ -950,10 +1181,24 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
state.tool = Some(self.tool);
|
||||
}
|
||||
if !self.editable {
|
||||
state.press = None;
|
||||
state.preview = None;
|
||||
// Pending edits block model changes, but read-only panning remains
|
||||
// available and must survive successive pointer events.
|
||||
state.cancel_edit();
|
||||
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))
|
||||
{
|
||||
state.cancel_gesture();
|
||||
}
|
||||
// Winit positions are ordered events. The cursor supplied by Iced is the
|
||||
// final cursor for an entire batch and cannot reinterpret earlier clicks.
|
||||
let cursor = match event {
|
||||
@@ -963,6 +1208,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(
|
||||
@@ -981,6 +1229,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
state.reset();
|
||||
return None;
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Resized(_)) => {
|
||||
state.cancel_gesture();
|
||||
state.viewport = None;
|
||||
return None;
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let window = cursor.position();
|
||||
@@ -1053,10 +1306,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 {
|
||||
@@ -1064,6 +1330,8 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
object,
|
||||
placement,
|
||||
anchor: window,
|
||||
bend,
|
||||
route_segment,
|
||||
})
|
||||
.and_capture(),
|
||||
);
|
||||
@@ -1081,6 +1349,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
|
||||
@@ -1101,6 +1377,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 => {
|
||||
@@ -1142,6 +1448,26 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
});
|
||||
Some(Action::capture())
|
||||
}
|
||||
Tool::AdjustAttachment { relation, end } => {
|
||||
let owner =
|
||||
state_edit::attachment_node(self.project, self.diagram, relation, end)?;
|
||||
let route = scene.routed.edges.get(&relation)?;
|
||||
let tip = match end {
|
||||
EdgeEnd::Source => route.points.first(),
|
||||
EdgeEnd::Target => route.points.last(),
|
||||
}?;
|
||||
if world.distance(point(*tip)) > 10. / self.scale() {
|
||||
return None;
|
||||
}
|
||||
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
||||
state.press = Some(Press::Attachment {
|
||||
relation,
|
||||
end,
|
||||
placement: owner.id,
|
||||
start: window,
|
||||
});
|
||||
Some(Action::capture())
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
@@ -1156,6 +1482,10 @@ 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())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
||||
@@ -1176,16 +1506,49 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
if let Some(window) = window {
|
||||
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,
|
||||
position,
|
||||
}),
|
||||
Press::Attachment {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
..
|
||||
} if inside => attachment_preview
|
||||
.filter(|preview| {
|
||||
preview.relation == relation
|
||||
&& preview.end == end
|
||||
&& preview.placement == placement
|
||||
})
|
||||
.map(|preview| Event::SetAttachment {
|
||||
context: self.context,
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
attachment: Some(preview.attachment),
|
||||
}),
|
||||
Press::Create { owner, kind, start }
|
||||
if inside
|
||||
&& window?.distance(start) < 3.
|
||||
@@ -1273,15 +1636,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: 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,
|
||||
@@ -1361,6 +1728,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()
|
||||
@@ -1391,6 +1774,20 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
.with_width(2.5 / self.scale()),
|
||||
);
|
||||
}
|
||||
if let Tool::AdjustAttachment { relation, end } = self.tool
|
||||
&& let Some(route) = scene.routed.edges.get(&relation)
|
||||
&& let Some(tip) = match end {
|
||||
EdgeEnd::Source => route.points.first(),
|
||||
EdgeEnd::Target => route.points.last(),
|
||||
}
|
||||
{
|
||||
frame.stroke(
|
||||
&Path::circle(point(*tip), 9. / self.scale()),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(2. / self.scale()),
|
||||
);
|
||||
}
|
||||
if let Some(at) = world
|
||||
&& self.editable
|
||||
{
|
||||
@@ -1761,6 +2158,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
|
||||
include!("state_bend_tests.rs");
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
@@ -2217,7 +2616,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(
|
||||
@@ -2227,7 +2626,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));
|
||||
}
|
||||
|
||||
@@ -2484,6 +2883,242 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_attachment_drag_is_view_only_and_one_source_history_operation() {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let mut f = fixture();
|
||||
let relation =
|
||||
f.project
|
||||
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
let second = state_edit::create_view(&f.project, f.machine).unwrap();
|
||||
let other_id = second.diagram;
|
||||
f.project = prepare_command(&f.project, &second.command)
|
||||
.unwrap()
|
||||
.project;
|
||||
let other = f.project.diagrams[&other_id].clone();
|
||||
let mut canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let route = &scene.routed.edges[&relation];
|
||||
let tip = match end {
|
||||
EdgeEnd::Source => route.points.first(),
|
||||
EdgeEnd::Target => route.points.last(),
|
||||
}
|
||||
.unwrap();
|
||||
let handle = screen(&canvas, point(*tip));
|
||||
motion(&canvas, &mut state, handle, handle);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
handle,
|
||||
);
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
handle
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
state.attachment_preview.is_none(),
|
||||
"A click must not materialize an automatic endpoint"
|
||||
);
|
||||
let owner =
|
||||
state_edit::attachment_node(&f.project, f.diagram, relation, end).unwrap();
|
||||
let destination = screen(
|
||||
&canvas,
|
||||
Point::new(owner.position.x + owner.size.width * 0.25, owner.position.y),
|
||||
);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
handle,
|
||||
);
|
||||
motion(&canvas, &mut state, destination, destination);
|
||||
assert!(state.attachment_preview.is_some());
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
destination,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(event, Event::SetAttachment { end: actual, .. } if actual == end));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let mut document =
|
||||
Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
let source = document.bundle().clone();
|
||||
document.execute(plan.command).unwrap();
|
||||
assert_eq!(document.project().relations, f.project.relations);
|
||||
assert_eq!(document.project().diagrams[&other_id], other);
|
||||
let route = &document.project().diagrams[&f.diagram].routes[0];
|
||||
assert_eq!(
|
||||
route.attachment(end),
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
let edited = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &source);
|
||||
assert!(!document.undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &edited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_return_to_press_restores_exact_automatic_or_fractional_original() {
|
||||
for original in [
|
||||
None,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.234567,
|
||||
}),
|
||||
] {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
let mut f = fixture();
|
||||
let relation =
|
||||
f.project
|
||||
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
if original.is_some() {
|
||||
let mut route =
|
||||
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation);
|
||||
route.set_attachment(end, original);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route);
|
||||
}
|
||||
let canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, 1.);
|
||||
let mut state = State::default();
|
||||
let before = canvas.scene(&state).unwrap();
|
||||
let edge = &before.routed.edges[&relation];
|
||||
let tip = match end {
|
||||
EdgeEnd::Source => edge.points.first(),
|
||||
EdgeEnd::Target => edge.points.last(),
|
||||
}
|
||||
.unwrap();
|
||||
let start = screen(&canvas, point(*tip));
|
||||
let away = start + Vector::new(80., 40.);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
start,
|
||||
);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.attachment_preview.is_some());
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(state.attachment_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
edge.points
|
||||
);
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
start
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation)
|
||||
.attachment(end),
|
||||
original
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_drag_cancels_on_camera_context_tool_pending_and_focus_changes() {
|
||||
let mut f = fixture();
|
||||
let relation = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
for cancel in 0..9 {
|
||||
let mut canvas = canvas(
|
||||
&f,
|
||||
Tool::AdjustAttachment {
|
||||
relation,
|
||||
end: EdgeEnd::Source,
|
||||
},
|
||||
1.,
|
||||
);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let handle = screen(&canvas, point(scene.routed.edges[&relation].points[0]));
|
||||
let destination = handle + Vector::new(40., 30.);
|
||||
motion(&canvas, &mut state, handle, handle);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
handle,
|
||||
);
|
||||
motion(&canvas, &mut state, destination, destination);
|
||||
assert!(state.attachment_preview.is_some());
|
||||
match cancel {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.x += 10.,
|
||||
2 => canvas.context.epoch += 1,
|
||||
3 => canvas.tool = Tool::Select,
|
||||
4 => canvas.editable = false,
|
||||
5 | 6 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(if cancel == 5 {
|
||||
mouse::Button::Right
|
||||
} else {
|
||||
mouse::Button::Middle
|
||||
}),
|
||||
destination,
|
||||
);
|
||||
}
|
||||
7 => {
|
||||
canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(destination),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
motion(&canvas, &mut state, Point::new(-100., -100.), destination);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
destination
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.attachment_preview.is_none());
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() {
|
||||
let mut f = fixture();
|
||||
|
||||
Reference in New Issue
Block a user