//! Local pointer state and ordinary command proposals for the derived process graph. use super::*; #[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, pub before: Option, } #[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), 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), Focus(Context), Viewport(Context, Size), } impl CanvasEvent { pub fn context(&self) -> Context { match self { Self::Scrolled(c, ..) | Self::Choose(c, ..) | Self::Select(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::Focus(c) | Self::Viewport(c, ..) => *c, Self::Begin { context, .. } | Self::Connect { context, .. } | Self::Reconnect { context, .. } | Self::Reorder { context, .. } => *context, } } } 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.zoom = Some(1.); self.fit = false; self.viewport = None; self.expanded.clear(); self.scroll_offsets = [iced::Vector::ZERO; 2]; } } #[cfg(test)] pub(crate) fn scroll_offset(&self) -> iced::Vector { self.scroll_offsets[0] + self.scroll_offsets[1] } pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool { let index = match area { scenario::ScrollArea::Canvas => 0, scenario::ScrollArea::Page => 1, }; let changed = self.scroll_offsets[index] != offset; self.scroll_offsets[index] = 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 zoom(&mut self, value: Option) { self.fit = value.is_none(); self.zoom = value.map(|v| v.clamp(0.5, 2.)); } pub(crate) fn viewport(&mut self, size: Size) -> bool { 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) { self.return_to .push((owner, occurrence, self.selected_context.clone())); } pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> { while let Some((owner, occurrence, address)) = self.return_to.pop() { if is_contribution(p, owner, occurrence) { self.selected_context = address.filter(|address| { address.owner == owner && oa_core::topology_context::TopologyIndex::new(p) .resolve(owner, &address.endpoint) .is_ok() }); return Some((owner, occurrence)); } } None } pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option { 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 { 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 { 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 { if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) { return Err("Reorder contributions in this process only.".into()); } let graph = Graph::new(p, owner, id); let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x); if position(id) != position(before) { return Err("Reorder within this column; links determine the path columns.".into()); } let mut steps = p.elements[&owner].steps().unwrap().clone(); if id != before { steps.retain(|v| *v != id); let index = steps .iter() .position(|v| *v == before) .ok_or("This slot no longer exists.")?; steps.insert(index, id); } let command = Command::SetSteps(owner, steps); execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; Ok(command) } #[derive(Default)] pub(super) struct Input { context: Option, tool: CanvasTool, pointer: Option, bounds: Option, source: Option, press: Option, } #[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; } } impl Graph<'_> { 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 { self.nodes .iter() .rev() .find(|n| n.rect.contains(point)) .map(|n| n.id) } fn qualified_at(&self, point: Point) -> Option { 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 { 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 { 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 { 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> { 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 + self.scroll_offset) } 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::Escape), .. }) => { state.cancel(); return Some(Action::request_redraw().and_capture()); } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), ) => state .pointer .map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)), canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { state.pointer = cursor.position().map(|p| p - self.scroll_offset); cursor } _ => cursor, }; let world = cursor .position_in(bounds) .map(|p| Point::new(p.x / self.scale, p.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()? - self.scroll_offset; 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, ) { let Some((context, tool, editable)) = self.interaction else { return; }; if !editable { return; } let point = state .pointer .map(|p| p + self.scroll_offset) .or_else(|| cursor.position()) .filter(|p| bounds.contains(*p)) .map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.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 { 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 selection_offset( &self, p: &Project, owner: Id, id: Id, ) -> Option { 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))?; Some(scrollable::AbsoluteOffset { x: (rect.x * scale - 12.).max(0.), y: (rect.y * scale - 12.).max(0.), }) } }