316 lines
12 KiB
Rust
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"),
|
|
}
|
|
}
|
|
}
|
|
}
|