diff --git a/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java b/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java index d1b156246c..2ca84f0c4f 100644 --- a/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java +++ b/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java @@ -9,15 +9,19 @@ import static org.phoebus.ui.application.PhoebusApplication.logger; +import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import java.util.logging.Level; import java.util.prefs.BackingStoreException; import java.util.prefs.Preferences; +import java.util.stream.Collectors; import javafx.application.Platform; import javafx.geometry.Bounds; +import javafx.geometry.Point2D; import javafx.geometry.Rectangle2D; import javafx.scene.Node; import javafx.scene.control.Dialog; @@ -103,6 +107,48 @@ public void run() { }); } + /** + * Clamp a rectangle to the closest rectangle in a list of screens. This is used to prevent a dialog from going + * off screen completely, and losing any control over the entire application. + * + * @param rect The rectangle to be clamped. + * @param screens A list of screen regions to clamp to. + * */ + static private Rectangle2D clampToClosest(final Rectangle2D rect, final List screens) { + Point2D center = new Point2D( + rect.getMinX() + rect.getWidth() / 2, + rect.getMinY() + rect.getHeight() / 2 + ); + + Optional closestOpt = screens.stream().min( + Comparator.comparingDouble(screen -> { + // if the dialog center is inside of a screen, it will always be the closest + if (screen.contains(center)) + return -1; + + // get distance to closest edge + double dx = Math.max(0, Math.max(screen.getMinX() - center.getX(), center.getX() - screen.getMaxX())); + double dy = Math.max(0, Math.max(screen.getMinY() - center.getY(), center.getY() - screen.getMaxY())); + + return dx * dx + dy * dy; + }) + ); + + if (closestOpt.isEmpty()) { + // no available screens (unlikely) + return rect; + } + + // clamp position to screen, note that this will move the rectangle into the screen in its entirety, + // with a preference for the top left corner + Rectangle2D closest = closestOpt.get(); + double newMinX = Math.max(closest.getMinX(), Math.min(rect.getMinX(), closest.getMaxX() - rect.getWidth())); + double newMinY = Math.max(closest.getMinY(), Math.min(rect.getMinY(), closest.getMaxY() - rect.getHeight())); + return new Rectangle2D( + newMinX, newMinY, rect.getWidth(), rect.getHeight() + ); + } + /** Position the given {@code dialog} initially relative to {@code owner}, * then it saves/restore the dialog's position and size into/from the * provided {@link Preferences}. @@ -233,9 +279,19 @@ public static void positionAndSize(final Dialog dialog, final Node owner, fin if (owner != null) { // Position relative to owner final Bounds pos = owner.localToScreen(owner.getBoundsInLocal()); + final Rectangle2D prefPos = new Rectangle2D( + pos.getMinX() - prefWidth, + pos.getMinY() - prefHeight/3, + prefWidth, + prefHeight + ); + List screens = Screen.getScreens(); + Rectangle2D clampedPos = clampToClosest( + prefPos, screens.stream().map(Screen::getVisualBounds).collect(Collectors.toList()) + ); - dialog.setX(pos.getMinX() - prefWidth); - dialog.setY(pos.getMinY() - prefHeight/3); + dialog.setX(clampedPos.getMinX()); + dialog.setY(clampedPos.getMinY()); } if (!Double.isNaN(prefWidth) && !Double.isNaN(prefHeight))