Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
@@ -0,0 +1,642 @@
|
||||
//! Contextual actions reuse the ordinary guarded application commands.
|
||||
use crate::{
|
||||
app::{App, Message, Page},
|
||||
context_menu::{Entry, Menu},
|
||||
port_workflow, route_edit,
|
||||
};
|
||||
use iced::Point;
|
||||
use oa_core::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub anchor: Point,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
pub fn object(object: Id, anchor: Point) -> Self {
|
||||
Self {
|
||||
anchor,
|
||||
object,
|
||||
placement: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
canvas: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Open(Request),
|
||||
Invoke(u64, Box<Message>),
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct Opened {
|
||||
pub token: u64,
|
||||
pub request: Request,
|
||||
document: Id,
|
||||
revision: u64,
|
||||
epoch: u64,
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
diagram: Option<Id>,
|
||||
}
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
if !app.project().contains(request.object)
|
||||
|| request
|
||||
.canvas
|
||||
.is_some_and(|c| Some(c) != app.route_context())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
token,
|
||||
request,
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
epoch: app.canvas_epoch,
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: app.active_diagram,
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
self.document == app.canvas_document
|
||||
&& self.revision == app.document.revision()
|
||||
&& self.epoch == app.canvas_epoch
|
||||
&& self.page == app.page
|
||||
&& self.layer == app.layer
|
||||
&& self.diagram == app.active_diagram
|
||||
&& app.project().contains(self.request.object)
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
.as_ref()
|
||||
.is_none_or(|path| app.source_workspace.files.contains_key(path))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(object: Id, anchor: Point) -> Message {
|
||||
Message::Menu(Event::Open(Request::object(object, anchor)))
|
||||
}
|
||||
|
||||
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
crate::context_menu::Area::left(
|
||||
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
|
||||
move |anchor| {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
}
|
||||
Message::Menu(Event::Open(request))
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
|
||||
let request = &opened.request;
|
||||
let p = app.project();
|
||||
let id = request.object;
|
||||
let clean =
|
||||
!app.busy && (request.source_file.is_some() || (!app.draft_dirty() && !app.source_dirty()));
|
||||
let item = |caption: &str, message: Message, enabled: bool| {
|
||||
if clean && enabled {
|
||||
Entry::action(
|
||||
caption,
|
||||
Message::Menu(Event::Invoke(opened.token, Box::new(message))),
|
||||
)
|
||||
} else {
|
||||
Entry::disabled(caption)
|
||||
}
|
||||
};
|
||||
if let Some(path) = &request.source_file {
|
||||
let buffer = app.source_workspace.files.get(path)?;
|
||||
let entries = vec![
|
||||
Entry::title(path),
|
||||
item("Open file", Message::SourceFile(path.clone()), true),
|
||||
item("Reveal active file", Message::SourceTreeLocate, true),
|
||||
Entry::separator(),
|
||||
item(
|
||||
"Close detached buffer",
|
||||
Message::SourceCloseFile(path.clone()),
|
||||
!app.source_workspace.reachable.contains(path) && !buffer.dirty(),
|
||||
),
|
||||
Entry::title(if app.source_workspace.reachable.contains(path) {
|
||||
"Included files remain part of the project"
|
||||
} else if buffer.dirty() {
|
||||
"Apply or revert this file before closing"
|
||||
} else {
|
||||
"Closing leaves the file on disk"
|
||||
}),
|
||||
];
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or revert pending edits first"));
|
||||
}
|
||||
|
||||
if request.canvas.is_some() && app.active_diagram == Some(id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Fit diagram", Message::Fit, true),
|
||||
item(
|
||||
"Arrange elements",
|
||||
Message::Command(Command::Layout(id)),
|
||||
true,
|
||||
),
|
||||
item("Export SVG…", Message::Export, true),
|
||||
Entry::separator(),
|
||||
item(
|
||||
&format!("Create {} at root", app.create_kind.label()),
|
||||
Message::Add(false),
|
||||
validation::can_parent(p, app.create_kind, app.layer, None),
|
||||
),
|
||||
item("Browse existing elements…", Message::Explore(None), true),
|
||||
]);
|
||||
} else {
|
||||
if request.route_point.is_some()
|
||||
|| request.bend.is_some()
|
||||
|| (request.canvas.is_some() && p.relations.contains_key(&id))
|
||||
{
|
||||
let diagram = app.active_diagram.and_then(|d| p.diagrams.get(&d));
|
||||
let view = diagram.map(|d| projection::project_view(p, d.id));
|
||||
let projected = view
|
||||
.as_ref()
|
||||
.and_then(|v| v.edges.iter().find(|e| e.id == id));
|
||||
let editable = diagram.zip(projected).is_some_and(|(d, e)| {
|
||||
projection::edge_endpoint_placements(p, d, id)
|
||||
.is_none_or(|(s, t)| s == e.source && t == e.target)
|
||||
});
|
||||
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
|
||||
if let Some(point) = request.route_point {
|
||||
entries.push(
|
||||
item(
|
||||
"Add bend here",
|
||||
Message::Route(route_edit::Event::AddBendAt(point)),
|
||||
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
||||
)
|
||||
.shortcut("Insert"),
|
||||
);
|
||||
} else {
|
||||
entries.push(
|
||||
item(
|
||||
"Add bend",
|
||||
Message::Route(route_edit::Event::AddBend),
|
||||
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
||||
)
|
||||
.shortcut("Insert"),
|
||||
);
|
||||
}
|
||||
if request.bend.is_some() {
|
||||
entries.push(
|
||||
item(
|
||||
"Remove bend",
|
||||
Message::Route(route_edit::Event::RemoveBend),
|
||||
editable,
|
||||
)
|
||||
.shortcut("Delete"),
|
||||
);
|
||||
}
|
||||
entries.push(item(
|
||||
"Reset routing",
|
||||
Message::Route(route_edit::Event::Reset),
|
||||
editable && route.is_some(),
|
||||
));
|
||||
if !editable {
|
||||
entries.push(Entry::title("Expand endpoint containers to edit routing"));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
entries.push(item("Properties", Message::EditProperties(id), true));
|
||||
entries.push(item("Open source", Message::SourceForSelection, true));
|
||||
entries.push(item(
|
||||
"Connections & references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
true,
|
||||
));
|
||||
if let Some(e) = p.elements.get(&id) {
|
||||
if e.kind() == Kind::Port
|
||||
&& app.page == Page::Diagram
|
||||
&& let Some(context) = app.route_context()
|
||||
&& let Some(port) = projection::project_view(p, context.diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == id)
|
||||
{
|
||||
let manual = p.diagrams[&context.diagram].nodes[&port.owner]
|
||||
.port_anchors
|
||||
.iter()
|
||||
.any(|anchor| anchor.port == id);
|
||||
entries.push(item(
|
||||
"Reset automatic position",
|
||||
Message::PortAnchor(crate::port_edit::Event::Reset(context, port.owner, id)),
|
||||
manual,
|
||||
));
|
||||
}
|
||||
if app.page == Page::Diagram {
|
||||
for tool in crate::connection_tool::Tool::for_layer(e.layer()) {
|
||||
if tool.can_start(p, id) {
|
||||
entries.push(item(
|
||||
&format!("Draw {}", tool.label().to_lowercase()),
|
||||
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
||||
app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(
|
||||
p, diagram,
|
||||
))
|
||||
.any(|element| element == id)
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(owner) = e.parent {
|
||||
entries.push(item("Open owner", Message::Select(owner), true));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
for action in port_workflow::actions(p, id) {
|
||||
if matches!(
|
||||
action,
|
||||
port_workflow::Action::Flow | port_workflow::Action::Standard
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let caption = match action {
|
||||
port_workflow::Action::Component => "Add port…",
|
||||
port_workflow::Action::Input => "Add input port…",
|
||||
port_workflow::Action::Output => "Add output port…",
|
||||
_ => action.label(),
|
||||
};
|
||||
entries.push(item(caption, Message::PortAction(action), true));
|
||||
}
|
||||
if let Some(PortContract::Component { kind, orientation }) = port_contract(e) {
|
||||
entries.push(item(
|
||||
"Configure boundary exchange…",
|
||||
Message::AddRelationKind(
|
||||
RelationKind::BoundaryExchange,
|
||||
*kind == ComponentPortKind::Flow
|
||||
&& *orientation == ComponentPortOrientation::In,
|
||||
),
|
||||
true,
|
||||
));
|
||||
}
|
||||
let work = if e.layer() == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
if e.kind().is_performer() || e.kind().is_work() {
|
||||
entries.push(item(
|
||||
if e.kind().is_performer() {
|
||||
"Create allocated work"
|
||||
} else {
|
||||
"Create child work"
|
||||
},
|
||||
Message::CreateContextElement(work, true),
|
||||
true,
|
||||
));
|
||||
} else if validation::can_parent(p, app.create_kind, e.layer(), Some(id))
|
||||
&& app.create_kind != Kind::Port
|
||||
{
|
||||
entries.push(item(
|
||||
&format!("Create child {}", app.create_kind.label()),
|
||||
Message::CreateContextElement(app.create_kind, true),
|
||||
true,
|
||||
));
|
||||
}
|
||||
for (caption, kind, incoming) in relation_actions(e.kind()) {
|
||||
entries.push(item(
|
||||
caption,
|
||||
Message::AddRelationKind(*kind, *incoming),
|
||||
true,
|
||||
));
|
||||
}
|
||||
if let Some(node) = request.placement.and_then(|node| {
|
||||
app.active_diagram
|
||||
.and_then(|d| p.diagrams.get(&d))?
|
||||
.nodes
|
||||
.get(&node)
|
||||
}) {
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item(
|
||||
if node.collapsed {
|
||||
"Unfold contents"
|
||||
} else {
|
||||
"Fold contents"
|
||||
},
|
||||
Message::ToggleCollapse,
|
||||
true,
|
||||
));
|
||||
entries.push(item("Placement properties", Message::Select(node.id), true));
|
||||
entries.push(item("Hide from this diagram", Message::HideSelected, true));
|
||||
} else if e.kind() != Kind::Port
|
||||
&& let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d))
|
||||
{
|
||||
entries.push(item(
|
||||
"Show in this diagram",
|
||||
Message::PlaceSelected,
|
||||
d.layer == e.layer() && d.projection.includes(e.kind()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(r) = p.relations.get(&id) {
|
||||
for action in port_workflow::actions(p, id) {
|
||||
entries.push(item(action.label(), Message::PortAction(action), true));
|
||||
}
|
||||
entries.push(item(
|
||||
"Open source endpoint",
|
||||
Message::Select(r.source()),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Open target endpoint",
|
||||
Message::Select(r.target()),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if p.layer_of(id).is_some() {
|
||||
entries.push(item("Create relationship…", Message::AddRelation, true));
|
||||
entries.push(item(
|
||||
"Create / update counterpart…",
|
||||
Message::StartTransition,
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Attach artifact reference…",
|
||||
Message::AddArtifact,
|
||||
true,
|
||||
));
|
||||
if id != p.id {
|
||||
entries.push(item("Delete from model…", Message::Delete, true));
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
})
|
||||
}
|
||||
|
||||
fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] {
|
||||
match kind {
|
||||
Kind::OperationalMission | Kind::Mission => &[
|
||||
(
|
||||
"Exploit capability…",
|
||||
RelationKind::MissionCapability,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant…",
|
||||
RelationKind::MissionInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
Kind::Capability | Kind::SystemCapability => &[
|
||||
(
|
||||
"Include capability…",
|
||||
RelationKind::CapabilityInclude,
|
||||
false,
|
||||
),
|
||||
("Extend capability…", RelationKind::CapabilityExtend, false),
|
||||
(
|
||||
"Specialize capability…",
|
||||
RelationKind::CapabilityGeneralization,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant or work…",
|
||||
RelationKind::CapabilityInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn choose(app: &App, caption: &str) -> Message {
|
||||
popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Expected enabled menu action {caption}"))
|
||||
}
|
||||
#[test]
|
||||
fn right_click_targets_actor_without_changing_selection_until_action() {
|
||||
let mut app = App::new();
|
||||
let actor = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::SystemActor)
|
||||
.unwrap()
|
||||
.id;
|
||||
let selected = app.selected;
|
||||
let model = app.project().clone();
|
||||
send(&mut app, open(actor, Point::new(240., 180.)));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.project(), &model);
|
||||
let action = choose(&app, "Add port…");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.selected, actor);
|
||||
assert!(matches!(
|
||||
&app.workflow,
|
||||
Some(crate::workflow::Workflow::Ports(_))
|
||||
));
|
||||
assert!(app.model_menu.is_none());
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&model,
|
||||
"Opening the existing creation workflow does not create a port"
|
||||
);
|
||||
send(&mut app, Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert_eq!(app.project().elements.len(), model.elements.len() + 1);
|
||||
assert_eq!(app.project().elements[&app.selected].parent, Some(actor));
|
||||
assert_eq!(app.project().elements[&app.selected].kind(), Kind::Port);
|
||||
}
|
||||
#[test]
|
||||
fn menu_preserves_pending_properties_and_rejects_delayed_mutations() {
|
||||
let mut app = App::new();
|
||||
let target = app.project().elements.keys().next().copied().unwrap();
|
||||
let selected = app.selected;
|
||||
let model = app.project().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied project title".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
let draft = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
send(&mut app, open(target, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
let token = app.model_menu.as_ref().unwrap().token;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Menu(Event::Invoke(token, Box::new(Message::Delete))),
|
||||
);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), draft);
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
#[test]
|
||||
fn late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context() {
|
||||
let mut app = App::new();
|
||||
let id = app.selected;
|
||||
send(&mut app, open(id, Point::ORIGIN));
|
||||
let old = choose(&app, "Open source");
|
||||
send(&mut app, open(id, Point::new(200., 80.)));
|
||||
let current = app.model_menu.as_ref().unwrap().token;
|
||||
send(&mut app, old);
|
||||
assert_eq!(app.model_menu.as_ref().unwrap().token, current);
|
||||
let stale = choose(&app, "Open source");
|
||||
app.canvas_epoch += 1;
|
||||
send(&mut app, stale);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(app.model_menu.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn pending_source_allows_file_navigation_but_disables_model_actions() {
|
||||
let mut app = App::new();
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Insert('x'),
|
||||
)),
|
||||
);
|
||||
assert!(app.source_dirty());
|
||||
let original = app.source_editor.text();
|
||||
let file = app.source_workspace.active.clone();
|
||||
let mut request = Request::object(app.project().id, Point::ORIGIN);
|
||||
request.source_file = Some(file.clone());
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let action = choose(&app, "Open file");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.source_editor.text(), original);
|
||||
assert_eq!(app.source_workspace.active, file);
|
||||
let id = app.project().id;
|
||||
send(&mut app, open(id, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
}
|
||||
#[test]
|
||||
fn add_bend_here_uses_context_point_and_one_source_aware_history_change() {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Activity, "Send", 0., 0.);
|
||||
let b = p.add(Kind::Activity, "Receive", 650., 0.);
|
||||
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let mut app = App::with_project(p);
|
||||
let original = app.document.bundle().clone();
|
||||
let point = Position { x: 420., y: -80. };
|
||||
let mut request = Request::object(edge, Point::new(540., 360.));
|
||||
request.canvas = app.route_context();
|
||||
request.route_point = Some(point);
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let action = choose(&app, "Add bend here");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.selected, edge);
|
||||
assert_eq!(app.selected_bend, Some((edge, 0)));
|
||||
assert_eq!(
|
||||
app.project().diagrams[&diagram].routes[0].waypoints,
|
||||
vec![point]
|
||||
);
|
||||
let changed = app.document.bundle().clone();
|
||||
assert_ne!(original, changed);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
|
||||
let mut p = Project::empty();
|
||||
let parent = p.add(Kind::Activity, "Respond", 0., 0.);
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess", Some(parent))
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let parent_node = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == parent)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent_node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let mut app = App::with_project(p);
|
||||
let original = app.document.bundle().clone();
|
||||
send(&mut app, Message::Explore(Some(child)));
|
||||
send(&mut app, open(child, Point::new(90., 300.)));
|
||||
let action = choose(&app, "Show in this diagram");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.selected, child);
|
||||
assert!(
|
||||
app.selected_node
|
||||
.is_some_and(|node| app.project().diagrams[&diagram].nodes[&node].element == child)
|
||||
);
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&parent_node].collapsed);
|
||||
assert!(
|
||||
projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.element == child)
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&original,
|
||||
"One Undo restores both placement and folded ancestors"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user