diff --git a/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowHelper.java b/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowHelper.java index a4393c7664f..a98fc99b7b6 100644 --- a/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowHelper.java +++ b/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2013, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2013, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -90,6 +90,10 @@ protected void visibleChangedImpl(Window window, boolean visible) { * Methods used by Window (base) class only */ + public static Window getWindowOwner(Window window) { + return windowAccessor.getWindowOwner(window); + } + public static TKStage getPeer(Window window) { return windowAccessor.getPeer(window); } @@ -145,6 +149,7 @@ public interface WindowAccessor { void setHelper(Window window, WindowHelper windowHelper); void doVisibleChanging(Window window, boolean visible); void doVisibleChanged(Window window, boolean visible); + Window getWindowOwner(Window window); TKStage getPeer(Window window); void setPeer(Window window, TKStage peer); WindowPeerListener getPeerListener(Window window); diff --git a/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowLocationAlgorithm.java b/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowLocationAlgorithm.java new file mode 100644 index 00000000000..83a09d94a64 --- /dev/null +++ b/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowLocationAlgorithm.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.stage; + +import javafx.stage.Screen; + +public interface WindowLocationAlgorithm { + + record ComputedLocation( + double x, double y, + double xGravity, double yGravity) {} + + ComputedLocation compute(Screen screen, double windowWidth, double windowHeight); +} diff --git a/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowRelocator.java b/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowRelocator.java new file mode 100644 index 00000000000..f4e0701ef9e --- /dev/null +++ b/modules/javafx.graphics/src/main/java/com/sun/javafx/stage/WindowRelocator.java @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.javafx.stage; + +import com.sun.javafx.util.Utils; +import javafx.geometry.AnchorPoint; +import javafx.geometry.Insets; +import javafx.geometry.Point2D; +import javafx.geometry.Rectangle2D; +import javafx.stage.AnchorPolicy; +import javafx.stage.Screen; +import javafx.stage.Window; +import java.util.List; +import java.util.Objects; + +public final class WindowRelocator { + + private WindowRelocator() {} + + /** + * Creates a location algorithm that computes the position of the {@link Window} at the requested screen + * coordinates using an {@link AnchorPoint}, {@link AnchorPolicy}, and per-edge screen constraints. + * {@code screenAnchor} is specified relative to {@code userScreen}. If {@code userScreen} is {@code null}, + * the screen anchor is specified relative to the current window screen; if the window has not been shown + * yet, it is specified relative to the primary screen. + *

+ * Screen edge constraints are specified by {@code screenPadding}: + *

+ * Enabled constraints reduce the usable area for placement by the given insets. + */ + public static WindowLocationAlgorithm newRelocationAlgorithm(Screen userScreen, + AnchorPoint screenAnchor, + Insets screenPadding, + AnchorPoint stageAnchor, + AnchorPolicy anchorPolicy) { + Objects.requireNonNull(screenAnchor, "screenAnchor cannot be null"); + Objects.requireNonNull(screenPadding, "screenPadding cannot be null"); + Objects.requireNonNull(stageAnchor, "stageAnchor cannot be null"); + Objects.requireNonNull(anchorPolicy, "anchorPolicy cannot be null"); + + return (windowScreen, windowWidth, windowHeight) -> { + double screenX, screenY; + double gravityX, gravityY; + Screen currentScreen = Objects.requireNonNullElse(userScreen, windowScreen); + Rectangle2D currentBounds = Utils.hasFullScreenStage(currentScreen) + ? currentScreen.getBounds() + : currentScreen.getVisualBounds(); + + // Compute the absolute coordinates of the screen anchor. + // If the screen anchor is specified in proportional coordinates, it is proportional to the complete + // screen bounds when a full-screen stage is showing on the screen, and the visual bounds otherwise. + if (screenAnchor.isProportional()) { + screenX = currentBounds.getMinX() + screenAnchor.getX() * currentBounds.getWidth(); + screenY = currentBounds.getMinY() + screenAnchor.getY() * currentBounds.getHeight(); + } else { + screenX = currentBounds.getMinX() + screenAnchor.getX(); + screenY = currentBounds.getMinY() + screenAnchor.getY(); + } + + // The absolute screen anchor might be on a different screen than the current window, so we + // need to recompute the actual screen and its bounds (complete when full-screen stage showing, + // visual otherwise). + Screen targetScreen = Utils.getScreenForPoint(screenX, screenY); + Rectangle2D screenBounds = Utils.hasFullScreenStage(targetScreen) + ? targetScreen.getBounds() + : targetScreen.getVisualBounds(); + + Point2D location = computeAdjustedLocation( + screenX, screenY, + windowWidth, windowHeight, + stageAnchor, anchorPolicy, + screenBounds, screenPadding); + + if (stageAnchor.isProportional()) { + gravityX = stageAnchor.getX(); + gravityY = stageAnchor.getY(); + } else { + gravityX = stageAnchor.getX() / windowWidth; + gravityY = stageAnchor.getY() / windowHeight; + } + + return new WindowLocationAlgorithm.ComputedLocation(location.getX(), location.getY(), gravityX, gravityY); + }; + } + + /** + * Computes the adjusted top-left location of a window for a requested anchor position on screen. + *

+ * The requested screen coordinates {@code (screenX, screenY)} are interpreted as the desired location + * of {@code anchor} on the window. The raw (unadjusted) window position is derived from the anchor and + * the given {@code width}/{@code height}. If that raw position violates any enabled constraints, the + * method considers alternative anchors depending on {@code policy} (for example, horizontally and/or + * vertically flipped anchors) and chooses the alternative that yields the smallest adjustment after + * constraints are applied. + *

+ * Screen edge constraints are specified by {@code screenPadding}: + * values {@code >= 0} enable a constraint for the corresponding edge (minimum distance to keep), + * values {@code < 0} disable the constraint for that edge. Enabled constraints reduce the usable area + * for placement by the given insets. + */ + public static Point2D computeAdjustedLocation(double screenX, double screenY, + double width, double height, + AnchorPoint anchor, AnchorPolicy policy, + Rectangle2D screenBounds, Insets screenPadding) { + Constraints constraints = computeConstraints(screenBounds, width, height, screenPadding); + Position preferredRaw = getRawForAnchor(screenX, screenY, anchor, width, height); + boolean validH = isHorizontalValid(preferredRaw, constraints); + boolean validV = isVerticalValid(preferredRaw, constraints); + if (validH && validV) { + return new Point2D(preferredRaw.x, preferredRaw.y); + } + + List alternatives = computeAlternatives(anchor, policy, validH, validV, width, height); + Point2D bestAdjusted = applyConstraints(preferredRaw, constraints); + double bestCost = getAdjustmentCost(preferredRaw, bestAdjusted); + + for (AnchorPoint alternative : alternatives) { + Position raw = getRawForAnchor(screenX, screenY, alternative, width, height); + Point2D adjusted = applyConstraints(raw, constraints); + double cost = getAdjustmentCost(raw, adjusted); + + if (cost < bestCost) { + bestCost = cost; + bestAdjusted = adjusted; + } + } + + return bestAdjusted; + } + + /** + * Computes effective constraints from screen bounds, window size, and edge insets. + *

+ * For each inset value: + *

+ * Enabled constraints shrink the usable region by the given amounts. The computed {@code maxX} + * and {@code maxY} incorporate the window size (i.e., they are the maximum allowed top-left + * coordinates that still keep the window within the constrained region). + */ + private static Constraints computeConstraints(Rectangle2D screenBounds, + double width, double height, + Insets screenPadding) { + boolean hasMinX = screenPadding.getLeft() >= 0; + boolean hasMaxX = screenPadding.getRight() >= 0; + boolean hasMinY = screenPadding.getTop() >= 0; + boolean hasMaxY = screenPadding.getBottom() >= 0; + + double minX = screenBounds.getMinX() + (hasMinX ? screenPadding.getLeft() : 0); + double maxX = screenBounds.getMaxX() - (hasMaxX ? screenPadding.getRight() : 0) - width; + double minY = screenBounds.getMinY() + (hasMinY ? screenPadding.getTop() : 0); + double maxY = screenBounds.getMaxY() - (hasMaxY ? screenPadding.getBottom() : 0) - height; + + return new Constraints(hasMinX, hasMaxX, hasMinY, hasMaxY, minX, maxX, minY, maxY); + } + + /** + * Computes the raw (unadjusted) top-left position for the given anchor. + *

+ * The result is the position at which the window would be located if no edge constraints were applied. + */ + private static Position getRawForAnchor(double screenX, double screenY, AnchorPoint anchor, + double width, double height) { + double x, y, relX, relY; + + if (anchor.isProportional()) { + x = width * anchor.getX(); + y = height * anchor.getY(); + relX = anchor.getX(); + relY = anchor.getY(); + } else { + x = anchor.getX(); + y = anchor.getY(); + relX = width != 0 ? anchor.getX() / width : 0; + relY = height != 0 ? anchor.getY() / height : 0; + } + + return new Position(screenX - x, screenY - y, relX, relY); + } + + /** + * Computes the list of alternative candidate anchors to consider, based on the requested policy + * and which constraint the preferred placement violates. + *

+ * Candidates are ordered from most preferred to least preferred for the given policy. + */ + private static List computeAlternatives(AnchorPoint preferred, AnchorPolicy policy, + boolean validH, boolean validV, + double width, double height) { + return switch (policy) { + case FIXED -> List.of(); + + case FLIP_HORIZONTAL -> validH + ? List.of() + : List.of(flipAnchor(preferred, width, height, true, false)); + + case FLIP_VERTICAL -> validV + ? List.of() + : List.of(flipAnchor(preferred, width, height, false, true)); + + case AUTO -> { + if (!validH && !validV) { + // Try diagonal flip first, then horizontal flip, then vertical flip + yield List.of( + flipAnchor(preferred, width, height, true, true), + flipAnchor(preferred, width, height, true, false), + flipAnchor(preferred, width, height, false, true)); + } else if (!validH) { + yield List.of(flipAnchor(preferred, width, height, true, false)); + } else if (!validV) { + yield List.of(flipAnchor(preferred, width, height, false, true)); + } else{ + yield List.of(); + } + } + }; + } + + /** + * Applies enabled edge constraints to a raw position. + *

+ * Constraints may be disabled per edge (via negative inset values). When both edges for an axis + * are enabled, the position is constrained to the resulting interval. When only one edge is enabled, + * a one-sided minimum or maximum constraint is applied. If the constrained interval is too small to + * fit the window, a side is chosen based on the relative anchor location. + */ + private static Point2D applyConstraints(Position raw, Constraints c) { + double x = raw.x; + double y = raw.y; + + if (c.hasMinX && c.hasMaxX) { + if (c.maxX >= c.minX) { + x = Utils.clamp(c.minX, x, c.maxX); + } else { + // Constrained space too small: choose a side based on anchor + x = raw.relX > 0.5 ? c.maxX : c.minX; + } + } else if (c.hasMinX) { + x = Math.max(x, c.minX); + } else if (c.hasMaxX) { + x = Math.min(x, c.maxX); + } + + if (c.hasMinY && c.hasMaxY) { + if (c.maxY >= c.minY) { + y = Utils.clamp(c.minY, y, c.maxY); + } else { + // Constrained space too small: choose a side based on anchor + y = raw.relY > 0.5 ? c.maxY : c.minY; + } + } else if (c.hasMinY) { + y = Math.max(y, c.minY); + } else if (c.hasMaxY) { + y = Math.min(y, c.maxY); + } + + return new Point2D(x, y); + } + + /** + * Computes a scalar "adjustment cost" used to select between candidate anchors. + *

+ * The current implementation uses Manhattan distance (|dx| + |dy|) between the raw and adjusted positions. + * Lower values indicate that fewer or smaller constraint adjustments were required. + */ + private static double getAdjustmentCost(Position raw, Point2D adjusted) { + return Math.abs(adjusted.getX() - raw.x) + Math.abs(adjusted.getY() - raw.y); + } + + private static boolean isHorizontalValid(Position raw, Constraints c) { + return !(c.hasMinX && raw.x < c.minX) && !(c.hasMaxX && raw.x > c.maxX); + } + + private static boolean isVerticalValid(Position raw, Constraints c) { + return !(c.hasMinY && raw.y < c.minY) && !(c.hasMaxY && raw.y > c.maxY); + } + + private static AnchorPoint flipAnchor(AnchorPoint anchor, + double width, double height, + boolean flipH, boolean flipV) { + double x = anchor.getX(); + double y = anchor.getY(); + + return anchor.isProportional() + ? AnchorPoint.proportional( + flipH ? (1.0 - x) : x, + flipV ? (1.0 - y) : y) + : AnchorPoint.absolute( + flipH ? (width - x) : x, + flipV ? (height - y) : y); + } + + private record Constraints(boolean hasMinX, boolean hasMaxX, + boolean hasMinY, boolean hasMaxY, + double minX, double maxX, + double minY, double maxY) {} + + private record Position(double x, double y, double relX, double relY) {} +} diff --git a/modules/javafx.graphics/src/main/java/com/sun/javafx/util/Utils.java b/modules/javafx.graphics/src/main/java/com/sun/javafx/util/Utils.java index 5f093e8eec1..f0a4b0da990 100644 --- a/modules/javafx.graphics/src/main/java/com/sun/javafx/util/Utils.java +++ b/modules/javafx.graphics/src/main/java/com/sun/javafx/util/Utils.java @@ -46,6 +46,7 @@ import java.math.BigDecimal; import java.util.List; import com.sun.javafx.PlatformUtil; +import com.sun.javafx.stage.WindowHelper; import com.sun.glass.utils.NativeLibLoader; import com.sun.prism.impl.PrismSettings; @@ -746,6 +747,24 @@ public static Screen getScreen(Object obj) { return getScreenForRectangle(rect); } + public static Screen getScreenForWindow(Window window) { + do { + if (!Double.isNaN(window.getX()) && !Double.isNaN(window.getY())) { + if (window.getWidth() >= 0 && window.getHeight() >= 0) { + var bounds = new Rectangle2D(window.getX(), window.getY(), + window.getWidth(), window.getHeight()); + return getScreenForRectangle(bounds); + } else { + return getScreenForPoint(window.getX(), window.getY()); + } + } + + window = WindowHelper.getWindowOwner(window); + } while (window != null); + + return Screen.getPrimary(); + } + public static Screen getScreenForRectangle(final Rectangle2D rect) { final List screens = Screen.getScreens(); diff --git a/modules/javafx.graphics/src/main/java/javafx/geometry/AnchorPoint.java b/modules/javafx.graphics/src/main/java/javafx/geometry/AnchorPoint.java new file mode 100644 index 00000000000..abb267c0745 --- /dev/null +++ b/modules/javafx.graphics/src/main/java/javafx/geometry/AnchorPoint.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package javafx.geometry; + +/** + * Represents a reference point within a target area, used for anchoring geometry-dependent calculations + * such as positioning a window relative to another location. + *

+ * An {@code AnchorPoint} provides a {@code (x, y)} coordinate together with a flag indicating + * how those coordinates should be interpreted: + *

+ * + * @since 26 + */ +public final class AnchorPoint { + + /** + * Anchor at the top-left corner of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(0, 0)}. + */ + public static final AnchorPoint TOP_LEFT = new AnchorPoint(0, 0, true); + + /** + * Anchor at the top-center midpoint of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(0.5, 0)}. + */ + public static final AnchorPoint TOP_CENTER = new AnchorPoint(0.5, 0, true); + + /** + * Anchor at the top-right corner of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(1, 0)}. + */ + public static final AnchorPoint TOP_RIGHT = new AnchorPoint(1, 0, true); + + /** + * Anchor at the center-left midpoint of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(0, 0.5)}. + */ + public static final AnchorPoint CENTER_LEFT = new AnchorPoint(0, 0.5, true); + + /** + * Anchor at the center of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(0.5, 0.5)}. + */ + public static final AnchorPoint CENTER = new AnchorPoint(0.5, 0.5, true); + + /** + * Anchor at the center-right midpoint of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(1, 0.5)}. + */ + public static final AnchorPoint CENTER_RIGHT = new AnchorPoint(1, 0.5, true); + + /** + * Anchor at the bottom-left corner of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(0, 1)}. + */ + public static final AnchorPoint BOTTOM_LEFT = new AnchorPoint(0, 1, true); + + /** + * Anchor at the bottom-center midpoint of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(0.5, 1)}. + */ + public static final AnchorPoint BOTTOM_CENTER = new AnchorPoint(0.5, 1, true); + + /** + * Anchor at the bottom-right corner of the target area. + *

+ * This constant is equivalent to {@code AnchorPoint.proportional(1, 1)}. + */ + public static final AnchorPoint BOTTOM_RIGHT = new AnchorPoint(1, 1, true); + + private final double x; + private final double y; + private final boolean proportional; + + private AnchorPoint(double x, double y, boolean proportional) { + this.x = x; + this.y = y; + this.proportional = proportional; + } + + /** + * Creates a proportional anchor point, expressed as fractions of the target area's width and height. + *

+ * In proportional coordinates, {@code (0, 0)} refers to the top-left corner of the target area and + * {@code (1, 1)} refers to the bottom-right corner. Values outside the {@code [0..1]} range represent + * points outside the bounds. + * + * @param x the horizontal fraction of the target width + * @param y the vertical fraction of the target height + * @return a proportional {@code AnchorPoint} + */ + public static AnchorPoint proportional(double x, double y) { + return new AnchorPoint(x, y, true); + } + + /** + * Creates an absolute anchor point, expressed as offsets from the top-left corner of the target area. + * + * @param x the horizontal offset from the left edge of the target area + * @param y the vertical offset from the top edge of the target area + * @return an absolute {@code AnchorPoint} + */ + public static AnchorPoint absolute(double x, double y) { + return new AnchorPoint(x, y, false); + } + + /** + * Returns the horizontal coordinate of this anchor point. + * + * @return the horizontal coordinate of this anchor point + */ + public double getX() { + return x; + } + + /** + * Returns the vertical coordinate of this anchor point. + * + * @return the vertical coordinate of this anchor point + */ + public double getY() { + return y; + } + + /** + * Indicates whether the {@code x} and {@code y} coordinates are proportional to the size of the target area. + * + * @return {@code true} if the coordinates are proportional, {@code false} otherwise + */ + public boolean isProportional() { + return proportional; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof AnchorPoint other + && x == other.x + && y == other.y + && proportional == other.proportional; + } + + @Override + public int hashCode() { + int hash = 7; + hash = 37 * hash + Double.hashCode(this.x); + hash = 37 * hash + Double.hashCode(this.y); + hash = 37 * hash + Boolean.hashCode(this.proportional); + return hash; + } + + @Override + public String toString() { + return "AnchorPoint [x = " + x + ", y = " + y + ", proportional = " + proportional + "]"; + } +} diff --git a/modules/javafx.graphics/src/main/java/javafx/stage/AnchorPolicy.java b/modules/javafx.graphics/src/main/java/javafx/stage/AnchorPolicy.java new file mode 100644 index 00000000000..f4a96d0bb1c --- /dev/null +++ b/modules/javafx.graphics/src/main/java/javafx/stage/AnchorPolicy.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package javafx.stage; + +import javafx.geometry.AnchorPoint; + +/** + * Specifies how a window repositioning operation may adjust an anchor point when the preferred anchor + * would place the window outside the usable screen area. + *

+ * The stage anchor passed to {@link Stage#relocate(AnchorPoint, AnchorPoint)} or specified by + * {@link PopupWindow#anchorLocationProperty() PopupWindow.anchorLocation} identifies the point on the + * window that should coincide with the requested screen coordinates. When the preferred anchor would place + * the window outside the usable screen area (as defined by the screen bounds and any configured insets), + * an {@code AnchorPolicy} can be used to select an alternative anchor before applying any final position + * adjustment. + * + * @since 26 + */ +public enum AnchorPolicy { + + /** + * Always use the preferred anchor and never select an alternative anchor. + *

+ * If the preferred anchor places the window outside the usable screen area, the window position is + * adjusted for the window to fall within the usable screen area. If this is not possible, the window + * is biased towards the edge that is closer to the anchor. + */ + FIXED, + + /** + * If the preferred anchor violates horizontal constraints, attempt a horizontally flipped anchor. + *

+ * A horizontal flip mirrors the anchor across the vertical center line of the window (for example, + * {@code TOP_LEFT} becomes {@code TOP_RIGHT}). If the horizontally flipped anchor does not improve + * the placement, the original anchor is used and the final position is adjusted for the window to + * fall within the usable screen area. If this is not possible, the window is biased towards the + * edge that is closer to the anchor. + */ + FLIP_HORIZONTAL, + + /** + * If the preferred anchor violates vertical constraints, attempt a vertically flipped anchor. + *

+ * A vertical flip mirrors the anchor across the horizontal center line of the window (for example, + * {@code TOP_LEFT} becomes {@code BOTTOM_LEFT}). If the vertically flipped anchor does not improve + * the placement, the original anchor is used and the final position is adjusted for the window to + * fall within the usable screen area. If this is not possible, the window is biased towards the + * edge that is closer to the anchor. + */ + FLIP_VERTICAL, + + /** + * Automatically chooses an alternative anchor based on which constraints are violated. + *

+ * This policy selects the "most natural" flip for the current situation: + *

+ * If no alternative anchor yields a better placement, the original anchor is used and the final + * position is adjusted for the window to fall within the usable screen area. + * If this is not possible, the window is biased towards the edge that is closer to the anchor. + */ + AUTO +} diff --git a/modules/javafx.graphics/src/main/java/javafx/stage/PopupWindow.java b/modules/javafx.graphics/src/main/java/javafx/stage/PopupWindow.java index a7b68f0fa54..7c97f2994d9 100644 --- a/modules/javafx.graphics/src/main/java/javafx/stage/PopupWindow.java +++ b/modules/javafx.graphics/src/main/java/javafx/stage/PopupWindow.java @@ -30,6 +30,7 @@ import com.sun.javafx.event.DirectEvent; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import javafx.beans.InvalidationListener; import javafx.beans.Observable; @@ -44,8 +45,11 @@ import javafx.collections.ObservableList; import javafx.event.Event; import javafx.event.EventHandler; +import javafx.geometry.AnchorPoint; import javafx.geometry.BoundingBox; import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Point2D; import javafx.geometry.Rectangle2D; import javafx.scene.Group; import javafx.scene.Node; @@ -61,6 +65,7 @@ import com.sun.javafx.stage.PopupWindowPeerListener; import com.sun.javafx.stage.WindowCloseRequestHandler; import com.sun.javafx.stage.WindowEventDispatcher; +import com.sun.javafx.stage.WindowRelocator; import com.sun.javafx.tk.Toolkit; import com.sun.javafx.stage.PopupWindowHelper; @@ -631,6 +636,32 @@ public final ReadOnlyDoubleProperty anchorYProperty() { return anchorY.getReadOnlyProperty(); } + /** + * Controls whether an alternative anchor location may be used when the preferred + * {@link #anchorLocationProperty() anchorLocation} would place the popup window outside the screen bounds. + * Depending on the policy, the preferred anchor location may be mirrored to the other side of the window + * horizontally or vertically, or an anchor location may be selected automatically. + *

+ * If no alternative anchor location yields a better placement, the specified {@code anchorLocation} is used. + * + * @defaultValue {@link AnchorPolicy#FIXED} + * @since 26 + */ + private final ObjectProperty anchorPolicy = + new SimpleObjectProperty<>(this, "anchorPolicy", AnchorPolicy.FIXED); + + public final ObjectProperty anchorPolicyProperty() { + return anchorPolicy; + } + + public final AnchorPolicy getAnchorPolicy() { + return anchorPolicy.get(); + } + + public final void setAnchorPolicy(AnchorPolicy value) { + anchorPolicy.set(value); + } + /** * Specifies the popup anchor point which is used in popup positioning. The * point can be set to a corner of the popup window or a corner of its @@ -721,12 +752,12 @@ boolean isContentLocation() { } @Override - void setXInternal(final double value) { + void setXInternal(double value, float xGravity) { updateWindow(windowToAnchorX(value), getAnchorY()); } @Override - void setYInternal(final double value) { + void setYInternal(double value, float yGravity) { updateWindow(getAnchorX(), windowToAnchorY(value)); } @@ -783,34 +814,15 @@ private void updateWindow(final double newAnchorX, ? currentScreen.getBounds() : currentScreen.getVisualBounds(); - if (anchorXCoef <= 0.5) { - // left side of the popup is more important, try to keep it - // visible if the popup width is larger than screen width - anchorScrMinX = Math.min(anchorScrMinX, - screenBounds.getMaxX() - - anchorBounds.getWidth()); - anchorScrMinX = Math.max(anchorScrMinX, screenBounds.getMinX()); - } else { - // right side of the popup is more important - anchorScrMinX = Math.max(anchorScrMinX, screenBounds.getMinX()); - anchorScrMinX = Math.min(anchorScrMinX, - screenBounds.getMaxX() - - anchorBounds.getWidth()); - } + Point2D location = WindowRelocator.computeAdjustedLocation( + newAnchorX, newAnchorY, + anchorBounds.getWidth(), anchorBounds.getHeight(), + AnchorPoint.proportional(anchorXCoef, anchorYCoef), + Objects.requireNonNullElse(getAnchorPolicy(), AnchorPolicy.FIXED), + screenBounds, Insets.EMPTY); - if (anchorYCoef <= 0.5) { - // top side of the popup is more important - anchorScrMinY = Math.min(anchorScrMinY, - screenBounds.getMaxY() - - anchorBounds.getHeight()); - anchorScrMinY = Math.max(anchorScrMinY, screenBounds.getMinY()); - } else { - // bottom side of the popup is more important - anchorScrMinY = Math.max(anchorScrMinY, screenBounds.getMinY()); - anchorScrMinY = Math.min(anchorScrMinY, - screenBounds.getMaxY() - - anchorBounds.getHeight()); - } + anchorScrMinX = location.getX(); + anchorScrMinY = location.getY(); } final double windowScrMinX = @@ -830,11 +842,11 @@ private void updateWindow(final double newAnchorX, // update popup position // don't set Window.xExplicit unnecessarily if (!Double.isNaN(windowScrMinX)) { - super.setXInternal(windowScrMinX); + super.setXInternal(windowScrMinX, 0); } // don't set Window.yExplicit unnecessarily if (!Double.isNaN(windowScrMinY)) { - super.setYInternal(windowScrMinY); + super.setYInternal(windowScrMinY, 0); } // set anchor x, anchor y diff --git a/modules/javafx.graphics/src/main/java/javafx/stage/Stage.java b/modules/javafx.graphics/src/main/java/javafx/stage/Stage.java index f3016ebd5ea..8aa4b05be48 100644 --- a/modules/javafx.graphics/src/main/java/javafx/stage/Stage.java +++ b/modules/javafx.graphics/src/main/java/javafx/stage/Stage.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import javafx.application.ColorScheme; import javafx.application.Platform; @@ -36,6 +37,8 @@ import javafx.beans.property.StringPropertyBase; import javafx.collections.ListChangeListener.Change; import javafx.collections.ObservableList; +import javafx.geometry.AnchorPoint; +import javafx.geometry.Insets; import javafx.geometry.NodeOrientation; import javafx.scene.Scene; import javafx.scene.image.Image; @@ -50,6 +53,8 @@ import com.sun.javafx.stage.HeaderButtonMetrics; import com.sun.javafx.stage.StageHelper; import com.sun.javafx.stage.StagePeerListener; +import com.sun.javafx.stage.WindowLocationAlgorithm; +import com.sun.javafx.stage.WindowRelocator; import com.sun.javafx.tk.TKStage; import com.sun.javafx.tk.Toolkit; import javafx.beans.NamedArg; @@ -1213,6 +1218,127 @@ public void toBack() { } } + /** + * Positions this stage so that a point on the stage ({@code stageAnchor}) coincides with a point on + * the screen ({@code screenAnchor}), and ensures that the stage is not placed off-screen. + *

+ * This method may be called either before or after {@link #show()} or {@link #showAndWait()}. + * If called before the stage is shown, then + *

    + *
  1. any previous call to {@code relocate(...)} or {@link #centerOnScreen()} is discarded, + *
  2. the {@link #xProperty() X} and {@link #yProperty() Y} properties are not updated + * immediately; instead, they are updated after the stage is shown. + *
+ * Calling this method is equivalent to calling {@link #relocate(AnchorPoint, Insets, AnchorPoint, AnchorPolicy) + * relocate(screenAnchor, Insets.EMPTY, stageAnchor, AnchorPolicy.FIXED)}. + * + * @param screenAnchor An anchor point in absolute or proportional coordinates relative to the screen that + * currently contains this stage (current screen); if the stage does not have a location + * yet, the primary screen is implied. If the screen anchor is + * {@linkplain AnchorPoint#proportional(double, double) proportional}, it is first resolved + * against the {@linkplain Screen#getVisualBounds() visual bounds} of the current screen; + * if a full-screen stage is showing on the current screen, the screen anchor is resolved + * against its complete {@linkplain Screen#getBounds() bounds}. + * @param stageAnchor An anchor point in absolute or proportional stage coordinates. + * @throws NullPointerException if any of the parameters is {@code null} + * @since 26 + */ + public final void relocate(AnchorPoint screenAnchor, AnchorPoint stageAnchor) { + relocateImpl(null, screenAnchor, Insets.EMPTY, stageAnchor, AnchorPolicy.FIXED); + } + + /** + * Positions this stage so that a point on the stage ({@code stageAnchor}) coincides with a point on + * the screen ({@code screenAnchor}), subject to the specified anchor policy and screen padding. + *

+ * This method may be called either before or after {@link #show()} or {@link #showAndWait()}. + * If called before the stage is shown, then + *

    + *
  1. any previous call to {@code relocate(...)} or {@link #centerOnScreen()} is discarded, + *
  2. the {@link #xProperty() X} and {@link #yProperty() Y} properties are not updated + * immediately; instead, they are updated after the stage is shown. + *
+ * + * @param screenAnchor An anchor point in absolute or proportional coordinates relative to the screen that + * currently contains this stage (current screen); if the stage does not have a location + * yet, the primary screen is implied. If the screen anchor is + * {@linkplain AnchorPoint#proportional(double, double) proportional}, it is first resolved + * against the {@linkplain Screen#getVisualBounds() visual bounds} of the current screen; + * if a full-screen stage is showing on the current screen, the screen anchor is resolved + * against its complete {@linkplain Screen#getBounds() bounds}. + * @param screenPadding Defines per-edge constraints against the screen bounds. Each inset value specifies the + * minimum distance to maintain between the stage edge and the corresponding screen edge. + * A value {@code >= 0} enables the corresponding edge constraint; a negative value disables + * the constraint for that edge. Enabled constraints effectively shrink the usable screen + * area by the given insets. For example, a left inset of {@code 10} ensures the stage will + * not be placed closer than 10 pixels to the left screen edge. + * @param stageAnchor An anchor point in absolute or proportional stage coordinates. + * @param anchorPolicy Controls whether an alternative stage anchor may be used when the preferred anchor would + * place the stage outside the usable screen area. Depending on the policy, the preferred + * anchor location may be mirrored across the vertical/horizontal center line of the stage, + * or an anchor might be selected automatically. If no alternative anchor yields a better + * placement, the specified {@code stageAnchor} is used. + * @throws NullPointerException if any of the parameters is {@code null} + * @since 26 + */ + public final void relocate(AnchorPoint screenAnchor, Insets screenPadding, + AnchorPoint stageAnchor, AnchorPolicy anchorPolicy) { + relocateImpl(null, screenAnchor, screenPadding, stageAnchor, anchorPolicy); + } + + /** + * Positions this stage so that a point on the stage ({@code stageAnchor}) coincides with a point on + * the screen ({@code screenAnchor}), subject to the specified anchor policy and screen padding. + *

+ * This method may be called either before or after {@link #show()} or {@link #showAndWait()}. + * If called before the stage is shown, then + *

    + *
  1. any previous call to {@code relocate(...)} or {@link #centerOnScreen()} is discarded, + *
  2. the {@link #xProperty() X} and {@link #yProperty() Y} properties are not updated + * immediately; instead, they are updated after the stage is shown. + *
+ * + * @param screen The reference screen that defines the coordinate space for {@code screenAnchor}. + * @param screenAnchor An anchor point in absolute or proportional coordinates relative to {@code screen}. + * If the screen anchor is {@linkplain AnchorPoint#proportional(double, double) proportional}, + * it is first resolved against the {@linkplain Screen#getVisualBounds() visual bounds} of + * the screen; if a full-screen stage is showing on the screen, the screen anchor is resolved + * against its complete {@linkplain Screen#getBounds() bounds}. + * @param screenPadding Defines per-edge constraints against the screen bounds. Each inset value specifies the + * minimum distance to maintain between the stage edge and the corresponding screen edge. + * A value {@code >= 0} enables the corresponding edge constraint; a negative value disables + * the constraint for that edge. Enabled constraints effectively shrink the usable screen + * area by the given insets. For example, a left inset of {@code 10} ensures the stage will + * not be placed closer than 10 pixels to the left screen edge. + * @param stageAnchor An anchor point in absolute or proportional stage coordinates. + * @param anchorPolicy Controls whether an alternative stage anchor may be used when the preferred anchor would + * place the stage outside the usable screen area. Depending on the policy, the preferred + * anchor location may be mirrored across the vertical/horizontal center line of the stage, + * or an anchor might be selected automatically. If no alternative anchor yields a better + * placement, the specified {@code stageAnchor} is used. + * @throws NullPointerException if any of the parameters is {@code null} + * @since 26 + */ + public final void relocate(Screen screen, AnchorPoint screenAnchor, Insets screenPadding, + AnchorPoint stageAnchor, AnchorPolicy anchorPolicy) { + Objects.requireNonNull(screen, "screen cannot be null"); + relocateImpl(screen, screenAnchor, screenPadding, stageAnchor, anchorPolicy); + } + + private void relocateImpl(Screen screen, AnchorPoint screenAnchor, Insets screenPadding, + AnchorPoint stageAnchor, AnchorPolicy anchorPolicy) { + clearLocationExplicit(); + + WindowLocationAlgorithm algorithm = WindowRelocator.newRelocationAlgorithm( + screen, screenAnchor, screenPadding, stageAnchor, anchorPolicy); + + if (isShowing()) { + applyLocationAlgorithm(algorithm); + } else { + this.locationAlgorithm = algorithm; + } + } + /** * Closes this {@code Stage}. * This call is equivalent to {@code hide()}. @@ -1315,4 +1441,17 @@ private void setPrefHeaderButtonHeight(double height) { peer.setPrefHeaderButtonHeight(height); } } + + @Override + final void clearLocationExplicit() { + locationAlgorithm = null; + super.clearLocationExplicit(); + } + + @Override + final WindowLocationAlgorithm getLocationAlgorithm() { + return locationAlgorithm; + } + + private WindowLocationAlgorithm locationAlgorithm; } diff --git a/modules/javafx.graphics/src/main/java/javafx/stage/Window.java b/modules/javafx.graphics/src/main/java/javafx/stage/Window.java index 9f518cfe7ff..916b4ab604f 100644 --- a/modules/javafx.graphics/src/main/java/javafx/stage/Window.java +++ b/modules/javafx.graphics/src/main/java/javafx/stage/Window.java @@ -61,6 +61,7 @@ import com.sun.javafx.stage.WindowEventDispatcher; import com.sun.javafx.stage.WindowHelper; import com.sun.javafx.stage.WindowPeerListener; +import com.sun.javafx.stage.WindowLocationAlgorithm; import com.sun.javafx.tk.TKPulseListener; import com.sun.javafx.tk.TKScene; import com.sun.javafx.tk.TKStage; @@ -135,6 +136,11 @@ public void doVisibleChanged(Window window, boolean visible) { window.doVisibleChanged(visible); } + @Override + public Window getWindowOwner(Window window) { + return window.getWindowOwner(); + } + @Override public TKStage getPeer(Window window) { return window.getPeer(); @@ -334,6 +340,16 @@ private void adjustSize(boolean selfSizePriority) { private static final float CENTER_ON_SCREEN_X_FRACTION = 1.0f / 2; private static final float CENTER_ON_SCREEN_Y_FRACTION = 1.0f / 3; + private static final WindowLocationAlgorithm CENTER_ON_SCREEN_ALGORITHM = new WindowLocationAlgorithm() { + @Override + public ComputedLocation compute(Screen screen, double windowWidth, double windowHeight) { + Rectangle2D bounds = screen.getVisualBounds(); + double centerX = bounds.getMinX() + (bounds.getWidth() - windowWidth) * CENTER_ON_SCREEN_X_FRACTION; + double centerY = bounds.getMinY() + (bounds.getHeight() - windowHeight) * CENTER_ON_SCREEN_Y_FRACTION; + return new ComputedLocation(centerX, centerY, CENTER_ON_SCREEN_X_FRACTION, CENTER_ON_SCREEN_Y_FRACTION); + } + }; + /** * Sets x and y properties on this Window so that it is centered on the * current screen. @@ -341,22 +357,10 @@ private void adjustSize(boolean selfSizePriority) { * visual bounds of all screens. */ public void centerOnScreen() { - xExplicit = false; - yExplicit = false; + clearLocationExplicit(); + if (peer != null) { - Rectangle2D bounds = getWindowScreen().getVisualBounds(); - double centerX = - bounds.getMinX() + (bounds.getWidth() - getWidth()) - * CENTER_ON_SCREEN_X_FRACTION; - double centerY = - bounds.getMinY() + (bounds.getHeight() - getHeight()) - * CENTER_ON_SCREEN_Y_FRACTION; - - x.set(centerX); - y.set(centerY); - peerBoundsConfigurator.setLocation(centerX, centerY, - CENTER_ON_SCREEN_X_FRACTION, - CENTER_ON_SCREEN_Y_FRACTION); + applyLocationAlgorithm(CENTER_ON_SCREEN_ALGORITHM); applyBounds(); } } @@ -547,14 +551,14 @@ public final DoubleProperty renderScaleYProperty() { new ReadOnlyDoubleWrapper(this, "x", Double.NaN); public final void setX(double value) { - setXInternal(value); + setXInternal(value, 0); } public final double getX() { return x.get(); } public final ReadOnlyDoubleProperty xProperty() { return x.getReadOnlyProperty(); } - void setXInternal(double value) { + void setXInternal(double value, float gravity) { x.set(value); - peerBoundsConfigurator.setX(value, 0); + peerBoundsConfigurator.setX(value, gravity); xExplicit = true; } @@ -578,14 +582,14 @@ void setXInternal(double value) { new ReadOnlyDoubleWrapper(this, "y", Double.NaN); public final void setY(double value) { - setYInternal(value); + setYInternal(value, 0); } public final double getY() { return y.get(); } public final ReadOnlyDoubleProperty yProperty() { return y.getReadOnlyProperty(); } - void setYInternal(double value) { + void setYInternal(double value, float gravity) { y.set(value); - peerBoundsConfigurator.setY(value, 0); + peerBoundsConfigurator.setY(value, gravity); yExplicit = true; } @@ -601,6 +605,40 @@ void notifyLocationChanged(double newX, double newY) { y.set(newY); } + void clearLocationExplicit() { + xExplicit = false; + yExplicit = false; + } + + /** + * Allows subclasses to specify an algorithm that computes the window location. + */ + WindowLocationAlgorithm getLocationAlgorithm() { + return null; + } + + /** + * Applies the specified location algorithm, but does not change explicitly specified window coordinates. + */ + final void applyLocationAlgorithm(WindowLocationAlgorithm algorithm) { + if (xExplicit && yExplicit) { + return; + } + + WindowLocationAlgorithm.ComputedLocation location = + algorithm.compute(Utils.getScreenForWindow(this), getWidth(), getHeight()); + + if (!xExplicit) { + x.set(location.x()); + peerBoundsConfigurator.setX(location.x(), (float)location.xGravity()); + } + + if (!yExplicit) { + y.set(location.y()); + peerBoundsConfigurator.setY(location.y(), (float)location.yGravity()); + } + } + private boolean widthExplicit = false; /** @@ -1060,7 +1098,10 @@ public final ObjectProperty> onHiddenProperty() { } else { windows.remove(Window.this); } + Toolkit tk = Toolkit.getToolkit(); + WindowLocationAlgorithm locationAlgorithm = getLocationAlgorithm(); + if (peer != null) { if (newVisible) { if (peerListener == null) { @@ -1110,14 +1151,15 @@ public final ObjectProperty> onHiddenProperty() { getWidth(), getHeight(), -1, -1); } - if (!xExplicit && !yExplicit) { - centerOnScreen(); - } else { - peerBoundsConfigurator.setLocation(getX(), getY(), - 0, 0); - } + // Set the location of the window peer first, because it might not have a location yet. + // This is the case when a window is hidden and then shown again: we have a location, but + // the new peer doesn't know about that yet. This location might be overwritten by a + // location algorithm later if X and Y are not specified explicitly. + peerBoundsConfigurator.setLocation(x.get(), y.get(), 0, 0); - // set peer bounds before the window is shown + // If a derived class has provided us a location algorithm, now is the time to apply it. + // If we don't have a location algorithm, we use the default center-on-screen algorithm. + applyLocationAlgorithm(locationAlgorithm != null ? locationAlgorithm : CENTER_ON_SCREEN_ALGORITHM); applyBounds(); peer.setOpacity((float)getOpacity()); @@ -1156,6 +1198,12 @@ public final ObjectProperty> onHiddenProperty() { // might have changed (e.g. due to setResizable(false)). Reapply the // sizeToScene() request if needed to account for the new insets. sizeToScene(); + + // If the window size has changed, we need to run the location algorithm again. + if (locationAlgorithm != null) { + applyLocationAlgorithm(locationAlgorithm); + applyBounds(); + } } // Reset the flag unconditionally upon visibility changes @@ -1371,26 +1419,6 @@ Window getWindowOwner() { return null; } - private Screen getWindowScreen() { - Window window = this; - do { - if (!Double.isNaN(window.getX()) - && !Double.isNaN(window.getY()) - && !Double.isNaN(window.getWidth()) - && !Double.isNaN(window.getHeight())) { - return Utils.getScreenForRectangle( - new Rectangle2D(window.getX(), - window.getY(), - window.getWidth(), - window.getHeight())); - } - - window = window.getWindowOwner(); - } while (window != null); - - return Screen.getPrimary(); - } - private final ReadOnlyObjectWrapper screen = new ReadOnlyObjectWrapper<>(Screen.getPrimary()); private ReadOnlyObjectProperty screenProperty() { return screen.getReadOnlyProperty(); } diff --git a/modules/javafx.graphics/src/test/java/test/javafx/stage/StageTest.java b/modules/javafx.graphics/src/test/java/test/javafx/stage/StageTest.java index 1607ec557dd..ba4d35da1eb 100644 --- a/modules/javafx.graphics/src/test/java/test/javafx/stage/StageTest.java +++ b/modules/javafx.graphics/src/test/java/test/javafx/stage/StageTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2011, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -26,22 +26,29 @@ package test.javafx.stage; import java.util.ArrayList; +import java.util.stream.Stream; +import javafx.geometry.AnchorPoint; +import javafx.geometry.Insets; import javafx.scene.image.Image; -import com.sun.javafx.stage.WindowHelper; import javafx.scene.Group; import javafx.scene.Scene; +import javafx.stage.AnchorPolicy; +import javafx.stage.Screen; +import javafx.stage.Stage; import test.com.sun.javafx.pgstub.StubStage; import test.com.sun.javafx.pgstub.StubToolkit; import test.com.sun.javafx.pgstub.StubToolkit.ScreenConfiguration; +import com.sun.javafx.stage.WindowHelper; import com.sun.javafx.tk.Toolkit; -import javafx.stage.Stage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.junit.jupiter.api.Assertions.*; public class StageTest { @@ -54,15 +61,19 @@ public class StageTest { @BeforeEach public void setUp() { toolkit = (StubToolkit) Toolkit.getToolkit(); + toolkit.setScreens(new ScreenConfiguration(0, 0, 800, 600, 0, 0, 800, 600, 96)); + s = new Stage(); - s.show(); - peer = (StubStage) WindowHelper.getPeer(s); - initialNumTimesSetSizeAndLocation = peer.numTimesSetSizeAndLocation; + s.setOnShown(_ -> { + peer = (StubStage) WindowHelper.getPeer(s); + initialNumTimesSetSizeAndLocation = peer.numTimesSetSizeAndLocation; + }); } @AfterEach public void tearDown() { s.hide(); + toolkit.resetScreens(); } private void pulse() { @@ -75,6 +86,7 @@ private void pulse() { */ @Test public void testMovingStage() { + s.show(); s.setX(100); pulse(); assertEquals(100f, peer.x); @@ -88,6 +100,7 @@ public void testMovingStage() { */ @Test public void testResizingStage() { + s.show(); s.setWidth(100); s.setHeight(100); pulse(); @@ -103,6 +116,7 @@ public void testResizingStage() { */ @Test public void testMovingAndResizingStage() { + s.show(); s.setX(101); s.setY(102); s.setWidth(103); @@ -122,6 +136,7 @@ public void testMovingAndResizingStage() { */ @Test public void testResizingTooSmallStage() { + s.show(); s.setWidth(60); s.setHeight(70); s.setMinWidth(150); @@ -137,6 +152,7 @@ public void testResizingTooSmallStage() { */ @Test public void testResizingTooBigStage() { + s.show(); s.setWidth(100); s.setHeight(100); s.setMaxWidth(60); @@ -152,6 +168,7 @@ public void testResizingTooBigStage() { */ @Test public void testSizeAndLocationChangedOverTime() { + s.show(); pulse(); assertTrue((peer.numTimesSetSizeAndLocation - initialNumTimesSetSizeAndLocation) <= 1); initialNumTimesSetSizeAndLocation = peer.numTimesSetSizeAndLocation; @@ -176,6 +193,7 @@ public void testSizeAndLocationChangedOverTime() { @Test public void testSecondCenterOnScreenNotIgnored() { + s.show(); s.centerOnScreen(); s.setX(0); @@ -191,6 +209,7 @@ public void testSecondCenterOnScreenNotIgnored() { @Test public void testSecondSizeToSceneNotIgnored() { + s.show(); final Scene scene = new Scene(new Group(), 200, 100); s.setScene(scene); @@ -215,6 +234,7 @@ public void testCenterOnScreenForWindowOnSecondScreen() { 1920, 160, 1440, 900, 96)); try { + s.show(); s.setX(1920); s.setY(160); s.setWidth(300); @@ -238,6 +258,7 @@ public void testCenterOnScreenForOwnerOnSecondScreen() { 1920, 160, 1440, 900, 96)); try { + s.show(); s.setX(1920); s.setY(160); s.setWidth(300); @@ -260,6 +281,7 @@ public void testCenterOnScreenForOwnerOnSecondScreen() { @Test public void testSwitchSceneWithFixedSize() { + s.show(); Scene scene = new Scene(new Group(), 200, 100); s.setScene(scene); @@ -285,6 +307,7 @@ public void testSwitchSceneWithFixedSize() { @Test public void testSetBoundsNotLostForAsyncNotifications() { + s.show(); s.setX(20); s.setY(50); s.setWidth(400); @@ -309,6 +332,7 @@ public void testSetBoundsNotLostForAsyncNotifications() { @Test public void testFullscreenNotLostForAsyncNotifications() { + s.show(); peer.holdNotifications(); s.setFullScreen(true); @@ -327,6 +351,7 @@ public void testFullscreenNotLostForAsyncNotifications() { @Test public void testFullScreenNotification() { + s.show(); peer.setFullScreen(true); assertTrue(s.isFullScreen()); peer.setFullScreen(false); @@ -335,6 +360,7 @@ public void testFullScreenNotification() { @Test public void testResizableNotLostForAsyncNotifications() { + s.show(); peer.holdNotifications(); s.setResizable(false); @@ -353,6 +379,7 @@ public void testResizableNotLostForAsyncNotifications() { @Test public void testResizableNotification() { + s.show(); peer.setResizable(false); assertFalse(s.isResizable()); peer.setResizable(true); @@ -361,6 +388,7 @@ public void testResizableNotification() { @Test public void testIconifiedNotLostForAsyncNotifications() { + s.show(); peer.holdNotifications(); s.setIconified(true); @@ -379,6 +407,7 @@ public void testIconifiedNotLostForAsyncNotifications() { @Test public void testIconifiedNotification() { + s.show(); peer.setIconified(true); assertTrue(s.isIconified()); peer.setIconified(false); @@ -387,6 +416,7 @@ public void testIconifiedNotification() { @Test public void testMaximixedNotLostForAsyncNotifications() { + s.show(); peer.holdNotifications(); s.setMaximized(true); @@ -405,6 +435,7 @@ public void testMaximixedNotLostForAsyncNotifications() { @Test public void testMaximizedNotification() { + s.show(); peer.setMaximized(true); assertTrue(s.isMaximized()); peer.setMaximized(false); @@ -413,6 +444,7 @@ public void testMaximizedNotification() { @Test public void testAlwaysOnTopNotLostForAsyncNotifications() { + s.show(); peer.holdNotifications(); s.setAlwaysOnTop(true); @@ -431,6 +463,7 @@ public void testAlwaysOnTopNotLostForAsyncNotifications() { @Test public void testAlwaysOnTopNotification() { + s.show(); peer.setAlwaysOnTop(true); assertTrue(s.isAlwaysOnTop()); peer.setAlwaysOnTop(false); @@ -439,6 +472,7 @@ public void testAlwaysOnTopNotification() { @Test public void testBoundsSetAfterPeerIsRecreated() { + s.show(); s.setX(20); s.setY(50); s.setWidth(400); @@ -518,4 +552,631 @@ public void testAddAndSetNullIcon() { assertTrue(e instanceof NullPointerException, failMessage); } } + + @Test + public void relocateNullArgumentsThrowNPE() { + s.show(); + assertNotNull(peer); + assertThrows(NullPointerException.class, () -> s.relocate(null, AnchorPoint.TOP_LEFT)); + assertThrows(NullPointerException.class, () -> s.relocate(AnchorPoint.TOP_LEFT, null)); + + assertThrows(NullPointerException.class, () -> s.relocate(null, Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED)); + assertThrows(NullPointerException.class, () -> s.relocate(AnchorPoint.TOP_LEFT, Insets.EMPTY, null, AnchorPolicy.FIXED)); + assertThrows(NullPointerException.class, () -> s.relocate(AnchorPoint.TOP_LEFT, Insets.EMPTY, AnchorPoint.TOP_LEFT, null)); + assertThrows(NullPointerException.class, () -> s.relocate(AnchorPoint.TOP_LEFT, null, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED)); + + assertThrows(NullPointerException.class, () -> s.relocate(null, null, Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED)); + assertThrows(NullPointerException.class, () -> s.relocate(null, AnchorPoint.TOP_LEFT, Insets.EMPTY, null, AnchorPolicy.FIXED)); + assertThrows(NullPointerException.class, () -> s.relocate(null, AnchorPoint.TOP_LEFT, Insets.EMPTY, AnchorPoint.TOP_LEFT, null)); + assertThrows(NullPointerException.class, () -> s.relocate(null, AnchorPoint.TOP_LEFT, null, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED)); + } + + /** + * Tests that {@code relocate()} called before {@code show()} is applied when the stage is shown. + */ + @Test + public void relocateBeforeShowPositionsStageOnShow() { + s.setWidth(300); + s.setHeight(200); + s.relocate(AnchorPoint.absolute(100, 120), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(100, peer.x, 0.0001); + assertEquals(120, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@code relocate()} called after {@code show()} updates the stage position immediately. + */ + @Test + public void relocateAfterShowMovesStageImmediately() { + s.setWidth(300); + s.setHeight(200); + s.show(); + s.relocate(AnchorPoint.absolute(200, 220), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + pulse(); + + assertEquals(200, peer.x, 0.0001); + assertEquals(220, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that proportional screen anchors resolve against visual bounds. + */ + @Test + public void relocateWithProportionalScreenAnchorResolvesAgainstVisualBounds() { + // Visual bounds differ from full bounds (e.g., task bar / menu bar reserved area). + toolkit.setScreens(new ScreenConfiguration(0, 0, 800, 600, 0, 30, 800, 570, 96)); + + s.setWidth(200); + s.setHeight(100); + + // Proportional screen anchors are resolved against visual bounds when no fullscreen stage is present. + s.relocate(AnchorPoint.proportional(0, 0), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(0, peer.x, 0.0001); + assertEquals(30, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that proportional screen anchors resolve against the stage's current screen. + */ + @Test + public void relocateWithProportionalScreenAnchorUsesCurrentScreen() { + toolkit.setScreens( + new ScreenConfiguration(0, 0, 800, 600, 0, 0, 800, 600, 96), + new ScreenConfiguration(800, 0, 800, 600, 800, 40, 800, 560, 96)); + + // Ensure the stage is on screen 2 when resolving the proportional screen anchor. + s.setX(850); + s.setY(10); + s.setWidth(200); + s.setHeight(200); + + // Center stage on screen 2's visual bounds: + // screen center = (800 + 0.5*800, 40 + 0.5*560) = (1200, 320) + // stage top-left = center - (100, 100) = (1100, 220) + s.relocate(AnchorPoint.proportional(0.5, 0.5), Insets.EMPTY, AnchorPoint.CENTER, AnchorPolicy.FIXED); + s.show(); + + assertEquals(1100, peer.x, 0.0001); + assertEquals(220, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().get(0), Insets.EMPTY); + assertWithinBounds(peer, toolkit.getScreens().get(1), Insets.EMPTY); + } + + /** + * Absolute screenAnchor is relative to the specified screen's reference rectangle. + */ + @Test + public void relocateWithScreenParameterAbsoluteAnchorIsRelativeToScreenVisualBounds() { + toolkit.setScreens( + new ScreenConfiguration(0, 0, 800, 600, 0, 0, 800, 600, 96), + new ScreenConfiguration(800, 0, 800, 600, 800, 40, 800, 560, 96)); + + s.setWidth(100); + s.setHeight(100); + + Screen screen2 = Screen.getScreens().get(1); + + // Absolute coordinates are relative to the reference rectangle of screen2. + // Here: visual min = (800, 40), so (10, 20) => (810, 60) + s.relocate(screen2, AnchorPoint.absolute(10, 20), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(810, peer.x, 0.0001); + assertEquals(60, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().get(0), Insets.EMPTY); + assertWithinBounds(peer, toolkit.getScreens().get(1), Insets.EMPTY); + } + + /** + * Proportional screenAnchor uses the specified screen even if the stage is currently on another screen. + */ + @Test + public void relocateWithScreenParameterProportionalAnchorUsesSpecifiedScreen() { + toolkit.setScreens( + new ScreenConfiguration(0, 0, 800, 600, 0, 0, 800, 600, 96), + new ScreenConfiguration(800, 0, 800, 600, 800, 40, 800, 560, 96)); + + s.setX(10); + s.setY(10); + s.setWidth(200); + s.setHeight(200); + s.show(); + + Screen screen2 = Screen.getScreens().get(1); + + // Center of screen2 visual bounds: + // (800 + 0.5*800, 40 + 0.5*560) = (1200, 320) + // Stage anchor CENTER => top-left = (1200-100, 320-100) = (1100, 220) + s.relocate(screen2, AnchorPoint.proportional(0.5, 0.5), Insets.EMPTY, AnchorPoint.CENTER, AnchorPolicy.FIXED); + pulse(); + + assertEquals(1100, peer.x, 0.0001); + assertEquals(220, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().get(0), Insets.EMPTY); + assertWithinBounds(peer, toolkit.getScreens().get(1), Insets.EMPTY); + } + + /** + * Constraints (screenPadding) are applied against the specified screen's usable bounds. + */ + @Test + public void relocateWithScreenParameterHonorsPaddingOnSpecifiedScreen() { + toolkit.setScreens( + new ScreenConfiguration(0, 0, 800, 600, 0, 0, 800, 600, 96), + new ScreenConfiguration(800, 0, 800, 600, 800, 40, 800, 560, 96)); + + s.setWidth(300); + s.setHeight(200); + + Screen screen2 = Screen.getScreens().get(1); + Insets padding = new Insets(10, 20, 30, 40); // top, right, bottom, left + + // Request a TOP_LEFT placement beyond bottom-right; should clamp within padded usable area. + // Screen2 visual: min=(800,40), size=(800,560) + // Padded usable maxX = 800+800-20 = 1580; maxY = 40+560-30 = 570 + // Stage max top-left: x = 1580-300 = 1280; y = 570-200 = 370 + s.relocate(screen2, AnchorPoint.absolute(800, 560), padding, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(1280, peer.x, 0.0001); + assertEquals(370, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().get(0), padding); + assertWithinBounds(peer, toolkit.getScreens().get(1), padding); + } + + /** + * "Before show" path works with the screen overload too (deferred application). + */ + @Test + public void relocateWithScreenParameterBeforeShowPositionsStageOnShow() { + toolkit.setScreens( + new ScreenConfiguration(0, 0, 800, 600, 0, 0, 800, 600, 96), + new ScreenConfiguration(800, 0, 800, 600, 800, 40, 800, 560, 96)); + + Screen screen2 = Screen.getScreens().get(1); + + s.setWidth(120); + s.setHeight(80); + s.relocate(screen2, AnchorPoint.TOP_LEFT, Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(800, peer.x, 0.0001); + assertEquals(40, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().get(0), Insets.EMPTY); + assertWithinBounds(peer, toolkit.getScreens().get(1), Insets.EMPTY); + } + + /** + * Tests that {@code relocate()} called before show overrides any prior {@code centerOnScreen()} request. + */ + @Test + public void relocateCancelsCenterOnScreenWhenCalledBeforeShow() { + s.setWidth(200); + s.setHeight(200); + s.centerOnScreen(); + + // If centerOnScreen were honored, we'd expect (300, 200) on 800x600. + // relocate should override/cancel it. + s.relocate(AnchorPoint.absolute(0, 0), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(0, peer.x, 0.0001); + assertEquals(0, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that enabled padding insets constrain the resulting stage position. + */ + @Test + public void relocateHonorsPaddingForEnabledEdges() { + s.setWidth(200); + s.setHeight(200); + + var padding = new Insets(10, 20, 30, 40); // top, right, bottom, left + + // Ask to place the TOP_LEFT anchor beyond the bottom-right safe area to force adjustment + s.relocate(AnchorPoint.absolute(800, 600), padding, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + // Allowed top-left: x <= 800 - 20 - 200 = 580, y <= 600 - 30 - 200 = 370 + assertEquals(580, peer.x, 0.0001); + assertEquals(370, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), padding); + } + + /** + * Tests that negative insets disable constraints for the corresponding edges. + */ + @Test + public void relocateNegativeInsetsDisableConstraintsPerEdge() { + s.setWidth(300); + s.setHeight(200); + + // Disable right and bottom constraints (negative), keep left/top enabled at 0. + var padding = new Insets(0, -1, -1, 0); + s.relocate(AnchorPoint.absolute(790, 590), padding, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(790, peer.x, 0.0001); + assertEquals(590, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().getFirst(), padding); + } + + /** + * Tests that a single enabled left-edge constraint is honored. + */ + @Test + public void relocateOneSidedLeftConstraintOnly() { + s.setWidth(300); + s.setHeight(200); + + // Enable left constraint (10), disable others + var padding = new Insets(-1, -1, -1, 10); + s.relocate(AnchorPoint.absolute(0, 100), padding, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(10, peer.x, 0.0001); + assertEquals(100, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), padding); + } + + /** + * Tests that {@link AnchorPolicy#FLIP_HORIZONTAL} selects a horizontally flipped anchor to avoid right overflow. + */ + @Test + public void relocateFlipHorizontalFitsWithoutAdjustment() { + s.setWidth(300); + s.setHeight(200); + + // TOP_LEFT at (790,10) overflows to the right. + // TOP_RIGHT at (790,10) => rawX=790-300=490 fits. + s.relocate(AnchorPoint.absolute(790, 10), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FLIP_HORIZONTAL); + s.show(); + + assertEquals(490, peer.x, 0.0001); + assertEquals(10, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@link AnchorPolicy#AUTO} prefers a diagonal flip when both horizontal and vertical + * constraints are violated. + */ + @Test + public void relocateAutoDiagonalBeatsAdjustOnly() { + s.setWidth(300); + s.setHeight(200); + + // TOP_LEFT at (790,590) overflows right and bottom. + // AUTO should choose BOTTOM_RIGHT (diagonal flip) => raw=(490,390) fits with no adjustment. + s.relocate(AnchorPoint.absolute(790, 590), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.AUTO); + s.show(); + + assertEquals(490, peer.x, 0.0001); + assertEquals(390, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@link AnchorPolicy#FLIP_HORIZONTAL} may still require vertical clamping after flipping. + */ + @Test + public void relocateFlipHorizontalStillRequiresVerticalAdjustment() { + s.setWidth(300); + s.setHeight(200); + + // Flip horizontally resolves X, but Y still needs adjustment. + // TOP_RIGHT raw = (490,590) => y clamps to 400. + s.relocate(AnchorPoint.absolute(790, 590), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FLIP_HORIZONTAL); + s.show(); + + assertEquals(490, peer.x, 0.0001); + assertEquals(400, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@link AnchorPolicy#FLIP_VERTICAL} may still require horizontal clamping after flipping. + */ + @Test + public void relocateFlipVerticalStillRequiresHorizontalAdjustment() { + s.setWidth(300); + s.setHeight(200); + + // Flip vertically resolves Y, but X still needs adjustment. + // BOTTOM_LEFT raw = (790,390) => x clamps to 500. + s.relocate(AnchorPoint.absolute(790, 590), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FLIP_VERTICAL); + s.show(); + + assertEquals(500, peer.x, 0.0001); + assertEquals(390, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@link AnchorPolicy#AUTO} flips horizontally when only the right constraint is enabled and violated. + */ + @Test + public void relocateAutoWithRightOnlyConstraintFlipsHorizontally() { + s.setWidth(300); + s.setHeight(200); + + // Only right edge constrained, others disabled + var constraints = new Insets(-1, 0, -1, -1); + + // Preferred TOP_LEFT: rawX=790 => violates right constraint (maxX=500) + // AUTO should choose TOP_RIGHT: rawX = 790-300 = 490 (fits without adjustment) + s.relocate(AnchorPoint.absolute(790, 10), constraints, AnchorPoint.TOP_LEFT, AnchorPolicy.AUTO); + s.show(); + + assertEquals(490, peer.x, 0.0001); + assertEquals(10, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@link AnchorPolicy#AUTO} keeps the preferred anchor when flipping would worsen the adjustment. + */ + @Test + public void relocateAutoWithLeftOnlyConstraintDoesNotFlipWhenFlipWouldBeWorse() { + s.setWidth(300); + s.setHeight(200); + + // Only left edge constrained to x >= 10, others disabled + var constraints = new Insets(-1, -1, -1, 10); + + // Preferred TOP_LEFT: rawX = 0 -> adjusted to 10 (cost 10) + // Flipped TOP_RIGHT: rawX = 0-300 = -300 -> adjusted to 10 (cost 310) + // AUTO may consider the flip, but should keep the original anchor as "better". + s.relocate(AnchorPoint.absolute(0, 10), constraints, AnchorPoint.TOP_LEFT, AnchorPolicy.AUTO); + s.show(); + + assertEquals(10, peer.x, 0.0001); + assertEquals(10, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@link AnchorPolicy#AUTO} flips vertically when only the bottom constraint is enabled and violated. + */ + @Test + public void relocateAutoWithBottomOnlyConstraintFlipsVertically() { + s.setWidth(300); + s.setHeight(200); + + // Only bottom constrained, others disabled + var constraints = new Insets(-1, -1, 0, -1); + + // Preferred TOP_LEFT at y=590 => rawY=590 violates bottom maxY=400 + // Vertical flip to BOTTOM_LEFT yields rawY=590-200=390 (fits) + s.relocate(AnchorPoint.absolute(100, 590), constraints, AnchorPoint.TOP_LEFT, AnchorPolicy.AUTO); + s.show(); + + assertEquals(100, peer.x, 0.0001); + assertEquals(390, peer.y, 0.0001); + assertWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests that {@link AnchorPolicy#AUTO} ignores disabled edge constraints when deciding whether to flip. + */ + @Test + public void relocateAutoIgnoresDisabledEdgesWhenDecidingWhetherToFlip() { + s.setWidth(300); + s.setHeight(200); + + // Disable right constraint, enable left constraint (x >= 0). + // This means "overflow to the right is allowed", so AUTO should not flip horizontally + // just because rawX would exceed the screen width. + var constraints = new Insets(-1, -1, -1, 0); + + s.relocate(AnchorPoint.absolute(790, 10), constraints, AnchorPoint.TOP_LEFT, AnchorPolicy.AUTO); + s.show(); + + // With only left constraint, rawX=790 is allowed (since right is disabled). + assertEquals(790.0, peer.x, 0.0001); + assertEquals(10.0, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().getFirst(), Insets.EMPTY); + } + + /** + * Tests side selection when the stage cannot fit in the constrained span. + */ + @Test + public void relocateWhenStageDoesNotFitInConstrainedSpanUsesAnchorToChooseSide() { + // Make a screen smaller than the stage, so maxX < minX (and maxY < minY). + toolkit.setScreens(new ScreenConfiguration(0, 0, 200, 200, 0, 0, 200, 200, 96)); + s.setWidth(300); + s.setHeight(250); + + // With TOP_LEFT, choose minX/minY in non-fit scenario. + s.relocate(AnchorPoint.absolute(0, 0), Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + assertEquals(0, peer.x, 0.0001); + assertEquals(0, peer.y, 0.0001); + + // Now recreate with TOP_RIGHT and ensure we choose maxX/minY in non-fit scenario. + s.hide(); + s.setWidth(300); + s.setHeight(250); + s.relocate(AnchorPoint.absolute(0, 0), Insets.EMPTY, AnchorPoint.TOP_RIGHT, AnchorPolicy.FIXED); + s.show(); + + assertEquals(-100, peer.x, 0.0001); // maxX = 200 - 300 = -100 + assertEquals(0, peer.y, 0.0001); // choose minY because TOP_RIGHT has y = 0 + } + + /** + * Tests that {@code relocate()} uses the screen containing the request point to apply constraints. + */ + @Test + public void relocateUsesSecondScreenBoundsForConstraints() { + toolkit.setScreens( + new ScreenConfiguration(0, 0, 1920, 1200, 0, 0, 1920, 1172, 96), + new ScreenConfiguration(1920, 160, 1440, 900, 1920, 160, 1440, 900, 96)); + + s.setWidth(400); + s.setHeight(300); + + // Point on screen 2, but near its bottom-right corner. + var p = AnchorPoint.absolute(1920 + 1440 - 1, 160 + 900 - 1); + s.relocate(p, Insets.EMPTY, AnchorPoint.TOP_LEFT, AnchorPolicy.FIXED); + s.show(); + + // Clamp within screen 2: x <= 1920+1440-400 = 2960, y <= 160+900-300 = 760 + assertEquals(2960, peer.x, 0.0001); + assertEquals(760, peer.y, 0.0001); + assertNotWithinBounds(peer, toolkit.getScreens().get(0), Insets.EMPTY); + assertWithinBounds(peer, toolkit.getScreens().get(1), Insets.EMPTY); + } + + /** + * Tests {@code relocate()} behavior for a zero-size stage with proportional anchors (no NaN/Infinity). + */ + @Test + public void relocateWithZeroSizeAndProportionalAnchorDoesNotProduceNaNAndConstrainsNormally() { + // Force zero size at positioning time. + s.setWidth(0); + s.setHeight(0); + + // Enable all edges (Insets.EMPTY), so negative coordinate requests are constrained. + s.relocate(AnchorPoint.absolute(-10, -20), Insets.EMPTY, AnchorPoint.CENTER, AnchorPolicy.AUTO); + s.show(); + + // With width/height == 0, maxX == 800, and maxY == 600; raw is (-10, -20) => constrained to (0,0) + assertEquals(0, peer.x, 0.0001); + assertEquals(0, peer.y, 0.0001); + assertFalse(Double.isNaN(peer.x) || Double.isInfinite(peer.x)); + assertFalse(Double.isNaN(peer.y) || Double.isInfinite(peer.y)); + } + + /** + * Tests side selection when constraints define an impossible usable area for a zero-size stage. + */ + @Test + public void relocateWithZeroSizeAndImpossibleConstraintsChoosesSideUsingAnchorPosition() { + s.setWidth(0); + s.setHeight(0); + + // Make the constrained space impossible even for a zero-size window: + // Horizontal: minX = 500, maxX = 800 - 400 - 0 = 400 => maxX < minX + // Vertical: minY = 300, maxY = 600 - 400 - 0 = 200 => maxY < minY + var constraints = new Insets(300, 400, 400, 500); + + // x = 0.25 => choose minX (since x <= 0.5) + // y = 0.75 => choose maxY (since y > 0.5) + var anchor = AnchorPoint.proportional(0.25, 0.75); + + s.relocate(AnchorPoint.absolute(0, 0), constraints, anchor, AnchorPolicy.FIXED); + s.show(); + + assertEquals(500, peer.x, 0.0001); + assertEquals(200, peer.y, 0.0001); + } + + /** + * Tests that zero-size relocation with absolute anchors does not divide by zero in fallback paths. + */ + @Test + public void relocateWithZeroSizeAndAbsoluteAnchorDoesNotDivideByZero() { + s.setWidth(0); + s.setHeight(0); + + // Force max < min to exercise the "choose side" fallback. + var constraints = new Insets(300, 400, 400, 500); + var anchor = AnchorPoint.absolute(10, 10); + + s.relocate(AnchorPoint.absolute(0, 0), constraints, anchor, AnchorPolicy.FIXED); + s.show(); + + assertEquals(500, peer.x, 0.0001); // minX + assertEquals(300, peer.y, 0.0001); // minY + } + + /** + * Tests that {@link AnchorPolicy#FIXED} constrains the stage within screen bounds for several anchors. + */ + @ParameterizedTest + @MethodSource("relocateHonorsScreenBounds_arguments") + public void relocateWithFixedAnchorPolicyHonorsScreenBounds( + AnchorPoint stageAnchor, + Insets screenPadding, + double stageW, double stageH, + double requestX, double requestY) { + s.setWidth(stageW); + s.setHeight(stageH); + s.relocate(AnchorPoint.absolute(requestX, requestY), screenPadding, stageAnchor, AnchorPolicy.FIXED); + s.show(); + + assertWithinBounds(peer, toolkit.getScreens().getFirst(), screenPadding); + } + + /** + * Tests that {@link AnchorPolicy#FIXED} constrains the stage within padded usable bounds for several anchors. + */ + @ParameterizedTest + @MethodSource("relocateHonorsScreenBoundsWithPadding_arguments") + public void relocateWithFixedAnchorPolicyHonorsScreenBoundsWithPadding( + AnchorPoint stageAnchor, + Insets screenPadding, + double stageW, double stageH, + double requestX, double requestY) { + s.setWidth(stageW); + s.setHeight(stageH); + s.relocate(AnchorPoint.absolute(requestX, requestY), screenPadding, stageAnchor, AnchorPolicy.FIXED); + s.show(); + + assertWithinBounds(peer, toolkit.getScreens().getFirst(), screenPadding); + } + + private static Stream relocateHonorsScreenBounds_arguments() { + return relocateHonorsScreenBounds_argumentsImpl(false); + } + + private static Stream relocateHonorsScreenBoundsWithPadding_arguments() { + return relocateHonorsScreenBounds_argumentsImpl(true); + } + + private static Stream relocateHonorsScreenBounds_argumentsImpl(boolean padding) { + final double screenW = 800; + final double screenH = 600; + final double stageW = 200; + final double stageH = 200; + final double overshoot = 10; // push past the edge to force adjustment + final var insets = padding ? new Insets(10, 20, 30, 40) : Insets.EMPTY; + + Stream.Builder b = Stream.builder(); + b.add(Arguments.of(AnchorPoint.TOP_LEFT, insets, stageW, stageH, + screenW - stageW + overshoot, screenH - stageH + overshoot)); + b.add(Arguments.of(AnchorPoint.TOP_RIGHT, insets, stageW, stageH, + stageW - overshoot, screenH - stageH + overshoot)); + b.add(Arguments.of(AnchorPoint.BOTTOM_LEFT, insets, stageW, stageH, + screenW - stageW + overshoot, stageH - overshoot)); + b.add(Arguments.of(AnchorPoint.BOTTOM_RIGHT, insets, stageW, stageH, + stageW - overshoot, stageH - overshoot)); + return b.build(); + } + + private static void assertWithinBounds(StubStage peer, ScreenConfiguration screen, Insets padding) { + assertTrue(isWithinBounds(peer, screen, padding), "Stage is not within bounds"); + } + + private static void assertNotWithinBounds(StubStage peer, ScreenConfiguration screen, Insets padding) { + assertFalse(isWithinBounds(peer, screen, padding), "Stage is within bounds"); + } + + private static boolean isWithinBounds(StubStage peer, ScreenConfiguration screen, Insets padding) { + return screen.getMinX() + padding.getLeft() <= peer.x + && screen.getMinY() + padding.getTop() <= peer.y + && screen.getMinX() + screen.getWidth() - padding.getRight() >= peer.x + peer.width + && screen.getMinY() + screen.getHeight() - padding.getBottom() >= peer.y + peer.height; + } }