/**
* @file DockNode.java
* @brief Class implementing basic dock node with floating and styling.
*
* @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 javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.css.PseudoClass;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.Window;
/**
* Base class for a dock node that provides the layout of the content along with a title bar and a
* styled border. The dock node can be detached and floated or closed and removed from the layout.
* Dragging behavior is implemented through the title bar.
*
* @since DockFX 0.1
*/
public class DockNode extends VBox implements EventHandler<MouseEvent> {
/**
* The style this dock node should use on its stage when set to floating.
*/
private StageStyle stageStyle = StageStyle.TRANSPARENT;
/**
* The stage that this dock node is currently using when floating.
*/
private Stage stage;
/**
* The contents of the dock node, i.e. a TreeView or ListView.
*/
private Node contents;
/**
* The title bar that implements our dragging and state manipulation.
*/
private DockTitleBar dockTitleBar;
/**
* The border pane used when floating to provide a styled custom border.
*/
private BorderPane borderPane;
/**
* The dock pane this dock node belongs to when not floating.
*/
private DockPane dockPane;
/**
* CSS pseudo class selector representing whether this node is currently floating.
*/
private static final PseudoClass FLOATING_PSEUDO_CLASS = PseudoClass.getPseudoClass("floating");
/**
* CSS pseudo class selector representing whether this node is currently docked.
*/
private static final PseudoClass DOCKED_PSEUDO_CLASS = PseudoClass.getPseudoClass("docked");
/**
* CSS pseudo class selector representing whether this node is currently maximized.
*/
private static final PseudoClass MAXIMIZED_PSEUDO_CLASS = PseudoClass.getPseudoClass("maximized");
/**
* Boolean property maintaining whether this node is currently maximized.
*
* @defaultValue false
*/
private BooleanProperty maximizedProperty = new SimpleBooleanProperty(false) {
@Override
protected void invalidated() {
DockNode.this.pseudoClassStateChanged(MAXIMIZED_PSEUDO_CLASS, get());
if (borderPane != null) {
borderPane.pseudoClassStateChanged(MAXIMIZED_PSEUDO_CLASS, get());
}
stage.setMaximized(get());
// TODO: This is a work around to fill the screen bounds and not overlap the task bar when
// the window is undecorated as in Visual Studio. A similar work around needs applied for
// JFrame in Swing. http://bugs.java.com/bugdatabase/view_bug.do?bug_id=4737788
// Bug report filed:
// https://bugs.openjdk.java.net/browse/JDK-8133330
if (this.get()) {
Screen screen = Screen
.getScreensForRectangle(stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight())
.get(0);
Rectangle2D bounds = screen.getVisualBounds();
stage.setX(bounds.getMinX());
stage.setY(bounds.getMinY());
stage.setWidth(bounds.getWidth());
stage.setHeight(bounds.getHeight());
}
}
@Override
public String getName() {
return "maximized";
}
};
/**
* Creates a default DockNode with a default title bar and layout.
*
* @param contents The contents of the dock node which may be a tree or another scene graph node.
* @param title The caption title of this dock node which maintains bidirectional state with the
* title bar and stage.
* @param graphic The caption graphic of this dock node which maintains bidirectional state with
* the title bar and stage.
*/
public DockNode(Node contents, String title, Node graphic) {
this.titleProperty.setValue(title);
this.graphicProperty.setValue(graphic);
this.contents = contents;
dockTitleBar = new DockTitleBar(this);
getChildren().addAll(dockTitleBar, contents);
VBox.setVgrow(contents, Priority.ALWAYS);
this.getStyleClass().add("dock-node");
}
/**
* Creates a default DockNode with a default title bar and layout.
*
* @param contents The contents of the dock node which may be a tree or another scene graph node.
* @param title The caption title of this dock node which maintains bidirectional state with the
* title bar and stage.
*/
public DockNode(Node contents, String title) {
this(contents, title, null);
}
/**
* Creates a default DockNode with a default title bar and layout.
*
* @param contents The contents of the dock node which may be a tree or another scene graph node.
*/
public DockNode(Node contents) {
this(contents, null, null);
}
/**
* The stage style that will be used when the dock node is floating. This must be set prior to
* setting the dock node to floating.
*
* @param stageStyle The stage style that will be used when the node is floating.
*/
public void setStageStyle(StageStyle stageStyle) {
this.stageStyle = stageStyle;
}
/**
* Changes the contents of the dock node.
*
* @param contents The new contents of this dock node.
*/
public void setContents(Node contents) {
this.getChildren().set(this.getChildren().indexOf(this.contents), contents);
this.contents = contents;
}
/**
* Changes the title bar in the layout of this dock node. This can be used to remove the dock
* title bar from the dock node by passing null.
*
* @param dockTitleBar null The new title bar of this dock node, can be set null indicating no
* title bar is used.
*/
public void setDockTitleBar(DockTitleBar dockTitleBar) {
if (dockTitleBar != null) {
if (this.dockTitleBar != null) {
this.getChildren().set(this.getChildren().indexOf(this.dockTitleBar), dockTitleBar);
} else {
this.getChildren().add(0, dockTitleBar);
}
} else {
this.getChildren().remove(this.dockTitleBar);
}
this.dockTitleBar = dockTitleBar;
}
/**
* Whether the node is currently maximized.
*
* @param maximized Whether the node is currently maximized.
*/
public final void setMaximized(boolean maximized) {
maximizedProperty.set(maximized);
}
/**
* Whether the node is currently floating.
*
* @param floating Whether the node is currently floating.
* @param translation null The offset of the node after being set floating. Used for aligning it
* with its layout bounds inside the dock pane when it becomes detached. Can be null
* indicating no translation.
*/
public void setFloating(boolean floating, Point2D translation) {
if (floating && !this.isFloating()) {
// position the new stage relative to the old scene offset
Point2D floatScene = this.localToScene(0, 0);
Point2D floatScreen = this.localToScreen(0, 0);
// setup window stage
dockTitleBar.setVisible(this.isCustomTitleBar());
dockTitleBar.setManaged(this.isCustomTitleBar());
if (this.isDocked()) {
this.undock();
}
stage = new Stage();
stage.titleProperty().bind(titleProperty);
if (dockPane != null && dockPane.getScene() != null
&& dockPane.getScene().getWindow() != null) {
stage.initOwner(dockPane.getScene().getWindow());
}
stage.initStyle(stageStyle);
// offset the new stage to cover exactly the area the dock was local to the scene
// this is useful for when the user presses the + sign and we have no information
// on where the mouse was clicked
Point2D stagePosition;
if (this.isDecorated()) {
Window owner = stage.getOwner();
stagePosition = floatScene.add(new Point2D(owner.getX(), owner.getY()));
} else {
stagePosition = floatScreen;
}
if (translation != null) {
stagePosition = stagePosition.add(translation);
}
// the border pane allows the dock node to
// have a drop shadow effect on the border
// but also maintain the layout of contents
// such as a tab that has no content
borderPane = new BorderPane();
borderPane.getStyleClass().add("dock-node-border");
borderPane.setCenter(this);
Scene scene = new Scene(borderPane);
// apply the floating property so we can get its padding size
// while it is floating to offset it by the drop shadow
// this way it pops out above exactly where it was when docked
this.floatingProperty.set(floating);
this.applyCss();
// apply the border pane css so that we can get the insets and
// position the stage properly
borderPane.applyCss();
Insets insetsDelta = borderPane.getInsets();
double insetsWidth = insetsDelta.getLeft() + insetsDelta.getRight();
double insetsHeight = insetsDelta.getTop() + insetsDelta.getBottom();
stage.setX(stagePosition.getX() - insetsDelta.getLeft());
stage.setY(stagePosition.getY() - insetsDelta.getTop());
stage.setMinWidth(borderPane.minWidth(this.getHeight()) + insetsWidth);
stage.setMinHeight(borderPane.minHeight(this.getWidth()) + insetsHeight);
borderPane.setPrefSize(this.getWidth() + insetsWidth, this.getHeight() + insetsHeight);
stage.setScene(scene);
if (stageStyle == StageStyle.TRANSPARENT) {
scene.setFill(null);
}
stage.setResizable(this.isStageResizable());
if (this.isStageResizable()) {
stage.addEventFilter(MouseEvent.MOUSE_PRESSED, this);
stage.addEventFilter(MouseEvent.MOUSE_MOVED, this);
stage.addEventFilter(MouseEvent.MOUSE_DRAGGED, this);
}
// we want to set the client area size
// without this it subtracts the native border sizes from the scene
// size
stage.sizeToScene();
stage.show();
} else if (!floating && this.isFloating()) {
this.floatingProperty.set(floating);
stage.removeEventFilter(MouseEvent.MOUSE_PRESSED, this);
stage.removeEventFilter(MouseEvent.MOUSE_MOVED, this);
stage.removeEventFilter(MouseEvent.MOUSE_DRAGGED, this);
stage.close();
}
}
/**
* Whether the node is currently floating.
*
* @param floating Whether the node is currently floating.
*/
public void setFloating(boolean floating) {
setFloating(floating, null);
}
/**
* The dock pane that was last associated with this dock node. Either the dock pane that it is
* currently docked to or the one it was detached from. Can be null if the node was never docked.
*
* @return The dock pane that was last associated with this dock node.
*/
public final DockPane getDockPane() {
return dockPane;
}
/**
* The dock title bar associated with this dock node.
*
* @return The dock title bar associated with this node.
*/
public final DockTitleBar getDockTitleBar() {
return this.dockTitleBar;
}
/**
* The stage associated with this dock node. Can be null if the dock node was never set to
* floating.
*
* @return The stage associated with this node.
*/
public final Stage getStage() {
return stage;
}
/**
* The border pane used to parent this dock node when floating. Can be null if the dock node was
* never set to floating.
*
* @return The stage associated with this node.
*/
public final BorderPane getBorderPane() {
return borderPane;
}
/**
* The contents managed by this dock node.
*
* @return The contents managed by this dock node.
*/
public final Node getContents() {
return contents;
}
/**
* Object property maintaining bidirectional state of the caption graphic for this node with the
* dock title bar or stage.
*
* @defaultValue null
*/
public final ObjectProperty<Node> graphicProperty() {
return graphicProperty;
}
private ObjectProperty<Node> graphicProperty = new SimpleObjectProperty<Node>() {
@Override
public String getName() {
return "graphic";
}
};
public final Node getGraphic() {
return graphicProperty.get();
}
public final void setGraphic(Node graphic) {
this.graphicProperty.setValue(graphic);
}
/**
* Boolean property maintaining bidirectional state of the caption title for this node with the
* dock title bar or stage.
*
* @defaultValue "Dock"
*/
public final StringProperty titleProperty() {
return titleProperty;
}
private StringProperty titleProperty = new SimpleStringProperty("Dock") {
@Override
public String getName() {
return "title";
}
};
public final String getTitle() {
return titleProperty.get();
}
public final void setTitle(String title) {
this.titleProperty.setValue(title);
}
/**
* Boolean property maintaining whether this node is currently using a custom title bar. This can
* be used to force the default title bar to show when the dock node is set to floating instead of
* using native window borders.
*
* @defaultValue true
*/
public final BooleanProperty customTitleBarProperty() {
return customTitleBarProperty;
}
private BooleanProperty customTitleBarProperty = new SimpleBooleanProperty(true) {
@Override
public String getName() {
return "customTitleBar";
}
};
public final boolean isCustomTitleBar() {
return customTitleBarProperty.get();
}
public final void setUseCustomTitleBar(boolean useCustomTitleBar) {
if (this.isFloating()) {
dockTitleBar.setVisible(useCustomTitleBar);
dockTitleBar.setManaged(useCustomTitleBar);
}
this.customTitleBarProperty.set(useCustomTitleBar);
}
/**
* Boolean property maintaining whether this node is currently floating.
*
* @defaultValue false
*/
public final BooleanProperty floatingProperty() {
return floatingProperty;
}
private BooleanProperty floatingProperty = new SimpleBooleanProperty(false) {
@Override
protected void invalidated() {
DockNode.this.pseudoClassStateChanged(FLOATING_PSEUDO_CLASS, get());
if (borderPane != null) {
borderPane.pseudoClassStateChanged(FLOATING_PSEUDO_CLASS, get());
}
}
@Override
public String getName() {
return "floating";
}
};
public final boolean isFloating() {
return floatingProperty.get();
}
/**
* Boolean property maintaining whether this node is currently floatable.
*
* @defaultValue true
*/
public final BooleanProperty floatableProperty() {
return floatableProperty;
}
private BooleanProperty floatableProperty = new SimpleBooleanProperty(true) {
@Override
public String getName() {
return "floatable";
}
};
public final boolean isFloatable() {
return floatableProperty.get();
}
public final void setFloatable(boolean floatable) {
if (!floatable && this.isFloating()) {
this.setFloating(false);
}
this.floatableProperty.set(floatable);
}
/**
* Boolean property maintaining whether this node is currently closable.
*
* @defaultValue true
*/
public final BooleanProperty closableProperty() {
return closableProperty;
}
private BooleanProperty closableProperty = new SimpleBooleanProperty(true) {
@Override
public String getName() {
return "closable";
}
};
public final boolean isClosable() {
return closableProperty.get();
}
public final void setClosable(boolean closable) {
this.closableProperty.set(closable);
}
/**
* Boolean property maintaining whether this node is currently resizable.
*
* @defaultValue true
*/
public final BooleanProperty resizableProperty() {
return stageResizableProperty;
}
private BooleanProperty stageResizableProperty = new SimpleBooleanProperty(true) {
@Override
public String getName() {
return "resizable";
}
};
public final boolean isStageResizable() {
return stageResizableProperty.get();
}
public final void setStageResizable(boolean resizable) {
stageResizableProperty.set(resizable);
}
/**
* Boolean property maintaining whether this node is currently docked. This is used by the dock
* pane to inform the dock node whether it is currently docked.
*
* @defaultValue false
*/
public final BooleanProperty dockedProperty() {
return dockedProperty;
}
private BooleanProperty dockedProperty = new SimpleBooleanProperty(false) {
@Override
protected void invalidated() {
if (get()) {
if (dockTitleBar != null) {
dockTitleBar.setVisible(true);
dockTitleBar.setManaged(true);
}
}
DockNode.this.pseudoClassStateChanged(DOCKED_PSEUDO_CLASS, get());
}
@Override
public String getName() {
return "docked";
}
};
public final boolean isDocked() {
return dockedProperty.get();
}
public final BooleanProperty maximizedProperty() {
return maximizedProperty;
}
public final boolean isMaximized() {
return maximizedProperty.get();
}
public final boolean isDecorated() {
return stageStyle != StageStyle.TRANSPARENT && stageStyle != StageStyle.UNDECORATED;
}
/**
* Dock this node into a dock pane.
*
* @param dockPane The dock pane to dock this node into.
* @param dockPos The docking position relative to the sibling of the dock pane.
* @param sibling The sibling node to dock this node relative to.
*/
public void dock(DockPane dockPane, DockPos dockPos, Node sibling) {
dockImpl(dockPane);
dockPane.dock(this, dockPos, sibling);
}
/**
* Dock this node into a dock pane.
*
* @param dockPane The dock pane to dock this node into.
* @param dockPos The docking position relative to the sibling of the dock pane.
*/
public void dock(DockPane dockPane, DockPos dockPos) {
dockImpl(dockPane);
dockPane.dock(this, dockPos);
}
private final void dockImpl(DockPane dockPane) {
if (isFloating()) {
setFloating(false);
}
this.dockPane = dockPane;
this.dockedProperty.set(true);
}
/**
* Detach this node from its previous dock pane if it was previously docked.
*/
public void undock() {
if (dockPane != null) {
dockPane.undock(this);
}
this.dockedProperty.set(false);
}
/**
* Close this dock node by setting it to not floating and making sure it is detached from any dock
* pane.
*/
public void close() {
if (isFloating()) {
setFloating(false);
} else if (isDocked()) {
undock();
}
}
/**
* The last position of the mouse that was within the minimum layout bounds.
*/
private Point2D sizeLast;
/**
* Whether we are currently resizing in a given direction.
*/
private boolean sizeWest = false, sizeEast = false, sizeNorth = false, sizeSouth = false;
/**
* Gets whether the mouse is currently in this dock node's resize zone.
*
* @return Whether the mouse is currently in this dock node's resize zone.
*/
public boolean isMouseResizeZone() {
return sizeWest || sizeEast || sizeNorth || sizeSouth;
}
@Override
public void handle(MouseEvent event) {
Cursor cursor = Cursor.DEFAULT;
// TODO: use escape to cancel resize/drag operation like visual studio
if (!this.isFloating() || !this.isStageResizable()) {
return;
}
if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
sizeLast = new Point2D(event.getScreenX(), event.getScreenY());
} else if (event.getEventType() == MouseEvent.MOUSE_MOVED) {
Insets insets = borderPane.getPadding();
sizeWest = event.getX() < insets.getLeft();
sizeEast = event.getX() > borderPane.getWidth() - insets.getRight();
sizeNorth = event.getY() < insets.getTop();
sizeSouth = event.getY() > borderPane.getHeight() - insets.getBottom();
if (sizeWest) {
if (sizeNorth) {
cursor = Cursor.NW_RESIZE;
} else if (sizeSouth) {
cursor = Cursor.SW_RESIZE;
} else {
cursor = Cursor.W_RESIZE;
}
} else if (sizeEast) {
if (sizeNorth) {
cursor = Cursor.NE_RESIZE;
} else if (sizeSouth) {
cursor = Cursor.SE_RESIZE;
} else {
cursor = Cursor.E_RESIZE;
}
} else if (sizeNorth) {
cursor = Cursor.N_RESIZE;
} else if (sizeSouth) {
cursor = Cursor.S_RESIZE;
}
this.getScene().setCursor(cursor);
} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED && this.isMouseResizeZone()) {
Point2D sizeCurrent = new Point2D(event.getScreenX(), event.getScreenY());
Point2D sizeDelta = sizeCurrent.subtract(sizeLast);
double newX = stage.getX(), newY = stage.getY(), newWidth = stage.getWidth(),
newHeight = stage.getHeight();
if (sizeNorth) {
newHeight -= sizeDelta.getY();
newY += sizeDelta.getY();
} else if (sizeSouth) {
newHeight += sizeDelta.getY();
}
if (sizeWest) {
newWidth -= sizeDelta.getX();
newX += sizeDelta.getX();
} else if (sizeEast) {
newWidth += sizeDelta.getX();
}
// TODO: find a way to do this synchronously and eliminate the flickering of moving the stage
// around, also file a bug report for this feature if a work around can not be found this
// primarily occurs when dragging north/west but it also appears in native windows and Visual
// Studio, so not that big of a concern.
// Bug report filed:
// https://bugs.openjdk.java.net/browse/JDK-8133332
double currentX = sizeLast.getX(), currentY = sizeLast.getY();
if (newWidth >= stage.getMinWidth()) {
stage.setX(newX);
stage.setWidth(newWidth);
currentX = sizeCurrent.getX();
}
if (newHeight >= stage.getMinHeight()) {
stage.setY(newY);
stage.setHeight(newHeight);
currentY = sizeCurrent.getY();
}
sizeLast = new Point2D(currentX, currentY);
// we do not want the title bar getting these events
// while we are actively resizing
if (sizeNorth || sizeSouth || sizeWest || sizeEast) {
event.consume();
}
}
}
}