Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
@@ -82,6 +82,13 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
PortAnchor(crate::port_edit::Event),
|
||||
Connection(crate::connection_edit::Event),
|
||||
Menu(crate::model_menu::Event),
|
||||
CreateContextElement(Kind, bool),
|
||||
SourceTree(crate::source_tree::Event),
|
||||
SourceTreeCollapse,
|
||||
SourceTreeLocate,
|
||||
ExactStrings(bool),
|
||||
Page(Page),
|
||||
Guide(crate::guide::Event),
|
||||
@@ -176,6 +183,10 @@ pub enum Message {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
menu_sequence: u64,
|
||||
pub canvas_document: Id,
|
||||
pub canvas_epoch: u64,
|
||||
pub selected_bend: Option<(Id, usize)>,
|
||||
@@ -260,6 +271,10 @@ impl App {
|
||||
let explorer_index = crate::explorer::Index::new(&project, layer);
|
||||
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
|
||||
Self {
|
||||
connection: None,
|
||||
model_menu: None,
|
||||
source_tree: Default::default(),
|
||||
menu_sequence: 0,
|
||||
canvas_document: Id::new_v4(),
|
||||
canvas_epoch: 0,
|
||||
selected_bend: None,
|
||||
@@ -346,6 +361,7 @@ impl App {
|
||||
self.lint_requested = None;
|
||||
}
|
||||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
self.source_select_line = false;
|
||||
let source = self.source_workspace.text().to_owned();
|
||||
self.source_navigation.refresh(&source);
|
||||
@@ -465,7 +481,10 @@ impl App {
|
||||
.and_then(|d| Ok((d.command()?, d.resulting_id()?)));
|
||||
match result.and_then(|(command, id)| {
|
||||
self.document
|
||||
.execute(command)
|
||||
.execute(crate::port_edit::repair_owner_change(
|
||||
self.document.project(),
|
||||
command,
|
||||
))
|
||||
.map(|_| id)
|
||||
.map_err(|e| e.to_string())
|
||||
}) {
|
||||
@@ -505,6 +524,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
fn execute(&mut self, command: Command) -> bool {
|
||||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(id) = selected {
|
||||
@@ -577,13 +597,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
crate::views::view(self)
|
||||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
||||
.into()
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
iced::event::listen_with(|event, _, _| {
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers: mods,
|
||||
@@ -593,6 +614,9 @@ impl App {
|
||||
return None;
|
||||
};
|
||||
if key == keyboard::Key::Named(keyboard::key::Named::Escape) {
|
||||
if status == iced::event::Status::Captured {
|
||||
return None;
|
||||
}
|
||||
return Some(Message::Escape);
|
||||
}
|
||||
if key == keyboard::Key::Named(keyboard::key::Named::F1) {
|
||||
@@ -624,6 +648,32 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Global subscriptions also receive keys consumed by an overlay.
|
||||
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
||||
self.model_menu = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::none();
|
||||
}
|
||||
if self.model_menu.is_some()
|
||||
&& !matches!(
|
||||
message,
|
||||
Message::Menu(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::SourceViewport
|
||||
| Message::Request(Pending::Close(_))
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
message,
|
||||
Message::WindowResized(_) | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.model_menu = None;
|
||||
}
|
||||
if self.source_dirty()
|
||||
&& matches!(
|
||||
message,
|
||||
@@ -723,6 +773,14 @@ impl App {
|
||||
let entering_catalog =
|
||||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||||
let mut task = self.handle(message);
|
||||
if self.connection.as_ref().is_some_and(|session| {
|
||||
Some(session.context) != self.route_context()
|
||||
|| self.page != Page::Diagram
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
}) {
|
||||
self.connection = None;
|
||||
}
|
||||
if previous_layer != self.layer {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.explorer_focus = None;
|
||||
@@ -758,7 +816,135 @@ impl App {
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::PortAnchor(event) => {
|
||||
use crate::port_edit::Event;
|
||||
let (captured, node, port, command) = match event {
|
||||
Event::Commit(c, node, anchor) => (
|
||||
c,
|
||||
node,
|
||||
anchor.port,
|
||||
Command::SetPortAnchor {
|
||||
diagram: c.diagram,
|
||||
node,
|
||||
anchor,
|
||||
},
|
||||
),
|
||||
Event::Reset(c, node, port) => (
|
||||
c,
|
||||
node,
|
||||
port,
|
||||
Command::RemovePortAnchor {
|
||||
diagram: c.diagram,
|
||||
node,
|
||||
port,
|
||||
},
|
||||
),
|
||||
};
|
||||
if self.route_context() != Some(captured)
|
||||
|| self.page != Page::Diagram
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| self.connection.is_some()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let view = project_view(self.project(), captured.diagram);
|
||||
if !view
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == port && p.owner == node)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.execute(command) {
|
||||
self.selected_node = None;
|
||||
self.status = "Port position updated in this diagram · Undo restores its previous position".into();
|
||||
}
|
||||
}
|
||||
Message::Connection(event) => self.edit_connection(event),
|
||||
Message::SourceTree(event) => match event {
|
||||
crate::source_tree::Event::ToggleFolder(path) => self.source_tree.toggle(&path),
|
||||
crate::source_tree::Event::OpenFile(path) => {
|
||||
return self.update(Message::SourceFile(path));
|
||||
}
|
||||
crate::source_tree::Event::FileMenu(path, anchor) => {
|
||||
let mut request = crate::model_menu::Request::object(self.project().id, anchor);
|
||||
request.source_file = Some(path);
|
||||
return self.update(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
}
|
||||
},
|
||||
Message::SourceTreeCollapse => self.source_tree.collapse_all(),
|
||||
Message::SourceTreeLocate => {
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
return iced::widget::operation::scroll_to(
|
||||
"source-files",
|
||||
crate::source_tree::active_offset(
|
||||
&self.source_workspace,
|
||||
&self.source_diagnostics,
|
||||
&self.source_tree,
|
||||
),
|
||||
);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Open(request)) => {
|
||||
// Validate the originating canvas before advancing its input
|
||||
// epoch. Actions are then generated with the refreshed context,
|
||||
// while pointer history hidden behind the menu becomes stale.
|
||||
let Some(opened) = crate::model_menu::Opened::new(
|
||||
self,
|
||||
request,
|
||||
self.menu_sequence.wrapping_add(1),
|
||||
) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.connection = None;
|
||||
self.menu_sequence = opened.token;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let mut request = opened.request;
|
||||
if request.canvas.is_some() {
|
||||
request.canvas = self.route_context();
|
||||
}
|
||||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Close) => {
|
||||
if self.model_menu.take().is_some() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Invoke(token, action)) => {
|
||||
if self
|
||||
.model_menu
|
||||
.as_ref()
|
||||
.is_none_or(|menu| menu.token != token)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(menu) = self.model_menu.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
if menu.token != token || !menu.valid(self) {
|
||||
return Task::none();
|
||||
}
|
||||
if menu.request.source_file.is_some() {
|
||||
return self.update(*action);
|
||||
}
|
||||
if self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
self.selected_node = menu.request.placement;
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
}
|
||||
Message::CreateContextElement(kind, child) => {
|
||||
self.create_kind = kind;
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.connection.take().is_some() {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
self.source_navigation.open = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
@@ -1315,6 +1501,7 @@ impl App {
|
||||
if self.apply()
|
||||
&& let Some(diagram) = self.active_diagram
|
||||
{
|
||||
let selected = self.selected;
|
||||
if let Some(node) = self
|
||||
.project()
|
||||
.diagrams
|
||||
@@ -1324,7 +1511,7 @@ impl App {
|
||||
{
|
||||
self.selected_node = Some(node);
|
||||
self.status = "Element is already shown in this diagram".into();
|
||||
return Task::none();
|
||||
return self.handle(Message::RevealInDiagram(diagram, selected));
|
||||
}
|
||||
let mut p = self.project().clone();
|
||||
let node = p.place(diagram, self.selected, Position { x: 60., y: 60. });
|
||||
@@ -1333,9 +1520,24 @@ impl App {
|
||||
.get(&diagram)
|
||||
.and_then(|d| d.nodes.get(&node))
|
||||
.cloned()
|
||||
&& self.execute(Command::CreateNode { diagram, node: n })
|
||||
{
|
||||
self.selected_node = Some(node);
|
||||
let mut commands = vec![Command::CreateNode { diagram, node: n }];
|
||||
for owner in p.diagrams[&diagram].nodes.values().filter(|n| {
|
||||
n.collapsed
|
||||
&& projection::descendant_nodes(&p, diagram, n.id).contains(&node)
|
||||
}) {
|
||||
let mut expanded = owner.clone();
|
||||
expanded.collapsed = false;
|
||||
commands.push(Command::EditNode {
|
||||
diagram,
|
||||
original: owner.id,
|
||||
node: expanded,
|
||||
});
|
||||
}
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
self.selected_node = Some(node);
|
||||
return self.handle(Message::RevealInDiagram(diagram, selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2356,8 +2558,111 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
|
||||
use crate::connection_edit::{Event, Session};
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
if matches!(event, Event::Choose(None)) {
|
||||
self.connection = None;
|
||||
self.status =
|
||||
"Select · drag elements, ports and connection routes to arrange the diagram".into();
|
||||
return;
|
||||
}
|
||||
if self.page != Page::Diagram || self.source_dirty() || self.draft_dirty() {
|
||||
self.status = "Apply or revert pending edits before drawing a connection.".into();
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
Event::Begin(tool, source) => {
|
||||
self.edit_connection(Event::Choose(Some(tool)));
|
||||
if let Some(context) = self.route_context() {
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
}
|
||||
}
|
||||
Event::Choose(Some(tool)) => {
|
||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||
return;
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.route_context().unwrap();
|
||||
self.connection = Some(Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
}
|
||||
Event::Endpoint(captured, id) => {
|
||||
if captured != context {
|
||||
return;
|
||||
}
|
||||
let Some(mut session) = self.connection.take() else {
|
||||
return;
|
||||
};
|
||||
if session.context != context {
|
||||
return;
|
||||
}
|
||||
if !session.candidates.contains(&id) {
|
||||
self.status =
|
||||
"Choose a highlighted compatible endpoint · Esc cancels drawing".into();
|
||||
self.connection = Some(session);
|
||||
return;
|
||||
}
|
||||
if let Some(source) = session.source {
|
||||
match session.tool.command(self.project(), source, id) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected_node = None;
|
||||
self.inspector_tab = Default::default();
|
||||
self.status = format!(
|
||||
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
|
||||
session.tool.label()
|
||||
);
|
||||
} else {
|
||||
self.connection = Some(session);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
self.connection = Some(session);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
session.start(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
id,
|
||||
);
|
||||
self.select(id);
|
||||
self.status = if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
|
||||
} else {
|
||||
format!(
|
||||
"From {} · choose a highlighted target · Esc cancels",
|
||||
self.project().named(id)
|
||||
)
|
||||
};
|
||||
self.connection = Some(session);
|
||||
}
|
||||
}
|
||||
Event::Restart(captured) => {
|
||||
if captured == context
|
||||
&& let Some(session) = &self.connection
|
||||
{
|
||||
self.edit_connection(Event::Choose(Some(session.tool)));
|
||||
}
|
||||
}
|
||||
Event::Choose(None) => {}
|
||||
}
|
||||
}
|
||||
fn edit_route(&mut self, event: crate::route_edit::Event) {
|
||||
use crate::route_edit::{Event, insert_pin, stored_route};
|
||||
if self.connection.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
@@ -2402,7 +2707,7 @@ impl App {
|
||||
route,
|
||||
}
|
||||
}
|
||||
Event::AddBend => {
|
||||
Event::AddBend | Event::AddBendAt(_) => {
|
||||
if route.waypoints.len() >= 128 {
|
||||
return;
|
||||
}
|
||||
@@ -2421,7 +2726,12 @@ impl App {
|
||||
|s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
|
||||
len(a).total_cmp(&len(b))
|
||||
});
|
||||
let point = if let Some(segment) = segment {
|
||||
let point = if let Event::AddBendAt(point) = event {
|
||||
if !point.x.is_finite() || !point.y.is_finite() {
|
||||
return;
|
||||
}
|
||||
point
|
||||
} else if let Some(segment) = segment {
|
||||
Position {
|
||||
x: (segment[0].x + segment[1].x) / 2.,
|
||||
y: (segment[0].y + segment[1].y) / 2.,
|
||||
|
||||
Reference in New Issue
Block a user