Match generic State label input to foreground paint order

This commit is contained in:
2026-09-24 16:00:41 -07:00
parent 2af40c7e27
commit a2b8204f8b
3 changed files with 289 additions and 4 deletions
+40 -4
View File
@@ -35,6 +35,9 @@ mod lifecycle_tests;
#[path = "diagram/port_hit_tests.rs"]
mod port_hit_tests;
#[cfg(test)]
#[path = "diagram/state_label_input_tests.rs"]
mod state_label_input_tests;
#[cfg(test)]
#[path = "diagram/view_options_tests.rs"]
mod view_options_tests;
@@ -326,7 +329,12 @@ impl DiagramCanvas<'_> {
request.bend = Some(index);
return request;
}
if let Some(port) = self.hit_port(&scene.view, local) {
if let Some(edge) = self.hit_state_label(&scene.routed, local) {
request.object = edge;
request.route_point =
routing::project_onto_polyline(&scene.routed.edges[&edge].points, position(world))
.map(|p| p.point);
} else if let Some(port) = self.hit_port(&scene.view, local) {
request.object = port.element;
} else if let Some(node) = self.hit(&scene.view, local) {
request.object = self.project.diagrams[&self.diagram].nodes[&node].element;
@@ -425,13 +433,35 @@ impl DiagramCanvas<'_> {
})
.map(|(end, owner, _)| (end, owner))
}
fn hit_state_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let world = self.world(p);
// State labels paint after nodes, in BTree order. Hit the frontmost first.
routed
.edges
.values()
.rev()
.find(|edge| {
edge.label.state_layout.is_some()
&& !edge.label.text.is_empty()
&& Rectangle::new(
point(edge.label.bounds.position),
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
)
.contains(world)
})
.map(|edge| edge.id)
}
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
if let Some(edge) = self.hit_state_label(routed, p) {
return Some(edge);
}
let p = self.world(p);
routed
.edges
.values()
.filter(|edge| {
!edge.label.text.is_empty()
edge.label.state_layout.is_none()
&& !edge.label.text.is_empty()
&& Rectangle::new(
point(edge.label.bounds.position),
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
@@ -963,8 +993,11 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
);
}
let label = &edge.label;
if self.hit(view, p).is_none()
&& !label.text.is_empty()
if (if label.state_layout.is_some() {
self.hit_state_label(&scene.routed, p) == Some(edge.id)
} else {
self.hit(view, p).is_none()
}) && !label.text.is_empty()
&& Rectangle::new(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
@@ -983,6 +1016,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::capture());
}
}
if let Some(edge) = self.hit_state_label(&scene.routed, p) {
return Some(Action::publish(Message::Select(edge)).and_capture());
}
if let Some(port) = self.hit_port(view, p) {
if self.editable
&& let Some(owner) = view.nodes.iter().find(|n| n.id == port.owner)
@@ -196,8 +196,27 @@ impl DiagramCanvas<'_> {
let pointer = cursor.position()?;
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
let scene = self.scene(state);
let world = self.world(local);
// Preserve foreground selected handles without bypassing Shift or the
// mixed-group admission/refusal lifecycle below.
let state_handle = self.project.diagrams[&self.diagram]
.projection
.state_machine()
.is_some()
&& scene.routed.edges.get(&self.selected).is_some_and(|edge| {
!edge.custom_suspended
&& (self.attachment_at(&scene, world).is_some()
|| edge
.pins
.iter()
.any(|pin| world.distance(point(*pin)) <= 8. / self.zoom))
});
let target = if self.allocation_surface() {
self.allocation_press_target(&scene, local)
} else if state_handle {
Some(Target::Edge(self.selected))
} else if let Some(id) = self.hit_state_label(&scene.routed, local) {
Some(Target::Edge(id))
} else if let Some(port) = self.hit_port(&scene.view, local) {
Some(Target::Port {
port: port.element,
@@ -0,0 +1,230 @@
use super::*;
use crate::{
app::multiselect::{Choice, Event, context},
diagram_selection::Selection,
diagram_usage::Target,
};
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p
.add_element(Kind::State, Layer::Oa, "A", Some(region))
.unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let mut edges = [
p.relate(RelationKind::StateTransition, "Ready\nfor review", a, b),
p.relate(RelationKind::StateTransition, "Ready\nfor review", a, b),
];
edges.sort();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "state_input".into(),
name: "State input".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: edges
.iter()
.map(|edge| EdgeRoute {
edge: *edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 200., y: 150. }],
label: None,
})
.collect(),
},
);
let node = p.place(diagram, a, Position { x: 100., y: 100. });
p.place(diagram, b, Position { x: 650., y: 100. });
let routed = route_diagram(&p, &p.diagrams[&diagram], &project_view(&p, diagram));
for route in &mut p.diagrams.get_mut(&diagram).unwrap().routes {
let label = &routed.edges[&route.edge].label;
route.label = Some(EdgeLabel {
fraction: label.fraction,
offset: Position {
x: 200. - label.anchor.x,
y: 150. - label.anchor.y,
},
});
}
(p, diagram, edges[0], edges[1], node)
}
fn canvas(p: &Project, diagram: Id, selected: Id, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project: p,
view_index: None,
diagram,
selected,
navigation_focus: None,
zoom,
pan: Vector::new(100., 100.),
context: Context {
document: p.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
}
}
fn screen(canvas: &DiagramCanvas<'_>, y: f32) -> Point {
Point::new(
200. * canvas.zoom + canvas.pan.x,
y * canvas.zoom + canvas.pan.y,
)
}
fn pointer(
canvas: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
at: Point,
) -> Option<Message> {
canvas::Program::update(
canvas,
state,
&canvas::Event::Mouse(event),
Rectangle::with_size(Size::new(1800., 1200.)),
mouse::Cursor::Available(at),
)
.and_then(|a| a.into_inner().0)
}
#[test]
fn state_generic_front_label_beats_node_and_selected_back_label_at_three_zooms() {
for zoom in [0.5, 1., 1.5] {
let (p, diagram, low, high, _) = fixture();
let mut canvas = canvas(&p, diagram, low, zoom);
// Ten world pixels below center remains on both labels but avoids bends.
let at = screen(&canvas, 160.);
let scene = canvas.scene(&State::default());
assert!(canvas.hit(&scene.view, at).is_some());
assert_eq!(canvas.hit_label(&scene.routed, at), Some(high));
// Selected bends have a screen radius: move further down at half zoom.
let at = if zoom == 0.5 {
screen(&canvas, 167.)
} else {
at
};
assert_eq!(
canvas
.context_request(
&State::default(),
at,
Rectangle::with_size(Size::new(1800., 1200.))
)
.object,
high
);
let mut state = State::default();
assert!(
matches!(pointer(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at), Some(Message::Select(id)) if id == high)
);
assert!(state.drag.is_none() && state.route_drag.is_none());
canvas.selected = high;
let mut state = State::default();
pointer(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(matches!(
state.route_drag.as_ref().map(|g| &g.handle),
Some(Handle::Label { .. })
));
canvas.editable = false;
let mut state = State::default();
assert!(
matches!(pointer(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at), Some(Message::Select(id)) if id == high)
);
assert!(state.drag.is_none() && state.route_drag.is_none());
}
}
#[test]
fn state_generic_shift_label_and_handle_keep_exact_targets_and_mixed_refusal() {
let (p, diagram, low, high, node) = fixture();
let base = canvas(&p, diagram, low, 1.);
let mut single_state = State::default();
assert!(
matches!(pointer(&base, &mut single_state, mouse::Event::ButtonPressed(mouse::Button::Left), screen(&base, 150.)), Some(Message::Route(RouteEvent::SelectBend(id, 0))) if id == low)
);
assert!(matches!(
single_state.route_drag.as_ref().map(|g| &g.handle),
Some(Handle::Bend(0))
));
let mut selection = Selection::new(context(base.context).scope);
selection.add(Target::Edge(low), |_| true);
selection.add(Target::Placement(node), |_| true);
let canvas = DiagramCanvas {
selection: Some(&selection),
..base
};
for (y, expected) in [(160., high), (150., low)] {
let mut state = State {
modifiers: iced::keyboard::Modifiers::SHIFT,
..State::default()
};
assert!(
matches!(pointer(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), screen(&canvas,y)), Some(Message::DiagramSelection(Event::Choose(_, Target::Edge(id), Choice::Toggle))) if id == expected)
);
assert!(state.drag.is_none() && state.route_drag.is_none());
}
let mut state = State::default();
let at = screen(&canvas, 150.);
assert_eq!(
canvas
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
.bend,
Some(0)
);
pointer(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(state.group.is_some() && state.route_drag.is_none());
let moved = at + Vector::new(20., 20.);
pointer(
&canvas,
&mut state,
mouse::Event::CursorMoved { position: moved },
moved,
);
assert!(matches!(
pointer(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
moved
),
Some(Message::DiagramSelection(Event::Refuse(_, _)))
));
assert!(state.route_drag.is_none() && state.drag.is_none());
}