diff --git a/Dorkbox-Util/src/dorkbox/util/javafx/Notifications.java b/Dorkbox-Util/src/dorkbox/util/javafx/Notifications.java new file mode 100644 index 0000000..af1282a --- /dev/null +++ b/Dorkbox-Util/src/dorkbox/util/javafx/Notifications.java @@ -0,0 +1,631 @@ +/** + * Copyright (c) 2014, ControlsFX + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of ControlsFX, any associated website, nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL CONTROLSFX BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * + * MODIFIED BY DORKBOX, LLC + */ +package dorkbox.util.javafx; + +import dorkbox.util.ScreenUtil; +import impl.org.controlsfx.skin.NotificationBar; +import javafx.animation.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.image.ImageView; +import javafx.stage.Popup; +import javafx.stage.Stage; +import javafx.stage.Window; +import javafx.util.Duration; +import org.controlsfx.control.action.Action; +import org.controlsfx.tools.Utils; + +import java.awt.*; +import java.util.*; +import java.util.List; + +/** + * An API to show popup notification messages to the user in the corner of their + * screen, unlike the {@link org.controlsfx.control.NotificationPane} which shows notification messages + * within your application itself. + * + *

Screenshot

+ *

+ * The following screenshot shows a sample notification rising from the + * bottom-right corner of my screen: + * + *
+ *
+ * + * + *

Code Example:

+ *

+ * To create the notification shown in the screenshot, simply do the following: + * + *

+ * {@code
+ * Notifications.create()
+ *              .title("Title Text")
+ *              .text("Hello World 0!")
+ *              .showWarning();
+ * }
+ * 
+ */ +public class Notifications { + + /*************************************************************************** + * * Static fields * * + **************************************************************************/ + + private static final String STYLE_CLASS_DARK = "dark"; //$NON-NLS-1$ + + /*************************************************************************** + * * Private fields * * + **************************************************************************/ + + private String title; + private String text; + private Node graphic; + private ObservableList actions = FXCollections.observableArrayList(); + private Pos position = Pos.BOTTOM_RIGHT; + private Duration hideAfterDuration = Duration.seconds(5); + private boolean hideCloseButton; + private EventHandler onAction; + private Window owner; + + private List styleClass = new ArrayList<>(); + + /*************************************************************************** + * * Constructors * * + **************************************************************************/ + + // we do not allow instantiation of the Notifications class directly - users + // must go via the builder API (that is, calling create()) + private Notifications() { + // no-op + } + + /*************************************************************************** + * * Public API * * + **************************************************************************/ + + /** + * Call this to begin the process of building a notification to show. + */ + public static + Notifications create() { + return new Notifications(); + } + + /** + * Specify the text to show in the notification. + */ + public + Notifications text(String text) { + this.text = text; + return this; + } + + /** + * Specify the title to show in the notification. + */ + public + Notifications title(String title) { + this.title = title; + return this; + } + + /** + * Specify the graphic to show in the notification. + */ + public + Notifications graphic(Node graphic) { + this.graphic = graphic; + return this; + } + + /** + * Specify the position of the notification on screen, by default it is + * {@link Pos#BOTTOM_RIGHT bottom-right}. + */ + public + Notifications position(Pos position) { + this.position = position; + return this; + } + + /** + * The dialog window owner - if specified the notifications will be inside + * the owner, otherwise the notifications will be shown within the whole + * screen. + */ + public + Notifications owner(Object owner) { + this.owner = Utils.getWindow(owner); + return this; + } + + /** + * Specify the duration that the notification should show, after which it + * will be hidden. + */ + public + Notifications hideAfter(Duration duration) { + this.hideAfterDuration = duration; + return this; + } + + /** + * Specify what to do when the user clicks on the notification (in addition + * to the notification hiding, which happens whenever the notification is + * clicked on). + */ + public + Notifications onAction(EventHandler onAction) { + this.onAction = onAction; + return this; + } + + /** + * Specify that the notification should use the built-in dark styling, + * rather than the default 'modena' notification style (which is a + * light-gray). + */ + public + Notifications darkStyle() { + styleClass.add(STYLE_CLASS_DARK); + return this; + } + + /** + * Specify that the close button in the top-right corner of the notification + * should not be shown. + */ + public + Notifications hideCloseButton() { + this.hideCloseButton = true; + return this; + } + + /** + * Specify the actions that should be shown in the notification as buttons. + */ + public + Notifications action(Action... actions) { + this.actions = actions == null ? FXCollections. observableArrayList() : FXCollections + .observableArrayList(actions); + return this; + } + + /** + * Instructs the notification to be shown, and that it should use the + * built-in 'warning' graphic. + */ + public void showWarning() { + graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-warning.png").toExternalForm())); //$NON-NLS-1$ + show(); + } + + /** + * Instructs the notification to be shown, and that it should use the + * built-in 'information' graphic. + */ + public void showInformation() { + graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-information.png").toExternalForm())); //$NON-NLS-1$ + show(); + } + + /** + * Instructs the notification to be shown, and that it should use the + * built-in 'error' graphic. + */ + public void showError() { + graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-error.png").toExternalForm())); //$NON-NLS-1$ + show(); + } + + /** + * Instructs the notification to be shown, and that it should use the + * built-in 'confirm' graphic. + */ + public void showConfirm() { + graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-confirm.png").toExternalForm())); //$NON-NLS-1$ + show(); + } + + /** + * Instructs the notification to be shown. + */ + public void show() { + NotificationPopupHandler.getInstance().show(this); + } + + /*************************************************************************** + * * Private support classes * * + **************************************************************************/ + + // not public so no need for JavaDoc + private static final + class NotificationPopupHandler { + + private static final NotificationPopupHandler INSTANCE = new NotificationPopupHandler(); + + static final + NotificationPopupHandler getInstance() { + return INSTANCE; + } + private final Map> popupsMap = new HashMap<>(); + private final double padding = 15; + + // for animating in the notifications + private final ParallelTransition parallelTransition = new ParallelTransition(); + + private double startX; + private double startY; + private double screenWidth; + private double screenHeight; + + private boolean isShowing = false; + + public + void show(Notifications notification) { + Window window; + final Window owner = notification.owner; + if (owner == null) { + Point mouseLocation = MouseInfo.getPointerInfo() + .getLocation(); + + GraphicsDevice deviceAtMouse = ScreenUtil.getGraphicsDeviceAt(mouseLocation); + + final Rectangle screenBounds = deviceAtMouse.getDefaultConfiguration() + .getBounds(); + + /* + * If the owner is not set, we work with the whole screen. + * EDIT: we use the screen that the mouse is currently on. + */ + startX = screenBounds.getX(); + startY = screenBounds.getY(); + screenWidth = screenBounds.getWidth(); + screenHeight = screenBounds.getHeight(); + + window = Utils.getWindow(null); + } + else { + /* + * If the owner is set, we will make the notifications popup + * inside its window. + */ + startX = owner.getX(); + startY = owner.getY(); + screenWidth = owner.getWidth(); + screenHeight = owner.getHeight(); + window = owner; + } + show(window, notification); + } + + private void show(Window owner, final Notifications notification) { + // need to install our CSS + if (owner instanceof Stage) { + Scene ownerScene = ((Stage) owner).getScene(); + ownerScene.getStylesheets().add(org.controlsfx.control.Notifications.class.getResource("notificationpopup.css") + .toExternalForm()); //$NON-NLS-1$ + } + + final Popup popup = new Popup(); + popup.setAutoFix(false); + + final Pos p = notification.position; + + final NotificationBar notificationBar = new NotificationBar() { + @Override public String getTitle() { + return notification.title; + } + + @Override public String getText() { + return notification.text; + } + + @Override public Node getGraphic() { + return notification.graphic; + } + + @Override public ObservableList getActions() { + return notification.actions; + } + + @Override public boolean isShowing() { + return isShowing; + } + + @Override protected double computeMinWidth(double height) { + String text = getText(); + Node graphic = getGraphic(); + if ((text == null || text.isEmpty()) && (graphic != null)) { + return graphic.minWidth(height); + } + return 400; + } + + @Override protected double computeMinHeight(double width) { + String text = getText(); + Node graphic = getGraphic(); + if ((text == null || text.isEmpty()) && (graphic != null)) { + return graphic.minHeight(width); + } + return 100; + } + + @Override public boolean isShowFromTop() { + return NotificationPopupHandler.this.isShowFromTop(notification.position); + } + + @Override public void hide() { + isShowing = false; + + // this would slide the notification bar out of view, + // but I prefer the fade out below + // doHide(); + + // animate out the popup by fading it + createHideTimeline(popup, this, p, Duration.ZERO).play(); + } + + @Override public boolean isCloseButtonVisible() { + return !notification.hideCloseButton; + } + + @Override public double getContainerHeight() { + return startY + screenHeight; + } + + @Override public void relocateInParent(double x, double y) { + // this allows for us to slide the notification upwards + switch (p) { + case BOTTOM_LEFT: + case BOTTOM_CENTER: + case BOTTOM_RIGHT: + popup.setAnchorY(y - padding); + break; + default: + // no-op + break; + } + } + }; + + notificationBar.getStyleClass().addAll(notification.styleClass); + + notificationBar.setOnMouseClicked(e -> { + if (notification.onAction != null) { + ActionEvent actionEvent = new ActionEvent(notificationBar, notificationBar); + notification.onAction.handle(actionEvent); + + // animate out the popup + createHideTimeline(popup, notificationBar, p, Duration.ZERO).play(); + } + }); + + popup.getContent().add(notificationBar); + popup.show(owner, 0, 0); + + // determine location for the popup + double anchorX = 0, anchorY = 0; + final double barWidth = notificationBar.getWidth(); + final double barHeight = notificationBar.getHeight(); + + // get anchorX + switch (p) { + case TOP_LEFT: + case CENTER_LEFT: + case BOTTOM_LEFT: + anchorX = padding + startX; + break; + + case TOP_CENTER: + case CENTER: + case BOTTOM_CENTER: + anchorX = startX + (screenWidth / 2.0) - barWidth / 2.0 - padding / 2.0; + break; + + default: + case TOP_RIGHT: + case CENTER_RIGHT: + case BOTTOM_RIGHT: + anchorX = startX + screenWidth - barWidth - padding; + break; + } + + // get anchorY + switch (p) { + case TOP_LEFT: + case TOP_CENTER: + case TOP_RIGHT: + anchorY = padding + startY; + break; + + case CENTER_LEFT: + case CENTER: + case CENTER_RIGHT: + anchorY = startY + (screenHeight / 2.0) - barHeight / 2.0 - padding / 2.0; + break; + + default: + case BOTTOM_LEFT: + case BOTTOM_CENTER: + case BOTTOM_RIGHT: + anchorY = startY + screenHeight - barHeight - padding; + break; + } + + popup.setAnchorX(anchorX); + popup.setAnchorY(anchorY); + + isShowing = true; + + notificationBar.doShow(); + + addPopupToMap(p, popup); + + // begin a timeline to get rid of the popup + Timeline timeline = createHideTimeline(popup, notificationBar, p, notification.hideAfterDuration); + timeline.play(); + } + + private void hide(Popup popup, Pos p) { + popup.hide(); + removePopupFromMap(p, popup); + } + + private Timeline createHideTimeline(final Popup popup, NotificationBar bar, final Pos p, Duration startDelay) { + KeyValue fadeOutBegin = new KeyValue(bar.opacityProperty(), 1.0); + KeyValue fadeOutEnd = new KeyValue(bar.opacityProperty(), 0.0); + + KeyFrame kfBegin = new KeyFrame(Duration.ZERO, fadeOutBegin); + KeyFrame kfEnd = new KeyFrame(Duration.millis(500), fadeOutEnd); + + Timeline timeline = new Timeline(kfBegin, kfEnd); + timeline.setDelay(startDelay); + timeline.setOnFinished(new EventHandler() { + @Override + public void handle(ActionEvent e) { + hide(popup, p); + } + }); + + return timeline; + } + + private void addPopupToMap(Pos p, Popup popup) { + List popups; + if (!popupsMap.containsKey(p)) { + popups = new LinkedList<>(); + popupsMap.put(p, popups); + } else { + popups = popupsMap.get(p); + } + + doAnimation(p, popup); + + // add the popup to the list so it is kept in memory and can be + // accessed later on + popups.add(popup); + } + + private void removePopupFromMap(Pos p, Popup popup) { + if (popupsMap.containsKey(p)) { + List popups = popupsMap.get(p); + popups.remove(popup); + } + } + + private void doAnimation(Pos p, Popup changedPopup) { + List popups = popupsMap.get(p); + if (popups == null) { + return; + } + + final double newPopupHeight = changedPopup.getContent().get(0).getBoundsInParent().getHeight(); + + parallelTransition.stop(); + parallelTransition.getChildren().clear(); + + final boolean isShowFromTop = isShowFromTop(p); + + // animate all other popups in the list upwards so that the new one + // is in the 'new' area. + // firstly, we need to determine the target positions for all popups + double sum = 0; + double targetAnchors[] = new double[popups.size()]; + for (int i = popups.size() - 1; i >= 0; i--) { + Popup _popup = popups.get(i); + + final double popupHeight = _popup.getContent().get(0).getBoundsInParent().getHeight(); + + if (isShowFromTop) { + if (i == popups.size() - 1) { + sum = startY + newPopupHeight + padding; + } else { + sum += popupHeight; + } + targetAnchors[i] = sum; + } else { + if (i == popups.size() - 1) { + sum = changedPopup.getAnchorY() - popupHeight; + } else { + sum -= popupHeight; + } + + targetAnchors[i] = sum; + } + } + + // then we set up animations for each popup to animate towards the + // target + for (int i = popups.size() - 1; i >= 0; i--) { + final Popup _popup = popups.get(i); + final double anchorYTarget = targetAnchors[i]; + if(anchorYTarget < 0){ + _popup.hide(); + } + final double oldAnchorY = _popup.getAnchorY(); + final double distance = anchorYTarget - oldAnchorY; + + Transition t = new Transition() { + { + setCycleDuration(Duration.millis(350)); + } + + @Override + protected void interpolate(double frac) { + double newAnchorY = oldAnchorY + distance * frac; + _popup.setAnchorY(newAnchorY); + } + }; + t.setCycleCount(1); + parallelTransition.getChildren().add(t); + } + parallelTransition.play(); + } + + private boolean isShowFromTop(Pos p) { + switch (p) { + case TOP_LEFT: + case TOP_CENTER: + case TOP_RIGHT: + return true; + default: + return false; + } + } + } +} + diff --git a/Dorkbox-Util/src/dorkbox/util/javafx/Wizard.java b/Dorkbox-Util/src/dorkbox/util/javafx/Wizard.java new file mode 100644 index 0000000..13135bf --- /dev/null +++ b/Dorkbox-Util/src/dorkbox/util/javafx/Wizard.java @@ -0,0 +1,914 @@ +/** + * Copyright (c) 2014, 2015 ControlsFX + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of ControlsFX, any associated website, nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL CONTROLSFX BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * + * MODIFIED BY DORKBOX + */ +package dorkbox.util.javafx; + +import dorkbox.util.JavaFxUtil; +import impl.org.controlsfx.ImplUtils; +import javafx.application.Platform; +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.ObservableMap; +import javafx.event.ActionEvent; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Dialog; +import javafx.scene.control.ToolBar; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.*; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.stage.Window; +import org.controlsfx.control.PopOver; +import org.controlsfx.tools.ValueExtractor; +import org.controlsfx.validation.ValidationMessage; +import org.controlsfx.validation.ValidationSupport; + +import java.util.*; +import java.util.function.BooleanSupplier; + +/** + *

The API for creating multi-page Wizards, based on JavaFX {@link Dialog} API.
+ * Wizard can be setup in following few steps:

+ *

    + *
  • Design wizard pages by inheriting them from {@link WizardPane}
  • + *
  • Define wizard flow by implementing {@link org.controlsfx.dialog.Wizard.Flow}
  • + *
  • Create and instance of the Wizard and assign flow to it
  • + *
  • Execute the wizard using showAndWait method
  • + *
  • Values can be extracted from settings map by calling getSettings + *
+ *

For simple, linear wizards, the {@link LinearFlow} can be used. + * It is a flow based on a collection of wizard pages. Here is the example:

+ * + *
{@code // Create pages. Here for simplicity we just create and instance of WizardPane.
+ * WizardPane page1 = new WizardPane();
+ * WizardPane page2 = new WizardPane();
+ * WizardPane page2 = new WizardPane();
+ *
+ * // create wizard
+ * Wizard wizard = new Wizard();
+ *
+ * // create and assign the flow
+ * wizard.setFlow(new LinearFlow(page1, page2, page3));
+ *
+ * // show wizard and wait for response
+ * wizard.showAndWait().ifPresent(result -> {
+ *     if (result == ButtonType.FINISH) {
+ *         System.out.println("Wizard finished, settings: " + wizard.getSettings());
+ *     }
+ * });}
+ * + *

For more complex wizard flows we suggest to create a custom ones, describing page traversal logic. + * Here is a simplified example:

+ * + *
{@code Wizard.Flow branchingFlow = new Wizard.Flow() {
+ *     public Optional advance(WizardPane currentPage) {
+ *         return Optional.of(getNext(currentPage));
+ *     }
+ *
+ *     public boolean canAdvance(WizardPane currentPage) {
+ *         return currentPage != page3;
+ *     }
+ *
+ *     private WizardPane getNext(WizardPane currentPage) {
+ *         if ( currentPage == null ) {
+ *             return page1;
+ *         } else if ( currentPage == page1) {
+ *             // skipNextPage() does not exist - this just represents that you
+ *             // can add a conditional statement here to change the page.
+ *             return page1.skipNextPage()? page3: page2;
+ *         } else {
+ *             return page3;
+ *         }
+ *     }
+ * };}
+ */ +public class Wizard { + final Stage stage = new Stage(StageStyle.UTILITY); + private final Text headerText; + private final VBox center; + + private final ObservableMap settings = FXCollections.observableHashMap(); + + final Stack pageHistory = new Stack<>(); + Optional currentPage = Optional.empty(); + + private final BooleanProperty invalidProperty = new SimpleBooleanProperty(false); + + + // Read settings activated by default for backward compatibility + private final BooleanProperty readSettingsProperty = new SimpleBooleanProperty(true); + + volatile boolean success = false; + + private final Button BUTTON_PREVIOUS = new Button("Previous"); + + private final Button BUTTON_NEXT = new Button("Next"); + private final EventHandler BUTTON_NEXT_EVENT_HANDLER = event -> { + if (event instanceof KeyEvent) { + final KeyCode code = ((KeyEvent)event).getCode(); + if (code == KeyCode.ENTER || code == KeyCode.SPACE) { + event.consume(); + goNext(); + } + } else { + event.consume(); + goNext(); + } + }; + + private final EventHandler BUTTON_FINISH_EVENT_HANDLER = event -> { + if (event instanceof KeyEvent) { + final KeyCode code = ((KeyEvent)event).getCode(); + if (code == KeyCode.ENTER || code == KeyCode.SPACE) { + event.consume(); + goFinish(); + } + } else { + event.consume(); + goFinish(); + } + }; + + private final StringProperty titleProperty = new SimpleStringProperty(); + private volatile boolean useSpecifiedSize = false; + private final PopOver popOver; + private Text errorText; + private Font defaultHeaderFont; + + + + /************************************************************************** + * + * Constructors + * + **************************************************************************/ + + /** + * Creates an instance of the wizard without an owner. + */ + public + Wizard() { + this(null); + } + + /** + * Creates an instance of the wizard with the given owner. + * @param owner The object from which the owner window is deduced (typically + * this is a Node, but it may also be a Scene or a Stage). + */ + public + Wizard(Object owner) { + this(owner, ""); //$NON-NLS-1$ + } + + /** + * Creates an instance of the wizard with the given owner and title. + * + * @param owner The object from which the owner window is deduced (typically + * this is a Node, but it may also be a Scene or a Stage). + * @param title The wizard title. + */ + public + Wizard(Object owner, String title) { + BUTTON_PREVIOUS.setDisable(true); + BUTTON_NEXT.setDisable(true); + + BUTTON_PREVIOUS.setId("prev-button"); + BUTTON_NEXT.setId("next-button"); + + BUTTON_PREVIOUS.addEventFilter(ActionEvent.ACTION, event -> { + event.consume(); + goPrev(); + }); + + BUTTON_PREVIOUS.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + final KeyCode code = event.getCode(); + if (code == KeyCode.ENTER || code == KeyCode.SPACE) { + event.consume(); + goPrev(); + } + }); + + + stage.titleProperty() + .bind(this.titleProperty); + setTitle(title); + + popOver = new PopOver(); + popOver.setDetachable(false); + popOver.setDetached(false); + popOver.setAutoHide(false); + + popOver.setArrowSize(12); + popOver.setArrowIndent(12); + popOver.setArrowLocation(PopOver.ArrowLocation.TOP_CENTER); + popOver.setCornerRadius(6); + + VBox content = new VBox(); + content.setPadding(new Insets(10)); + + errorText = new Text(); + errorText.setFont(new Font(13)); + + content.setPadding(new Insets(20, 10, 0, 10)); + content.getChildren() + .add(errorText); + + popOver.setContentNode(content); + + invalidProperty.addListener((o, ov, nv) -> validateActionState()); + + BorderPane borderPane = new BorderPane(); + + // Auto-sizing spacer + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + + Button cancel = new Button("Cancel"); + cancel.addEventFilter(ActionEvent.ACTION, event -> { + event.consume(); + success = false; + stage.close(); + }); + + + ToolBar toolbar = new ToolBar(cancel, spacer, BUTTON_PREVIOUS, BUTTON_NEXT); + toolbar.setPadding(new Insets(8)); + borderPane.setBottom(toolbar); + + headerText = new Text(); + defaultHeaderFont = new Font(25); + headerText.setFont(defaultHeaderFont); + + ToolBar region = new ToolBar(headerText); + region.setPadding(new Insets(15, 12, 15, 12)); + borderPane.setTop(region); + + center = new VBox(); + borderPane.setCenter(center); + + Scene scene2 = new Scene(borderPane); + stage.setScene(scene2); + stage.setMinWidth(300); + stage.setMinHeight(140); + stage.setWidth(300); + stage.setHeight(140); + + Window window = null; + if (owner instanceof Window) { + window = (Window) owner; + } + else if (owner instanceof Node) { + window = ((Node) owner).getScene() + .getWindow(); + } + + stage.initOwner(window); + } + + /************************************************************************** + * + * Public API + * + **************************************************************************/ + + + /** + * Allows you to customize the width and height of the wizard. Must be set before the flow is set + */ + public final + void setSize(int width, int height) { + stage.setWidth(width); + stage.setHeight(height); + useSpecifiedSize = true; + } + + + /** + * Shows the wizard but does not wait for a user response (in other words, this brings up a non-blocking dialog). + */ + public final + void show() { + JavaFxUtil.showOnSameScreenAsMouseCenter(stage); + stage.show(); + } + + /** + * Shows the wizard and waits for the user response (in other words, brings up a blocking dialog, with the returned value (true=finished + * or false=cancel/close) + * + * @return An true/false depending on how they closed the wizard. + */ + public final + boolean showAndWait() { + JavaFxUtil.showOnSameScreenAsMouseCenter(stage); + stage.showAndWait(); + + return success; + } + + /** + * The settings map is the place where all data from pages is kept once the user moves on from the page, assuming there is a {@link + * ValueExtractor} that is capable of extracting a value out of the various fields on the page. + */ + public final + ObservableMap getSettings() { + return settings; + } + + /** + * Goes to the next page, or finishes the wizard + */ + public + void goNext() { + currentPage.ifPresent(pageHistory::push); + currentPage = getFlow().advance(currentPage.orElse(null)); + updatePage(stage, true); + } + + private + void goPrev() { + currentPage = Optional.ofNullable(pageHistory.isEmpty() ? null : pageHistory.pop()); + updatePage(stage, false); + } + + private + void goFinish() { + success = true; + stage.close(); + } + + /************************************************************************** + * + * Properties + * + **************************************************************************/ + + // --- title + + /** + * Return the titleProperty of the wizard. + */ + public final + StringProperty titleProperty() { + return titleProperty; + } + + /** + * Return the title of the wizard. + */ + public final + String getTitle() { + return titleProperty.get(); + } + + /** + * Change the Title of the wizard. + */ + public final + void setTitle(String title) { + titleProperty.set(title); + } + + // --- flow + /** + * The {@link Flow} property represents the flow of pages in the wizard. + */ + private final + ObjectProperty flow = new SimpleObjectProperty(new LinearFlow()) { + @Override + protected + void invalidated() { + updatePage(stage, false); + } + + @Override + public + void set(Flow flow) { + super.set(flow); + pageHistory.clear(); + + if (flow != null) { + currentPage = flow.advance(currentPage.orElse(null)); + updatePage(stage, true); + } + } + }; + + public final + ObjectProperty flowProperty() { + return flow; + } + + /** + * Returns the currently set {@link Flow}, which represents the flow of pages in the wizard. + */ + public final + Flow getFlow() { + return flow.get(); + } + + /** + * Sets the {@link Flow}, which represents the flow of pages in the wizard. + */ + public final + void setFlow(Flow flow) { + this.flow.set(flow); + } + + + // --- Properties + private static final Object USER_DATA_KEY = new Object(); + + // A map containing a set of properties for this Wizard + private ObservableMap properties; + + /** + * Returns an observable map of properties on this Wizard for use primarily by application developers - not to be confused with the + * {@link #getSettings()} map that represents the values entered by the user into the wizard. + * + * @return an observable map of properties on this Wizard for use primarily by application developers + */ + @SuppressWarnings("FieldRepeatedlyAccessedInMethod") + public final + ObservableMap getProperties() { + if (properties == null) { + properties = FXCollections.observableMap(new HashMap<>()); + } + return properties; + } + + /** + * Tests if this Wizard has properties. + * + * @return true if this Wizard has properties. + */ + public + boolean hasProperties() { + return properties != null && !properties.isEmpty(); + } + + + // --- UserData + + /** + * Returns a previously set Object property, or null if no such property has been set using the {@link #setUserData(Object)} method. + * + * @return The Object that was previously set, or null if no property has been set or if null was set. + */ + public + Object getUserData() { + return getProperties().get(USER_DATA_KEY); + } + + /** + * Convenience method for setting a single Object property that can be retrieved at a later date. This is functionally equivalent to + * calling the getProperties().put(Object key, Object value) method. This can later be retrieved by calling {@link #getUserData()}. + * + * @param value + * The value to be stored - this can later be retrieved by calling {@link #getUserData()}. + */ + public + void setUserData(Object value) { + getProperties().put(USER_DATA_KEY, value); + } + + /** + * Gets the value of the property {@code invalid}. + * + * @return The validation state + * + * @see #invalidProperty() + */ + public final + boolean isInvalid() { + return invalidProperty.get(); + } + + /** + * Sets the value of the property {@code invalid}. + * + * @param invalid + * The new validation state {@link #invalidProperty() } + */ + public final + void setInvalid(boolean invalid) { + invalidProperty.set(invalid); + } + + /** + * Property for overriding the individual validation state of this {@link org.controlsfx.dialog.Wizard}. Setting {@code invalid} to true + * will disable the next/finish Button and the user will not be able to advance to the next page of the {@link + * org.controlsfx.dialog.Wizard}. Setting {@code invalid} to false will enable the next/finish Button.

For example you can use + * the {@link ValidationSupport#invalidProperty()} of a page and bind it to the {@code invalid} property:
{@code + * wizard.invalidProperty().bind(page.validationSupport.invalidProperty()); } + * + * @return The validation state property + */ + public final + BooleanProperty invalidProperty() { + return invalidProperty; + } + + /** + * Gets the value of the property {@code readSettings}. + * + * @return The read-settings state + * + * @see #readSettingsProperty() + */ + public final + boolean isReadSettings() { + return readSettingsProperty.get(); + } + + /** + * Sets the value of the property {@code readSettings}. + * + * @param readSettings + * The new read-settings state + * + * @see #readSettingsProperty() + */ + public final + void setReadSettings(boolean readSettings) { + readSettingsProperty.set(readSettings); + } + + /** + * Property for overriding the individual read-settings state of this {@link org.controlsfx.dialog.Wizard}. Setting {@code readSettings} + * to true will enable the value extraction for this {@link org.controlsfx.dialog.Wizard}. Setting {@code readSettings} to false will + * disable the value extraction for this {@link org.controlsfx.dialog.Wizard}. + * + * @return The readSettings state property + */ + public final + BooleanProperty readSettingsProperty() { + return readSettingsProperty; + } + + + + /************************************************************************** + * Private implementation + **************************************************************************/ + boolean BUTTON_PREV_INIT = false; + boolean BUTTON_NEXT_INIT = false; + + void updatePage(Stage stage, boolean advancing) { + Flow flow = getFlow(); + if (flow == null) { + return; + } + + Optional prevPage = Optional.ofNullable(pageHistory.isEmpty() ? null : pageHistory.peek()); + prevPage.ifPresent(page -> { + // if we are going forward in the wizard, we read in the settings + // from the page and store them in the settings map. + // If we are going backwards, we do nothing + // This is only performed if readSettings is true. + if (advancing && isReadSettings()) { + readSettings(page); + } + + // give the previous wizard page a chance to update the pages list + // based on the settings it has received + page.onExitingPage(this); + + invalidProperty.set(false); + popOver.hide(); + }); + + currentPage.ifPresent(currentPage -> { + // put in default actions + if (!BUTTON_PREV_INIT) { + BUTTON_PREV_INIT = true; + BUTTON_PREVIOUS.setDisable(false); + } + if (!BUTTON_NEXT_INIT) { + BUTTON_NEXT_INIT = true; + BUTTON_NEXT.setDisable(false); + + BUTTON_NEXT.addEventFilter(ActionEvent.ACTION, BUTTON_NEXT_EVENT_HANDLER); + BUTTON_NEXT.addEventFilter(KeyEvent.KEY_PRESSED, BUTTON_NEXT_EVENT_HANDLER); + } + + // then give user a chance to modify the default actions + currentPage.onEnteringPage(this); + + final Node firstFocusElement = currentPage.firstFocusElement; + if (firstFocusElement != null) { + Platform.runLater(() -> { + if (isInvalid()) { + firstFocusElement.requestFocus(); + } + else { + Platform.runLater(BUTTON_NEXT::requestFocus); + } + }); + } + else { + if (isInvalid()) { + Platform.runLater(BUTTON_PREVIOUS::requestFocus); + } + else { + Platform.runLater(BUTTON_NEXT::requestFocus); + } + } + + // and then switch to the new pane + if (currentPage.headerFont != null) { + headerText.setFont(currentPage.headerFont); + } + else { + headerText.setFont(defaultHeaderFont); + } + headerText.setText(currentPage.getHeaderText()); + ObservableList children = center.getChildren(); + children.clear(); + children.add(currentPage.getContent()); + + + if (!useSpecifiedSize) { + currentPage.getContent() + .autosize(); + stage.sizeToScene(); + } + + currentPage.validationSupport.redecorate(); + notifyValidationChange(currentPage, currentPage.validationErrors); + }); + + validateActionState(); + } + + private + void validateActionState() { + // Note that we put the 'next' and 'finish' actions at the beginning of + // the actions list, so that it takes precedence as the default button, + // over, say, cancel. We will probably want to handle this better in the + // future... + + if (!getFlow().canAdvance(currentPage.orElse(null))) { + BUTTON_NEXT.setText("Finish"); + BUTTON_NEXT.removeEventFilter(ActionEvent.ACTION, BUTTON_NEXT_EVENT_HANDLER); + BUTTON_NEXT.removeEventFilter(KeyEvent.KEY_PRESSED, BUTTON_NEXT_EVENT_HANDLER); + + BUTTON_NEXT.addEventFilter(ActionEvent.ACTION, BUTTON_FINISH_EVENT_HANDLER); + BUTTON_NEXT.addEventFilter(KeyEvent.KEY_PRESSED, BUTTON_FINISH_EVENT_HANDLER); + } + else { + if (!BUTTON_NEXT.getText() + .equals("Next")) { + BUTTON_NEXT.addEventFilter(ActionEvent.ACTION, BUTTON_NEXT_EVENT_HANDLER); + BUTTON_NEXT.addEventFilter(KeyEvent.KEY_PRESSED, BUTTON_NEXT_EVENT_HANDLER); + + BUTTON_NEXT.removeEventFilter(ActionEvent.ACTION, BUTTON_FINISH_EVENT_HANDLER); + BUTTON_NEXT.removeEventFilter(KeyEvent.KEY_PRESSED, BUTTON_FINISH_EVENT_HANDLER); + } + BUTTON_NEXT.setText("Next"); + } + + validateButton(BUTTON_PREVIOUS, pageHistory::isEmpty); + validateButton(BUTTON_NEXT, invalidProperty::get); + } + + // Functional design allows to delay condition evaluation until it is actually needed + private static + void validateButton(Button button, BooleanSupplier condition) { + if ( button != null ) { + button.setDisable(condition.getAsBoolean()); + } + } + + private int settingCounter; + + private + void readSettings(WizardPane page) { + // for now we cannot know the structure of the page, so we just drill down + // through the entire scenegraph (from page.content down) until we get + // to the leaf nodes. We stop only if we find a node that is a + // ValueContainer (either by implementing the interface), or being + // listed in the internal valueContainers map. + + settingCounter = 0; + checkNode(page.getContent()); + } + + private + boolean checkNode(Node n) { + boolean success = readSetting(n); + + if (success) { + // we've added the setting to the settings map and we should stop drilling deeper + return true; + } + else { + /** + * go into children of this node (if possible) and see if we can get + * a value from them (recursively) We use reflection to fix + * https://bitbucket.org/controlsfx/controlsfx/issue/412 . + */ + List children = ImplUtils.getChildren(n, true); + + // we're doing a depth-first search, where we stop drilling down + // once we hit a successful read + boolean childSuccess = false; + for (Node child : children) { + childSuccess |= checkNode(child); + } + return childSuccess; + } + } + + private + boolean readSetting(Node n) { + if (n == null) { + return false; + } + + Object setting = ValueExtractor.getValue(n); + + if (setting != null) { + // save it into the settings map. + // if the node has an id set, we will use that as the setting name + String settingName = n.getId(); + + // but if the id is not set, we will use a generic naming scheme + if (settingName == null || settingName.isEmpty()) { + settingName = "page_" /*+ previousPageIndex*/ + ".setting_" + settingCounter; //$NON-NLS-1$ //$NON-NLS-2$ + } + + getSettings().put(settingName, setting); + + settingCounter++; + } + + return setting != null; + } + + public + void requestNextFocus() { + BUTTON_NEXT.requestFocus(); + } + + public + void requestPrevFocus() { + BUTTON_PREVIOUS.requestFocus(); + } + + void notifyValidationChange(final WizardPane wizardPane, Collection errors) { + if (currentPage.orElse(null) == wizardPane) { + Platform.runLater(() -> { + boolean hasErrors = !errors.isEmpty(); + invalidProperty.set(hasErrors); + + final PopOver popOver = this.popOver; + if (hasErrors) { + String errorText = errors.iterator() + .next() + .getText() + .trim(); + + this.errorText.setText(errorText); + + if (!popOver.isShowing()) { + popOver.setX(0); + popOver.setY(0); + popOver.show(BUTTON_NEXT, -10); + } + } + else { + popOver.hide(); + } + + validateActionState(); + }); + } + } + + + /************************************************************************** + * + * Support classes + * + **************************************************************************/ + + + /** + * Represents the page flow of the wizard. It defines only methods required to move forward in the wizard logic, as backward movement is + * automatically handled by wizard itself, using internal page history. + */ + public + interface Flow { + + /** + * Advances the wizard to the next page if possible. + * + * @param currentPage + * The current wizard page + * + * @return {@link Optional} value containing the next wizard page. + */ + Optional advance(WizardPane currentPage); + + /** + * Check if advancing to the next page is possible + * + * @param currentPage + * The current wizard page + * + * @return true if it is possible to advance to the next page, false otherwise. + */ + boolean canAdvance(WizardPane currentPage); + } + + + /** + * LinearFlow is an implementation of the {@link org.controlsfx.dialog.Wizard.Flow} interface, designed to support the most common type + * of wizard flow - namely, a linear wizard page flow (i.e. through all pages in the order that they are specified). Therefore, this + * {@link Flow} implementation simply traverses a collections of {@link WizardPane WizardPanes}. + *

+ *

For example of how to use this API, please refer to the {@link org.controlsfx.dialog.Wizard} documentation

+ * + * @see org.controlsfx.dialog.Wizard + * @see WizardPane + */ + public static + class LinearFlow implements Wizard.Flow { + + private final List pages; + + /** + * Creates a new LinearFlow instance that will allow for stepping through the given collection of {@link WizardPane} instances. + */ + public + LinearFlow(Collection pages) { + this.pages = new ArrayList<>(pages); + } + + /** + * Creates a new LinearFlow instance that will allow for stepping through the given varargs array of {@link WizardPane} instances. + */ + public + LinearFlow(WizardPane... pages) { + this(Arrays.asList(pages)); + } + + /** + * {@inheritDoc} + */ + @Override + public + Optional advance(WizardPane currentPage) { + int pageIndex = pages.indexOf(currentPage); + return Optional.ofNullable(pages.get(++pageIndex)); + } + + /** + * {@inheritDoc} + */ + @Override + public + boolean canAdvance(WizardPane currentPage) { + int pageIndex = pages.indexOf(currentPage); + return pages.size() - 1 > pageIndex; + } + } +} diff --git a/Dorkbox-Util/src/dorkbox/util/javafx/WizardPane.java b/Dorkbox-Util/src/dorkbox/util/javafx/WizardPane.java new file mode 100644 index 0000000..7d4372e --- /dev/null +++ b/Dorkbox-Util/src/dorkbox/util/javafx/WizardPane.java @@ -0,0 +1,135 @@ +package dorkbox.util.javafx; + +import dorkbox.util.JavaFxUtil; +import javafx.scene.Node; +import javafx.scene.control.Control; +import javafx.scene.control.DialogPane; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import org.controlsfx.validation.ValidationMessage; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.Validator; + +import java.util.Collection; +import java.util.Collections; + +/** + * WizardPane is the base class for all wizard pages. The API is essentially the {@link DialogPane}, with the addition of convenience + * methods related to {@link #onEnteringPage(Wizard) entering} and {@link #onExitingPage(Wizard) exiting} the page. + */ +public +class WizardPane { + + String headerText; + Font headerFont; + + AnchorPane content = new AnchorPane(); + + Node firstFocusElement; + + ValidationSupport validationSupport = new ValidationSupport(); + volatile Collection validationErrors = Collections.emptyList(); + + /** + * Creates an instance of wizard pane. + * @param wizard necessary for validation support, to notify the wizard when this page becomes valid + */ + public + WizardPane(final Wizard wizard) { + validationSupport.validationResultProperty() + .addListener((o, ov, nv) -> { + validationErrors = nv.getErrors(); + wizard.notifyValidationChange(this, nv.getErrors()); + }); + } + + + + /** + * Called on entering a page. This is a good place to read values from wizard settings and assign them to controls on the page + * + * @param wizard + * which page will be used on + */ + public + void onEnteringPage(Wizard wizard) { + // no-op + } + + /** + * Called on existing the page. This is a good place to read values from page controls and store them in wizard settings + * + * @param wizard + * which page was used on + */ + public + void onExitingPage(Wizard wizard) { + // no-opd + } + + public + void setHeaderText(final String headerText) { + this.headerText = headerText; + } + + public + void setContent(final Region content) { + content.setMinSize(0, 0); + + AnchorPane.setTopAnchor(content, 0.0); + AnchorPane.setRightAnchor(content, 0.0); + AnchorPane.setLeftAnchor(content, 0.0); + AnchorPane.setBottomAnchor(content, 0.0); + + this.content.getChildren().setAll(content); + } + + public + void setContentText(final String contentText) { + Text text = new Text(); + text.setFont(JavaFxUtil.DEFAULT_FONT); + text.setText(contentText); + + VBox region = new VBox(); + region.getChildren().add(text); + + region.setMinSize(0, 0); + + AnchorPane.setTopAnchor(region, 0.0); + AnchorPane.setRightAnchor(region, 0.0); + AnchorPane.setLeftAnchor(region, 0.0); + AnchorPane.setBottomAnchor(region, 0.0); + + this.content.getChildren().setAll(region); + } + + public + String getHeaderText() { + return headerText; + } + + public + Region getContent() { + return content; + } + + public + void setFirstFocusElement(final Node firstFocusElement) { + this.firstFocusElement = firstFocusElement; + } + + + public + void setHeaderFont(final Font headerFont) { + this.headerFont = headerFont; + } + + + public + void registerValidator(final Control control, final Validator validator) { + this.validationSupport.registerValidator(control, validator); + } +}