GuideOverlay.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.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

import org.osate.ge.gef.ui.editor.AgeEditor;
import org.osate.ge.internal.diagram.runtime.DiagramElement;
import org.osate.ge.internal.diagram.runtime.DiagramElementPredicates;
import org.osate.ge.internal.diagram.runtime.DiagramNode;

import javafx.beans.binding.DoubleBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Bounds;
import javafx.scene.shape.Line;

/**
 * Displays a horizontal and/or a vertical guide line to aide in aligning shapes.
 * Typically, an instance of this class is used during interactive movement and resize operations.
 */
public final class GuideOverlay implements AutoCloseable {
	private final AgeEditor editor;
	private final Line horizontalLine = new Line();
	private final ObjectProperty<Double> diagramHorizontalY = new SimpleObjectProperty<>();
	private final Line verticalLine = new Line();
	private final ObjectProperty<Double> diagramVerticalX = new SimpleObjectProperty<>();
	private final double[] xValues;
	private final double[] xCenterValues;
	private final double[] yValues;
	private final double[] yCenterValues;

	/**
	 * Creates a new instance. The {@link #close()} method must be called when the overlay is no longer needed.
	 * @param editor the editor which is displaying the diagram.
	 * @param diagramElementsBeingModified the diagram elements which are being modified. These elements will be ignored when determining whether guide lines
	 * should be shown.
	 * @see #close()
	 */
	public GuideOverlay(final AgeEditor editor, final Set<DiagramElement> diagramElementsBeingModified) {
		this.editor = Objects.requireNonNull(editor, "editor must not be null");

		// Set style of lines
		this.horizontalLine.setStroke(OverlayColors.GUIDE_COLOR);
		this.horizontalLine.setStrokeWidth(2.0);
		this.verticalLine.setStroke(OverlayColors.GUIDE_COLOR);
		this.verticalLine.setStrokeWidth(2.0);

		final Overlays overlays = editor.getOverlays();
		overlays.getChildren().addAll(horizontalLine, verticalLine);

		final ObjectBinding<Bounds> rootBoundsInOverlay = new ObjectBinding<Bounds>() {
			{
				bind(overlays.sceneToLocalTransformProperty());
			}

			@Override
			protected Bounds computeValue() {
				final Bounds rootBounds = editor.getFxCanvas().getScene().getRoot().getLayoutBounds();
				return overlays.getSceneToLocalTransform().transform(rootBounds);
			}
		};

		//
		// Horizontal line
		//
		final DoubleBinding horizontalStartXBinding = new DoubleBinding() {
			{
				bind(rootBoundsInOverlay);
			}

			@Override
			protected double computeValue() {
				return rootBoundsInOverlay.get().getMinX();
			}
		};
		this.horizontalLine.startXProperty().bind(horizontalStartXBinding);

		final DoubleBinding horizontalEndXBinding = new DoubleBinding() {
			{
				bind(rootBoundsInOverlay);
			}

			@Override
			protected double computeValue() {
				return rootBoundsInOverlay.get().getMaxX();
			}
		};
		this.horizontalLine.endXProperty().bind(horizontalEndXBinding);

		final DoubleBinding localHorizontalYBinding = new DoubleBinding() {
			{
				bind(overlays.diagramToLocalTransformProperty(), diagramHorizontalY);
			}

			@Override
			protected double computeValue() {
				final Double value = diagramHorizontalY.get();
				return overlays.getDiagramToLocalTransform().transform(0, value == null ? 0 : value).getY();
			}
		};
		this.horizontalLine.startYProperty().bind(localHorizontalYBinding);
		this.horizontalLine.endYProperty().bind(localHorizontalYBinding);
		this.horizontalLine.visibleProperty().bind(diagramHorizontalY.isNotNull());

		//
		// Vertical Line
		//
		final DoubleBinding verticalStartYBinding = new DoubleBinding() {
			{
				bind(rootBoundsInOverlay);
			}

			@Override
			protected double computeValue() {
				return rootBoundsInOverlay.get().getMinY();
			}
		};
		this.verticalLine.startYProperty().bind(verticalStartYBinding);

		final DoubleBinding verticalEndYBinding = new DoubleBinding() {
			{
				bind(rootBoundsInOverlay);
			}

			@Override
			protected double computeValue() {
				return rootBoundsInOverlay.get().getMaxY();
			}
		};
		this.verticalLine.endYProperty().bind(verticalEndYBinding);

		final DoubleBinding localVerticalXBinding = new DoubleBinding() {
			{
				bind(overlays.diagramToLocalTransformProperty(), diagramVerticalX);
			}

			@Override
			protected double computeValue() {
				final Double value = diagramVerticalX.get();
				return overlays.getDiagramToLocalTransform().transform(value == null ? 0 : value, 0).getX();
			}
		};
		this.verticalLine.startXProperty().bind(localVerticalXBinding);
		this.verticalLine.endXProperty().bind(localVerticalXBinding);
		this.verticalLine.visibleProperty().bind(diagramVerticalX.isNotNull());

		// Create a set of diagram elements whose bounds will be included in the list of values for which guides will be shown
		final Set<DiagramElement> undockedDiagramElementsToInclude = new HashSet<>();
		final Set<DiagramElement> diagramElementsToIgnore = new HashSet<>(diagramElementsBeingModified);
		boolean hasDockedElements = false;
		for (final DiagramElement diagramElementBeingModified : diagramElementsBeingModified) {
			if (DiagramElementPredicates.isShape(diagramElementBeingModified)) {
				if (diagramElementBeingModified.getDockArea() == null) {
					// Add siblings to check for alignment
					for (final DiagramElement sibling : diagramElementBeingModified.getParent().getChildren()) {
						if (DiagramElementPredicates.isMoveableShape(sibling) && sibling.getDockArea() == null) {
							undockedDiagramElementsToInclude.add(sibling);
						}
					}
				} else {
					hasDockedElements = true;

					// Add docked siblings to ignore set
					diagramElementBeingModified.getParent()
							.getChildren()
							.stream()
							.filter(DiagramElementPredicates::isDocked)
							.forEachOrdered(diagramElementsToIgnore::add);
				}
			}
		}

		// Build a list of x and y values
		final ArrayList<Double> tmpXValues = new ArrayList<>(100);
		final ArrayList<Double> tmpXCenterValues = new ArrayList<>(100);
		final ArrayList<Double> tmpYValues = new ArrayList<>(100);
		final ArrayList<Double> tmpYCenterValues = new ArrayList<>(100);
		addValues(editor.getDiagram(), 0.0, 0.0, diagramElementsToIgnore, undockedDiagramElementsToInclude,
				hasDockedElements, tmpXValues, tmpXCenterValues, tmpYValues, tmpYCenterValues);

		// Convert to sorted arrays
		this.xValues = tmpXValues.stream().mapToDouble(Double::doubleValue).toArray();
		Arrays.sort(this.xValues);

		this.xCenterValues = tmpXCenterValues.stream().mapToDouble(Double::doubleValue).toArray();
		Arrays.sort(this.xCenterValues);

		this.yValues = tmpYValues.stream().mapToDouble(Double::doubleValue).toArray();
		Arrays.sort(this.yValues);

		this.yCenterValues = tmpYCenterValues.stream().mapToDouble(Double::doubleValue).toArray();
		Arrays.sort(this.yCenterValues);
	}

	// Adds X and Y values for which the guides should appear to the collections
	private static void addValues(final DiagramNode parent, final double parentX, final double parentY,
			final Set<DiagramElement> diagramElementsToIgnore, final Set<DiagramElement> diagramElementsToInclude,
			final boolean includeDockedShapes, final Collection<Double> xValues, final Collection<Double> centerXValues,
			final Collection<Double> yValues, final Collection<Double> centerYValues) {
		for (final DiagramElement child : parent.getChildren()) {
			if (!diagramElementsToIgnore.contains(child)) {
				final double childLeft = parentX + child.getX();
				final double childTop = parentY + child.getY();

				// Add values
				if ((child.getDockArea() != null && includeDockedShapes) || diagramElementsToInclude.contains(child)) {
					xValues.add(childLeft);
					centerXValues.add(childLeft + child.getWidth() / 2.0);
					xValues.add(childLeft + child.getWidth());
					yValues.add(childTop);
					centerYValues.add(childTop + child.getHeight() / 2.0);
					yValues.add(childTop + child.getHeight());
				}

				// Add values for children
				addValues(child, childLeft, childTop, diagramElementsToIgnore, diagramElementsToInclude,
						includeDockedShapes, xValues, centerXValues, yValues, centerYValues);
			}
		}
	}

	@Override
	public void close() {
		editor.getOverlays().getChildren().removeAll(horizontalLine, verticalLine);
	}

	/**
	 * Reset the values for which guides are being shown. This will hide the guides until these values are set.
	 */
	public void reset() {
		diagramHorizontalY.set(null);
		diagramVerticalX.set(null);
	}

	/**
	 * Returns whether the update* methods should be called.
	 * @return true if either the horizontal or vertical guide is not being shown due to a position not being set.
	 */
	public boolean shouldUpdate() {
		return diagramHorizontalY.get() == null || diagramVerticalX.get() == null;
	}

	/**
	 * Updates whether guides are shown for the specified bounds in diagram coordinates.
	 * @param bounds is the bounds in diagram coordinates.
	 */
	public void update(final Bounds bounds) {
		updateCenterX(bounds.getCenterX());
		updateX(bounds.getMinX());
		updateX(bounds.getMaxX());
		updateCenterY(bounds.getCenterY());
		updateY(bounds.getMinY());
		updateY(bounds.getMaxY());
	}

	/**
	 * Updates whether a vertical guide is shown for the specified X value. Does not check center X values.
	 * @param value is the X value.
	 */
	public void updateX(final double value) {
		if (diagramVerticalX.get() == null && Arrays.binarySearch(xValues, value) >= 0) {
			diagramVerticalX.set(value);
		}
	}

	/**
	 * Updates whether a vertical guide is shown for the specified X value. Only checks center X values.
	 * @param value is the X value.
	 */
	public void updateCenterX(final double value) {
		if (diagramVerticalX.get() == null && Arrays.binarySearch(xCenterValues, value) >= 0) {
			diagramVerticalX.set(value);
		}
	}

	/**
	 * Updates whether a horizontal guide is shown for the specified X value. Does not check center Y values.
	 * @param value is the Y value.
	 */
	public void updateY(final double value) {
		if (diagramHorizontalY.get() == null && Arrays.binarySearch(yValues, value) >= 0) {
			diagramHorizontalY.set(value);
		}
	}

	/**
	 * Updates whether a horizontal guide is shown for the specified Y value. Only checks center Y values.
	 * @param value is the Y value.
	 */
	public void updateCenterY(final double value) {
		if (diagramHorizontalY.get() == null && Arrays.binarySearch(yCenterValues, value) >= 0) {
			diagramHorizontalY.set(value);
		}
	}
}