Placement of newly opened Component Browser dictated by the mouse pointer. (#3301)

Use a new algorithm for placement of new nodes in cases when:

- a) there is no selected node, and the `TAB` key is pressed while the mouse pointer is near an existing node (especially in an area below an existing node);
- b) a connection is dragged out from an existing node and dropped near the node (especially in an area below the node).

In both cases mentioned above, the new node will now be placed in a location suggested by an internal algorithm, aligned to existing nodes. Specifically, the placement algorithm used is similar to when pressing `TAB` with a node selected.

For more details, see: https://www.pivotaltracker.com/story/show/181076066

# Important Notes
- Visible visualizations enabled with the "eye icon" button are treated as part of a node. (In case of nodes with errors, visualizations are not visible, and are not treated as part of a node.)
This commit is contained in:
Mateusz Czapliński 2022-03-31 16:16:28 +02:00 committed by GitHub
parent 43265f10a8
commit b8a5e22ef8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 604 additions and 108 deletions

View File

@ -2,6 +2,11 @@
#### Visual Environment
- [Nodes created near existing nodes via the <kbd>TAB</kbd> key or by dropping a
connection are now repositioned and aligned to existing nodes.][3301] This is
to make the resulting graph prettier and avoid overlapping. In such cases,
created nodes will be placed below an existing node or on the bottom-left
diagonal if there is no space underneath.
- [Nodes can be added to the graph by double-clicking the output ports of
existing nodes (or by clicking them with the right mouse button).][3346]
- [Node Searcher preserves its zoom factor.][3327] The visible size of the node
@ -106,6 +111,7 @@
[3285]: https://github.com/enso-org/enso/pull/3285
[3287]: https://github.com/enso-org/enso/pull/3287
[3292]: https://github.com/enso-org/enso/pull/3292
[3301]: https://github.com/enso-org/enso/pull/3301
[3302]: https://github.com/enso-org/enso/pull/3302
[3305]: https://github.com/enso-org/enso/pull/3305
[3309]: https://github.com/enso-org/enso/pull/3309

View File

@ -41,4 +41,7 @@ debug-assertions = true
[profile.integration-test]
inherits = "test"
opt-level = 2
# The integration-test profile was created to be able run integration tests with optimizations (as they took a lot of
# time). There is, however, an issue with running them with optimizations #181740444.
# opt-level = 2
opt-level = 0

View File

@ -610,21 +610,22 @@ impl NodeModel {
self.drag_area.size.set(padded_size);
self.error_indicator.size.set(padded_size);
self.vcs_indicator.set_size(padded_size);
self.backdrop.mod_position(|t| t.x = width / 2.0);
self.background.mod_position(|t| t.x = width / 2.0);
self.drag_area.mod_position(|t| t.x = width / 2.0);
self.error_indicator.set_position_x(width / 2.0);
self.vcs_indicator.set_position_x(width / 2.0);
let x_offset_to_node_center = x_offset_to_node_center(width);
self.backdrop.set_position_x(x_offset_to_node_center);
self.background.set_position_x(x_offset_to_node_center);
self.drag_area.set_position_x(x_offset_to_node_center);
self.error_indicator.set_position_x(x_offset_to_node_center);
self.vcs_indicator.set_position_x(x_offset_to_node_center);
let action_bar_width = ACTION_BAR_WIDTH;
self.action_bar.mod_position(|t| {
t.x = width + CORNER_RADIUS + action_bar_width / 2.0;
t.x = x_offset_to_node_center + width / 2.0 + CORNER_RADIUS + action_bar_width / 2.0;
});
self.action_bar.frp.set_size(Vector2::new(action_bar_width, ACTION_BAR_HEIGHT));
let visualization_pos = Vector2(width / 2.0, VISUALIZATION_OFFSET_Y);
self.error_visualization.set_position_xy(visualization_pos);
self.visualization.set_position_xy(visualization_pos);
let visualization_offset = visualization_offset(width);
self.error_visualization.set_position_xy(visualization_offset);
self.visualization.set_position_xy(visualization_offset);
size
}
@ -752,14 +753,6 @@ impl Node {
eval new_size ((t) model.output.frp.set_size.emit(t));
// === Bounding Box ===
bounding_box_input <- all2(&new_size,&position);
out.bounding_box <+ bounding_box_input.map(|(size,position)| {
let position = position - Vector2::new(0.0,size.y / 2.0);
BoundingBox::from_position_and_size(position,*size)
});
// === Action Bar ===
let visualization_enabled = action_bar.action_visibility.clone_ref();
@ -934,6 +927,16 @@ impl Node {
<+ visualization_visible.not().and(&no_error_set);
// === Bounding Box ===
let visualization_size = &model.visualization.frp.size;
// Visualization can be enabled and not visible when the node has an error.
visualization_enabled_and_visible <- visualization_enabled && visualization_visible;
bbox_input <- all4(
&position,&new_size,&visualization_enabled_and_visible,visualization_size);
out.bounding_box <+ bbox_input.map(|(a,b,c,d)| bounding_box(*a,*b,*c,*d));
// === VCS Handling ===
model.vcs_indicator.frp.set_status <+ input.set_vcs_status;
@ -971,6 +974,40 @@ impl display::Object for Node {
}
// === Positioning ===
fn x_offset_to_node_center(node_width: f32) -> f32 {
node_width / 2.0
}
/// Calculate a position where to render the [`visualization::Container`] of a node, relative to
/// the node's origin.
fn visualization_offset(node_width: f32) -> Vector2 {
Vector2(x_offset_to_node_center(node_width), VISUALIZATION_OFFSET_Y)
}
fn bounding_box(
node_position: Vector2,
node_size: Vector2,
visualization_enabled_and_visible: bool,
visualization_size: Vector2,
) -> BoundingBox {
let x_offset_to_node_center = x_offset_to_node_center(node_size.x);
let node_bbox_pos = node_position + Vector2(x_offset_to_node_center, 0.0) - node_size / 2.0;
let node_bbox = BoundingBox::from_position_and_size(node_bbox_pos, node_size);
if visualization_enabled_and_visible {
let visualization_offset = visualization_offset(node_size.x);
let visualization_pos = node_position + visualization_offset;
let visualization_bbox_pos = visualization_pos - visualization_size / 2.0;
let visualization_bbox =
BoundingBox::from_position_and_size(visualization_bbox_pos, visualization_size);
node_bbox.concat_ref(visualization_bbox)
} else {
node_bbox
}
}
// ==================
// === Test Utils ===

View File

@ -31,8 +31,7 @@ pub mod component;
pub mod builtin;
pub mod data;
#[warn(missing_docs)]
pub mod free_place_finder;
pub mod new_node_position;
#[warn(missing_docs)]
pub mod profiling;
#[warn(missing_docs)]
@ -49,8 +48,6 @@ use crate::component::visualization;
use crate::component::visualization::instance::PreprocessorConfiguration;
use crate::component::visualization::MockDataGenerator3D;
use crate::data::enso;
use crate::free_place_finder::find_free_place;
use crate::free_place_finder::OccupiedArea;
pub use crate::node::profiling::Status as NodeProfilingStatus;
use enso_config::ARGS;
@ -394,6 +391,12 @@ impl<K, V, S> SharedHashMap<K, V, S> {
where K: Clone {
self.raw.borrow().keys().cloned().collect_vec()
}
/// Get the vector of map's values.
pub fn values(&self) -> Vec<V>
where V: Clone {
self.raw.borrow().values().cloned().collect_vec()
}
}
@ -1388,17 +1391,20 @@ impl GraphEditorModelWithNetwork {
// === Node Creation ===
/// Describes the way used to request creation of a new node.
#[derive(Clone, Debug)]
enum WayOfCreatingNode {
pub enum WayOfCreatingNode {
/// "add_node" FRP event was emitted.
AddNodeEvent,
/// "start_node_creation" FRP event was emitted.
StartCreationEvent,
/// "start_node_creation_from_port" FRP event was emitted.
#[allow(missing_docs)]
StartCreationFromPortEvent { endpoint: EdgeEndpoint },
/// add_node_button was clicked.
ClickingButton,
/// The edge was dropped on the stage.
#[allow(missing_docs)]
DroppingEdge { edge_id: EdgeId },
}
@ -1425,34 +1431,31 @@ impl GraphEditorModelWithNetwork {
way: &WayOfCreatingNode,
mouse_position: Vector2,
) -> (NodeId, Option<NodeSource>, bool) {
use WayOfCreatingNode::*;
let should_edit = !matches!(way, AddNodeEvent);
let selection = self.nodes.selected.first_cloned();
let position = new_node_position::new_node_position(self, way, selection, mouse_position);
let node = self.new_node(ctx);
node.set_position_xy(position);
let should_edit = !matches!(way, WayOfCreatingNode::AddNodeEvent);
if should_edit {
node.view.set_expression(node::Expression::default());
}
let source = self.data_source_for_new_node(way, selection);
(node.id(), source, should_edit)
}
fn data_source_for_new_node(
&self,
way: &WayOfCreatingNode,
selection: Option<NodeId>,
) -> Option<NodeSource> {
use WayOfCreatingNode::*;
let source_node = match way {
AddNodeEvent => None,
StartCreationEvent | ClickingButton => selection,
DroppingEdge { edge_id } => self.edge_source_node_id(*edge_id),
StartCreationFromPortEvent { endpoint } => Some(endpoint.node_id),
};
let source = source_node.map(|node| NodeSource { node });
let screen_center =
self.scene().screen_to_object_space(&self.display_object, Vector2(0.0, 0.0));
let position: Vector2 = match way {
AddNodeEvent => default(),
StartCreationEvent | ClickingButton if selection.is_some() =>
self.find_free_place_under(selection.unwrap()),
StartCreationEvent => mouse_position,
ClickingButton =>
self.find_free_place_for_node(screen_center, Vector2(0.0, -1.0)).unwrap(),
DroppingEdge { .. } => mouse_position,
StartCreationFromPortEvent { endpoint } => self.find_free_place_under(endpoint.node_id),
};
let node = self.new_node(ctx);
node.set_position_xy(position);
if should_edit {
node.view.set_expression(node::Expression::default());
}
(node.id(), source, should_edit)
source_node.map(|node| NodeSource { node })
}
fn new_node(&self, ctx: &NodeCreationContext) -> Node {
@ -1722,7 +1725,7 @@ impl GraphEditorModel {
/// Create a new node and place it at a free place below `above` node.
pub fn add_node_below(&self, above: NodeId) -> NodeId {
let pos = self.find_free_place_under(above);
let pos = new_node_position::under(self, above);
self.add_node_at(pos)
}
@ -1732,46 +1735,6 @@ impl GraphEditorModel {
self.frp.set_node_position((node_id, pos));
node_id
}
/// Return the first available position for a new node below `node_above` node.
pub fn find_free_place_under(&self, node_above: NodeId) -> Vector2 {
let above_pos = self.node_position(node_above);
let y_gap = self.frp.default_y_gap_between_nodes.value();
let y_offset = y_gap + node::HEIGHT;
let starting_point = above_pos - Vector2(0.0, y_offset);
let direction = Vector2(-1.0, 0.0);
self.find_free_place_for_node(starting_point, direction).unwrap()
}
/// Return the first unoccupied point when going along the ray starting from `starting_point`
/// and parallel to `direction` vector.
pub fn find_free_place_for_node(
&self,
starting_from: Vector2,
direction: Vector2,
) -> Option<Vector2> {
let x_gap = self.frp.default_x_gap_between_nodes.value();
let y_gap = self.frp.default_y_gap_between_nodes.value();
// This is how much horizontal space we are looking for.
let min_spacing = self.frp.min_x_spacing_for_new_nodes.value();
let nodes = self.nodes.all.raw.borrow();
// The "occupied area" for given node consists of:
// - area taken by node view (obviously);
// - the minimum gap between nodes in all directions, so the new node won't be "glued" to
// another;
// - the new node size measured from origin point at each direction accordingly: because
// `find_free_place` looks for free place for the origin point, and we want to fit not
// only the point, but the whole node.
let node_areas = nodes.values().map(|node| {
let position = node.position();
let left = position.x - x_gap - min_spacing;
let right = position.x + node.view.model.width() + x_gap;
let top = position.y + node::HEIGHT + y_gap;
let bottom = position.y - node::HEIGHT - y_gap;
OccupiedArea { x1: left, x2: right, y1: top, y2: bottom }
});
find_free_place(starting_from, direction, node_areas)
}
}

View File

@ -0,0 +1,241 @@
//! This module provides functions returning positions for newly created nodes.
//!
//! The returned positions are such that the new nodes will not overlap with existing ones.
pub mod free_place_finder;
use crate::prelude::*;
use crate::component::node;
use crate::new_node_position::free_place_finder::find_free_place;
use crate::new_node_position::free_place_finder::OccupiedArea;
use crate::selection::BoundingBox;
use crate::EdgeId;
use crate::GraphEditorModel;
use crate::Node;
use crate::NodeId;
use crate::WayOfCreatingNode;
use ensogl_hardcoded_theme as theme;
/// ============================
/// === New Node Positioning ===
/// ============================
/// Return a position for a newly created node. The position is calculated by establishing a
/// reference position and then aligning it to existing nodes.
///
/// The reference position is chosen from among:
/// - the position of a source node of the dropped edge (if available),
/// - the mouse position,
/// - the screen center.
/// The position is then aligned to either:
/// - the source node of the dropped edge (if available),
/// - the `selection` (if available),
/// - the node closest to the reference position (if available),
/// - not aligned.
/// The choice among the options described above is governed by the `way`.
///
/// To learn more about the align algorithm, see the docs of [`aligned_if_close_to_node`].
pub fn new_node_position(
graph_editor: &GraphEditorModel,
way: &WayOfCreatingNode,
selection: Option<NodeId>,
mouse_position: Vector2,
) -> Vector2 {
use WayOfCreatingNode::*;
let scene = graph_editor.scene();
let origin = Vector2(0.0, 0.0);
let screen_center = scene.screen_to_object_space(&graph_editor.display_object, origin);
assert!(!screen_center.x.is_nan());
assert!(!screen_center.y.is_nan());
match way {
AddNodeEvent => default(),
StartCreationEvent | ClickingButton if selection.is_some() =>
under(graph_editor, selection.unwrap()),
StartCreationEvent => at_mouse_aligned_to_close_nodes(graph_editor, mouse_position),
ClickingButton => on_ray(graph_editor, screen_center, Vector2(0.0, -1.0)).unwrap(),
DroppingEdge { edge_id } =>
at_mouse_aligned_to_source_node(graph_editor, *edge_id, mouse_position),
StartCreationFromPortEvent { endpoint } => under(graph_editor, endpoint.node_id),
}
}
/// Return a position for a newly created node. The position is calculated by taking the mouse
/// position and aligning it to the closest existing node if the mouse position is close enough to
/// the node.
///
/// To learn more about the align algorithm, see the docs of [`aligned_if_close_to_node`].
pub fn at_mouse_aligned_to_close_nodes(
graph_editor: &GraphEditorModel,
mouse_position: Vector2,
) -> Vector2 {
let nearest_node = node_nearest_to_point(graph_editor, mouse_position);
aligned_if_close_to_node(graph_editor, mouse_position, nearest_node)
}
/// Return a position for a newly created node. The position is calculated by taking the mouse
/// position and aligning it to the source node (the node at the source of the [`edge_id`] edge) if
/// the source node is close to the mouse position.
///
/// To learn more about the align algorithm, see the docs of [`aligned_if_close_to_node`].
pub fn at_mouse_aligned_to_source_node(
graph_editor: &GraphEditorModel,
edge_id: EdgeId,
mouse_position: Vector2,
) -> Vector2 {
let source_node_id = graph_editor.edge_source_node_id(edge_id);
let source_node = source_node_id.and_then(|id| graph_editor.nodes.get_cloned_ref(&id));
aligned_if_close_to_node(graph_editor, mouse_position, source_node)
}
/// Return a position for a newly created node, aligning it to the `alignment_node` if the proposed
/// position is close enough to it.
///
/// A point is close enough to a node if it is located in an alignment area around a node,
/// defined in the current theme ([`theme::graph_editor::alignment_area_around_node`]).
/// The alignment algorithm is described in the docs of [`under`].
///
/// In the picture below, the solid border represents the node, while the dashed border
/// represents the alignment area. The captions are used as the variables in the code.
/// ```text
/// ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐
/// ┆ ▲ ┆
/// ┆ top │ ┆
/// ┆ ▼ ┆
/// ┆ left ┌───────────────┐ right ┆
/// ┆ ◀───▶ └───────────────┘ ◀───▶ ┆
/// ┆ ▲ ┆
/// ┆ bottom │ ┆
/// ┆ ▼ ┆
/// └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
/// ```
pub fn aligned_if_close_to_node(
graph_editor: &GraphEditorModel,
proposed_position: Vector2,
alignment_node: Option<Node>,
) -> Vector2 {
let alignment_node = alignment_node.filter(|node| {
use theme::graph_editor::alignment_area_around_node as alignment_area_style;
let node_bounding_box = node.frp.bounding_box.value();
let styles = &graph_editor.styles_frp;
let left = styles.get_number_or(alignment_area_style::to_the_left_of_node, 0.0);
let alignment_area_min_x = node_bounding_box.left() - left.value();
let right = styles.get_number_or(alignment_area_style::to_the_right_of_node, 0.0);
let alignment_area_max_x = node_bounding_box.right() + right.value();
let top = styles.get_number_or(alignment_area_style::above_node, 0.0);
let alignment_area_max_y = node_bounding_box.top() + top.value();
let bottom = styles.get_number_or(alignment_area_style::below_node, 0.0);
let alignment_area_min_y = node_bounding_box.bottom() - bottom.value();
let alignment_area = BoundingBox::from_corners(
Vector2(alignment_area_min_x, alignment_area_min_y),
Vector2(alignment_area_max_x, alignment_area_max_y),
);
alignment_area.contains(proposed_position)
});
match alignment_node {
Some(node) => under(graph_editor, node.id()),
None => proposed_position,
}
}
/// Return a position for a newly created node. Returns a position closely below the `node_id` node
/// if the position is available, or a first available point on a ray extending to the left of that
/// position.
///
/// Availability of a position is defined in the docs of [`on_ray`].
pub fn under(graph_editor: &GraphEditorModel, node_above: NodeId) -> Vector2 {
let above_pos = graph_editor.node_position(node_above);
let y_gap = graph_editor.frp.default_y_gap_between_nodes.value();
let y_offset = y_gap + node::HEIGHT;
let starting_point = above_pos - Vector2(0.0, y_offset);
let direction = Vector2(-1.0, 0.0);
on_ray(graph_editor, starting_point, direction).unwrap()
}
/// Return a position for a newly created node. Return the first available position on a ray
/// extending from `starting_point` in the `direction`, or [`None`] if the magnitude of each
/// coordinate of `direction` is smaller than [`f32::EPSILON`].
///
/// ## Available position
///
/// An available position is a position such that a newly created [`Node`] with origin at this
/// position does not overlap existing nodes. A node is said to overlap another node if an "overlap
/// area" (represented by the dashed border in the picture below) around the former intersects with
/// the bounding box of the latter. A newly created node is assumed to have a fixed size
/// (represented by the solid border in the picture below). The captions in the picture below are
/// used as variables in the code.
/// ```text
/// ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐
/// ┆ ▲ ┆
/// ┆ y_gap │ ┆
/// ┆ ▼ ┆
/// ┆ ┌──────────────────────┐ ┆
/// ┆ │ ▲ │ ┆
/// ┆ │ node::HEIGHT │ │ ┆
/// ┆ │ │ │ ┆
/// ┆ │ │ │ ┆
/// ┆ x_gap │ min_spacing │ │ x_gap ┆
/// ┆ ◀───▶ │◀──────────────────+─▶│ ◀───▶ ┆
/// ┆ │ │ │ ┆
/// ┆ │ ▼ │ ┆
/// ┆ └──────────────────────┘ ┆
/// ┆ ▲ ┆
/// ┆ y_gap │ ┆
/// ┆ ▼ ┆
/// └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
/// ```
pub fn on_ray(
graph_editor: &GraphEditorModel,
starting_point: Vector2,
direction: Vector2,
) -> Option<Vector2> {
let x_gap = graph_editor.frp.default_x_gap_between_nodes.value();
let y_gap = graph_editor.frp.default_y_gap_between_nodes.value();
// This is how much horizontal space we are looking for.
let min_spacing = graph_editor.frp.min_x_spacing_for_new_nodes.value();
let nodes = graph_editor.nodes.all.raw.borrow();
// The "occupied area" for given node consists of:
// - area taken by node view (obviously);
// - the minimum gap between nodes in all directions, so the new node won't be "glued" to
// another;
// - the new node size measured from origin point at each direction accordingly: because
// `find_free_place` looks for free place for the origin point, and we want to fit not only
// the point, but the whole node.
let node_areas = nodes.values().map(|node| {
let bounding_box = node.frp.bounding_box.value();
let left = bounding_box.left() - x_gap - min_spacing;
let right = bounding_box.right() + x_gap;
let top = bounding_box.top() + node::HEIGHT / 2.0 + y_gap;
let bottom = bounding_box.bottom() - node::HEIGHT / 2.0 - y_gap;
OccupiedArea { x1: left, x2: right, y1: top, y2: bottom }
});
find_free_place(starting_point, direction, node_areas)
}
/// ================================
/// === Private Helper Functions ===
/// ================================
/// Return a node nearest to the specified point.
///
/// The distance between a point and a node is the distance between the point and the node's
/// bounding box.
fn node_nearest_to_point(graph_editor: &GraphEditorModel, point: Vector2) -> Option<Node> {
let mut min_distance_squared = f32::MAX;
let mut nearest_node = None;
let nodes = graph_editor.nodes.all.raw.borrow();
for node in nodes.values() {
let node_bounding_box = node.frp.bounding_box.value();
let distance_squared = node_bounding_box.squared_distance_to_point(point);
if distance_squared < min_distance_squared {
min_distance_squared = distance_squared;
nearest_node = Some(node.clone_ref());
}
}
nearest_node
}

View File

@ -126,7 +126,7 @@ pub fn find_free_place(
let mut current_point = starting_point;
for area in sorted_areas {
if area.contains(current_point) {
current_point = area.boundary_intersection(current_point, direction)
current_point = area.boundary_intersection(current_point, direction);
}
}
current_point

View File

@ -2,6 +2,8 @@
use ensogl::prelude::*;
use nalgebra::clamp;
// ===================
@ -9,6 +11,22 @@ use ensogl::prelude::*;
// ===================
/// Describes a 2D bounding box of an UI component.
///
/// As pictured below, the left edge of a bounding box has a smaller or equal x coordinate compared
/// to the right edge, and the bottom edge has a smaller or equal y coordinate compared to the top
/// edge.
/// ```text
/// ▲
/// ┆ y
/// ┆ top
/// ┆ ┌────────┐
/// ┆ left │ │ right
/// ┆ └────────┘
/// ┆ bottom
/// ┆ x
/// ┄+┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄▶
/// ┆
/// ```
#[derive(Clone, Copy, Default, Debug)]
pub struct BoundingBox {
top: f32,
@ -75,6 +93,49 @@ impl BoundingBox {
self.bottom -= size / 2.0;
self.top += size / 2.0;
}
/// Return the x position of the left boundary.
pub fn left(&self) -> f32 {
self.left
}
/// Return the x position of the right boundary.
pub fn right(&self) -> f32 {
self.right
}
/// Return the y position of the top boundary.
pub fn top(&self) -> f32 {
self.top
}
/// Return the y position of the bottom boundary.
pub fn bottom(&self) -> f32 {
self.bottom
}
/// Calculates the squared norm of a vector between the point passed as an argument, and a
/// point in the bounding box that is nearest to the point passed as an argument.
///
/// If the point passed as an argument is inside the bounding box, returns 0.
pub fn squared_distance_to_point(&self, point: Vector2) -> f32 {
let x_of_nearest_point_in_bounding_box = clamp(point.x, self.left, self.right);
let y_of_nearest_point_in_bounding_box = clamp(point.y, self.bottom, self.top);
let nearest_point_in_bounding_box =
Vector2(x_of_nearest_point_in_bounding_box, y_of_nearest_point_in_bounding_box);
(nearest_point_in_bounding_box - point).norm_squared()
}
}
impl PartialSemigroup<BoundingBox> for BoundingBox {
/// Expand the boundaries to make them contain all points belonging to the bounding box passed
/// as an argument.
fn concat_mut(&mut self, other: BoundingBox) {
self.left = min(self.left, other.left);
self.right = max(self.right, other.right);
self.bottom = min(self.bottom, other.bottom);
self.top = max(self.top, other.top);
}
}
@ -84,31 +145,96 @@ impl BoundingBox {
mod tests {
use super::*;
impl From<((f32, f32), (f32, f32))> for BoundingBox {
fn from(corners: ((f32, f32), (f32, f32))) -> BoundingBox {
let corner0 = corners.0;
let corner1 = corners.1;
BoundingBox::from_corners(Vector2(corner0.0, corner0.1), Vector2(corner1.0, corner1.1))
}
}
macro_rules! assert_intersect {
( $bbox1:tt *? $bbox2:tt == $expected_result:literal ) => {
let bbox1: BoundingBox = $bbox1.into();
let bbox2: BoundingBox = $bbox2.into();
assert_eq!(bbox1.intersects(&bbox2), $expected_result);
assert_eq!(bbox2.intersects(&bbox1), $expected_result);
};
}
#[test]
fn test_intersection() {
let bb1 = BoundingBox::from_corners(Vector2::new(0.5, 0.5), Vector2::new(1.0, 1.0));
let bb2 = BoundingBox::from_corners(Vector2::new(0.0, 0.0), Vector2::new(2.0, 2.0));
assert!(bb1.intersects(&bb2));
assert!(bb2.intersects(&bb1));
assert_intersect! { ((0.5, 0.5), (1.0, 1.0)) *? ((0.0, 0.0), ( 2.0, 2.0)) == true };
assert_intersect! { ((3.0, 3.0), (4.0, 4.0)) *? ((0.0, 0.0), ( 2.0, 2.0)) == false };
assert_intersect! { ((0.0, 0.0), (4.0, 4.0)) *? ((0.0, 0.0), ( -2.0, -2.0)) == true };
assert_intersect! { ((0.0, 0.0), (4.0, 4.0)) *? ((2.0, 2.0), (200.0, 200.0)) == true };
assert_intersect! {
((-50.0, -50.0), (25.0, 25.0)) *? ((5.00, 50.0), (100.0, 100.0)) == false
};
}
let bb1 = BoundingBox::from_corners(Vector2::new(3.0, 3.0), Vector2::new(4.0, 4.0));
let bb2 = BoundingBox::from_corners(Vector2::new(0.0, 0.0), Vector2::new(2.0, 2.0));
assert!(!bb1.intersects(&bb2));
assert!(!bb2.intersects(&bb1));
macro_rules! assert_concat {
( $( $bbox1:tt + $bbox2:tt == $bbox3:tt ; )+ ) => {
$( assert_concat!{$bbox1 + $bbox2 == $bbox3}; )+
};
let bb1 = BoundingBox::from_corners(Vector2::new(0.0, 0.0), Vector2::new(4.0, 4.0));
let bb2 = BoundingBox::from_corners(Vector2::new(0.0, 0.0), Vector2::new(-2.0, -2.0));
assert!(bb1.intersects(&bb2));
assert!(bb2.intersects(&bb1));
( $bbox1:tt + $bbox2:tt == $bbox3:tt ) => {
let bbox1: BoundingBox = $bbox1.into();
let bbox2: BoundingBox = $bbox2.into();
let bbox3: BoundingBox = $bbox3.into();
let result = bbox1.concat(bbox2);
let assert_msg = iformat!(
"Concat result was expected to be: " bbox3;? " but got: " result;? " instead.");
assert_eq!(result.left, bbox3.left, "{}", assert_msg);
assert_eq!(result.right, bbox3.right, "{}", assert_msg);
assert_eq!(result.top, bbox3.top, "{}", assert_msg);
assert_eq!(result.bottom, bbox3.bottom, "{}", assert_msg);
};
}
let bb1 = BoundingBox::from_corners(Vector2::new(0.0, 0.0), Vector2::new(4.0, 4.0));
let bb2 = BoundingBox::from_corners(Vector2::new(2.0, 2.0), Vector2::new(200.0, 200.0));
assert!(bb1.intersects(&bb2));
assert!(bb2.intersects(&bb1));
#[test]
fn test_concat() {
assert_concat! {
((0.0, 0.0), (2.0, 3.0)) + (( 2.0, 3.0), (4.0, 5.0)) == ((0.0, 0.0), (4.0, 5.0));
((0.0, 0.0), (1.0, 1.0)) + ((-1.0, -1.0), (0.5, 0.5)) == ((-1.0, -1.0), (1.0, 1.0));
((0.0, 0.0), (1.0, 1.0)) + (( 0.3, 0.3), (0.6, 0.6)) == ((0.0, 0.0), (1.0, 1.0));
};
}
let bb1 = BoundingBox::from_corners(Vector2::new(-50.0, -50.0), Vector2::new(25.0, 25.0));
let bb2 = BoundingBox::from_corners(Vector2::new(5.00, 50.0), Vector2::new(100.0, 100.0));
assert!(!bb1.intersects(&bb2));
assert!(!bb2.intersects(&bb1));
const SQUARED_DISTANCE_COMPARISON_PRECISION: f32 = 0.001;
macro_rules! assert_squared_distance_to_point {
( $( $bbox:tt <-sq-> $point:tt =~ $expected_sq_distance:expr ; )+ ) => {
$(
assert_squared_distance_to_point!{ $bbox <-sq-> $point =~ $expected_sq_distance };
)+
};
($bbox:tt <-sq-> $point:tt =~ $expected_sq_distance:expr) => {
let bbox: BoundingBox = $bbox.into();
let point = Vector2($point.0, $point.1);
let result = bbox.squared_distance_to_point(point);
let result_deviation = (result - $expected_sq_distance).abs();
let result_ok = result_deviation < SQUARED_DISTANCE_COMPARISON_PRECISION;
let assert_msg = iformat!(
"Squared distance between " bbox;? " and " point;?
" expected to approximately equal " $expected_sq_distance
", but got " result " instead.");
assert!(result_ok, "{}", assert_msg);
};
}
#[test]
fn test_squared_distance_to_point() {
assert_squared_distance_to_point! {
((-1.0, -1.0), (0.0, 0.0)) <-sq-> ( 3.0, 4.0) =~ 5.0.pow(2.0);
// Distance between a bounding box and a point inside it should be 0.
(( 0.0, 0.0), (1.0, 1.0)) <-sq-> ( 0.5, 0.5) =~ 0.0;
(( 0.0, 0.0), (1.0, 1.0)) <-sq-> ( 3.0, 0.0) =~ 2.0.pow(2.0);
(( 0.0, 0.0), (1.0, 1.0)) <-sq-> (-2.0, 0.0) =~ 2.0.pow(2.0);
(( 0.0, 0.0), (1.0, 1.0)) <-sq-> ( 0.0, -2.0) =~ 2.0.pow(2.0);
(( 0.0, 0.0), (1.0, 1.0)) <-sq-> ( 0.0, 3.0) =~ 2.0.pow(2.0);
};
}
}

View File

@ -288,7 +288,7 @@ commands['integration-test'].rust = async function (argv) {
}
try {
console.log(`Running Rust WASM test suite.`)
process.env.WASM_BINDGEN_TEST_TIMEOUT = 180
process.env.WASM_BINDGEN_TEST_TIMEOUT = 300
let args = [
'test',
'--headless',

View File

@ -5,6 +5,10 @@
use enso_integration_test::prelude::*;
use approx::assert_abs_diff_eq;
use enso_frp::future::FutureEvent;
use enso_frp::io::mouse::PrimaryButton;
use enso_gui::view::graph_editor;
use enso_gui::view::graph_editor::component::node as node_view;
use enso_gui::view::graph_editor::component::node::test_utils::NodeModelExt;
use enso_gui::view::graph_editor::component::node::Expression;
use enso_gui::view::graph_editor::GraphEditor;
@ -13,6 +17,8 @@ use enso_gui::view::graph_editor::NodeId;
use enso_gui::view::graph_editor::NodeSource;
use enso_web::sleep;
use ensogl::display::navigation::navigator::ZoomEvent;
use ensogl::display::scene::test_utils::MouseExt;
use ensogl::display::Scene;
use ordered_float::OrderedFloat;
use std::time::Duration;
@ -152,7 +158,10 @@ async fn adding_node_with_add_node_button() {
let (node_id, node_source, _) = add_node_with_add_node_button(&graph_editor, "1");
assert!(node_source.is_none());
assert_eq!(graph_editor.nodes().all.len(), INITIAL_NODE_COUNT + 3);
let node_position = graph_editor.model.get_node_position(node_id).expect("Node was not added");
let node_position = graph_editor.model.get_node_position(node_id).expect(
"Node was not
added",
);
let center_of_screen = scene.screen_to_scene_coordinates(Vector3::zeros());
assert_abs_diff_eq!(node_position.x, center_of_screen.x, epsilon = 10.0);
assert_abs_diff_eq!(node_position.y, center_of_screen.y, epsilon = 10.0);
@ -205,3 +214,103 @@ fn add_node_with_add_node_button(
let method = |_: &GraphEditor| add_node_button.click();
add_node(graph_editor, expression, method)
}
#[wasm_bindgen_test]
async fn mouse_oriented_node_placement() {
struct Case {
scene: Scene,
graph_editor: GraphEditor,
source_node: Node,
mouse_position: Vector2,
expected_position: Vector2,
}
impl Case {
fn run(&self) {
self.check_tab_key();
self.check_edge_drop();
}
fn check_searcher_opening_place(
&self,
added_node: FutureEvent<(NodeId, Option<NodeSource>, bool)>,
) {
let (new_node_id, _, _) = added_node.expect();
let new_node_pos =
self.graph_editor.model.get_node_position(new_node_id).map(|v| v.xy());
assert_eq!(new_node_pos, Some(self.expected_position));
self.graph_editor.stop_editing();
assert_eq!(self.graph_editor.model.nodes.all.len(), 2);
}
fn check_tab_key(&self) {
self.scene.mouse.frp.position.emit(self.mouse_position);
let added_node = self.graph_editor.node_added.next_event();
self.graph_editor.start_node_creation();
self.check_searcher_opening_place(added_node);
}
fn check_edge_drop(&self) {
let port = self.source_node.view.model.output_port_shape().unwrap();
port.events.mouse_down.emit(PrimaryButton);
port.events.mouse_up.emit(PrimaryButton);
self.scene.mouse.frp.position.emit(self.mouse_position);
assert!(
self.graph_editor.has_detached_edge.value(),
"No detached edge after clicking port"
);
let added_node = self.graph_editor.node_added.next_event();
self.scene.mouse.click_on_background();
self.check_searcher_opening_place(added_node);
}
}
let test = IntegrationTestOnNewProject::setup().await;
let scene = &test.ide.ensogl_app.display.default_scene;
let graph_editor = test.graph_editor();
let gap_x = graph_editor.default_x_gap_between_nodes.value();
let gap_y = graph_editor.default_y_gap_between_nodes.value();
let min_spacing = graph_editor.min_x_spacing_for_new_nodes.value();
let InitialNodes { above, below } = InitialNodes::obtain_from_graph_editor(&graph_editor);
let create_case =
|source_node: &Node, mouse_position: Vector2, expected_position: Vector2| Case {
scene: scene.clone_ref(),
graph_editor: graph_editor.clone_ref(),
source_node: source_node.clone_ref(),
mouse_position,
expected_position,
};
let far_away = below.position().xy() + Vector2(500.0, 500.0);
let far_away_expect = far_away;
create_case(&below, far_away, far_away_expect).run();
let under_below = below.position().xy() + Vector2(30.0, -15.0);
let under_below_expect = below.position().xy() + Vector2(0.0, -gap_y - node_view::HEIGHT);
create_case(&below, under_below, under_below_expect).run();
let under_above = above.position().xy() + Vector2(30.0, 15.0);
let under_above_expect = Vector2(
below.position().x - gap_x - min_spacing,
above.position().y - gap_y - node_view::HEIGHT,
);
create_case(&above, under_above, under_above_expect).run();
}
struct InitialNodes {
above: graph_editor::Node,
below: graph_editor::Node,
}
impl InitialNodes {
fn obtain_from_graph_editor(graph_editor: &GraphEditor) -> Self {
let nodes = graph_editor.model.nodes.all.values();
let mut sorted = nodes.into_iter().sorted_by_key(|node| OrderedFloat(node.position().y));
match (sorted.next(), sorted.next()) {
(Some(below), Some(above)) => Self { above, below },
_ => panic!("Expected two nodes in initial Graph Editor"),
}
}
}

View File

@ -371,6 +371,17 @@ define_themes! { [light:0, dark:1]
default_x_gap_between_nodes = 48.0 , 48.0;
default_y_gap_between_nodes = 32.0 , 32.0;
minimal_x_spacing_for_new_nodes = 150.0 , 150.0;
// Area around every existing node where attempts to place a new node may trigger a node
// alignment mechanism.
//
// The specific conditions when the alignment mechanism is triggered, as well as the
// algorithm used to perform the alignment, are governed by the Graph Editor.
alignment_area_around_node {
above_node = 7.0 , 7.0;
below_node = 50.0 , 50.0;
to_the_left_of_node = 5.0 , 5.0;
to_the_right_of_node = 5.0 , 5.0;
}
node {
// Original RGB values (for reference after fixing color-conversion issues)
// light: rgb(253,254,255), old-dark: Lcha(0.2,0.014,0.18,1.0), dark: rgb(47,48,50)