Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
connection_edit,
|
||||
connection_tool::Tool,
|
||||
diagram::{DiagramCanvas, State},
|
||||
model_menu, port_edit,
|
||||
};
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Vector, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 200.);
|
||||
let b = p.add(Kind::Function, "Receive", 500., 200.);
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
p.elements.insert(
|
||||
output,
|
||||
make_port(
|
||||
&p,
|
||||
output,
|
||||
a,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.elements.insert(
|
||||
input,
|
||||
make_port(
|
||||
&p,
|
||||
input,
|
||||
b,
|
||||
"Receive",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
(
|
||||
{
|
||||
let mut app = App::with_project(p);
|
||||
app.pan = Vector::new(20., 30.);
|
||||
app
|
||||
},
|
||||
output,
|
||||
input,
|
||||
)
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
|
||||
}
|
||||
fn screen(p: Position, zoom: f32) -> Point {
|
||||
Point::new(bounds().x + p.x * zoom + 20., bounds().y + p.y * zoom + 30.)
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
fn event(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> Option<Message> {
|
||||
c.update(
|
||||
state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn batch(app: &App, state: &mut State, start: Point, end: Point, zoom: f32) -> Vec<Message> {
|
||||
let c = canvas(app, zoom);
|
||||
[
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, state, e, end))
|
||||
.collect()
|
||||
}
|
||||
fn assert_history(app: &mut App, before: &oa_core::source_project::SourceBundle, revision: u64) {
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let after = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_fe_ports_commit_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let at = |id| {
|
||||
screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == id)
|
||||
.unwrap()
|
||||
.position,
|
||||
zoom,
|
||||
)
|
||||
};
|
||||
let messages = batch(&app, &mut State::default(), at(output), at(input), zoom);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(
|
||||
matches!(&messages[0],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==output)
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[1],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==input)
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(
|
||||
matches!(app.project().relations[&app.selected].body,RelationBody::FunctionalExchange{source_port,target_port,..}if source_port==output&&target_port==input)
|
||||
);
|
||||
assert!(app.connection.is_none());
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_port_drag_commits_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, _) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let dest = projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35);
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(port.position, zoom),
|
||||
screen(dest, zoom),
|
||||
zoom,
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
|
||||
assert!(
|
||||
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
|
||||
);
|
||||
let node = owner.id;
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(app.project().relations.is_empty());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()].nodes[&node].port_anchors[0].port,
|
||||
output
|
||||
);
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let a = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == output)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let b = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let _ = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: a },
|
||||
a,
|
||||
);
|
||||
let right = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
a,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(right);
|
||||
assert!(app.model_menu.is_some());
|
||||
// The overlay consumes the right release, movement to B, outside press and
|
||||
// its release. None of those events reach the underlying canvas.
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Close));
|
||||
let press = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
b,
|
||||
)
|
||||
.unwrap();
|
||||
let release = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
b,
|
||||
);
|
||||
let picked = match &press {
|
||||
Message::Select(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let release_debug = format!("{release:?}");
|
||||
let _ = app.update(press);
|
||||
if let Some(message) = release {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
picked,
|
||||
Some(input),
|
||||
"Click at B picked another port; following release: {release_debug}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"A stationary click after closing a menu must not edit port geometry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
|
||||
let (mut app, output, _) = fixture();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let a = screen(port.position, 1.);
|
||||
let b = screen(
|
||||
projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35),
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let selected = batch(&app, &mut state, a, a, 1.);
|
||||
for message in selected {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.selected, output);
|
||||
// No new motion to A occurs: the previous selection ended there. Winit
|
||||
// batches press(A), motion(B), release(B) with cursor B on every event.
|
||||
let c = canvas(&app, 1.);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, b))
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
|
||||
"Stationary source was lost between batches: {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(messages.last(),Some(Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))) if anchor.port==output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_and_stale_epoch_block_queued_connection_and_port_commits() {
|
||||
for is_connection in [false, true] {
|
||||
for barrier in ["menu", "modal", "epoch"] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
if is_connection {
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
}
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let out = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let target = if is_connection {
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position
|
||||
} else {
|
||||
projection::port_anchor_position(
|
||||
view.nodes.iter().find(|n| n.id == out.owner).unwrap(),
|
||||
PortAnchorSide::Top,
|
||||
0.35,
|
||||
)
|
||||
};
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(out.position, 1.),
|
||||
screen(target, 1.),
|
||||
1.,
|
||||
);
|
||||
match barrier {
|
||||
"menu" => {
|
||||
let _ = app.update(model_menu::open(output, Point::ORIGIN));
|
||||
}
|
||||
"modal" => {
|
||||
let _ = app.update(Message::AddRelation);
|
||||
}
|
||||
_ => {
|
||||
let _ = app.update(Message::Escape);
|
||||
}
|
||||
}
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"{barrier}; connection={is_connection}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user