Overlays.java

/**
 * Copyright (c) 2004-2025 Carnegie Mellon University and others. (see Contributors file).
 * All Rights Reserved.
 *
 * NO WARRANTY. ALL MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY
 * KIND, EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE
 * OR MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT
 * MAKE ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT.
 *
 * This program and the accompanying materials are made available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 * SPDX-License-Identifier: EPL-2.0
 *
 * Created, in part, with funding and support from the United States Government. (see Acknowledgments file).
 *
 * This program includes and/or can make use of certain third party source code, object code, documentation and other
 * files ("Third Party Software"). The Third Party Software that is used by this program is dependent upon your system
 * configuration. By using this program, You agree to comply with any and all relevant Third Party Software terms and
 * conditions contained in any such Third Party Software or separate license file distributed with such Third Party
 * Software. The parties who own the Third Party Software ("Third Party Licensors") are intended third party benefici-
 * aries to this license with respect to the terms applicable to their Third Party Software. Third Party Software li-
 * censes only apply to the Third Party Software and not any other portion of this program or this program as a whole.
 */
package org.osate.ge.gef.ui.editor.overlays;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.eclipse.gef.fx.nodes.Connection;
import org.eclipse.gef.geometry.euclidean.Vector;
import org.eclipse.gef.geometry.planar.Point;
import org.eclipse.jface.viewers.ISelectionChangedListener;
import org.eclipse.jface.viewers.IStructuredSelection;
import org.eclipse.jface.viewers.SelectionChangedEvent;
import org.eclipse.swt.widgets.Display;
import org.osate.ge.gef.AgeGefRuntimeException;
import org.osate.ge.gef.BaseConnectionNode;
import org.osate.ge.gef.ContainerShape;
import org.osate.ge.gef.DockedShape;
import org.osate.ge.gef.FlowIndicatorNode;
import org.osate.ge.gef.LabelNode;
import org.osate.ge.gef.ui.diagram.GefAgeDiagram;
import org.osate.ge.internal.diagram.runtime.DiagramElement;
import org.osate.ge.internal.diagram.runtime.DiagramElementPredicates;

import com.google.common.collect.ImmutableMap;

import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ObservableList;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.NonInvertibleTransformException;
import javafx.scene.transform.Transform;

/**
 * A group for diagram overlays. Also handles the creation and removal of overlays for the current selection.
 * Each overlay is attached to another node and provides node and contains a selection indicator, resize handles, etc.
 *
 * This class assumes that the provided selection contains {@link DiagramElement} instances and that it does not contain
 * duplicates. Other objects are ignored.
 */
public class Overlays extends Group implements ISelectionChangedListener {
	private final GefAgeDiagram gefDiagram;

	/**
	 * Overlays for the current selection.
	 */
	private final Group selectionOverlays = new Group();

	/**
	 * Mapping from diagram elements to the selected node overlays.
	 */
	private Map<DiagramElement, SelectedNodeOverlay> diagramElementToSelectedNodeOverlayMap = Collections.emptyMap();

	/**
	 * Property for the transform between scene coordinates and the overlay
	 */
	private final ReadOnlyObjectWrapper<Transform> sceneToLocalTransform = new ReadOnlyObjectWrapper<>();

	/**
	 * Property for the transform between diagram coordinates and the overlay
	 */
	private final ReadOnlyObjectWrapper<Transform> diagramToLocalTransform = new ReadOnlyObjectWrapper<>();

	/**
	 * Creates a new instance
	 * @param gefDiagram the diagram which is being overlaid.
	 */
	public Overlays(final GefAgeDiagram gefDiagram) {
		this.gefDiagram = gefDiagram;
		this.setAutoSizeChildren(false);

		selectionOverlays.setAutoSizeChildren(false);
		this.getChildren().add(selectionOverlays);

		// Bind properties
		sceneToLocalTransform.bind(new ObjectBinding<Transform>() {
			{
				bind(localToSceneTransformProperty());
			}

			@Override
			protected Transform computeValue() {
				try {
					return getLocalToSceneTransform().createInverse();
				} catch (final NonInvertibleTransformException e) {
					throw new AgeGefRuntimeException("Unable to create scene to overlay transform", e);
				}
			}
		});

		final ReadOnlyObjectProperty<Transform> diagramToSceneTransform = gefDiagram.getSceneNode()
				.localToSceneTransformProperty();
		diagramToLocalTransform.bind(new ObjectBinding<Transform>() {
			{
				bind(diagramToSceneTransform, sceneToLocalTransform);
			}

			@Override
			protected Transform computeValue() {
				return sceneToLocalTransform.get().createConcatenation(diagramToSceneTransform.get());

			}
		});
	}

	/**
	 * The transform between scene to the overlay coordinate system.
	 * @return the transform from scene to the overlay coordinate system.
	 */
	public final ReadOnlyObjectProperty<Transform> sceneToLocalTransformProperty() {
		return sceneToLocalTransform.getReadOnlyProperty();
	}

	/**
	 * Returns the current value of {@link #sceneToLocalTransformProperty()}
	 * @return the current value of {@link #sceneToLocalTransformProperty()}
	 */
	public final Transform getSceneToLocalTransform() {
		return sceneToLocalTransformProperty().get();
	}

	/**
	 * The transform between diagram to the overlay coordinate system.
	 * @return the transform from diagram to the overlay coordinate system.
	 */
	public final ReadOnlyObjectProperty<Transform> diagramToLocalTransformProperty() {
		return diagramToLocalTransform.getReadOnlyProperty();
	}

	/**
	 * Returns the current value of {@link #diagramToLocalTransformProperty()}
	 * @return the current value of {@link #diagramToLocalTransformProperty()}
	 */
	public final Transform getDiagramToLocalTransform() {
		return diagramToLocalTransformProperty().get();
	}

	@Override
	public void selectionChanged(final SelectionChangedEvent event) {
		refresh(event.getStructuredSelection());
	}

	/** Refreshes the overlays based on the current selection
	 * This should be called by the editor whenever a change could affect the overlay.
	 * Specifically, it needs to be called whenever the diagram changes. Connection diagram elements may change to shapes and vice versa
	 * which would cause the nodes to change without changing the selected diagram elements.
	 * @param selection the current selection.
	 */
	@SuppressWarnings("unchecked")
	public void refresh(final IStructuredSelection selection) {
		Display.getCurrent().asyncExec(() -> {
			final ImmutableMap.Builder<DiagramElement, SelectedNodeOverlay> newDiagramElementToSelectedNodeOverlayMapBuilder = ImmutableMap
					.builderWithExpectedSize(selection.size());

			final ArrayList<DiagramElement> selectedDiagramElements = selectionToDiagramElements(selection);

			// Add existing selected node overlays for selected diagram elements to new map.
			for (final Entry<DiagramElement, SelectedNodeOverlay> existingEntry : diagramElementToSelectedNodeOverlayMap
					.entrySet()) {
				if (selectedDiagramElements.contains(existingEntry.getKey()) && gefDiagram
						.getSceneNode(existingEntry.getKey()) == existingEntry.getValue().getSelectedNode()) {
					newDiagramElementToSelectedNodeOverlayMapBuilder.put(existingEntry.getKey(),
							existingEntry.getValue());
				}
			}

			// Create selected node overlays for newly selected nodes.
			for (int i = 0; i < selectedDiagramElements.size(); i++) {
				final DiagramElement selectedDiagramElement = selectedDiagramElements.get(i);
				final boolean primary = i == selectedDiagramElements.size() - 1;
				final SelectedNodeOverlay existingOverlay = diagramElementToSelectedNodeOverlayMap
						.get(selectedDiagramElement);
				if (diagramElementToSelectedNodeOverlayMap.containsKey(selectedDiagramElement)) {
					// Set whether it is primary
					existingOverlay.setPrimary(primary);
				} else {
					final Node selectedNode = gefDiagram.getSceneNode(selectedDiagramElement);
					if (selectedNode != null) {
						if (selectedNode instanceof ContainerShape || selectedNode instanceof DockedShape
								|| selectedNode instanceof LabelNode) {
							newDiagramElementToSelectedNodeOverlayMapBuilder.put(selectedDiagramElement,
									new SelectedShapeOverlay(this, selectedDiagramElement, selectedNode, primary));
						} else if (selectedNode instanceof BaseConnectionNode) {
							// Create overlay for connection nodes
							newDiagramElementToSelectedNodeOverlayMapBuilder.put(selectedDiagramElement,
									new SelectedConnectionOverlay(this, selectedDiagramElement,
											(BaseConnectionNode) selectedNode, primary));
						}
					}
				}
			}

			diagramElementToSelectedNodeOverlayMap = newDiagramElementToSelectedNodeOverlayMapBuilder.build();

			// Update children to reflect the new nodes.
			selectionOverlays.getChildren()
					.setAll((Collection<? extends Node>) diagramElementToSelectedNodeOverlayMap.values());
		});
	}

	private ArrayList<DiagramElement> selectionToDiagramElements(final IStructuredSelection selection) {
		final ArrayList<DiagramElement> diagramElements = new ArrayList<>(selection.size());

		for (final Object o : selection) {
			if (o instanceof DiagramElement) {
				diagramElements.add((DiagramElement) o);
			}
		}

		return diagramElements;
	}

	/**
	 * Interface shared by shape and connection overlays
	 */
	public static interface SelectedNodeOverlay {
		/**
		 * Returns the node which is selected
		 * @return the node which is selected.
		 */
		Node getSelectedNode();

		/**
		 * Sets whether the overlay is for the primary selection. The primary selection is visually distinct because the primary selection may
		 * have special meaning to the modification operation. For example: alignment operations align nodes to the primary selection.
		 * @param primary whether the overlay is primary selection.
		 */
		void setPrimary(boolean primary);
	}

	/**
	 * Overlay used for selected shapes. Specifically, {@link ContainerShape} and {@link DockedShape}.
	 */
	private static class SelectedShapeOverlay extends Group implements SelectedNodeOverlay {
		private static Vector[] resizeShapeDirections = { new Vector(-1.0, -1.0), new Vector(0.0, -1.0),
				new Vector(1.0, -1.0), new Vector(-1.0, 0.0), new Vector(1.0, 0.0), new Vector(-1.0, 1.0),
				new Vector(0.0, 1.0), new Vector(1.0, 1.0) };

		private Node selectedNode;
		private final Group selectionIndicator = new Group();
		private Rectangle selectionIndicatorRect;

		/**
		 * Creates a new instance.
		 * @param overlays is the overlays object that will be used to determine the transform into local space. This instance
		 * must be in the same coordinate system as the specified overlays.
		 * @param de is the diagram element which is represented by the selected node.
		 * @param selectedNode the node for which this instance is an overlay
		 * @param primary whether the selected node is the primary selection
		 */
		public SelectedShapeOverlay(final Overlays overlays, final DiagramElement de, final Node selectedNode,
				boolean primary) {
			setAutoSizeChildren(false);
			this.selectedNode = selectedNode;
			selectionIndicator.setAutoSizeChildren(false);

			this.getChildren().add(selectionIndicator);

			// Binding which transforms the selected node's layout bounds into the local coordinate system.
			final ObjectBinding<Bounds> selectedNodeLayoutBoundsInLocal = new ObjectBinding<Bounds>() {
				{
					bind(overlays.sceneToLocalTransformProperty(), selectedNode.localToSceneTransformProperty(),
							selectedNode.layoutBoundsProperty());
				}

				@Override
				protected Bounds computeValue() {
					return overlays.getSceneToLocalTransform()
							.transform(
									selectedNode.getLocalToSceneTransform().transform(selectedNode.getLayoutBounds()));
				}
			};

			//
			// Selection Indicator
			//
			selectionIndicatorRect = new Rectangle();
			selectionIndicatorRect.setFill(null);
			selectionIndicatorRect.setStroke(OverlayColors.SELECTION_INDICATOR_COLOR);
			selectionIndicatorRect.setStrokeWidth(1.0);
			selectionIndicatorRect.widthProperty().bind(new DoubleBinding() {
				{
					bind(selectedNodeLayoutBoundsInLocal);
				}

				@Override
				protected double computeValue() {
					return selectedNodeLayoutBoundsInLocal.get().getWidth();
				}
			});
			selectionIndicatorRect.heightProperty().bind(new DoubleBinding() {
				{
					bind(selectedNodeLayoutBoundsInLocal);
				}

				@Override
				protected double computeValue() {
					return selectedNodeLayoutBoundsInLocal.get().getHeight();
				}
			});
			selectionIndicatorRect.xProperty().bind(new DoubleBinding() {
				{
					bind(selectedNodeLayoutBoundsInLocal);
				}

				@Override
				protected double computeValue() {
					return selectedNodeLayoutBoundsInLocal.get().getMinX();
				}
			});
			selectionIndicatorRect.yProperty().bind(new DoubleBinding() {
				{
					bind(selectedNodeLayoutBoundsInLocal);
				}

				@Override
				protected double computeValue() {
					return selectedNodeLayoutBoundsInLocal.get().getMinY();
				}
			});
			selectionIndicator.getChildren().add(selectionIndicatorRect);

			if (DiagramElementPredicates.isResizeable(de)) {
				// Create resize handles
				for (final Vector resizeDirection : resizeShapeDirections) {
					// Create the resize handle
					final ResizeShapeHandle handle = new ResizeShapeHandle(de, resizeDirection, primary);
					selectionIndicator.getChildren().add(handle);

					// Create handle position bindings
					final DoubleBinding xBinding = new DoubleBinding() {
						{
							bind(selectedNodeLayoutBoundsInLocal);
						}

						@Override
						protected double computeValue() {
							final Bounds selectedBounds = selectedNodeLayoutBoundsInLocal.get();
							final double halfWidth = selectedBounds.getWidth() / 2.0;
							return selectedBounds.getMinX() + halfWidth + (halfWidth * resizeDirection.x)
									- (handle.getWidth() / 2.0);
						}
					};
					final DoubleBinding yBinding = new DoubleBinding() {
						{
							bind(selectedNodeLayoutBoundsInLocal);
						}

						@Override
						protected double computeValue() {
							final Bounds selectedBounds = selectedNodeLayoutBoundsInLocal.get();
							final double halfHeight = selectedBounds.getHeight() / 2.0;
							return selectedBounds.getMinY() + halfHeight + (halfHeight * resizeDirection.y)
									- (handle.getHeight() / 2.0);
						}
					};

					handle.xProperty().bind(xBinding);
					handle.yProperty().bind(yBinding);
				}
			}
		}

		@Override
		public Node getSelectedNode() {
			return selectedNode;
		}

		@Override
		public void setPrimary(boolean value) {
			for (final Node child : selectionIndicator.getChildren()) {
				if (child instanceof Handle) {
					((Handle) child).setPrimary(value);
				}
			}
		}
	}

	/**
	 * Overlay used for selected connections.
	 */
	private static class SelectedConnectionOverlay extends Group implements SelectedNodeOverlay {
		private final DiagramElement diagramElement;
		private BaseConnectionNode selectedNode;
		private boolean primary;
		private final Group handles = new Group();

		/**
		 * Connection points. Strong reference is stored to ensure events are freed.
		 */
		private final ObservableList<Point> connectionPoints;
		private InvalidationListener invalidationListener = (InvalidationListener) c -> {
			// This is required because control points may not be updated when this is called
			selectedNode.refresh();
			updateSelectionIndicator(selectedNode);
		};

		private ChangeListener<?> changeListener = (ChangeListener<?>) (o, oldValue,
				newValue) -> updateSelectionIndicator(selectedNode);

		private final ReadOnlyObjectWrapper<Transform> selectedToOverlayTransform = new ReadOnlyObjectWrapper<>();

		/**
		 * Creates a new instance.
		 * @param overlays is the overlays object that will be used to determine the transform into local space. This instance
		 * must be in the same coordinate system as the specified overlays.
		 * @param diagramElement is the diagram element which is represented by the selected node.
		 * @param selectedNode the node for which this instance is an overlay
		 * @param primary whether the selected node is the primary selection
		 */
		@SuppressWarnings("unchecked")
		public SelectedConnectionOverlay(final Overlays overlays, final DiagramElement diagramElement,
				final BaseConnectionNode selectedNode, final boolean primary) {
			this.diagramElement = diagramElement;
			this.selectedNode = selectedNode;
			this.primary = primary;
			setAutoSizeChildren(false);
			handles.setAutoSizeChildren(false);
			getChildren().add(handles);

			selectedToOverlayTransform.bind(new ObjectBinding<Transform>() {
				{
					bind(overlays.sceneToLocalTransformProperty(), selectedNode.localToSceneTransformProperty());
				}

				@Override
				protected Transform computeValue() {
					return overlays.getSceneToLocalTransform()
							.createConcatenation(selectedNode.getLocalToSceneTransform());
				}
			});

			// Update the selection indicator whenever the curve changes
			selectedNode.getInnerConnection()
					.curveProperty()
					.addListener(new WeakChangeListener<Node>((ChangeListener<Node>) changeListener));
			selectedToOverlayTransform
					.addListener(new WeakChangeListener<Transform>((ChangeListener<Transform>) changeListener));

			connectionPoints = selectedNode.getInnerConnection().getPointsUnmodifiable();
			connectionPoints.addListener(new WeakInvalidationListener(invalidationListener));

			updateSelectionIndicator(selectedNode);
		}

		private void updateSelectionIndicator(final BaseConnectionNode selectedNode) {
			final Transform selectedToOverlay = selectedToOverlayTransform.get();

			// Only remove visible children. Invisible children are retained and will be removed by whatever set them to invisible.
			// This is important to ensure events are received while dragging.
			handles.getChildren().removeIf(n -> n.isVisible());

			// Create new selection indicator
			final Connection c = selectedNode.getInnerConnection();

			// Create handles for control points
			final List<Point> allPoints = c.getPointsUnmodifiable();
			final List<Point> controlPoints = c.getControlPoints();
			for (int controlPointIndex = 0; controlPointIndex <= controlPoints.size(); controlPointIndex++) {
				final org.eclipse.gef.geometry.planar.Point prev = controlPointIndex == 0 ? allPoints.get(0)
						: controlPoints.get(controlPointIndex - 1);
				final org.eclipse.gef.geometry.planar.Point p = controlPointIndex == controlPoints.size()
						? allPoints.get(allPoints.size() - 1)
						: controlPoints.get(controlPointIndex);
				final Point2D midInLocal = selectedToOverlay.transform((prev.x + p.x) / 2.0, (prev.y + p.y) / 2.0);

				// Create a handle for the control point
				if (controlPointIndex < controlPoints.size()) {
					final ControlPointHandle controlPointHandle = new ControlPointHandle(diagramElement, selectedNode,
							primary, controlPointIndex);
					final Point2D pInLocal = selectedToOverlay.transform(p.x, p.y);
					controlPointHandle.setCenterX(pInLocal.getX());
					controlPointHandle.setCenterY(pInLocal.getY());
					handles.getChildren().add(controlPointHandle);
				}

				// Create handle for creating new control points
				final CreateControlPointHandle createControlPointHandle = new CreateControlPointHandle(diagramElement,
						selectedNode, primary, controlPointIndex);
				createControlPointHandle.setCenterX(midInLocal.getX());
				createControlPointHandle.setCenterY(midInLocal.getY());
				handles.getChildren().add(createControlPointHandle);
			}

			// Create handle for flow indicator position
			if (selectedNode instanceof FlowIndicatorNode) {
				final FlowIndicatorPositionHandle handle = new FlowIndicatorPositionHandle(diagramElement,
						(FlowIndicatorNode) selectedNode, primary);
				final org.eclipse.gef.geometry.planar.Point p = connectionPoints.get(connectionPoints.size() - 1);
				final Point2D pInLocal = selectedToOverlay.transform(p.x, p.y);
				handle.setCenterX(pInLocal.getX());
				handle.setCenterY(pInLocal.getY());
				handles.getChildren().add(handle);
			}
		}

		@Override
		public Node getSelectedNode() {
			return selectedNode;
		}

		@Override
		public void setPrimary(boolean value) {
			if (this.primary != value) {
				this.primary = value;
				for (final Node child : handles.getChildren()) {
					if (child instanceof Handle) {
						((Handle) child).setPrimary(value);
					}
				}
			}
		}
	}
}