Files
ARCADIA-Studio/crates/oa-desktop/src/diagram/multiselect.rs
T

316 lines
12 KiB
Rust

use super::group_gesture::{Gesture as GroupGesture, Outcome, Viewport};
use super::*;
use crate::app::multiselect::{Choice, Event, context};
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
/// Even an unsupported mixed group must retain an ordinary click lifecycle.
pub(super) struct Press {
snapshot: Snapshot,
target: Target,
start: Point,
viewport: Viewport,
movement: Option<GroupGesture>,
reason: Option<String>,
crossed: bool,
retired: bool,
}
impl Press {
fn snapshot(&self) -> &Snapshot {
self.movement
.as_ref()
.map_or(&self.snapshot, GroupGesture::snapshot)
}
fn viewport_matches(&self, viewport: Viewport) -> bool {
!self.retired
&& self.viewport == viewport
&& self
.movement
.as_ref()
.is_none_or(|movement| movement.viewport_matches(viewport))
}
fn crossed_threshold(&self) -> bool {
self.movement
.as_ref()
.map_or(self.crossed, GroupGesture::crossed_threshold)
}
fn delta(&self) -> Position {
self.movement
.as_ref()
.map_or(Position { x: 0., y: 0. }, GroupGesture::delta)
}
fn move_to(&mut self, pointer: Point) {
if self.retired {
return;
}
if !pointer.x.is_finite()
|| !pointer.y.is_finite()
|| !self.viewport.bounds.contains(pointer)
{
self.retired = true;
self.movement = None;
return;
}
self.crossed |= pointer.distance(self.start) > 3.;
if let Some(movement) = &mut self.movement
&& !movement.move_to(pointer)
{
self.retired = true;
}
}
fn release(mut self, pointer: Point) -> Option<Event> {
self.move_to(pointer);
if self.retired {
return None;
}
if !self.crossed {
return Some(Event::Collapse(self.snapshot, self.target));
}
if let Some(reason) = self.reason {
return Some(Event::Refuse(self.snapshot, reason));
}
match self.movement?.release(pointer)? {
Outcome::Click(id) => Some(Event::Collapse(self.snapshot, Target::Placement(id))),
Outcome::Move(snapshot, delta) => Some(Event::Move(snapshot, delta)),
}
}
}
impl DiagramCanvas<'_> {
pub(super) fn selection_contains(&self, target: Target) -> bool {
self.selection
.is_some_and(|s| s.scope() == context(self.context).scope && s.contains(&target))
}
pub(super) fn group_input(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
// The Allocation token owns all ordered motion/release while active.
if state.work.is_some() {
return None;
}
let viewport = Viewport {
zoom: self.zoom,
pan: self.pan,
bounds,
};
if let Some(gesture) = &state.group {
let valid = gesture.viewport_matches(viewport)
&& self.editable
&& self.connection.is_none()
&& !self.placing
&& self
.selection
.is_some_and(|s| s.matches(gesture.snapshot(), context(self.context)));
let cancelled = 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(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
);
if !valid || cancelled {
state.group = None;
*state.cache.borrow_mut() = None;
return Some(Action::request_redraw().and_capture());
}
}
if let Some(gesture) = &mut state.group {
match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
gesture.move_to(*position);
*state.cache.borrow_mut() = None;
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let gesture = state.group.take().unwrap();
*state.cache.borrow_mut() = None;
let outcome = cursor
.position()
.and_then(|pointer| gesture.release(pointer));
return Some(match outcome {
Some(event) => {
Action::publish(Message::DiagramSelection(event)).and_capture()
}
None => Action::request_redraw().and_capture(),
});
}
canvas::Event::Keyboard(_) => return Some(Action::capture()),
_ => {}
}
}
let selection = self
.selection
.filter(|s| s.scope() == context(self.context).scope)?;
if state.pan_start.is_some() {
return None;
}
if self.connection.is_some() || self.placing {
return None;
}
if let canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key, modifiers, ..
}) = event
{
use iced::keyboard::{Key, key::Named};
if *key == Key::Named(Named::Escape)
&& state.drag.is_none()
&& state.route_drag.is_none()
&& state.port_drag.is_none()
&& state.work.is_none()
{
return Some(
Action::publish(Message::DiagramSelection(Event::Clear(context(
self.context,
))))
.and_capture(),
);
}
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
let snapshot = selection.snapshot(context(self.context))?;
if *modifiers == iced::keyboard::Modifiers::SHIFT {
let Some(input_generation) = self.removal_input.idle() else {
return Some(Action::capture());
};
return Some(
Action::publish(Message::SavedRemoval(Box::new(
crate::app::saved_removal::Event::Begin {
input_generation,
context: self.context,
snapshot: Box::new(snapshot),
},
)))
.and_capture(),
);
}
let action = if modifiers.is_empty() {
Event::Hide(snapshot)
} else {
Event::Refuse(snapshot, "Use plain Delete to hide placements or Shift+Delete to review deletion from the model.".into())
};
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
}
}
if !matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) || !cursor.is_over(bounds)
{
return None;
}
let pointer = cursor.position()?;
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
let scene = self.scene(state);
let target = if self.allocation_surface() {
self.allocation_press_target(&scene, local)
} else if let Some(port) = self.hit_port(&scene.view, local) {
Some(Target::Port {
port: port.element,
placement: port.owner,
})
} else if let Some(id) = self.hit(&scene.view, local) {
Some(Target::Placement(id))
} else {
self.hit_label(&scene.routed, local)
.or_else(|| self.hit_association_label(&scene.routed, local))
.or_else(|| self.hit_edge(&scene.routed, local).map(|hit| hit.0))
.map(Target::Edge)
}?;
if state.modifiers.shift() {
state.drag = None;
state.preview = None;
state.port_drag = None;
state.route_drag = None;
return Some(
Action::publish(Message::DiagramSelection(Event::Choose(
context(self.context),
target,
Choice::Toggle,
)))
.and_capture(),
);
}
if self.allocation_handles_press(&target) {
return Some(self.begin_allocation(state, target, pointer, bounds));
}
if selection.targets().len() <= 1 || !selection.contains(&target) {
return None;
}
let snapshot = selection.snapshot(context(self.context))?;
if !self.editable {
return Some(Action::capture());
}
let reason = if crate::app::multiselect::placements(&snapshot).is_none() {
Some(
"Move requires only placed elements. Remove ports and connections from the selection.",
)
} else {
None
};
let movement = if reason.is_none()
&& let Target::Placement(anchor) = target
{
let original = self.project.diagrams[&self.diagram].nodes[&anchor].position;
GroupGesture::new(snapshot.clone(), anchor, original, pointer, viewport)
} else {
None
};
if movement.is_none() && reason.is_none() {
return Some(Action::capture());
}
state.group = Some(Press {
snapshot,
target,
start: pointer,
viewport,
movement,
reason: reason.map(str::to_owned),
crossed: false,
retired: false,
});
*state.cache.borrow_mut() = None;
Some(Action::request_redraw().and_capture())
}
pub(super) fn group_preview(&self, state: &State, diagram: &mut Diagram) {
let Some(gesture) = &state.group else {
return;
};
if !gesture.crossed_threshold() {
return;
}
let Some(ids) = crate::app::multiselect::placements(gesture.snapshot()) else {
return;
};
let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
self.project,
self.diagram,
&ids,
gesture.delta(),
) else {
return;
};
// Apply only the planner's presentation records. Full model validation
// and source/history updates occur once, when App accepts the release.
for command in commands {
match command {
Command::EditNode { node, .. } => {
diagram.nodes.insert(node.id, node);
}
Command::SetEdgeRoute { route, .. } => route_edit::with_route(diagram, route),
_ => unreachable!("placement planner returned a non-geometry command"),
}
}
}
}