ResizeInputEventHandler.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;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import org.eclipse.gef.geometry.euclidean.Vector;
import org.osate.ge.gef.ConfigureSize;
import org.osate.ge.gef.ContainerShape;
import org.osate.ge.gef.DockSide;
import org.osate.ge.gef.DockedShape;
import org.osate.ge.gef.FlowIndicatorNode;
import org.osate.ge.gef.PreferredPosition;
import org.osate.ge.gef.ui.editor.overlays.GuideOverlay;
import org.osate.ge.gef.ui.editor.overlays.ResizeShapeHandle;
import org.osate.ge.internal.diagram.runtime.DiagramElement;
import org.osate.ge.internal.diagram.runtime.DiagramElementPredicates;

import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.transform.Transform;

/**
 *  {@link InputEventHandler} for resizing diagram elements. The handler allows the user to resize all selected elements
 *  using a resize handle.
 */
public class ResizeInputEventHandler implements InputEventHandler {
	private final AgeEditor editor;

	/**
	 * Creates a new instance
	 * @param editor the editor from which events originate.
	 */
	public ResizeInputEventHandler(final AgeEditor editor) {
		this.editor = Objects.requireNonNull(editor, "editor must not be null");
	}

	@Override
	public Cursor getCursor(final MouseEvent mouseMoveEvent) {
		if (!(mouseMoveEvent.getTarget() instanceof ResizeShapeHandle)) {
			return null;
		}

		final ResizeShapeHandle handle = (ResizeShapeHandle) mouseMoveEvent.getTarget();
		return ResizeInteraction.getCursor(handle);
	}

	@Override
	public HandledEvent handleEvent(final InputEvent e) {
		if (e.getEventType() != MouseEvent.MOUSE_PRESSED || ((MouseEvent) e).getButton() != MouseButton.PRIMARY
				|| !(e.getTarget() instanceof ResizeShapeHandle)) {
			return null;
		}

		return HandledEvent.newInteraction(new ResizeInteraction(editor, (MouseEvent) e));
	}
}

/**
 * Interaction which resizes the selected diagram elements
 *
 */
class ResizeInteraction extends BaseInteraction {
	private final AgeEditor editor;
	private final ResizeShapeHandle handle;
	private final Point2D initialClickLocationInDiagram;
	private final List<DiagramElementSnapshot> elementsToResize;
	private final GuideOverlay guides;

	/**
	 * Creates a new instance
	 * @param editor the editor containing the diagram being modified
	 * @param e the mouse pressed event which started the interaction
	 */
	public ResizeInteraction(final AgeEditor editor, final MouseEvent e) {
		this.editor = editor;
		this.handle = (ResizeShapeHandle) e.getTarget();
		this.initialClickLocationInDiagram = editor.getGefDiagram()
				.getSceneNode()
				.getSceneToLocalTransform()
				.transform(e.getSceneX(), e.getSceneY());
		this.elementsToResize = createResizeElementSnapshotsFromSelection(editor);
		this.guides = new GuideOverlay(editor,
				elementsToResize.stream().map(s -> s.diagramElement).collect(Collectors.toSet()));

		// Hides connections because in some cases, some anchors/connections are not updated during resize and it will cause
		// connections to render incorrectly.
		setAffectedConnectionsVisible(false);
	}

	@Override
	public void close() {
		this.guides.close();

		setAffectedConnectionsVisible(true);

		// Update scene graph based on diagram elements. This is needed to revert any scene changes that have been made
		// during the interaction and to ensure that the scene node reflects the diagram elements after modification.
		editor.getGefDiagram().updateSceneGraph();
	}

	@Override
	public Cursor getCursor() {
		return getCursor(handle);
	}

	@Override
	protected Interaction.InteractionState onMouseDragged(final MouseEvent e) {
		if (e.getButton() != MouseButton.PRIMARY) {
			return super.onMouseDragged(e);
		}

		guides.reset();

		final Transform sceneToDiagramTransform = editor.getGefDiagram().getSceneNode().getSceneToLocalTransform();
		final Point2D eventInDiagram = sceneToDiagramTransform.transform(e.getSceneX(), e.getSceneY());
		final Point2D totalDelta = eventInDiagram.subtract(initialClickLocationInDiagram);
		final boolean snapToGrid = !e.isAltDown();
		// Resize all selection diagram elements
		for (final DiagramElementSnapshot snapshot : elementsToResize) {
			// Determine position adjustment and new size
			double newPositionX = snapshot.sceneNode.getLayoutX();
			double newPositionY = snapshot.sceneNode.getLayoutY();
			double newWidth = snapshot.boundsInDiagram.getWidth();
			double newHeight = snapshot.boundsInDiagram.getHeight();
			final Vector dir = handle.getDirection();
			final Node container = InputEventHandlerUtil.getLogicalShapeContainer(snapshot.sceneNode);

			//
			// Determine the minimum layout X and Y values for shapes which will be repositioned during the resize.
			// This value is used to constrain the new position of the node when the resize results in both a movement
			// and a size change.
			//
			double minChildLayoutX = Double.POSITIVE_INFINITY;
			double minChildLayoutY = Double.POSITIVE_INFINITY;
			for (final DiagramElement childDiagramElement : snapshot.diagramElement.getChildren()) {
				final Node childSceneNode = editor.getGefDiagram().getSceneNode(childDiagramElement);
				if (childSceneNode instanceof ContainerShape) {
					minChildLayoutX = Math.min(minChildLayoutX, childSceneNode.getLayoutX());
					minChildLayoutY = Math.min(minChildLayoutY, childSceneNode.getLayoutY());
				}

				// Docked shapes are only repositioned in one axis.
				if (childSceneNode instanceof DockedShape) {
					final DockSide side = ((DockedShape) childSceneNode).getSide();
					if (side.vertical) {
						minChildLayoutY = Math.min(minChildLayoutY, childSceneNode.getLayoutY());
					} else {
						minChildLayoutX = Math.min(minChildLayoutX, childSceneNode.getLayoutX());
					}
				}
			}

			if (dir.x < 0) {
				double newPositionDiagramX = InputEventHandlerUtil.snapX(editor,
						snapshot.boundsInDiagram.getMinX() + totalDelta.getX(), snapToGrid);
				// It is critical to not consider children that are being repositioned. Otherwise the minimum width
				// will change as the node is resized. The layout of such children are considered to ensure that the resize
				// does not clip children not included in the minimum width.
				final double minWidthOfNotRepositionedChildren = snapshot.sceneNode instanceof ContainerShape
						? ((ContainerShape) snapshot.sceneNode).computeMinWidth(false)
						: snapshot.sceneNode.minWidth(-1);
				newPositionDiagramX = Math.min(newPositionDiagramX,
						snapshot.boundsInDiagram.getMaxX() - minWidthOfNotRepositionedChildren);
				newPositionX = snapshot.positionInLocal.getX()
						+ (newPositionDiagramX - snapshot.boundsInDiagram.getMinX());
				newPositionX = Math.min(newPositionX, snapshot.sceneNode.getLayoutX() + minChildLayoutX);
				if (container instanceof ContainerShape) {
					newPositionX = Math.max(newPositionX, 0);
				}

				newWidth = snapshot.boundsInDiagram.getWidth() + (snapshot.positionInLocal.getX() - newPositionX);
			} else if (dir.x > 0) {
				final double newMaxX = InputEventHandlerUtil.snapX(editor,
						snapshot.boundsInDiagram.getMaxX() + totalDelta.getX(), snapToGrid);
				newWidth = newMaxX - snapshot.boundsInDiagram.getMinX();
			}

			if (dir.y < 0) {
				double newPositionDiagramY = InputEventHandlerUtil.snapX(editor,
						snapshot.boundsInDiagram.getMinY() + totalDelta.getY(), snapToGrid);

				// It is critical to not consider children that are being repositioned. Otherwise the minimum height
				// will change as the node is resized. The layout of such children are considered to ensure that the resize
				// does not clip children not included in the minimum height.
				final double minHeightWithoutFreeChildren = snapshot.sceneNode instanceof ContainerShape
						? ((ContainerShape) snapshot.sceneNode).computeMinHeight(false)
						: snapshot.sceneNode.minHeight(-1);
				newPositionDiagramY = Math.min(newPositionDiagramY,
						snapshot.boundsInDiagram.getMaxY() - minHeightWithoutFreeChildren);
				newPositionY = snapshot.positionInLocal.getY()
						+ (newPositionDiagramY - snapshot.boundsInDiagram.getMinY());
				newPositionY = Math.min(newPositionY, snapshot.sceneNode.getLayoutY() + minChildLayoutY);
				if (container instanceof ContainerShape) {
					newPositionY = Math.max(newPositionY, 0);
				}
				newHeight = snapshot.boundsInDiagram.getHeight() + (snapshot.positionInLocal.getY() - newPositionY);
			} else if (dir.y > 0) {
				final double newMaxY = InputEventHandlerUtil.snapX(editor,
						snapshot.boundsInDiagram.getMaxY() + totalDelta.getY(), snapToGrid);
				newHeight = newMaxY - snapshot.boundsInDiagram.getMinY();
			}

			// Adjust the position and size
			final Point2D currentPreferredPosition = PreferredPosition.get(snapshot.sceneNode);
			if (currentPreferredPosition == null || currentPreferredPosition.getX() != newPositionX
					|| currentPreferredPosition.getY() != newPositionY) {
				PreferredPosition.set(snapshot.sceneNode, new Point2D(newPositionX, newPositionY));

				final double dx = currentPreferredPosition == null ? 0
						: (newPositionX - currentPreferredPosition.getX());
				final double dy = currentPreferredPosition == null ? 0
						: (newPositionY - currentPreferredPosition.getY());

				// Reposition children so that their absolute positions do not change.
				for (final DiagramElement childDiagramElement : snapshot.diagramElement.getChildren()) {
					final Node childSceneNode = editor.getGefDiagram().getSceneNode(childDiagramElement);
					if (childSceneNode instanceof ContainerShape || childSceneNode instanceof DockedShape
							|| childSceneNode instanceof FlowIndicatorNode) {
						final Point2D childPosition = PreferredPosition.get(childSceneNode);
						if (childPosition != null) {
							final double newPreferredPositionX;
							final double newPreferredPositionY;

							// Special handling of flow indicators since they will only shift in one axis.
							// This assumes the flow indicator is attaches to a vertical side.
							if (childSceneNode instanceof FlowIndicatorNode) {
								newPreferredPositionX = childPosition.getX();
								newPreferredPositionY = childPosition.getY() - dy;
							} else {
								newPreferredPositionX = childPosition.getX() - dx;
								newPreferredPositionY = childPosition.getY() - dy;
							}

							PreferredPosition.set(childSceneNode,
									new Point2D(newPreferredPositionX, newPreferredPositionY));
						}
					}
				}
			}

			final ConfigureSize configureSize = (ConfigureSize) snapshot.sceneNode;
			final double minWidth = snapshot.sceneNode.minWidth(-1);
			final double minHeight = snapshot.sceneNode.minHeight(-1);
			configureSize.setConfiguredWidth(Math.max(newWidth, minWidth));
			configureSize.setConfiguredHeight(Math.max(newHeight, minHeight));

			// Update guide overlay
			if (guides.shouldUpdate()) {
				final Bounds newBoundsInDiagram = sceneToDiagramTransform.transform(
						snapshot.sceneNode.getLocalToSceneTransform().transform(snapshot.sceneNode.getLayoutBounds()));

				// Show guides for the values in the bounds that are being changed as part of the resize
				if (dir.x < 0) {
					guides.updateX(newBoundsInDiagram.getMinX());
					guides.updateCenterX(newBoundsInDiagram.getCenterX());
				} else if (dir.x > 0) {
					guides.updateX(newBoundsInDiagram.getMaxX());
					guides.updateCenterX(newBoundsInDiagram.getCenterX());
				}

				if (dir.y < 0) {
					guides.updateY(newBoundsInDiagram.getMinY());
					guides.updateCenterY(newBoundsInDiagram.getCenterY());
				} else if (dir.y > 0) {
					guides.updateY(newBoundsInDiagram.getMaxY());
					guides.updateCenterY(newBoundsInDiagram.getCenterY());
				}
			}
		}

		return InteractionState.IN_PROGRESS;
	}

	@Override
	protected Interaction.InteractionState onMouseReleased(final MouseEvent e) {
		if (e.getButton() != MouseButton.PRIMARY) {
			return super.onMouseReleased(e);
		}

		// Resize diagram elements by updating the diagram to reflect the current scene graph
		editor.getDiagram().modify("Resize", m -> editor.getGefDiagram().updateDiagramFromSceneGraph());

		return InteractionState.COMPLETE;
	}


	private void setAffectedConnectionsVisible(final boolean value) {
		for (final DiagramElementSnapshot snapshot : elementsToResize) {
			for (final DiagramElement affectedConnectionDiagramElement : snapshot.affectedConnections) {
				final Node affectedConnectionSceneNode = editor.getGefDiagram()
						.getSceneNode(affectedConnectionDiagramElement);
				if (affectedConnectionSceneNode != null) {
					affectedConnectionSceneNode.setVisible(value);
				}
			}
		}
	}

	/**
	 * Creates a list of snapshots for diagram elements which will be resized by the interaction.
	 * @param editor the editor containing the scene nodes for the elements.
	 * @return the snapshots
	 */
	public static List<DiagramElementSnapshot> createResizeElementSnapshotsFromSelection(final AgeEditor editor) {
		final List<DiagramElement> selectedDiagramElements = editor.getSelectedDiagramElements();
		final List<DiagramElementSnapshot> results = new ArrayList<>(selectedDiagramElements.size());
		for (final DiagramElement selectedDiagramElement : selectedDiagramElements) {
			if (DiagramElementPredicates.isResizeable(selectedDiagramElement)) {
				DiagramElementSnapshot.create(editor, selectedDiagramElement).ifPresent(results::add);
			}
		}

		return results;
	}

	/**
	 * Returns the cursor to be shown when the mouse cursor is over the specified resize shape handle.
	 * @param handle the handle for which to get the cursor
	 * @return the cursor based on the handle's direction
	 */
	static Cursor getCursor(final ResizeShapeHandle handle) {
		final Vector d = handle.getDirection();

		if (d.x < 0) {
			if (d.y < 0) {
				return Cursor.NW_RESIZE;
			} else if (d.y == 0.0) {
				return Cursor.W_RESIZE;
			} else { // d.y > 0.0
				return Cursor.SW_RESIZE;
			}
		} else if (d.x == 0) {
			if (d.y < 0) {
				return Cursor.N_RESIZE;
			} else if (d.y == 0.0) {
				// Invalid combination
				return null;
			} else { // d.y > 0.0
				return Cursor.S_RESIZE;
			}
		} else { // d.x > 0
			if (d.y < 0) {
				return Cursor.NE_RESIZE;
			} else if (d.y == 0.0) {
				return Cursor.E_RESIZE;
			} else { // d.y > 0.0
				return Cursor.SE_RESIZE;
			}
		}
	}
}