Box the optional Process menu context and changed order command without changing identity or command semantics. Add opt-in actual WGPU readbacks to the split-event OA/SA reorder evaluator. Strict all-target lint, format and affected desktop paths pass; native acceptance follows.
475 lines
19 KiB
Rust
475 lines
19 KiB
Rust
//! 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<Prepared, String>,
|
|
destination: Option<Destination>,
|
|
plan: Option<Result<Plan, String>>,
|
|
}
|
|
|
|
impl Graph<'_> {
|
|
fn exact_target_at(&self, point: Point) -> Option<Target> {
|
|
// 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<Destination> {
|
|
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<Action<Message>> {
|
|
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;
|