Add direct diagram connections, movable ports, and contextual authoring

This commit is contained in:
2026-09-06 02:13:49 +03:00
parent 35133de650
commit fef2e0dbce
39 changed files with 7076 additions and 499 deletions
+318 -8
View File
@@ -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.,