//! Exact-target selection and an order gesture over one immutable selection. use super::super::group_reorder::{Destination, Plan, Prepared}; use super::*; use crate::app::multiselect::{Choice, Event as SelectionEvent, process_context}; use crate::diagram_selection::Snapshot; use crate::diagram_usage::Target; pub(super) struct GroupPress { snapshot: Snapshot, target: Target, start: Point, bounds: Rectangle, pan: iced::Vector, scale: f32, crossed: bool, retired: bool, prepared: Result, destination: Option, plan: Option>, } impl Graph<'_> { fn exact_target_at(&self, point: Point) -> Option { // Visible singleton reconnect rings are painted over the card/edge. // Shift and context selection must resolve that same topmost target. if self.interaction.is_some_and(|(_, tool, editable)| editable && tool == CanvasTool::Select) && self.selection.as_ref().is_some_and(|snapshot| { snapshot.targets().len() == 1 && matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected) }) && let Some((link, _)) = self.endpoint_at(point) { return Some(Target::ProcessLink(link)); } if let Some(endpoint) = self.qualified_at(point) { return Some(Target::ProcessOccurrence(endpoint)); } for node in &self.nodes { for link in &node.preview_links { if node .preview_link_visible_segment(link) .is_some_and(|[a, b]| { crate::diagram::distance_to_segment(point, a, b) < 5. / self.scale }) { return Some(Target::ProcessPreviewLink { relation: link.relation, source: link.source.clone(), target: link.target.clone(), }); } } } self.contextual_edge_at(point) .map(Target::ProcessLink) .or_else(|| { self.node_at(point).map(|id| { Target::ProcessOccurrence(OccurrenceEndpoint { references: vec![], occurrence: id, }) }) }) .or_else(|| self.edge_at(point).map(Target::ProcessLink)) } fn destination_at(&self, point: Point) -> Option { let id = self.node_at(point)?; let node = self.nodes.iter().find(|n| n.id == id)?; Some(if point.y < node.rect.center_y() { Destination::Before(id) } else { Destination::After(id) }) } fn update_group(&self, group: &mut GroupPress, pointer: Point) { if group.retired { return; } if !pointer.x.is_finite() || !pointer.y.is_finite() || !group.bounds.contains(pointer) { group.retired = true; group.plan = None; return; } group.crossed |= pointer.distance(group.start) >= 3.; let world = Point::new( (pointer.x - group.bounds.x - self.camera_pan.x) / self.scale, (pointer.y - group.bounds.y - self.camera_pan.y) / self.scale, ); let destination = self.destination_at(world); // The captured semantic facts are prepared once. Only a changed object // anchor/side needs another order plan; motion within a slot is free. if destination != group.destination { group.destination = destination; group.plan = destination.map(|destination| match &group.prepared { Ok(prepared) => prepared.plan(destination), Err(reason) => Err(reason.clone()), }); } } pub(super) fn selection_input( &self, state: &mut Input, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { use iced::keyboard::{Event as KeyEvent, Key, key::Named}; match event { canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) | canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. }) | canvas::Event::Keyboard(KeyEvent::KeyReleased { modifiers, .. }) => { state.modifiers = *modifiers } canvas::Event::Window(iced::window::Event::Unfocused) => { state.modifiers = iced::keyboard::Modifiers::empty() } _ => {} } let Some((context, tool, editable)) = self.interaction else { state.group = None; return None; }; let snapshot = self .selection .as_ref() .filter(|s| s.context() == process_context(context)); if let Some(group) = &state.group { let cancelled = !editable || tool != CanvasTool::Select || snapshot != Some(&group.snapshot) || group.bounds != bounds || group.pan != self.camera_pan || group.scale != self.scale || matches!( event, canvas::Event::Window( iced::window::Event::Unfocused | iced::window::Event::Resized(_) ) | canvas::Event::Mouse( mouse::Event::CursorLeft | mouse::Event::WheelScrolled { .. } | mouse::Event::ButtonPressed( mouse::Button::Right | mouse::Button::Middle ) ) | canvas::Event::Keyboard(KeyEvent::KeyPressed { key: Key::Named(Named::Escape), .. }) ); if cancelled { state.cancel(); state.pointer = None; return Some(Action::request_redraw().and_capture()); } } if let Some(group) = &mut state.group { match event { canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { state.pointer = Some(*position); self.update_group(group, *position); return Some(Action::request_redraw().and_capture()); } canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let mut group = state.group.take().unwrap(); // An unavailable cursor must never reuse the last inside point. if let Some(pointer) = cursor.position() { self.update_group(&mut group, pointer); } else { group.retired = true; } let result = if group.retired { None } else if !group.crossed { Some(SelectionEvent::Collapse(group.snapshot, group.target)) } else if let Err(reason) = group.prepared { Some(SelectionEvent::Refuse(group.snapshot, reason)) } else { match (group.destination, group.plan) { (Some(destination), Some(Ok(Plan::Change(_)))) => { Some(SelectionEvent::ProcessReorder(group.snapshot, destination)) } (_, Some(Err(reason))) => { Some(SelectionEvent::Refuse(group.snapshot, reason)) } _ => None, } }; return Some( result .map_or_else(Action::request_redraw, |event| { Action::publish(Message::DiagramSelection(event)) }) .and_capture(), ); } canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => return None, canvas::Event::Keyboard(_) => return Some(Action::capture()), _ => {} } } let snapshot = snapshot?; if !editable { return None; } let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture()); if matches!( event, canvas::Event::Keyboard(KeyEvent::KeyPressed { key: Key::Named(Named::Delete), repeat: false, .. }) ) && (snapshot.targets().len() > 1 || snapshot.targets().iter().any(|t| match t { Target::ProcessPreviewLink { .. } => true, Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(), _ => false, })) { return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into())); } let pointer = match event { canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position), _ => cursor.position().and(state.pointer.or(cursor.position())), }?; if !bounds.contains(pointer) { return None; } let world = Point::new( (pointer.x - bounds.x - self.camera_pan.x) / self.scale, (pointer.y - bounds.y - self.camera_pan.y) / self.scale, ); if matches!( event, canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) ) { state.cancel(); let target = self.exact_target_at(world)?; let object = match &target { Target::ProcessOccurrence(endpoint) => endpoint.occurrence, Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => *id, _ => return None, }; let mut request = crate::model_menu::Request::object(object, pointer); request.selection_target = Some(Box::new(target)); request.process = Some(Box::new(context)); return Some( Action::publish(Message::Menu(crate::model_menu::Event::Open(request))) .and_capture(), ); } if !matches!( event, canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) ) { return None; } if tool == CanvasTool::Connect && snapshot.targets().len() > 1 { state.cancel(); return publish(SelectionEvent::Refuse( snapshot.clone(), "Connecting a selection as a group is unavailable. Select one contribution first." .into(), )); } if tool != CanvasTool::Select { return None; } let target = self.exact_target_at(world); if state.modifiers.shift() { state.cancel(); return target .and_then(|target| { publish(SelectionEvent::Choose( process_context(context), target, Choice::Toggle, )) }) .or_else(|| Some(Action::capture())); } let Some(target) = target else { state.cancel(); // Clearing and starting the existing background camera pan can // share this press; only semantic object hits capture it. return Some(Action::publish(Message::DiagramSelection( SelectionEvent::Clear(process_context(context)), ))); }; // The ordinary endpoint gesture is valid only for an exact singleton // local link. A mixed selection must never reconnect just its primary. if snapshot.targets().len() == 1 && matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected) && self.endpoint_at(world).is_some() { return None; } if !snapshot.targets().contains(&target) { state.cancel(); return publish(SelectionEvent::Choose( process_context(context), target, Choice::Replace, )); } state.cancel(); state.context = Some(context); state.tool = tool; state.bounds = Some(bounds); state.pointer = Some(pointer); state.group = Some(GroupPress { snapshot: snapshot.clone(), target, start: pointer, bounds, pan: self.camera_pan, scale: self.scale, crossed: false, retired: false, prepared: Prepared::new(self.project, context.owner, snapshot.targets()), destination: None, plan: None, }); Some(Action::request_redraw().and_capture()) } pub(super) fn selection_overlay(&self, state: &Input, frame: &mut Frame, bounds: Rectangle) { if let Some(selection) = &self.selection { for target in selection.targets() { let primary = selection.primary() == Some(target); let width = if primary { 3.5 } else { 2. }; let color = if primary { style::TEAL } else { Color::from_rgb8(64, 123, 188) }; match target { Target::ProcessOccurrence(endpoint) => { let rect = if endpoint.references.is_empty() { self.nodes .iter() .find(|n| n.id == endpoint.occurrence) .map(|n| n.rect) } else { self.nodes .iter() .find(|n| n.preview.iter().any(|(shown, _)| shown == endpoint)) .map(|n| n.endpoint_rect(endpoint)) }; if let Some(rect) = rect { frame.stroke( &Path::rounded_rectangle(rect.position(), rect.size(), 5.into()), Stroke::default().with_color(color).with_width(width), ); } } Target::ProcessPreviewLink { relation, source, target, } => { let link = PreviewLink { relation: *relation, source: source.clone(), target: target.clone(), }; for node in &self.nodes { if node.preview_links.contains(&link) && let Some([a, b]) = node.preview_link_visible_segment(&link) { frame.stroke( &Path::line(a, b), Stroke::default().with_color(color).with_width(width), ); } } } _ => {} } } } let Some(group) = &state.group else { return; }; if group.retired || !group.crossed || group.bounds != bounds || self.selection.as_ref() != Some(&group.snapshot) || group.pan != self.camera_pan || group.scale != self.scale { return; } let reason = group .prepared .as_ref() .err() .or_else(|| group.plan.as_ref().and_then(|plan| plan.as_ref().err())); let color = if reason.is_some() { Color::from_rgb8(178, 49, 49) } else { style::TEAL }; let mut position = state .pointer .map(|p| { Point::new( (p.x - bounds.x - self.camera_pan.x) / self.scale + 12., (p.y - bounds.y - self.camera_pan.y) / self.scale + 18., ) }) .unwrap_or(Point::ORIGIN); if let Some(destination) = group.destination { let (id, after) = match destination { Destination::Before(id) => (id, false), Destination::After(id) => (id, true), }; if let Some(node) = self.nodes.iter().find(|n| n.id == id) { let y = if after { node.rect.y + node.rect.height + 7. } else { node.rect.y - 7. }; frame.stroke( &Path::line( Point::new(node.rect.x, y), Point::new(node.rect.x + node.rect.width, y), ), Stroke::default().with_color(color).with_width(3.), ); position = Point::new(node.rect.x, y + 3.); } } let caption = reason.cloned().unwrap_or_else(|| match &group.plan { Some(Ok(Plan::Noop)) => "Order unchanged".into(), Some(Ok(Plan::Change(_))) => format!( "Insert {} in source order ยท paths unchanged", group.snapshot.targets().len() ), _ => "Point at a card's upper / lower half to insert before / after".into(), }); draw_text(frame, caption, position, 12. / self.scale, color); if reason.is_none() { let mut index = 0; // Graph nodes are in original steps order; these numbers describe // the block the exact same cached plan will commit on release. for node in &self.nodes { if group .snapshot .targets() .contains(&Target::ProcessOccurrence(OccurrenceEndpoint { references: vec![], occurrence: node.id, })) { index += 1; draw_text( frame, format!("{index}"), Point::new(node.rect.x + node.rect.width - 18., node.rect.y + 7.), 13., style::TEAL, ); } } } } } #[cfg(test)] #[path = "direct_selection_tests.rs"] mod tests;