From a2b8204f8b8dc23369eca02d86c845715902befb Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Thu, 24 Sep 2026 15:57:46 -0700 Subject: [PATCH] Match generic State label input to foreground paint order --- crates/oa-desktop/src/diagram.rs | 44 +++- crates/oa-desktop/src/diagram/multiselect.rs | 19 ++ .../src/diagram/state_label_input_tests.rs | 230 ++++++++++++++++++ 3 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 crates/oa-desktop/src/diagram/state_label_input_tests.rs diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 4d92a183..93a21c88 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -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 { + 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 { + 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 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 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) diff --git a/crates/oa-desktop/src/diagram/multiselect.rs b/crates/oa-desktop/src/diagram/multiselect.rs index 5f95b92c..3f578c51 100644 --- a/crates/oa-desktop/src/diagram/multiselect.rs +++ b/crates/oa-desktop/src/diagram/multiselect.rs @@ -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, diff --git a/crates/oa-desktop/src/diagram/state_label_input_tests.rs b/crates/oa-desktop/src/diagram/state_label_input_tests.rs new file mode 100644 index 00000000..a9b7eaf3 --- /dev/null +++ b/crates/oa-desktop/src/diagram/state_label_input_tests.rs @@ -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 { + 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()); +}