/** * @file DockPane.java * @brief Class implementing a generic dock pane for the layout of dock nodes. * * @section License * * This file is a part of the DockFX Library. Copyright (C) 2015 Robert B. Colton * * This program is free software: you can redistribute it and/or modify it under the terms * of the GNU Lesser General Public License as published by the Free Software Foundation, * either version 3 of the License, or (at your option) any later version. * * This program 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License along with this * program. If not, see <http://www.gnu.org/licenses/>. **/ package org.dockfx; import java.util.Stack; import com.sun.javafx.css.StyleManager; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.ObservableMap; import javafx.css.PseudoClass; import javafx.event.EventHandler; import javafx.geometry.Orientation; import javafx.geometry.Point2D; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.control.Button; import javafx.scene.control.SplitPane; import javafx.scene.layout.GridPane; import javafx.scene.layout.StackPane; import javafx.scene.shape.Rectangle; import javafx.stage.Popup; import javafx.util.Duration; /** * Base class for a dock pane that provides the layout of the dock nodes. Stacking the dock nodes to * the center in a TabPane will be added in a future release. For now the DockPane uses the relative * sizes of the dock nodes and lays them out in a tree of SplitPanes. * * @since DockFX 0.1 */ public class DockPane extends StackPane implements EventHandler<DockEvent> { /** * The current root node of this dock pane's layout. */ private Node root; /** * Whether a DOCK_ENTER event has been received by this dock pane since the last DOCK_EXIT event * was received. */ private boolean receivedEnter = false; /** * The current node in this dock pane that we may be dragging over. */ private Node dockNodeDrag; /** * The docking area of the current dock indicator button if any is selected. This is either the * root or equal to dock node drag. */ private Node dockAreaDrag; /** * The docking position of the current dock indicator button if any is selected. */ private DockPos dockPosDrag; /** * The docking area shape with a dotted animated border on the indicator overlay popup. */ private Rectangle dockAreaIndicator; /** * The timeline used to animate the borer of the docking area indicator shape. Because JavaFX has * no CSS styling for timelines/animations yet we will make this private and offer an accessor for * the user to programmatically modify the animation or disable it. */ private Timeline dockAreaStrokeTimeline; /** * The popup used to display the root dock indicator buttons and the docking area indicator. */ private Popup dockIndicatorOverlay; /** * The grid pane used to lay out the local dock indicator buttons. This is the grid used to lay * out the buttons in the circular indicator. */ private GridPane dockPosIndicator; /** * The popup used to display the local dock indicator buttons. This allows these indicator buttons * to be displayed outside the window of this dock pane. */ private Popup dockIndicatorPopup; /** * Base class for a dock indicator button that allows it to be displayed during a dock event and * continue to receive input. * * @since DockFX 0.1 */ public class DockPosButton extends Button { /** * Whether this dock indicator button is used for docking a node relative to the root of the * dock pane. */ private boolean dockRoot = true; /** * The docking position indicated by this button. */ private DockPos dockPos = DockPos.CENTER; /** * Creates a new dock indicator button. */ public DockPosButton(boolean dockRoot, DockPos dockPos) { super(); this.dockRoot = dockRoot; this.dockPos = dockPos; } /** * Whether this dock indicator button is used for docking a node relative to the root of the * dock pane. * * @param dockRoot Whether this indicator button is used for docking a node relative to the root * of the dock pane. */ public final void setDockRoot(boolean dockRoot) { this.dockRoot = dockRoot; } /** * The docking position indicated by this button. * * @param dockPos The docking position indicated by this button. */ public final void setDockPos(DockPos dockPos) { this.dockPos = dockPos; } /** * The docking position indicated by this button. * * @return The docking position indicated by this button. */ public final DockPos getDockPos() { return dockPos; } /** * Whether this dock indicator button is used for docking a node relative to the root of the * dock pane. * * @return Whether this indicator button is used for docking a node relative to the root of the * dock pane. */ public final boolean isDockRoot() { return dockRoot; } } /** * A collection used to manage the indicator buttons and automate hit detection during DOCK_OVER * events. */ private ObservableList<DockPosButton> dockPosButtons; /** * Creates a new DockPane adding event handlers for dock events and creating the indicator * overlays. */ public DockPane() { super(); this.addEventHandler(DockEvent.ANY, this); this.addEventFilter(DockEvent.ANY, new EventHandler<DockEvent>() { @Override public void handle(DockEvent event) { if (event.getEventType() == DockEvent.DOCK_ENTER) { DockPane.this.receivedEnter = true; } else if (event.getEventType() == DockEvent.DOCK_OVER) { DockPane.this.dockNodeDrag = null; } } }); dockIndicatorPopup = new Popup(); dockIndicatorPopup.setAutoFix(false); dockIndicatorOverlay = new Popup(); dockIndicatorOverlay.setAutoFix(false); StackPane dockRootPane = new StackPane(); dockRootPane.prefWidthProperty().bind(this.widthProperty()); dockRootPane.prefHeightProperty().bind(this.heightProperty()); dockAreaIndicator = new Rectangle(); dockAreaIndicator.setManaged(false); dockAreaIndicator.setMouseTransparent(true); dockAreaStrokeTimeline = new Timeline(); dockAreaStrokeTimeline.setCycleCount(Timeline.INDEFINITE); // 12 is the cumulative offset of the stroke dash array in the default.css style sheet // RFE filed for CSS styled timelines/animations: // https://bugs.openjdk.java.net/browse/JDK-8133837 KeyValue kv = new KeyValue(dockAreaIndicator.strokeDashOffsetProperty(), 12); KeyFrame kf = new KeyFrame(Duration.millis(500), kv); dockAreaStrokeTimeline.getKeyFrames().add(kf); dockAreaStrokeTimeline.play(); DockPosButton dockCenter = new DockPosButton(false, DockPos.CENTER); dockCenter.getStyleClass().add("dock-center"); DockPosButton dockTop = new DockPosButton(false, DockPos.TOP); dockTop.getStyleClass().add("dock-top"); DockPosButton dockRight = new DockPosButton(false, DockPos.RIGHT); dockRight.getStyleClass().add("dock-right"); DockPosButton dockBottom = new DockPosButton(false, DockPos.BOTTOM); dockBottom.getStyleClass().add("dock-bottom"); DockPosButton dockLeft = new DockPosButton(false, DockPos.LEFT); dockLeft.getStyleClass().add("dock-left"); DockPosButton dockTopRoot = new DockPosButton(true, DockPos.TOP); StackPane.setAlignment(dockTopRoot, Pos.TOP_CENTER); dockTopRoot.getStyleClass().add("dock-top-root"); DockPosButton dockRightRoot = new DockPosButton(true, DockPos.RIGHT); StackPane.setAlignment(dockRightRoot, Pos.CENTER_RIGHT); dockRightRoot.getStyleClass().add("dock-right-root"); DockPosButton dockBottomRoot = new DockPosButton(true, DockPos.BOTTOM); StackPane.setAlignment(dockBottomRoot, Pos.BOTTOM_CENTER); dockBottomRoot.getStyleClass().add("dock-bottom-root"); DockPosButton dockLeftRoot = new DockPosButton(true, DockPos.LEFT); StackPane.setAlignment(dockLeftRoot, Pos.CENTER_LEFT); dockLeftRoot.getStyleClass().add("dock-left-root"); // TODO: dockCenter goes first when tabs are added in a future version dockPosButtons = FXCollections.observableArrayList(dockTop, dockRight, dockBottom, dockLeft, dockTopRoot, dockRightRoot, dockBottomRoot, dockLeftRoot); dockPosIndicator = new GridPane(); dockPosIndicator.add(dockTop, 1, 0); dockPosIndicator.add(dockRight, 2, 1); dockPosIndicator.add(dockBottom, 1, 2); dockPosIndicator.add(dockLeft, 0, 1); // dockPosIndicator.add(dockCenter, 1, 1); dockRootPane.getChildren().addAll(dockAreaIndicator, dockTopRoot, dockRightRoot, dockBottomRoot, dockLeftRoot); dockIndicatorOverlay.getContent().add(dockRootPane); dockIndicatorPopup.getContent().addAll(dockPosIndicator); this.getStyleClass().add("dock-pane"); dockRootPane.getStyleClass().add("dock-root-pane"); dockPosIndicator.getStyleClass().add("dock-pos-indicator"); dockAreaIndicator.getStyleClass().add("dock-area-indicator"); } /** * The Timeline used to animate the docking area indicator in the dock indicator overlay for this * dock pane. * * @return The Timeline used to animate the docking area indicator in the dock indicator overlay * for this dock pane. */ public final Timeline getDockAreaStrokeTimeline() { return dockAreaStrokeTimeline; } /** * Helper function to retrieve the URL of the default style sheet used by DockFX. * * @return The URL of the default style sheet used by DockFX. */ public final static String getDefaultUserAgentStyleheet() { return DockPane.class.getResource("default.css").toExternalForm(); } /** * Helper function to add the default style sheet of DockFX to the user agent style sheets. */ public final static void initializeDefaultUserAgentStylesheet() { StyleManager.getInstance() .addUserAgentStylesheet(DockPane.class.getResource("default.css").toExternalForm()); } /** * A cache of all dock node event handlers that we have created for tracking the current docking * area. */ private ObservableMap<Node, DockNodeEventHandler> dockNodeEventFilters = FXCollections.observableHashMap(); /** * A wrapper to the type parameterized generic EventHandler that allows us to remove it from its * listener when the dock node becomes detached. It is specifically used to monitor which dock * node in this dock pane's layout we are currently dragging over. * * @since DockFX 0.1 */ private class DockNodeEventHandler implements EventHandler<DockEvent> { /** * The node associated with this event handler that reports to the encapsulating dock pane. */ private Node node = null; /** * Creates a default dock node event handler that will help this dock pane track the current * docking area. * * @param node The node that is to listen for docking events and report to the encapsulating * docking pane. */ public DockNodeEventHandler(Node node) { this.node = node; } @Override public void handle(DockEvent event) { DockPane.this.dockNodeDrag = node; } } /** * Dock the node into this dock pane at the given docking position relative to the sibling in the * layout. This is used to relatively position the dock nodes to other nodes given their preferred * size. * * @param node The node that is to be docked into this dock pane. * @param dockPos The docking position of the node relative to the sibling. * @param sibling The sibling of this node in the layout. */ public void dock(Node node, DockPos dockPos, Node sibling) { DockNodeEventHandler dockNodeEventHandler = new DockNodeEventHandler(node); dockNodeEventFilters.put(node, dockNodeEventHandler); node.addEventFilter(DockEvent.DOCK_OVER, dockNodeEventHandler); SplitPane split = (SplitPane) root; if (split == null) { split = new SplitPane(); split.getItems().add(node); root = split; this.getChildren().add(root); return; } // find the parent of the sibling if (sibling != null && sibling != root) { Stack<Parent> stack = new Stack<Parent>(); stack.push((Parent) root); while (!stack.isEmpty()) { Parent parent = stack.pop(); ObservableList<Node> children = parent.getChildrenUnmodifiable(); if (parent instanceof SplitPane) { SplitPane splitPane = (SplitPane) parent; children = splitPane.getItems(); } for (int i = 0; i < children.size(); i++) { if (children.get(i) == sibling) { split = (SplitPane) parent; } else if (children.get(i) instanceof Parent) { stack.push((Parent) children.get(i)); } } } } Orientation requestedOrientation = (dockPos == DockPos.LEFT || dockPos == DockPos.RIGHT) ? Orientation.HORIZONTAL : Orientation.VERTICAL; // if the orientation is different then reparent the split pane if (split.getOrientation() != requestedOrientation) { if (split.getItems().size() > 1) { SplitPane splitPane = new SplitPane(); if (split == root && sibling == root) { this.getChildren().set(this.getChildren().indexOf(root), splitPane); splitPane.getItems().add(split); root = splitPane; } else { split.getItems().set(split.getItems().indexOf(sibling), splitPane); splitPane.getItems().add(sibling); } split = splitPane; } split.setOrientation(requestedOrientation); } // finally dock the node to the correct split pane ObservableList<Node> splitItems = split.getItems(); double magnitude = 0; if (splitItems.size() > 0) { if (split.getOrientation() == Orientation.HORIZONTAL) { for (Node splitItem : splitItems) { magnitude += splitItem.prefWidth(0); } } else { for (Node splitItem : splitItems) { magnitude += splitItem.prefHeight(0); } } } if (dockPos == DockPos.LEFT || dockPos == DockPos.TOP) { int relativeIndex = 0; if (sibling != null && sibling != root) { relativeIndex = splitItems.indexOf(sibling); } splitItems.add(relativeIndex, node); if (splitItems.size() > 1) { if (split.getOrientation() == Orientation.HORIZONTAL) { split.setDividerPosition(relativeIndex, node.prefWidth(0) / (magnitude + node.prefWidth(0))); } else { split.setDividerPosition(relativeIndex, node.prefHeight(0) / (magnitude + node.prefHeight(0))); } } } else if (dockPos == DockPos.RIGHT || dockPos == DockPos.BOTTOM) { int relativeIndex = splitItems.size(); if (sibling != null && sibling != root) { relativeIndex = splitItems.indexOf(sibling) + 1; } splitItems.add(relativeIndex, node); if (splitItems.size() > 1) { if (split.getOrientation() == Orientation.HORIZONTAL) { split.setDividerPosition(relativeIndex - 1, 1 - node.prefWidth(0) / (magnitude + node.prefWidth(0))); } else { split.setDividerPosition(relativeIndex - 1, 1 - node.prefHeight(0) / (magnitude + node.prefHeight(0))); } } } } /** * Dock the node into this dock pane at the given docking position relative to the root in the * layout. This is used to relatively position the dock nodes to other nodes given their preferred * size. * * @param node The node that is to be docked into this dock pane. * @param dockPos The docking position of the node relative to the sibling. */ public void dock(Node node, DockPos dockPos) { dock(node, dockPos, root); } /** * Detach the node from this dock pane removing it from the layout. * * @param node The node that is to be removed from this dock pane. */ public void undock(DockNode node) { DockNodeEventHandler dockNodeEventHandler = dockNodeEventFilters.get(node); node.removeEventFilter(DockEvent.DOCK_OVER, dockNodeEventHandler); dockNodeEventFilters.remove(node); // depth first search to find the parent of the node Stack<Parent> findStack = new Stack<Parent>(); findStack.push((Parent) root); while (!findStack.isEmpty()) { Parent parent = findStack.pop(); ObservableList<Node> children = parent.getChildrenUnmodifiable(); if (parent instanceof SplitPane) { SplitPane split = (SplitPane) parent; children = split.getItems(); } for (int i = 0; i < children.size(); i++) { if (children.get(i) == node) { children.remove(i); // start from the root again and remove any SplitPane's with no children in them Stack<Parent> clearStack = new Stack<Parent>(); clearStack.push((Parent) root); while (!clearStack.isEmpty()) { parent = clearStack.pop(); children = parent.getChildrenUnmodifiable(); if (parent instanceof SplitPane) { SplitPane split = (SplitPane) parent; children = split.getItems(); } for (i = 0; i < children.size(); i++) { if (children.get(i) instanceof SplitPane) { SplitPane split = (SplitPane) children.get(i); if (split.getItems().size() < 1) { children.remove(i); continue; } else { clearStack.push(split); } } } } return; } else if (children.get(i) instanceof Parent) { findStack.push((Parent) children.get(i)); } } } } @Override public void handle(DockEvent event) { if (event.getEventType() == DockEvent.DOCK_ENTER) { if (!dockIndicatorOverlay.isShowing()) { Point2D topLeft = DockPane.this.localToScreen(0, 0); dockIndicatorOverlay.show(DockPane.this, topLeft.getX(), topLeft.getY()); } } else if (event.getEventType() == DockEvent.DOCK_OVER) { this.receivedEnter = false; dockPosDrag = null; dockAreaDrag = dockNodeDrag; for (DockPosButton dockIndicatorButton : dockPosButtons) { if (dockIndicatorButton .contains(dockIndicatorButton.screenToLocal(event.getScreenX(), event.getScreenY()))) { dockPosDrag = dockIndicatorButton.getDockPos(); if (dockIndicatorButton.isDockRoot()) { dockAreaDrag = root; } dockIndicatorButton.pseudoClassStateChanged(PseudoClass.getPseudoClass("focused"), true); break; } else { dockIndicatorButton.pseudoClassStateChanged(PseudoClass.getPseudoClass("focused"), false); } } if (dockPosDrag != null) { Point2D originToScene = dockAreaDrag.localToScene(0, 0); dockAreaIndicator.setVisible(true); dockAreaIndicator.relocate(originToScene.getX(), originToScene.getY()); if (dockPosDrag == DockPos.RIGHT) { dockAreaIndicator.setTranslateX(dockAreaDrag.getLayoutBounds().getWidth() / 2); } else { dockAreaIndicator.setTranslateX(0); } if (dockPosDrag == DockPos.BOTTOM) { dockAreaIndicator.setTranslateY(dockAreaDrag.getLayoutBounds().getHeight() / 2); } else { dockAreaIndicator.setTranslateY(0); } if (dockPosDrag == DockPos.LEFT || dockPosDrag == DockPos.RIGHT) { dockAreaIndicator.setWidth(dockAreaDrag.getLayoutBounds().getWidth() / 2); } else { dockAreaIndicator.setWidth(dockAreaDrag.getLayoutBounds().getWidth()); } if (dockPosDrag == DockPos.TOP || dockPosDrag == DockPos.BOTTOM) { dockAreaIndicator.setHeight(dockAreaDrag.getLayoutBounds().getHeight() / 2); } else { dockAreaIndicator.setHeight(dockAreaDrag.getLayoutBounds().getHeight()); } } else { dockAreaIndicator.setVisible(false); } if (dockNodeDrag != null) { Point2D originToScreen = dockNodeDrag.localToScreen(0, 0); double posX = originToScreen.getX() + dockNodeDrag.getLayoutBounds().getWidth() / 2 - dockPosIndicator.getWidth() / 2; double posY = originToScreen.getY() + dockNodeDrag.getLayoutBounds().getHeight() / 2 - dockPosIndicator.getHeight() / 2; if (!dockIndicatorPopup.isShowing()) { dockIndicatorPopup.show(DockPane.this, posX, posY); } else { dockIndicatorPopup.setX(posX); dockIndicatorPopup.setY(posY); } // set visible after moving the popup dockPosIndicator.setVisible(true); } else { dockPosIndicator.setVisible(false); } } if (event.getEventType() == DockEvent.DOCK_RELEASED && event.getContents() != null) { if (dockPosDrag != null && dockIndicatorOverlay.isShowing()) { DockNode dockNode = (DockNode) event.getContents(); dockNode.dock(this, dockPosDrag, dockAreaDrag); } } if ((event.getEventType() == DockEvent.DOCK_EXIT && !this.receivedEnter) || event.getEventType() == DockEvent.DOCK_RELEASED) { if (dockIndicatorPopup.isShowing()) { dockIndicatorOverlay.hide(); dockIndicatorPopup.hide(); } } } }