/** * @file DockTitleBar.java * @brief Class implementing a generic base for a dock node title bar. * * @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.HashMap; import java.util.List; import java.util.Stack; import javafx.event.ActionEvent; import javafx.event.Event; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.stage.Stage; import javafx.stage.Window; /** * Base class for a dock node title bar that provides the mouse dragging functionality, captioning, * docking, and state manipulation. * * @since DockFX 0.1 */ public class DockTitleBar extends HBox implements EventHandler<MouseEvent> { /** * The DockNode this node is a title bar for. */ private DockNode dockNode; /** * The label node used for captioning and the graphic. */ private Label label; /** * State manipulation buttons including close, maximize, detach, and restore. */ private Button closeButton, stateButton; /** * Creates a default DockTitleBar with captions and dragging behavior. * * @param dockNode The docking node that requires a title bar. */ public DockTitleBar(DockNode dockNode) { this.dockNode = dockNode; label = new Label("Dock Title Bar"); label.textProperty().bind(dockNode.titleProperty()); label.graphicProperty().bind(dockNode.graphicProperty()); stateButton = new Button(); stateButton.setOnAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent event) { if (dockNode.isFloating()) { dockNode.setMaximized(!dockNode.isMaximized()); } else { dockNode.setFloating(true); } } }); closeButton = new Button(); closeButton.setOnAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent event) { dockNode.close(); } }); closeButton.visibleProperty().bind(dockNode.closableProperty()); // create a pane that will stretch to make the buttons right aligned Pane fillPane = new Pane(); HBox.setHgrow(fillPane, Priority.ALWAYS); getChildren().addAll(label, fillPane, stateButton, closeButton); this.addEventHandler(MouseEvent.MOUSE_PRESSED, this); this.addEventHandler(MouseEvent.DRAG_DETECTED, this); this.addEventHandler(MouseEvent.MOUSE_DRAGGED, this); this.addEventHandler(MouseEvent.MOUSE_RELEASED, this); label.getStyleClass().add("dock-title-label"); closeButton.getStyleClass().add("dock-close-button"); stateButton.getStyleClass().add("dock-state-button"); this.getStyleClass().add("dock-title-bar"); } /** * Whether this title bar is currently being dragged. * * @return Whether this title bar is currently being dragged. */ public final boolean isDragging() { return dragging; } /** * The label used for captioning and to provide a graphic. * * @return The label used for captioning and to provide a graphic. */ public final Label getLabel() { return label; } /** * The button used for closing this title bar and its associated dock node. * * @return The button used for closing this title bar and its associated dock node. */ public final Button getCloseButton() { return closeButton; } /** * The button used for detaching, maximizing, or restoring this title bar and its associated dock * node. * * @return The button used for detaching, maximizing, or restoring this title bar and its * associated dock node. */ public final Button getStateButton() { return stateButton; } /** * The dock node that is associated with this title bar. * * @return The dock node that is associated with this title bar. */ public final DockNode getDockNode() { return dockNode; } /** * The mouse location of the original click which we can use to determine the offset during drag. * Title bar dragging is asynchronous so it will not be negatively impacted by less frequent or * lagging mouse events as in the case of most current JavaFX implementations on Linux. */ private Point2D dragStart; /** * Whether this title bar is currently being dragged. */ private boolean dragging = false; /** * The current node being dragged over for each window so we can keep track of enter/exit events. */ private HashMap<Window, Node> dragNodes = new HashMap<Window, Node>(); /** * The task that is to be executed when the dock event target is picked. This provides context for * what specific events and what order the events should be fired. * * @since DockFX 0.1 */ private abstract class EventTask { /** * The number of times this task has been executed. */ protected int executions = 0; /** * Creates a default DockTitleBar with captions and dragging behavior. * * @param node The node that was chosen as the event target. * @param dragNode The node that was last event target. */ public abstract void run(Node node, Node dragNode); /** * The number of times this task has been executed. * * @return The number of times this task has been executed. */ public int getExecutions() { return executions; } /** * Reset the execution count to zero. */ public void reset() { executions = 0; } } /** * Traverse the scene graph for all open stages and pick an event target for a dock event based on * the location. Once the event target is chosen run the event task with the target and the * previous target of the last dock event if one is cached. If an event target is not found fire * the explicit dock event on the stage root if one is provided. * * @param location The location of the dock event in screen coordinates. * @param eventTask The event task to be run when the event target is found. * @param explicit The explicit event to be fired on the stage root when no event target is found. */ private void pickEventTarget(Point2D location, EventTask eventTask, Event explicit) { // RFE for public scene graph traversal API filed but closed: // https://bugs.openjdk.java.net/browse/JDK-8133331 List<DockPane> dockPanes = DockPane.dockPanes; // fire the dock over event for the active stages for (DockPane dockPane : dockPanes) { Window window = dockPane.getScene().getWindow(); if (!(window instanceof Stage)) continue; Stage targetStage = (Stage) window; // obviously this title bar does not need to receive its own events // though users of this library may want to know when their // dock node is being dragged by subclassing it or attaching // an event listener in which case a new event can be defined or // this continue behavior can be removed if (targetStage == this.dockNode.getStage()) continue; eventTask.reset(); Node dragNode = dragNodes.get(targetStage); Parent root = targetStage.getScene().getRoot(); Stack<Parent> stack = new Stack<Parent>(); if (root.contains(root.screenToLocal(location.getX(), location.getY())) && !root.isMouseTransparent()) { stack.push(root); } // depth first traversal to find the deepest node or parent with no children // that intersects the point of interest while (!stack.isEmpty()) { Parent parent = stack.pop(); // if this parent contains the mouse click in screen coordinates in its local bounds // then traverse its children boolean notFired = true; for (Node node : parent.getChildrenUnmodifiable()) { if (node.contains(node.screenToLocal(location.getX(), location.getY())) && !node.isMouseTransparent()) { if (node instanceof Parent) { stack.push((Parent) node); } else { eventTask.run(node, dragNode); } notFired = false; break; } } // if none of the children fired the event or there were no children // fire it with the parent as the target to receive the event if (notFired) { eventTask.run(parent, dragNode); } } if (explicit != null && dragNode != null && eventTask.getExecutions() < 1) { Event.fireEvent(dragNode, explicit.copyFor(this, dragNode)); dragNodes.put(targetStage, null); } } } @Override public void handle(MouseEvent event) { if (event.getEventType() == MouseEvent.MOUSE_PRESSED) { if (dockNode.isFloating() && event.getClickCount() == 2 && event.getButton() == MouseButton.PRIMARY) { dockNode.setMaximized(!dockNode.isMaximized()); } else { // drag detected is used in place of mouse pressed so there is some threshold for the // dragging which is determined by the default drag detection threshold dragStart = new Point2D(event.getX(), event.getY()); } } else if (event.getEventType() == MouseEvent.DRAG_DETECTED) { if (!dockNode.isFloating()) { // if we are not using a custom title bar and the user // is not forcing the default one for floating and // the dock node does have native window decorations // then we need to offset the stage position by // the height of this title bar if (!dockNode.isCustomTitleBar() && dockNode.isDecorated()) { dockNode.setFloating(true, new Point2D(0, DockTitleBar.this.getHeight())); } else { dockNode.setFloating(true); } // TODO: Find a better solution. // Temporary work around for nodes losing the drag event when removed from // the scene graph. // A possible alternative is to use "ghost" panes in the DockPane layout // while making DockNode simply an overlay stage that is always shown. // However since flickering when popping out was already eliminated that would // be overkill and is not a suitable solution for native decorations. // Bug report open: https://bugs.openjdk.java.net/browse/JDK-8133335 DockPane dockPane = this.getDockNode().getDockPane(); if (dockPane != null) { dockPane.addEventFilter(MouseEvent.MOUSE_DRAGGED, this); dockPane.addEventFilter(MouseEvent.MOUSE_RELEASED, this); } } else if (dockNode.isMaximized()) { double ratioX = event.getX() / this.getDockNode().getWidth(); double ratioY = event.getY() / this.getDockNode().getHeight(); // Please note that setMaximized is ruined by width and height changes occurring on the // stage and there is currently a bug report filed for this though I did not give them an // accurate test case which I should and wish I would have. This was causing issues in the // original release requiring maximized behavior to be implemented manually by saving the // restored bounds. The problem was that the resize functionality in DockNode.java was // executing at the same time canceling the maximized change. // https://bugs.openjdk.java.net/browse/JDK-8133334 // restore/minimize the window after we have obtained its dimensions dockNode.setMaximized(false); // scale the drag start location by our restored dimensions dragStart = new Point2D(ratioX * dockNode.getWidth(), ratioY * dockNode.getHeight()); } dragging = true; event.consume(); } else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED) { if (dockNode.isFloating() && event.getClickCount() == 2 && event.getButton() == MouseButton.PRIMARY) { event.setDragDetect(false); event.consume(); return; } if (!dragging) return; Stage stage = dockNode.getStage(); Insets insetsDelta = this.getDockNode().getBorderPane().getInsets(); // dragging this way makes the interface more responsive in the event // the system is lagging as is the case with most current JavaFX // implementations on Linux stage.setX(event.getScreenX() - dragStart.getX() - insetsDelta.getLeft()); stage.setY(event.getScreenY() - dragStart.getY() - insetsDelta.getTop()); // TODO: change the pick result by adding a copyForPick() DockEvent dockEnterEvent = new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_ENTER, event.getX(), event.getY(), event.getScreenX(), event.getScreenY(), null); DockEvent dockOverEvent = new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_OVER, event.getX(), event.getY(), event.getScreenX(), event.getScreenY(), null); DockEvent dockExitEvent = new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_EXIT, event.getX(), event.getY(), event.getScreenX(), event.getScreenY(), null); EventTask eventTask = new EventTask() { @Override public void run(Node node, Node dragNode) { executions++; if (dragNode != node) { Event.fireEvent(node, dockEnterEvent.copyFor(DockTitleBar.this, node)); if (dragNode != null) { // fire the dock exit first so listeners // can actually keep track of the node we // are currently over and know when we // aren't over any which DOCK_OVER // does not provide Event.fireEvent(dragNode, dockExitEvent.copyFor(DockTitleBar.this, dragNode)); } dragNodes.put(node.getScene().getWindow(), node); } Event.fireEvent(node, dockOverEvent.copyFor(DockTitleBar.this, node)); } }; this.pickEventTarget(new Point2D(event.getScreenX(), event.getScreenY()), eventTask, dockExitEvent); } else if (event.getEventType() == MouseEvent.MOUSE_RELEASED) { dragging = false; DockEvent dockReleasedEvent = new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_RELEASED, event.getX(), event.getY(), event.getScreenX(), event.getScreenY(), null, this.getDockNode()); EventTask eventTask = new EventTask() { @Override public void run(Node node, Node dragNode) { executions++; if (dragNode != node) { Event.fireEvent(node, dockReleasedEvent.copyFor(DockTitleBar.this, node)); } Event.fireEvent(node, dockReleasedEvent.copyFor(DockTitleBar.this, node)); } }; this.pickEventTarget(new Point2D(event.getScreenX(), event.getScreenY()), eventTask, null); dragNodes.clear(); // Remove temporary event handler for bug mentioned above. DockPane dockPane = this.getDockNode().getDockPane(); if (dockPane != null) { dockPane.removeEventFilter(MouseEvent.MOUSE_DRAGGED, this); dockPane.removeEventFilter(MouseEvent.MOUSE_RELEASED, this); } } } }