1 /*
   2  * Copyright (c) 2008, 2014, Oracle and/or its affiliates.
   3  * All rights reserved. Use is subject to license terms.
   4  *
   5  * This file is available and licensed under the following license:
   6  *
   7  * Redistribution and use in source and binary forms, with or without
   8  * modification, are permitted provided that the following conditions
   9  * are met:
  10  *
  11  *  - Redistributions of source code must retain the above copyright
  12  *    notice, this list of conditions and the following disclaimer.
  13  *  - Redistributions in binary form must reproduce the above copyright
  14  *    notice, this list of conditions and the following disclaimer in
  15  *    the documentation and/or other materials provided with the distribution.
  16  *  - Neither the name of Oracle Corporation nor the names of its
  17  *    contributors may be used to endorse or promote products derived
  18  *    from this software without specific prior written permission.
  19  *
  20  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
  21  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
  22  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
  23  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
  24  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
  25  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
  26  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  27  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  28  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  29  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  30  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  31  */
  32 package ensemble.control;
  33 
  34 import java.util.LinkedList;
  35 
  36 import javafx.animation.Animation;
  37 import javafx.animation.FadeTransition;
  38 import javafx.animation.Interpolator;
  39 import javafx.animation.KeyFrame;
  40 import javafx.animation.KeyValue;
  41 import javafx.animation.ParallelTransition;
  42 import javafx.animation.ScaleTransition;
  43 import javafx.animation.Timeline;
  44 import javafx.beans.property.DoubleProperty;
  45 import javafx.beans.property.SimpleDoubleProperty;
  46 import javafx.event.ActionEvent;
  47 import javafx.event.Event;
  48 import javafx.event.EventHandler;
  49 import javafx.geometry.Insets;
  50 import javafx.geometry.Point2D;
  51 import javafx.geometry.VPos;
  52 import javafx.scene.Node;
  53 import javafx.scene.Scene;
  54 import javafx.scene.control.Button;
  55 import javafx.scene.input.MouseEvent;
  56 import javafx.scene.layout.Pane;
  57 import javafx.scene.layout.Region;
  58 import javafx.scene.shape.Rectangle;
  59 import javafx.scene.text.Text;
  60 import javafx.util.Duration;
  61 
  62 /**
  63  * A Popover is a mini-window that pops up and contains some application specific content.
  64  * It's width is defined by the application, but defaults to a hard-coded pref width.
  65  * The height will always be between a minimum height (determined by the application, but
  66  * pre-set with a minimum value) and a maximum height (specified by the application, or
  67  * based on the height of the scene). The value for the pref height is determined by
  68  * inspecting the pref height of the current displayed page. At time this value is animated
  69  * (when switching from page to page).
  70  */
  71 public class Popover extends Region implements EventHandler<Event>{
  72     private static final int PAGE_GAP = 15;
  73 
  74     /**
  75      * The visual frame of the popover is defined as an addition region, rather than simply styling
  76      * the popover itself as one might expect. The reason for this is that our frame is styled via
  77      * a border image, and it has an inner shadow associated with it, and we want to be able to ensure
  78      * that the shadow is on top of whatever content is drawn within the popover. In addition, the inner
  79      * edge of the frame is rounded, and we want the content to slide under it, only to be clipped beneath
  80      * the frame. So it works best for the frame to be overlaid on top, even though it is not intuitive.
  81      */
  82     private final Region frameBorder = new Region();
  83     private final Button leftButton = new Button("Left");
  84     private final Button rightButton = new Button("Right");
  85     private final LinkedList<Page> pages = new LinkedList<Page>();
  86     private final Pane pagesPane = new Pane();
  87     private final Rectangle pagesClipRect = new Rectangle();
  88     private final Pane titlesPane = new Pane();
  89     private Text title; // the current title
  90     private final Rectangle titlesClipRect = new Rectangle();
  91 //    private final EventHandler<ScrollEvent> popoverScrollHandler;
  92     private final EventHandler<MouseEvent> popoverHideHandler;
  93     private Runnable onHideCallback = null;
  94     private double maxPopupHeight = -1;
  95 
  96     private DoubleProperty popoverHeight = new SimpleDoubleProperty(400) {
  97         @Override protected void invalidated() {
  98             requestLayout();
  99         }
 100     };
 101 
 102     public Popover() {
 103         // TODO Could pagesPane be a region instead? I need to draw some opaque background. Right now when
 104         // TODO animating from one page to another you can see the background "shine through" because the
 105         // TODO group background is transparent. That can't be good for performance either.
 106         getStyleClass().setAll("popover");
 107         frameBorder.getStyleClass().setAll("popover-frame");
 108         frameBorder.setMouseTransparent(true);
 109         // setup buttons
 110         leftButton.setOnMouseClicked(this);
 111         leftButton.getStyleClass().add("popover-left-button");
 112         leftButton.setMinWidth(USE_PREF_SIZE);
 113         rightButton.setOnMouseClicked(this);
 114         rightButton.getStyleClass().add("popover-right-button");
 115         rightButton.setMinWidth(USE_PREF_SIZE);
 116         pagesClipRect.setSmooth(false);
 117         pagesPane.setClip(pagesClipRect);
 118         titlesClipRect.setSmooth(false);
 119         titlesPane.setClip(titlesClipRect);
 120         getChildren().addAll(pagesPane, frameBorder, titlesPane, leftButton, rightButton);
 121         // always hide to start with
 122         setVisible(false);
 123         setOpacity(0);
 124         setScaleX(.8);
 125         setScaleY(.8);
 126         // create handlers for auto hiding
 127         popoverHideHandler = (MouseEvent t) -> {
 128             // check if event is outside popup
 129             Point2D mouseInFilterPane = sceneToLocal(t.getX(), t.getY());
 130             if (mouseInFilterPane.getX() < 0 || mouseInFilterPane.getX() > (getWidth()) ||
 131                     mouseInFilterPane.getY() < 0 || mouseInFilterPane.getY() > (getHeight())) {
 132                 hide();
 133                 t.consume();
 134             }
 135         };
 136 //        popoverScrollHandler = new EventHandler<ScrollEvent>() {
 137 //            @Override public void handle(ScrollEvent t) {
 138 //                t.consume(); // consume all scroll events
 139 //            }
 140 //        };
 141     }
 142 
 143     /**
 144      * Handle mouse clicks on the left and right buttons.
 145      */
 146     @Override public void handle(Event event) {
 147         if (event.getSource() == leftButton) {
 148             pages.getFirst().handleLeftButton();
 149         } else if (event.getSource() == rightButton) {
 150             pages.getFirst().handleRightButton();
 151         }
 152     }
 153 
 154     @Override protected double computeMinWidth(double height) {
 155         Page page = pages.isEmpty() ? null : pages.getFirst();
 156         if (page != null) {
 157             Node n = page.getPageNode();
 158             if (n != null) {
 159                 Insets insets = getInsets();
 160                 return insets.getLeft() + n.minWidth(-1) + insets.getRight();
 161             }
 162         }
 163         return 200;
 164     }
 165 
 166     @Override protected double computeMinHeight(double width) {
 167         Insets insets = getInsets();
 168         return insets.getLeft() + 100 + insets.getRight();
 169     }
 170 
 171     @Override protected double computePrefWidth(double height) {
 172         Page page = pages.isEmpty() ? null : pages.getFirst();
 173         if (page != null) {
 174             Node n = page.getPageNode();
 175             if (n != null) {
 176                 Insets insets = getInsets();
 177                 return insets.getLeft() + n.prefWidth(-1) + insets.getRight();
 178             }
 179         }
 180         return 400;
 181     }
 182 
 183     @Override protected double computePrefHeight(double width) {
 184         double minHeight = minHeight(-1);
 185         double maxHeight = maxHeight(-1);
 186         double prefHeight = popoverHeight.get();
 187         if (prefHeight == -1) {
 188             Page page = pages.getFirst();
 189             if (page != null) {
 190                 Insets inset = getInsets();
 191                 if (width == -1) {
 192                     width = prefWidth(-1);
 193                 }
 194                 double contentWidth = width - inset.getLeft() - inset.getRight();
 195                 double contentHeight = page.getPageNode().prefHeight(contentWidth);
 196                 prefHeight = inset.getTop() + contentHeight + inset.getBottom();
 197                 popoverHeight.set(prefHeight);
 198             } else {
 199                 prefHeight = minHeight;
 200             }
 201         }
 202         return boundedSize(minHeight, prefHeight, maxHeight);
 203     }
 204 
 205     static double boundedSize(double min, double pref, double max) {
 206         double a = pref >= min ? pref : min;
 207         double b = min >= max ? min : max;
 208         return a <= b ? a : b;
 209     }
 210 
 211     @Override protected double computeMaxWidth(double height) {
 212         return Double.MAX_VALUE;
 213     }
 214 
 215     @Override protected double computeMaxHeight(double width) {
 216         Scene scene = getScene();
 217         if (scene != null) {
 218             return scene.getHeight() - 100;
 219         } else {
 220             return Double.MAX_VALUE;
 221         }
 222     }
 223 
 224     @Override protected void layoutChildren() {
 225         if (maxPopupHeight == -1) {
 226             maxPopupHeight = (int)getScene().getHeight()-100;
 227         }
 228         final Insets insets = getInsets();
 229         final double width = getWidth();
 230         final double height = getHeight();
 231         final double top = insets.getTop();
 232         final double right = insets.getRight();
 233         final double bottom = insets.getBottom();
 234         final double left = insets.getLeft();
 235 
 236         double pageWidth = width - left - right;
 237         double pageHeight = height - top - bottom;
 238 
 239         frameBorder.resize(width, height);
 240 
 241         pagesPane.resizeRelocate(left, top, pageWidth, pageHeight);
 242         pagesClipRect.setWidth(pageWidth);
 243         pagesClipRect.setHeight(pageHeight);
 244 
 245         double pageX = 0;
 246         for (Node page : pagesPane.getChildren()) {
 247             page.resizeRelocate(pageX, 0, pageWidth, pageHeight);
 248             pageX += pageWidth + PAGE_GAP;
 249         }
 250 
 251         double buttonHeight = leftButton.prefHeight(-1);
 252         if (buttonHeight < 30) buttonHeight = 30;
 253         final double buttonTop = (top-buttonHeight) / 2.0;
 254         final double leftButtonWidth = snapSizeX(leftButton.prefWidth(-1));
 255         leftButton.resizeRelocate(left, buttonTop,leftButtonWidth,buttonHeight);
 256         final double rightButtonWidth = snapSizeX(rightButton.prefWidth(-1));
 257         rightButton.resizeRelocate(width-right-rightButtonWidth, buttonTop,rightButtonWidth,buttonHeight);
 258 
 259         final double leftButtonRight = leftButton.isVisible() ? (left + leftButtonWidth) : left;
 260         final double rightButtonLeft = rightButton.isVisible() ? (right + rightButtonWidth) : right;
 261         titlesClipRect.setX(leftButtonRight);
 262         titlesClipRect.setWidth(pageWidth - leftButtonRight - rightButtonLeft);
 263         titlesClipRect.setHeight(top);
 264 
 265         if (title != null) {
 266             title.setTranslateY((int) (top / 2d));
 267         }
 268     }
 269 
 270     public final void clearPages() {
 271         while (!pages.isEmpty()) {
 272             pages.pop().handleHidden();
 273         }
 274         pagesPane.getChildren().clear();
 275         titlesPane.getChildren().clear();
 276         pagesClipRect.setX(0);
 277         pagesClipRect.setWidth(400);
 278         pagesClipRect.setHeight(400);
 279         popoverHeight.set(400);
 280         pagesPane.setTranslateX(0);
 281         titlesPane.setTranslateX(0);
 282         titlesClipRect.setTranslateX(0);
 283     }
 284 
 285     public final void popPage() {
 286         Page oldPage = pages.pop();
 287         oldPage.handleHidden();
 288         oldPage.setPopover(null);
 289         Page page = pages.getFirst();
 290         leftButton.setVisible(page.leftButtonText() != null);
 291         leftButton.setText(page.leftButtonText());
 292         rightButton.setVisible(page.rightButtonText() != null);
 293         rightButton.setText(page.rightButtonText());
 294         if (pages.size() > 0) {
 295             final Insets insets = getInsets();
 296             final int width = (int)prefWidth(-1);
 297             final int right = (int)insets.getRight();
 298             final int left = (int)insets.getLeft();
 299             int pageWidth = width - left - right;
 300             final int newPageX = (pageWidth+PAGE_GAP) * (pages.size()-1);
 301             new Timeline(
 302                     new KeyFrame(Duration.millis(350), (ActionEvent t) -> {
 303                         pagesPane.setCache(false);
 304                         pagesPane.getChildren().remove(pagesPane.getChildren().size()-1);
 305                         titlesPane.getChildren().remove(titlesPane.getChildren().size()-1);
 306                         resizePopoverToNewPage(pages.getFirst().getPageNode());
 307             },
 308                         new KeyValue(pagesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
 309                         new KeyValue(titlesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
 310                         new KeyValue(pagesClipRect.xProperty(), newPageX, Interpolator.EASE_BOTH),
 311                         new KeyValue(titlesClipRect.translateXProperty(), newPageX, Interpolator.EASE_BOTH)
 312                     )
 313                 ).play();
 314         } else {
 315             hide();
 316         }
 317     }
 318 
 319     public final void pushPage(final Page page) {
 320         final Node pageNode = page.getPageNode();
 321         pageNode.setManaged(false);
 322         pagesPane.getChildren().add(pageNode);
 323         final Insets insets = getInsets();
 324         final int pageWidth = (int)(prefWidth(-1) - insets.getLeft() - insets.getRight());
 325         final int newPageX = (pageWidth + PAGE_GAP) * pages.size();
 326         leftButton.setVisible(page.leftButtonText() != null);
 327         leftButton.setText(page.leftButtonText());
 328         rightButton.setVisible(page.rightButtonText() != null);
 329         rightButton.setText(page.rightButtonText());
 330 
 331         title = new Text(page.getPageTitle());
 332         title.getStyleClass().add("popover-title");
 333         //debtest title.setFill(Color.WHITE);
 334         title.setTextOrigin(VPos.CENTER);
 335         title.setTranslateX(newPageX + (int) ((pageWidth - title.getLayoutBounds().getWidth()) / 2d));
 336         titlesPane.getChildren().add(title);
 337 
 338         if (!pages.isEmpty() && isVisible()) {
 339             final Timeline timeline = new Timeline(
 340                     new KeyFrame(Duration.millis(350), (ActionEvent t) -> {
 341                         pagesPane.setCache(false);
 342                         resizePopoverToNewPage(pageNode);
 343             },
 344                         new KeyValue(pagesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
 345                         new KeyValue(titlesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
 346                         new KeyValue(pagesClipRect.xProperty(), newPageX, Interpolator.EASE_BOTH),
 347                         new KeyValue(titlesClipRect.translateXProperty(), newPageX, Interpolator.EASE_BOTH)
 348                     )
 349                 );
 350             timeline.play();
 351         }
 352         page.setPopover(this);
 353         page.handleShown();
 354         pages.push(page);
 355     }
 356 
 357     private void resizePopoverToNewPage(final Node newPageNode) {
 358         final Insets insets = getInsets();
 359         final double width = prefWidth(-1);
 360         final double contentWidth = width - insets.getLeft() - insets.getRight();
 361         double h = newPageNode.prefHeight(contentWidth);
 362         h += insets.getTop() + insets.getBottom();
 363         new Timeline(
 364             new KeyFrame(Duration.millis(200),
 365                 new KeyValue(popoverHeight, h, Interpolator.EASE_BOTH)
 366             )
 367         ).play();
 368     }
 369 
 370     public void show(){
 371         show(null);
 372     }
 373 
 374     private Animation fadeAnimation = null;
 375 
 376     public void show(Runnable onHideCallback){
 377         if (!isVisible() || fadeAnimation != null) {
 378             this.onHideCallback = onHideCallback;
 379             getScene().addEventFilter(MouseEvent.MOUSE_CLICKED, popoverHideHandler);
 380 //            getScene().addEventFilter(ScrollEvent.ANY,popoverScrollHandler);
 381 
 382             if (fadeAnimation != null) {
 383                 fadeAnimation.stop();
 384                 setVisible(true); // for good measure
 385             } else {
 386                 popoverHeight.set(-1);
 387                 setVisible(true);
 388             }
 389 
 390             FadeTransition fade = new FadeTransition(Duration.seconds(.1), this);
 391             fade.setToValue(1.0);
 392             fade.setOnFinished((ActionEvent event) -> {
 393                 fadeAnimation = null;
 394             });
 395 
 396             ScaleTransition scale = new ScaleTransition(Duration.seconds(.1), this);
 397             scale.setToX(1);
 398             scale.setToY(1);
 399 
 400             ParallelTransition tx = new ParallelTransition(fade, scale);
 401             fadeAnimation = tx;
 402             tx.play();
 403         }
 404     }
 405 
 406     public void hide(){
 407         if (isVisible() || fadeAnimation != null) {
 408             getScene().removeEventFilter(MouseEvent.MOUSE_CLICKED, popoverHideHandler);
 409 //            getScene().removeEventFilter(ScrollEvent.ANY,popoverScrollHandler);
 410 
 411             if (fadeAnimation != null) {
 412                 fadeAnimation.stop();
 413             }
 414 
 415             FadeTransition fade = new FadeTransition(Duration.seconds(.1), this);
 416             fade.setToValue(0);
 417             fade.setOnFinished((ActionEvent event) -> {
 418                 fadeAnimation = null;
 419                 setVisible(false);
 420                 clearPages();
 421                 if (onHideCallback != null) onHideCallback.run();
 422             });
 423 
 424             ScaleTransition scale = new ScaleTransition(Duration.seconds(.1), this);
 425             scale.setToX(.8);
 426             scale.setToY(.8);
 427 
 428             ParallelTransition tx = new ParallelTransition(fade, scale);
 429             fadeAnimation = tx;
 430             tx.play();
 431         }
 432     }
 433 
 434     /**
 435      * Represents a page in a popover.
 436      */
 437     public static interface Page {
 438         public void setPopover(Popover popover);
 439         public Popover getPopover();
 440 
 441         /**
 442          * Get the node that represents the page.
 443          *
 444          * @return the page node.
 445          */
 446         public Node getPageNode();
 447 
 448         /**
 449          * Get the title to display for this page.
 450          *
 451          * @return The page title
 452          */
 453         public String getPageTitle();
 454 
 455         /**
 456          * The text for left button, if null then button will be hidden.
 457          * @return The button text
 458          */
 459         public String leftButtonText();
 460 
 461         /**
 462          * Called on a click of the left button of the popover.
 463          */
 464         public void handleLeftButton();
 465 
 466         /**
 467          * The text for right button, if null then button will be hidden.
 468          * @return The button text
 469          */
 470         public String rightButtonText();
 471 
 472         /**
 473          * Called on a click of the right button of the popover.
 474          */
 475         public void handleRightButton();
 476 
 477         public void handleShown();
 478         public void handleHidden();
 479     }
 480 }