Preserve the full-desktop-1 captured source. Qualified previews inspect their exact subject read-only; group movement preserves source order and other columns. Native acceptance and strict-lint payload-size corrections follow separately.
1077 lines
38 KiB
Rust
1077 lines
38 KiB
Rust
//! Local pointer state and ordinary command proposals for the derived process graph.
|
|
use super::*;
|
|
#[path = "direct_selection.rs"]
|
|
mod selection;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Context {
|
|
pub document: Id,
|
|
pub owner: Id,
|
|
pub revision: u64,
|
|
pub epoch: u64,
|
|
}
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Insertion {
|
|
pub link: Option<Id>,
|
|
pub before: Option<Id>,
|
|
}
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CanvasTool {
|
|
#[default]
|
|
Select,
|
|
Add(Tool, ControlKind),
|
|
Connect,
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum End {
|
|
Source,
|
|
Target,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum CanvasEvent {
|
|
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
|
Choose(Context, CanvasTool),
|
|
Select(Context, Id),
|
|
Remove(Context, Id),
|
|
SelectQualified(Context, OccurrenceEndpoint),
|
|
ExpandQualified(Context, OccurrenceEndpoint),
|
|
OpenQualified(Context, OccurrenceEndpoint),
|
|
AppendExchange(Context),
|
|
Begin {
|
|
context: Context,
|
|
tool: Tool,
|
|
control: ControlKind,
|
|
insertion: Insertion,
|
|
},
|
|
Connect {
|
|
context: Context,
|
|
source: Id,
|
|
target: Id,
|
|
},
|
|
Reconnect {
|
|
context: Context,
|
|
link: Id,
|
|
end: End,
|
|
target: Id,
|
|
},
|
|
Reorder {
|
|
context: Context,
|
|
occurrence: Id,
|
|
before: Id,
|
|
},
|
|
Expand(Context, Id),
|
|
Open(Context, Id),
|
|
Back(Context),
|
|
Zoom(Context, Option<f32>),
|
|
Camera(Context, u64, super::super::canvas_workspace::Event),
|
|
Viewport(Context, Size),
|
|
}
|
|
impl CanvasEvent {
|
|
pub fn context(&self) -> Context {
|
|
match self {
|
|
Self::Scrolled(c, ..)
|
|
| Self::Choose(c, ..)
|
|
| Self::Select(c, ..)
|
|
| Self::Remove(c, ..)
|
|
| Self::SelectQualified(c, ..)
|
|
| Self::ExpandQualified(c, ..)
|
|
| Self::OpenQualified(c, ..)
|
|
| Self::Expand(c, ..)
|
|
| Self::Open(c, ..)
|
|
| Self::Back(c)
|
|
| Self::AppendExchange(c)
|
|
| Self::Zoom(c, ..)
|
|
| Self::Camera(c, ..)
|
|
| Self::Viewport(c, ..) => *c,
|
|
Self::Begin { context, .. }
|
|
| Self::Connect { context, .. }
|
|
| Self::Reconnect { context, .. }
|
|
| Self::Reorder { context, .. } => *context,
|
|
}
|
|
}
|
|
}
|
|
pub(super) struct ReturnFrame {
|
|
identity: Option<(Id, Id)>,
|
|
owner: Id,
|
|
occurrence: Id,
|
|
target: Id,
|
|
address: Option<oa_core::topology_context::ContextAddress>,
|
|
pan: iced::Vector,
|
|
zoom: Option<f32>,
|
|
fit: bool,
|
|
viewport: Option<Size>,
|
|
expanded: BTreeSet<Id>,
|
|
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
|
|
}
|
|
impl State {
|
|
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
|
|
self.selected_context = Some(address);
|
|
}
|
|
pub(crate) fn clear_context(&mut self) {
|
|
self.selected_context = None;
|
|
}
|
|
pub(super) fn qualified_selection(
|
|
&self,
|
|
p: &Project,
|
|
owner: Id,
|
|
) -> Option<&oa_core::topology_context::ContextAddress> {
|
|
self.selected_context.as_ref().filter(|address| {
|
|
address.owner == owner
|
|
&& oa_core::topology_context::TopologyIndex::new(p)
|
|
.resolve(owner, &address.endpoint)
|
|
.is_ok()
|
|
})
|
|
}
|
|
pub(crate) fn expand_context(
|
|
&mut self,
|
|
p: &Project,
|
|
owner: Id,
|
|
endpoint: OccurrenceEndpoint,
|
|
) -> bool {
|
|
if oa_core::topology_context::TopologyIndex::new(p)
|
|
.resolve(owner, &endpoint)
|
|
.is_err()
|
|
|| !matches!(
|
|
p.elements
|
|
.get(&endpoint.occurrence)
|
|
.and_then(|e| e.shared()),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
|
|
..
|
|
})
|
|
)
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(root) = endpoint.references.first() {
|
|
self.expanded.insert(*root);
|
|
}
|
|
if !self.contextual_expanded.remove(&endpoint) {
|
|
self.contextual_expanded.insert(endpoint);
|
|
}
|
|
true
|
|
}
|
|
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
|
if self.identity != Some((document, owner)) {
|
|
self.identity = Some((document, owner));
|
|
self.tool = CanvasTool::Select;
|
|
self.pan = iced::Vector::ZERO;
|
|
self.zoom = Some(1.);
|
|
self.fit = false;
|
|
self.viewport = None;
|
|
self.expanded.clear();
|
|
self.details_offset = iced::Vector::ZERO;
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
pub(crate) fn details_offset(&self) -> iced::Vector {
|
|
self.details_offset
|
|
}
|
|
pub(crate) fn scrolled(
|
|
&mut self,
|
|
scenario::ScrollArea::Page: scenario::ScrollArea,
|
|
offset: iced::Vector,
|
|
) -> bool {
|
|
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
|
return false;
|
|
}
|
|
let changed = self.details_offset != offset;
|
|
self.details_offset = offset;
|
|
changed
|
|
}
|
|
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
|
// Choosing how to edit must retain the user's current view.
|
|
self.tool = tool;
|
|
}
|
|
pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool {
|
|
super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event)
|
|
}
|
|
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
|
self.fit = value.is_none();
|
|
self.zoom = value.map(|v| v.clamp(0.05, 3.));
|
|
}
|
|
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
|
if !size.width.is_finite()
|
|
|| !size.height.is_finite()
|
|
|| size.width <= 0.
|
|
|| size.height <= 0.
|
|
{
|
|
return false;
|
|
}
|
|
let changed = self.viewport != Some(size);
|
|
self.viewport = Some(size);
|
|
changed
|
|
}
|
|
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
|
if !is_contribution(p, owner, id)
|
|
|| !matches!(
|
|
p.elements[&id].shared(),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::SequenceReference { .. },
|
|
..
|
|
})
|
|
)
|
|
{
|
|
return false;
|
|
}
|
|
if !self.expanded.remove(&id) {
|
|
self.expanded.insert(id);
|
|
}
|
|
true
|
|
}
|
|
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id, target: Id) {
|
|
if self.return_to.len() == 32 {
|
|
self.return_to.remove(0);
|
|
}
|
|
self.return_to.push(ReturnFrame {
|
|
identity: self.identity,
|
|
owner,
|
|
occurrence,
|
|
target,
|
|
address: self.selected_context.clone(),
|
|
pan: self.pan,
|
|
zoom: self.zoom,
|
|
fit: self.fit,
|
|
viewport: self.viewport,
|
|
expanded: self.expanded.clone(),
|
|
contextual_expanded: self.contextual_expanded.clone(),
|
|
});
|
|
}
|
|
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
|
while let Some(frame) = self.return_to.pop() {
|
|
if frame.identity.map(|i| i.0) != self.identity.map(|i| i.0) {
|
|
continue;
|
|
}
|
|
let valid = referenced(p, frame.owner, frame.occurrence) == Some(frame.target)
|
|
|| if let Some(address) = &frame.address {
|
|
oa_core::topology_context::TopologyIndex::new(p)
|
|
.resolve(frame.owner, &address.endpoint)
|
|
.is_ok_and(|r| r.defining_sequence == frame.target)
|
|
} else {
|
|
false
|
|
};
|
|
if valid && is_contribution(p, frame.owner, frame.occurrence) {
|
|
self.identity = frame.identity;
|
|
self.selected_context = frame.address.filter(|a| {
|
|
oa_core::topology_context::TopologyIndex::new(p)
|
|
.resolve(frame.owner, &a.endpoint)
|
|
.is_ok()
|
|
});
|
|
self.pan = frame.pan;
|
|
self.zoom = frame.zoom;
|
|
self.fit = frame.fit;
|
|
self.viewport = frame.viewport;
|
|
self.expanded = frame.expanded;
|
|
self.contextual_expanded = frame.contextual_expanded;
|
|
return Some((frame.owner, frame.occurrence));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) {
|
|
let graph =
|
|
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded);
|
|
let old = self.scale(graph.size, 500.);
|
|
if let Some(next) = zoom {
|
|
let size = self.viewport.unwrap_or(Size::new(500., 300.));
|
|
self.camera(super::super::canvas_workspace::Event::Zoom {
|
|
factor: next / old,
|
|
at: Point::new(size.width / 2., size.height / 2.),
|
|
base: old,
|
|
});
|
|
} else {
|
|
self.zoom(None);
|
|
let fitted = self.scale(graph.size, 500.);
|
|
self.zoom(Some(fitted));
|
|
self.pan = iced::Vector::ZERO;
|
|
}
|
|
}
|
|
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
|
let owner = self.pending_owner()?;
|
|
if self
|
|
.draft
|
|
.as_ref()
|
|
.is_some_and(|d| d.source.label(d.target).is_some())
|
|
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
|
{
|
|
return None;
|
|
}
|
|
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
|
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
|
{
|
|
return Some(r.id);
|
|
}
|
|
let Command::Batch(commands) = command else {
|
|
return None;
|
|
};
|
|
commands.iter().find_map(|c| match c {
|
|
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
|
|
_ => None,
|
|
})
|
|
}
|
|
pub(crate) fn begin(
|
|
&mut self,
|
|
p: &Project,
|
|
context: Context,
|
|
tool: Tool,
|
|
control: ControlKind,
|
|
insertion: Insertion,
|
|
) -> Result<(), String> {
|
|
if self.draft.is_some() {
|
|
return Err(PENDING_DRAFT.into());
|
|
}
|
|
if !matches!(
|
|
tool,
|
|
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
|
|
) {
|
|
return Err("Choose a contribution tool.".into());
|
|
}
|
|
let order = legacy_order(p, context.owner);
|
|
let link = insertion
|
|
.link
|
|
.map(|id| {
|
|
p.relations
|
|
.get(&id)
|
|
.filter(|r| {
|
|
r.kind() == RelationKind::SequenceLink
|
|
&& is_contribution(p, context.owner, r.source())
|
|
&& is_contribution(p, context.owner, r.target())
|
|
})
|
|
.ok_or("Choose a sequence link in this process.")
|
|
})
|
|
.transpose()?;
|
|
if insertion
|
|
.before
|
|
.is_some_and(|id| !is_contribution(p, context.owner, id))
|
|
{
|
|
return Err("Choose a presentation slot in this process.".into());
|
|
}
|
|
self.update(p, Event::Start(context.owner, tool, None))?;
|
|
let d = self.draft.as_mut().expect("started form");
|
|
d.fixed = true;
|
|
d.insertion = insertion;
|
|
d.control = control;
|
|
if let Some(r) = link {
|
|
d.before = Some(r.source());
|
|
d.after = Some(r.target());
|
|
} else if !order.is_empty() {
|
|
let index = insertion
|
|
.before
|
|
.and_then(|id| order.iter().position(|v| *v == id))
|
|
.unwrap_or(order.len());
|
|
d.before = index.checked_sub(1).map(|i| order[i]);
|
|
d.after = order.get(index).copied();
|
|
}
|
|
Ok(())
|
|
}
|
|
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
|
|
let last = legacy_order(p, owner)
|
|
.last()
|
|
.copied()
|
|
.ok_or("Choose an ordered exchange path to append to.")?;
|
|
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
|
|
self.draft.as_mut().expect("started form").fixed = true;
|
|
Ok(())
|
|
}
|
|
pub(crate) fn connect(
|
|
&mut self,
|
|
p: &Project,
|
|
owner: Id,
|
|
source: Id,
|
|
target: Id,
|
|
) -> Result<(), String> {
|
|
if self.draft.is_some() {
|
|
return Err(PENDING_DRAFT.into());
|
|
}
|
|
if !is_contribution(p, owner, source)
|
|
|| !is_contribution(p, owner, target)
|
|
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
|
|
{
|
|
return Err("Choose compatible contributions directly owned by this process.".into());
|
|
}
|
|
self.update(p, Event::Start(owner, Tool::Connect, None))?;
|
|
let d = self.draft.as_mut().expect("started form");
|
|
d.fixed = true;
|
|
d.before = Some(source);
|
|
d.after = Some(target);
|
|
Ok(())
|
|
}
|
|
}
|
|
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
|
|
if !is_contribution(p, owner, occurrence) {
|
|
return None;
|
|
}
|
|
match p.elements[&occurrence].shared()? {
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::Action { action, .. },
|
|
..
|
|
} => *action,
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange { exchange, .. },
|
|
..
|
|
} => *exchange,
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::SequenceReference { sequence },
|
|
..
|
|
} => *sequence,
|
|
_ => None,
|
|
}
|
|
}
|
|
pub(crate) fn reconnect(
|
|
p: &Project,
|
|
owner: Id,
|
|
id: Id,
|
|
end: End,
|
|
endpoint: Id,
|
|
) -> Result<Command, String> {
|
|
let mut r = p
|
|
.relations
|
|
.get(&id)
|
|
.filter(|r| {
|
|
r.kind() == RelationKind::SequenceLink
|
|
&& is_contribution(p, owner, r.source())
|
|
&& is_contribution(p, owner, r.target())
|
|
})
|
|
.ok_or("Choose a sequence link in this process.")?
|
|
.clone();
|
|
if !is_contribution(p, owner, endpoint) {
|
|
return Err("Choose a local contribution endpoint.".into());
|
|
}
|
|
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
|
|
match end {
|
|
End::Source => *source = endpoint,
|
|
End::Target => *target = endpoint,
|
|
}
|
|
}
|
|
let command = Command::EditRelation(id, r);
|
|
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
|
Ok(command)
|
|
}
|
|
pub(crate) fn reorder(
|
|
p: &Project,
|
|
owner: Id,
|
|
id: Id,
|
|
before: Id,
|
|
) -> Result<super::group_reorder::Plan, String> {
|
|
super::group_reorder::Prepared::new(
|
|
p,
|
|
owner,
|
|
&[crate::diagram_usage::Target::ProcessOccurrence(
|
|
OccurrenceEndpoint {
|
|
references: vec![],
|
|
occurrence: id,
|
|
},
|
|
)],
|
|
)?
|
|
.plan(super::group_reorder::Destination::Before(before))
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(super) struct Input {
|
|
context: Option<Context>,
|
|
tool: CanvasTool,
|
|
pointer: Option<Point>,
|
|
bounds: Option<Rectangle>,
|
|
source: Option<Id>,
|
|
press: Option<Press>,
|
|
modifiers: iced::keyboard::Modifiers,
|
|
group: Option<selection::GroupPress>,
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
enum Press {
|
|
Connect {
|
|
source: Id,
|
|
first: bool,
|
|
start: Point,
|
|
moved: bool,
|
|
},
|
|
Reconnect {
|
|
link: Id,
|
|
end: End,
|
|
start: Point,
|
|
moved: bool,
|
|
},
|
|
Reorder {
|
|
occurrence: Id,
|
|
start: Point,
|
|
moved: bool,
|
|
},
|
|
Add {
|
|
insertion: Insertion,
|
|
},
|
|
}
|
|
impl Input {
|
|
fn cancel(&mut self) {
|
|
self.source = None;
|
|
self.press = None;
|
|
self.group = None;
|
|
}
|
|
}
|
|
impl Graph<'_> {
|
|
fn removal_target(&self, owner: Id, id: Id) -> bool {
|
|
id != owner
|
|
&& super::super::selected_behavior(self.project, id) == Some(owner)
|
|
&& (self.nodes.iter().any(|node| node.id == id)
|
|
|| self.edges.iter().any(|edge| edge.id == id))
|
|
}
|
|
fn ordered_path_at(&self, point: Point) -> Option<&OrderedPath> {
|
|
self.ordered_paths.iter().find(|path| {
|
|
path.points.windows(2).any(|pair| {
|
|
crate::diagram::distance_to_segment(point, pair[0], pair[1]) < 8. / self.scale
|
|
})
|
|
})
|
|
}
|
|
fn node_at(&self, point: Point) -> Option<Id> {
|
|
self.nodes
|
|
.iter()
|
|
.rev()
|
|
.find(|n| n.rect.contains(point))
|
|
.map(|n| n.id)
|
|
}
|
|
fn qualified_at(&self, point: Point) -> Option<OccurrenceEndpoint> {
|
|
self.nodes.iter().find_map(|node| {
|
|
node.preview.iter().enumerate().find_map(|(i, (id, _))| {
|
|
let rect = Rectangle::new(
|
|
Point::new(
|
|
node.rect.x + 12. + (i % 2) as f32 * 125.,
|
|
node.rect.y + 118. + (i / 2) as f32 * 44.,
|
|
),
|
|
Size::new(113., 32.),
|
|
);
|
|
rect.contains(point).then(|| id.clone())
|
|
})
|
|
})
|
|
}
|
|
fn edge_at(&self, point: Point) -> Option<Id> {
|
|
self.edges
|
|
.iter()
|
|
.find(|e| {
|
|
(!e.guard.is_empty() && e.label.contains(point))
|
|
|| e.points.windows(2).any(|v| {
|
|
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
|
|
})
|
|
})
|
|
.map(|e| e.id)
|
|
}
|
|
fn contextual_edge_at(&self, point: Point) -> Option<Id> {
|
|
self.edges
|
|
.iter()
|
|
.filter(|edge| edge.layer == EdgeLayer::Contextual)
|
|
.find(|edge| {
|
|
edge.label.contains(point)
|
|
|| edge.points.windows(2).any(|segment| {
|
|
crate::diagram::distance_to_segment(point, segment[0], segment[1])
|
|
< 8. / self.scale
|
|
})
|
|
})
|
|
.map(|edge| edge.id)
|
|
}
|
|
fn handle(&self, id: Id) -> Option<Rectangle> {
|
|
let n = self.nodes.iter().find(|n| n.id == id)?;
|
|
Some(Rectangle::new(
|
|
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
|
|
Size::new(23., 24.),
|
|
))
|
|
}
|
|
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
|
if self.project.relations.get(&self.selected)?.kind() != RelationKind::SequenceLink {
|
|
return None;
|
|
}
|
|
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
|
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
|
.into_iter()
|
|
.find(|(p, _)| p.distance(point) < 10. / self.scale)
|
|
.map(|(_, end)| (e.id, end))
|
|
}
|
|
fn connectable(&self, source: Id, target: Id) -> bool {
|
|
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
|
|
}
|
|
pub(super) fn input(
|
|
&self,
|
|
state: &mut Input,
|
|
event: &canvas::Event,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Option<Action<Message>> {
|
|
if let Some(action) = self.selection_input(state, event, bounds, cursor) {
|
|
return Some(action);
|
|
}
|
|
let (context, tool, editable) = self
|
|
.interaction
|
|
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
|
(Some(c), t, e)
|
|
});
|
|
if state.context != context
|
|
|| state.tool != tool
|
|
|| state.bounds.is_some_and(|b| b != bounds)
|
|
{
|
|
state.cancel();
|
|
state.pointer = None;
|
|
state.context = context;
|
|
state.tool = tool;
|
|
}
|
|
state.bounds = Some(bounds);
|
|
if !editable {
|
|
state.cancel();
|
|
}
|
|
let visible = cursor.position().is_some();
|
|
if !visible
|
|
&& matches!(
|
|
event,
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
|
)
|
|
{
|
|
state.cancel();
|
|
return Some(Action::request_redraw());
|
|
}
|
|
let cursor = match event {
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
|
state.pointer = Some(*position);
|
|
if let Some(
|
|
Press::Connect { start, moved, .. }
|
|
| Press::Reconnect { start, moved, .. }
|
|
| Press::Reorder { start, moved, .. },
|
|
) = &mut state.press
|
|
{
|
|
*moved |= position.distance(*start) >= 3.;
|
|
}
|
|
mouse::Cursor::Available(*position)
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
|
|
| canvas::Event::Window(
|
|
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
|
|
) => {
|
|
state.cancel();
|
|
state.pointer = None;
|
|
return Some(Action::request_redraw());
|
|
}
|
|
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete),
|
|
modifiers,
|
|
repeat: false,
|
|
..
|
|
}) if editable
|
|
&& tool == CanvasTool::Select
|
|
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
|
&& state.press.is_none()
|
|
&& state.source.is_none() =>
|
|
{
|
|
let context = context?;
|
|
if self.removal_target(context.owner, self.selected) {
|
|
return Some(
|
|
Action::publish(Message::TopologyCanvas(CanvasEvent::Remove(
|
|
context,
|
|
self.selected,
|
|
)))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
return None;
|
|
}
|
|
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
|
..
|
|
}) => {
|
|
state.cancel();
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
canvas::Event::Mouse(
|
|
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
|
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
|
|
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
|
state.pointer = cursor.position();
|
|
cursor
|
|
}
|
|
_ => cursor,
|
|
};
|
|
let world = cursor.position_in(bounds).map(|p| {
|
|
Point::new(
|
|
(p.x - self.camera_pan.x) / self.scale,
|
|
(p.y - self.camera_pan.y) / self.scale,
|
|
)
|
|
});
|
|
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
|
match event {
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
|
let point = world?;
|
|
let Some(context) = context else {
|
|
return self
|
|
.node_at(point)
|
|
.or_else(|| self.edge_at(point))
|
|
.map(|id| Action::publish(Message::Select(id)).and_capture());
|
|
};
|
|
if !editable {
|
|
return None;
|
|
}
|
|
let start = cursor.position()?;
|
|
match tool {
|
|
CanvasTool::Add(..) => {
|
|
state.press = Some(Press::Add {
|
|
insertion: Insertion {
|
|
link: self
|
|
.edge_at(point)
|
|
.filter(|_| self.node_at(point).is_none()),
|
|
before: self.node_at(point).or_else(|| {
|
|
self.ordered_path_at(point).map(|path| path.target)
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
CanvasTool::Connect => {
|
|
let first = state.source.is_none();
|
|
let source = state.source.or_else(|| self.node_at(point))?;
|
|
state.source = Some(source);
|
|
state.press = Some(Press::Connect {
|
|
source,
|
|
first,
|
|
start,
|
|
moved: false,
|
|
});
|
|
}
|
|
CanvasTool::Select => {
|
|
if let Some(endpoint) = self.qualified_at(point) {
|
|
return publish(CanvasEvent::SelectQualified(context, endpoint));
|
|
} else if let Some((link, end)) = self.endpoint_at(point) {
|
|
state.press = Some(Press::Reconnect {
|
|
link,
|
|
end,
|
|
start,
|
|
moved: false,
|
|
});
|
|
} else if let Some(link) = self.contextual_edge_at(point) {
|
|
return publish(CanvasEvent::Select(context, link));
|
|
} else if self
|
|
.handle(self.selected)
|
|
.is_some_and(|r| r.contains(point))
|
|
{
|
|
state.press = Some(Press::Reorder {
|
|
occurrence: self.selected,
|
|
start,
|
|
moved: false,
|
|
});
|
|
} else {
|
|
return self
|
|
.node_at(point)
|
|
.or_else(|| self.edge_at(point))
|
|
.and_then(|id| publish(CanvasEvent::Select(context, id)));
|
|
}
|
|
}
|
|
}
|
|
Some(Action::request_redraw().and_capture())
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
let press = state.press.take()?;
|
|
let context = context?;
|
|
match press {
|
|
Press::Add { insertion } if world.is_some() => {
|
|
if let CanvasTool::Add(tool, control) = tool {
|
|
state.cancel();
|
|
return publish(CanvasEvent::Begin {
|
|
context,
|
|
tool,
|
|
control,
|
|
insertion,
|
|
});
|
|
}
|
|
}
|
|
Press::Connect {
|
|
source,
|
|
first,
|
|
moved,
|
|
..
|
|
} if moved || !first => {
|
|
if let Some(target) = world
|
|
.and_then(|p| self.node_at(p))
|
|
.filter(|target| self.connectable(source, *target))
|
|
{
|
|
state.cancel();
|
|
return publish(CanvasEvent::Connect {
|
|
context,
|
|
source,
|
|
target,
|
|
});
|
|
}
|
|
}
|
|
Press::Reconnect {
|
|
link,
|
|
end,
|
|
moved: true,
|
|
..
|
|
} => {
|
|
if let Some(target) = world.and_then(|p| self.node_at(p)) {
|
|
state.cancel();
|
|
return publish(CanvasEvent::Reconnect {
|
|
context,
|
|
link,
|
|
end,
|
|
target,
|
|
});
|
|
}
|
|
}
|
|
Press::Reorder {
|
|
occurrence,
|
|
moved: true,
|
|
..
|
|
} => {
|
|
if let Some(before) = world.and_then(|p| self.node_at(p)) {
|
|
state.cancel();
|
|
return publish(CanvasEvent::Reorder {
|
|
context,
|
|
occurrence,
|
|
before,
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Some(Action::request_redraw().and_capture())
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
|
Some(Action::request_redraw())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
pub(super) fn overlay(
|
|
&self,
|
|
state: &Input,
|
|
f: &mut Frame,
|
|
cursor: mouse::Cursor,
|
|
bounds: Rectangle,
|
|
) {
|
|
self.selection_overlay(state, f, bounds);
|
|
let Some((context, tool, editable)) = self.interaction else {
|
|
return;
|
|
};
|
|
if let Some(insertion) = self.pending_insertion {
|
|
if let Some(edge) = insertion
|
|
.link
|
|
.and_then(|id| self.edges.iter().find(|e| e.id == id))
|
|
{
|
|
draw_arrow(f, &edge.points, style::TEAL, 3.);
|
|
} else if let Some(node) = insertion
|
|
.before
|
|
.and_then(|id| self.nodes.iter().find(|n| n.id == id))
|
|
{
|
|
f.stroke(
|
|
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
|
Stroke::default().with_color(style::TEAL).with_width(3.),
|
|
);
|
|
}
|
|
}
|
|
if !editable {
|
|
return;
|
|
}
|
|
let point = state
|
|
.pointer
|
|
.or_else(|| cursor.position())
|
|
.filter(|p| bounds.contains(*p))
|
|
.map(|p| {
|
|
Point::new(
|
|
(p.x - bounds.x - self.camera_pan.x) / self.scale,
|
|
(p.y - bounds.y - self.camera_pan.y) / self.scale,
|
|
)
|
|
});
|
|
let live =
|
|
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
|
if live && let Some(source) = state.source {
|
|
for n in &self.nodes {
|
|
if n.id == source || self.connectable(source, n.id) {
|
|
f.stroke(
|
|
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
|
|
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
|
);
|
|
}
|
|
}
|
|
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
|
|
draw_arrow(
|
|
f,
|
|
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
|
|
style::TEAL,
|
|
2.,
|
|
);
|
|
}
|
|
}
|
|
if live
|
|
&& let Some(Press::Reconnect { link, end, .. }) = state.press
|
|
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
|
|
{
|
|
for node in &self.nodes {
|
|
let (source, target) = match end {
|
|
End::Source => (node.id, edge.target),
|
|
End::Target => (edge.source, node.id),
|
|
};
|
|
if self.connectable(source, target) {
|
|
f.stroke(
|
|
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
|
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
|
);
|
|
}
|
|
}
|
|
if let Some(point) = point {
|
|
let (from, to) = match end {
|
|
End::Source => (point, *edge.points.last().unwrap()),
|
|
End::Target => (edge.points[0], point),
|
|
};
|
|
draw_arrow(f, &[from, to], style::TEAL, 2.5);
|
|
}
|
|
}
|
|
if live
|
|
&& let Some(Press::Reorder { occurrence, .. }) = state.press
|
|
&& let Some(point) = point
|
|
&& let Some(target) = self.node_at(point)
|
|
{
|
|
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
|
|
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
|
|
if source.rect.x == target.rect.x {
|
|
f.stroke(
|
|
&Path::line(
|
|
Point::new(target.rect.x, target.rect.y - 7.),
|
|
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
|
|
),
|
|
Stroke::default().with_color(style::TEAL).with_width(3.),
|
|
);
|
|
draw_text(
|
|
f,
|
|
"Order before · paths unchanged".into(),
|
|
Point::new(target.rect.x, target.rect.y - 24.),
|
|
12.,
|
|
style::TEAL,
|
|
);
|
|
}
|
|
}
|
|
if tool == CanvasTool::Select
|
|
&& self
|
|
.selection
|
|
.as_ref()
|
|
.is_none_or(|s| s.targets().len() == 1)
|
|
{
|
|
if let Some(r) = self.handle(self.selected) {
|
|
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
|
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
|
|
}
|
|
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
|
|
for p in [e.points[0], *e.points.last().unwrap()] {
|
|
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
|
|
}
|
|
}
|
|
}
|
|
if let CanvasTool::Add(..) = tool
|
|
&& let Some(point) = point
|
|
{
|
|
let at = self
|
|
.edge_at(point)
|
|
.filter(|_| self.node_at(point).is_none());
|
|
let caption = if let Some(id) = at {
|
|
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
|
|
draw_arrow(f, &e.points, style::TEAL, 3.);
|
|
}
|
|
"Insert on this path · preserve its guard"
|
|
} else if let Some(path) = self.ordered_path_at(point) {
|
|
draw_arrow(f, &path.points, style::TEAL, 3.);
|
|
"Insert on ordered path · preserve its order"
|
|
} else if self.ordered && self.node_at(point).is_some() {
|
|
"Insert before this contribution · preserve path"
|
|
} else if self.ordered {
|
|
"Continue the ordered path"
|
|
} else if self.node_at(point).is_some() {
|
|
"Add before this contribution · unconnected"
|
|
} else {
|
|
"Append contribution · unconnected"
|
|
};
|
|
draw_text(
|
|
f,
|
|
caption.into(),
|
|
Point::new(point.x + 12., point.y + 16.),
|
|
12. / self.scale,
|
|
style::TEAL,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl State {
|
|
pub(crate) fn selecting(&self) -> bool {
|
|
self.tool == CanvasTool::Select
|
|
}
|
|
pub(crate) fn removal_target(&self, p: &Project, owner: Id, id: Id) -> bool {
|
|
self.qualified_selection(p, owner).is_none()
|
|
&& Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded)
|
|
.removal_target(owner, id)
|
|
}
|
|
pub(crate) fn has_qualified_selection(&self, p: &Project, owner: Id) -> bool {
|
|
self.qualified_selection(p, owner).is_some()
|
|
}
|
|
#[cfg(test)]
|
|
pub(crate) fn removal_test_point(
|
|
&self,
|
|
app: &App,
|
|
owner: Id,
|
|
id: Id,
|
|
qualified: Option<&OccurrenceEndpoint>,
|
|
) -> Point {
|
|
let graph = Graph::expanded_qualified(
|
|
app.project(),
|
|
owner,
|
|
id,
|
|
&self.expanded,
|
|
&self.contextual_expanded,
|
|
);
|
|
let point = graph
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.id == id)
|
|
.map(|node| {
|
|
qualified.map_or(
|
|
Point::new(node.rect.x + 20., node.rect.y + 25.),
|
|
|endpoint| node.endpoint_rect(endpoint).center(),
|
|
)
|
|
})
|
|
.or_else(|| {
|
|
graph
|
|
.edges
|
|
.iter()
|
|
.find(|edge| edge.id == id)
|
|
.map(|edge| edge.label.center())
|
|
})
|
|
.expect("rendered process target");
|
|
Point::ORIGIN
|
|
+ self.pan
|
|
+ iced::Vector::new(point.x, point.y)
|
|
* self.scale(graph.size, (app.window_size.width - 600.).max(200.))
|
|
}
|
|
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
|
let graph =
|
|
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
|
|
let scale = self.scale(graph.size, 500.);
|
|
let rect = graph
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.id == id)
|
|
.map(|n| {
|
|
self.qualified_selection(p, owner)
|
|
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
|
|
})
|
|
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label));
|
|
let Some(rect) = rect else {
|
|
return false;
|
|
};
|
|
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
|
|
let x = rect.x * scale + self.pan.x;
|
|
let y = rect.y * scale + self.pan.y;
|
|
let delta = iced::Vector::new(
|
|
if x < 12. || x + rect.width * scale > viewport.width - 12. {
|
|
12. - x
|
|
} else {
|
|
0.
|
|
},
|
|
if y < 12. || y + rect.height * scale > viewport.height - 12. {
|
|
12. - y
|
|
} else {
|
|
0.
|
|
},
|
|
);
|
|
self.camera(super::super::canvas_workspace::Event::Pan(delta))
|
|
}
|
|
}
|