/* * Copyright (c) 2011, 2015, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. Oracle designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. * * This code 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 General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ package com.sun.javafx.scene.control; import com.sun.javafx.scene.control.behavior.TwoLevelFocusPopupBehavior; import com.sun.javafx.scene.control.skin.Utils; import javafx.animation.Animation.Status; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.css.CssMetaData; import javafx.css.PseudoClass; import javafx.css.Styleable; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.geometry.*; import javafx.scene.AccessibleAction; import javafx.scene.AccessibleAttribute; import javafx.scene.AccessibleRole; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.control.*; import javafx.scene.control.skin.MenuBarSkin; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.shape.Rectangle; import javafx.stage.Window; import javafx.util.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * This is a the SkinBase for ContextMenu based controls so that the CSS parts * work right, because otherwise we would have to copy the Keys from there to here. */ public class ContextMenuContent extends Region { private ContextMenu contextMenu; /*************************************************************************** * UI subcomponents **************************************************************************/ private double maxGraphicWidth = 0; // we keep this margin to left for graphic private double maxRightWidth = 0; private double maxLabelWidth = 0; private double maxRowHeight = 0; private double maxLeftWidth = 0; private double oldWidth = 0; private Rectangle clipRect; MenuBox itemsContainer; private ArrowMenuItem upArrow; private ArrowMenuItem downArrow; /* * We maintain a current focused index which is used * in keyboard navigation of menu items. */ private int currentFocusedIndex = -1; private boolean itemsDirty = true; private InvalidationListener popupShowingListener = arg0 -> { updateItems(); }; private WeakInvalidationListener weakPopupShowingListener = new WeakInvalidationListener(popupShowingListener); /*************************************************************************** * Constructors **************************************************************************/ public ContextMenuContent(final ContextMenu popupMenu) { this.contextMenu = popupMenu; clipRect = new Rectangle(); clipRect.setSmooth(false); itemsContainer = new MenuBox(); // itemsContainer = new VBox(); itemsContainer.setClip(clipRect); upArrow = new ArrowMenuItem(this); upArrow.setUp(true); upArrow.setFocusTraversable(false); downArrow = new ArrowMenuItem(this); downArrow.setUp(false); downArrow.setFocusTraversable(false); getChildren().add(itemsContainer); getChildren().add(upArrow); getChildren().add(downArrow); initialize(); setUpBinds(); updateItems(); // RT-20197 add menuitems only on first show. popupMenu.showingProperty().addListener(weakPopupShowingListener); /* ** only add this if we're on an embedded ** platform that supports 5-button navigation */ if (Utils.isTwoLevelFocus()) { new TwoLevelFocusPopupBehavior(this); } } //For access from controls public VBox getItemsContainer() { return itemsContainer; } //For testing purpose only int getCurrentFocusIndex() { return currentFocusedIndex; } //For testing purpose only void setCurrentFocusedIndex(int index) { if (index < itemsContainer.getChildren().size()) { currentFocusedIndex = index; } } private void updateItems() { if (itemsDirty) { updateVisualItems(); itemsDirty = false; } } private void computeVisualMetrics() { maxRightWidth = 0; maxLabelWidth = 0; maxRowHeight = 0; maxGraphicWidth = 0; maxLeftWidth = 0; for (int i = 0; i < itemsContainer.getChildren().size(); i++) { Node child = itemsContainer.getChildren().get(i); if (child instanceof MenuItemContainer) { final MenuItemContainer menuItemContainer = (MenuItemContainer)itemsContainer.getChildren().get(i); if (! menuItemContainer.isVisible()) continue; double alt = -1; Node n = menuItemContainer.left; if (n != null) { if (n.getContentBias() == Orientation.VERTICAL) { // width depends on height alt = snapSize(n.prefHeight(-1)); } else alt = -1; maxLeftWidth = Math.max(maxLeftWidth, snapSize(n.prefWidth(alt))); maxRowHeight = Math.max(maxRowHeight, n.prefHeight(-1)); } n = menuItemContainer.graphic; if (n != null) { if (n.getContentBias() == Orientation.VERTICAL) { // width depends on height alt = snapSize(n.prefHeight(-1)); } else alt = -1; maxGraphicWidth = Math.max(maxGraphicWidth, snapSize(n.prefWidth(alt))); maxRowHeight = Math.max(maxRowHeight, n.prefHeight(-1)); } n = menuItemContainer.label; if (n != null) { if (n.getContentBias() == Orientation.VERTICAL) { alt = snapSize(n.prefHeight(-1)); } else alt = -1; maxLabelWidth = Math.max(maxLabelWidth, snapSize(n.prefWidth(alt))); maxRowHeight = Math.max(maxRowHeight, n.prefHeight(-1)); } n = menuItemContainer.right; if (n != null) { if (n.getContentBias() == Orientation.VERTICAL) { // width depends on height alt = snapSize(n.prefHeight(-1)); } else alt = -1; maxRightWidth = Math.max(maxRightWidth, snapSize(n.prefWidth(alt))); maxRowHeight = Math.max(maxRowHeight, n.prefHeight(-1)); } } } // Fix for RT-38838. // This fixes the issue where CSS is applied to a menu after it has been // showing, resulting in its bounds changing. In this case, we need to // shift the submenu such that it is properly aligned with its parent menu. // // To do this, we must firstly determine if the open submenu is shifted // horizontally to appear on the other side of this menu, as this is the // only situation where shifting has to happen. If so, we need to check // if we should shift the submenu due to changes in width. // // We need to get the parent menu of this contextMenu, so that we only // modify the X value in the following conditions: // 1) There exists a parent menu // 2) The parent menu is in the correct position (i.e. to the left of this // menu in normal LTR systems). final double newWidth = maxRightWidth + maxLabelWidth + maxGraphicWidth + maxLeftWidth; Window ownerWindow = contextMenu.getOwnerWindow(); if (ownerWindow instanceof ContextMenu) { if (contextMenu.getX() < ownerWindow.getX()) { if (oldWidth != newWidth) { contextMenu.setX(contextMenu.getX() + oldWidth - newWidth); } } } oldWidth = newWidth; } private void updateVisualItems() { ObservableList itemsContainerChilder = itemsContainer.getChildren(); disposeVisualItems(); for (int row = 0; row < getItems().size(); row++) { final MenuItem item = getItems().get(row); if (item instanceof CustomMenuItem && ((CustomMenuItem) item).getContent() == null) { continue; } if (item instanceof SeparatorMenuItem) { // we don't want the hover highlight for separators, so for // now this is the simplest approach - just remove the // background entirely. This may cause issues if people // intend to style the background differently. Node node = ((CustomMenuItem) item).getContent(); node.visibleProperty().bind(item.visibleProperty()); itemsContainerChilder.add(node); // Add the (separator) menu item to properties map of this node. // Special casing this for separator : // This allows associating this container with SeparatorMenuItem. node.getProperties().put(MenuItem.class, item); } else { MenuItemContainer menuItemContainer = new MenuItemContainer(item); menuItemContainer.visibleProperty().bind(item.visibleProperty()); itemsContainerChilder.add(menuItemContainer); } } // Add the Menu to properties map of this skin. Used by QA for testing // This enables associating a parent menu for this skin showing menu items. if (getItems().size() > 0) { final MenuItem item = getItems().get(0); getProperties().put(Menu.class, item.getParentMenu()); } // RT-36513 made this applyCss(). Modified by RT-36995 to impl_reapplyCSS() impl_reapplyCSS(); } private void disposeVisualItems() { // clean up itemsContainer ObservableList itemsContainerChilder = itemsContainer.getChildren(); for (int i = 0, max = itemsContainerChilder.size(); i < max; i++) { Node n = itemsContainerChilder.get(i); if (n instanceof MenuItemContainer) { MenuItemContainer container = (MenuItemContainer) n; container.visibleProperty().unbind(); container.dispose(); } } itemsContainerChilder.clear(); } /** * Can be called by Skins when they need to clean up the content of any * ContextMenu instances they might have created. This ensures that contents * of submenus if any, also get cleaned up. */ public void dispose() { disposeBinds(); disposeVisualItems(); disposeContextMenu(submenu); submenu = null; openSubmenu = null; selectedBackground = null; if (contextMenu != null) { contextMenu.getItems().clear(); contextMenu = null; } } public void disposeContextMenu(ContextMenu menu) { if (menu == null) return; Skin skin = menu.getSkin(); if (skin == null) return; ContextMenuContent cmContent = (ContextMenuContent)skin.getNode(); if (cmContent == null) return; cmContent.dispose(); // recursive call to dispose submenus. } @Override protected void layoutChildren() { if (itemsContainer.getChildren().size() == 0) return; final double x = snappedLeftInset(); final double y = snappedTopInset(); final double w = getWidth() - x - snappedRightInset(); final double h = getHeight() - y - snappedBottomInset(); final double contentHeight = snapSize(getContentHeight()); // itemsContainer.prefHeight(-1); itemsContainer.resize(w,contentHeight); itemsContainer.relocate(x, y); if (isFirstShow && ty == 0) { upArrow.setVisible(false); isFirstShow = false; } else { upArrow.setVisible(ty < y && ty < 0); } downArrow.setVisible(ty + contentHeight > (y + h)); clipRect.setX(0); clipRect.setY(0); clipRect.setWidth(w); clipRect.setHeight(h); if (upArrow.isVisible()) { final double prefHeight = snapSize(upArrow.prefHeight(-1)); clipRect.setHeight(snapSize(clipRect.getHeight() - prefHeight)); clipRect.setY(snapSize(clipRect.getY()) + prefHeight); upArrow.resize(snapSize(upArrow.prefWidth(-1)), prefHeight); positionInArea(upArrow, x, y, w, prefHeight, /*baseline ignored*/0, HPos.CENTER, VPos.CENTER); } if (downArrow.isVisible()) { final double prefHeight = snapSize(downArrow.prefHeight(-1)); clipRect.setHeight(snapSize(clipRect.getHeight()) - prefHeight); downArrow.resize(snapSize(downArrow.prefWidth(-1)), prefHeight); positionInArea(downArrow, x, (y + h - prefHeight), w, prefHeight, /*baseline ignored*/0, HPos.CENTER, VPos.CENTER); } } @Override protected double computePrefWidth(double height) { computeVisualMetrics(); double prefWidth = 0; if (itemsContainer.getChildren().size() == 0) return 0; for (Node n : itemsContainer.getChildren()) { if (! n.isVisible()) continue; prefWidth = Math.max(prefWidth, snapSize(n.prefWidth(-1))); } return snappedLeftInset() + snapSize(prefWidth) + snappedRightInset(); } @Override protected double computePrefHeight(double width) { if (itemsContainer.getChildren().size() == 0) return 0; final double screenHeight = getScreenHeight(); final double contentHeight = getContentHeight(); // itemsContainer.prefHeight(width); double totalHeight = snappedTopInset() + snapSize(contentHeight) + snappedBottomInset(); // the pref height of this menu is the smaller value of the // actual pref height and the height of the screens _visual_ bounds. double prefHeight = (screenHeight <= 0) ? (totalHeight) : (Math.min(totalHeight, screenHeight)); return prefHeight; } @Override protected double computeMinHeight(double width) { return 0.0; } @Override protected double computeMaxHeight(double height) { return getScreenHeight(); } private double getScreenHeight() { if (contextMenu == null || contextMenu.getOwnerWindow() == null || contextMenu.getOwnerWindow().getScene() == null) { return -1; } return snapSize(com.sun.javafx.util.Utils.getScreen( contextMenu.getOwnerWindow().getScene().getRoot()).getVisualBounds().getHeight()); } private double getContentHeight() { double h = 0.0d; for (Node i : itemsContainer.getChildren()) { if (i.isVisible()) { h += snapSize(i.prefHeight(-1)); } } return h; } // This handles shifting ty when doing keyboard navigation. private void ensureFocusedMenuItemIsVisible(Node node) { if (node == null) return; final Bounds nodeBounds = node.getBoundsInParent(); final Bounds clipBounds = clipRect.getBoundsInParent(); if (nodeBounds.getMaxY() >= clipBounds.getMaxY()) { // this is for moving down the menu scroll(-nodeBounds.getMaxY() + clipBounds.getMaxY()); } else if (nodeBounds.getMinY() <= clipBounds.getMinY()) { // this is for moving up the menu scroll(-nodeBounds.getMinY() + clipBounds.getMinY()); } } protected ObservableList getItems() { return contextMenu.getItems(); } /** * Finds the index of currently focused item. */ private int findFocusedIndex() { for (int i = 0; i < itemsContainer.getChildren().size(); i++) { Node n = itemsContainer.getChildren().get(i); if (n.isFocused()) { return i; } } return -1; } private boolean isFirstShow = true; private double ty; private void initialize() { // keyboard navigation support. Initially focus goes to this ContextMenu, // but when the user first hits the up or down arrow keys, the focus // is transferred to the first or last item respectively. Once this // happens, it is up to the menu items to navigate between themselves. contextMenu.focusedProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { // initialize the focused index for keyboard navigation. currentFocusedIndex = -1; requestFocus(); } }); // RT-19624 calling requestFocus inside layout was casuing repeated layouts. contextMenu.addEventHandler(Menu.ON_SHOWN, event -> { for (Node child : itemsContainer.getChildren()) { if (child instanceof MenuItemContainer) { final MenuItem item = ((MenuItemContainer)child).item; // When the choiceBox popup is shown, if this menu item is selected // do a requestFocus so CSS kicks in and the item is highlighted. if ("choice-box-menu-item".equals(item.getId())) { if (((RadioMenuItem)item).isSelected()) { child.requestFocus(); break; } } } } }); // // FIXME For some reason getSkinnable()Behavior traversal functions don't // // get called as expected, so I've just put the important code below. // We use setOnKeyPressed here as we are not adding a listener to a public // event type (ContextMenuContent is not public API), and without this // we get the issue shown in RT-34429 setOnKeyPressed(new EventHandler() { @Override public void handle(KeyEvent ke) { switch (ke.getCode()) { case LEFT: if (getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { processRightKey(ke); } else { processLeftKey(ke); } break; case RIGHT: if (getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { processLeftKey(ke); } else { processRightKey(ke); } break; case CANCEL: ke.consume(); break; case ESCAPE: // if the owner is not a menubar button, just close the // menu - this will move focus up to the parent menu // as required. In the case of the parent being a // menubar button we special case in the conditional code // beneath this switch statement. See RT-34429 for more context. final Node ownerNode = contextMenu.getOwnerNode(); if (! (ownerNode instanceof MenuBarButton)) { contextMenu.hide(); ke.consume(); } break; case DOWN: // move to the next sibling moveToNextSibling(); ke.consume(); break; case UP: // move to previous sibling moveToPreviousSibling(); ke.consume(); break; case SPACE: case ENTER: // select the menuitem selectMenuItem(); ke.consume(); break; default: break; } if (!ke.isConsumed()) { final Node ownerNode = contextMenu.getOwnerNode(); if (ownerNode instanceof MenuItemContainer) { // Forward to parent menu Parent parent = ownerNode.getParent(); while (parent != null && !(parent instanceof ContextMenuContent)) { parent = parent.getParent(); } if (parent instanceof ContextMenuContent) { parent.getOnKeyPressed().handle(ke); } } else if (ownerNode instanceof MenuBarButton) { // the following code no longer appears necessary, but // leaving in intact for now... // // This is a top-level MenuBar Menu, so forward event to MenuBar // MenuBarSkin mbs = ((MenuBarButton)ownerNode).getMenuBarSkin(); // if (mbs != null && mbs.getKeyEventHandler() != null) { // mbs.getKeyEventHandler().handle(ke); // } } } } }); addEventHandler(ScrollEvent.SCROLL, event -> { /* * we'll only scroll if the arrows are visible in the direction * that we're going, otherwise we go into empty space. */ final double textDeltaY = event.getTextDeltaY(); final double deltaY = event.getDeltaY(); if ((downArrow.isVisible() && (textDeltaY < 0.0 || deltaY < 0.0)) || (upArrow.isVisible() && (textDeltaY > 0.0 || deltaY > 0.0))) { switch(event.getTextDeltaYUnits()) { case LINES: /* ** scroll lines, use the row height of selected row, ** or row 0 if none selected */ int focusedIndex = findFocusedIndex(); if (focusedIndex == -1) { focusedIndex = 0; } double rowHeight = itemsContainer.getChildren().get(focusedIndex).prefHeight(-1); scroll(textDeltaY * rowHeight); break; case PAGES: /* ** page scroll, scroll the menu height */ scroll(textDeltaY * itemsContainer.getHeight()); break; case NONE: /* ** pixel scroll */ scroll(deltaY); break; } event.consume(); } }); } private void processLeftKey(KeyEvent ke) { if (currentFocusedIndex != -1) { Node n = itemsContainer.getChildren().get(currentFocusedIndex); if (n instanceof MenuItemContainer) { MenuItem item = ((MenuItemContainer)n).item; if (item instanceof Menu) { final Menu menu = (Menu) item; // if the submenu for this menu is showing, hide it if (menu == openSubmenu && submenu != null && submenu.isShowing()) { hideSubmenu(); ke.consume(); } } } } } private void processRightKey(KeyEvent ke) { if (currentFocusedIndex != -1) { Node n = itemsContainer.getChildren().get(currentFocusedIndex); if (n instanceof MenuItemContainer) { MenuItem item = ((MenuItemContainer)n).item; if (item instanceof Menu) { final Menu menu = (Menu) item; if (menu.isDisable()) return; selectedBackground = ((MenuItemContainer)n); // RT-15103 // if submenu for this menu is already showing then do nothing // Menubar will process the right key and move to the next menu if (openSubmenu == menu && submenu != null && submenu.isShowing()) { return; } showMenu(menu); ke.consume(); } } } } private void showMenu(Menu menu) { menu.show(); // request focus on the first item of the submenu after it is shown ContextMenuContent cmContent = (ContextMenuContent)submenu.getSkin().getNode(); if (cmContent != null) { if (cmContent.itemsContainer.getChildren().size() > 0) { cmContent.itemsContainer.getChildren().get(0).requestFocus(); cmContent.currentFocusedIndex = 0; } else { cmContent.requestFocus(); } } } private void selectMenuItem() { if (currentFocusedIndex != -1) { Node n = itemsContainer.getChildren().get(currentFocusedIndex); if (n instanceof MenuItemContainer) { MenuItem item = ((MenuItemContainer)n).item; if (item instanceof Menu) { final Menu menu = (Menu) item; if (openSubmenu != null) { hideSubmenu(); } if (menu.isDisable()) return; selectedBackground = ((MenuItemContainer)n); menu.show(); } else { ((MenuItemContainer)n).doSelect(); } } } } /* * Find the index of the next MenuItemContainer in the itemsContainer children. */ private int findNext(int from) { for (int i = from; i < itemsContainer.getChildren().size(); i++) { Node n = itemsContainer.getChildren().get(i); if (n instanceof MenuItemContainer) { return i; } } // find from top for (int i = 0; i < from; i++) { Node n = itemsContainer.getChildren().get(i); if (n instanceof MenuItemContainer) { return i; } } return -1; // should not happen } private void moveToNextSibling() { // If focusedIndex is -1 then start from 0th menu item. // Note that this will cycle through such that when you move to last item, // it will move to 1st item on the next Down key press. if (currentFocusedIndex != -1) { currentFocusedIndex = findNext(currentFocusedIndex + 1); } else if (currentFocusedIndex == -1 || currentFocusedIndex == (itemsContainer.getChildren().size() - 1)) { currentFocusedIndex = findNext(0); } // request focus on the next sibling which currentFocusIndex points to if (currentFocusedIndex != -1) { Node n = itemsContainer.getChildren().get(currentFocusedIndex); selectedBackground = ((MenuItemContainer)n); n.requestFocus(); ensureFocusedMenuItemIsVisible(n); } } /* * Find the index the previous MenuItemContaner in the itemsContainer children. */ private int findPrevious(int from) { for (int i = from; i >= 0; i--) { Node n = itemsContainer.getChildren().get(i); if (n instanceof MenuItemContainer) { return(i); } } for (int i = itemsContainer.getChildren().size() - 1 ; i > from; i--) { Node n = itemsContainer.getChildren().get(i); if (n instanceof MenuItemContainer) { return(i); } } return -1; } private void moveToPreviousSibling() { // If focusedIndex is -1 then start from the last menu item to go up. // Note that this will cycle through such that when you move to first item, // it will move to last item on the next Up key press. if (currentFocusedIndex != -1) { currentFocusedIndex = findPrevious(currentFocusedIndex - 1); } else if(currentFocusedIndex == -1 || currentFocusedIndex == 0) { currentFocusedIndex = findPrevious(itemsContainer.getChildren().size() - 1); } // request focus on the previous sibling which currentFocusIndex points to if (currentFocusedIndex != -1) { Node n = itemsContainer.getChildren().get(currentFocusedIndex); selectedBackground = ((MenuItemContainer)n); n.requestFocus(); ensureFocusedMenuItemIsVisible(n); } } /* * Get the Y offset from the top of the popup to the menu item whose index * is given. */ public double getMenuYOffset(int menuIndex) { double offset = 0; if (itemsContainer.getChildren().size() > menuIndex) { offset = snappedTopInset(); Node menuitem = itemsContainer.getChildren().get(menuIndex); offset += menuitem.getLayoutY() + menuitem.prefHeight(-1); } return offset; } private void setUpBinds() { updateMenuShowingListeners(contextMenu.getItems(), true); contextMenu.getItems().addListener(contextMenuItemsListener); } private void disposeBinds() { updateMenuShowingListeners(contextMenu.getItems(), false); contextMenu.getItems().removeListener(contextMenuItemsListener); } private ChangeListener menuShowingListener = (observable, wasShowing, isShowing) -> { ReadOnlyBooleanProperty isShowingProperty = (ReadOnlyBooleanProperty) observable; Menu menu = (Menu) isShowingProperty.getBean(); if (wasShowing && ! isShowing) { // hide the submenu popup hideSubmenu(); } else if (! wasShowing && isShowing) { // show the submenu popup showSubmenu(menu); } }; private ListChangeListener contextMenuItemsListener = (ListChangeListener) c -> { // Add listeners to the showing property of all menus that have // been added, and remove listeners from menus that have been removed // FIXME this is temporary - we should be adding and removing // listeners such that they use the one listener defined above // - but that can't be done until we have the bean in the // ObservableValue while (c.next()) { updateMenuShowingListeners(c.getRemoved(), false); updateMenuShowingListeners(c.getAddedSubList(), true); } // Listener to items in PopupMenu to update items in PopupMenuContent itemsDirty = true; updateItems(); // RT-29761 }; private ChangeListener menuItemVisibleListener = (observable, oldValue, newValue) -> { // re layout as item's visibility changed requestLayout(); }; private void updateMenuShowingListeners(List items, boolean addListeners) { for (MenuItem item : items) { if (item instanceof Menu) { final Menu menu = (Menu) item; if (addListeners) { menu.showingProperty().addListener(menuShowingListener); } else { menu.showingProperty().removeListener(menuShowingListener); } } // listen to menu items's visible property. if (addListeners) { item.visibleProperty().addListener(menuItemVisibleListener); } else { item.visibleProperty().removeListener(menuItemVisibleListener); } } } // For test purpose only ContextMenu getSubMenu() { return submenu; } Menu getOpenSubMenu() { return openSubmenu; } private void createSubmenu() { if (submenu == null) { submenu = new ContextMenu(); submenu.showingProperty().addListener(new ChangeListener() { @Override public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { if (!submenu.isShowing()) { // Maybe user clicked outside or typed ESCAPE. // Make sure menus are in sync. for (Node node : itemsContainer.getChildren()) { if (node instanceof MenuItemContainer && ((MenuItemContainer)node).item instanceof Menu) { Menu menu = (Menu)((MenuItemContainer)node).item; if (menu.isShowing()) { menu.hide(); } } } } } }); } } private void showSubmenu(Menu menu) { openSubmenu = menu; createSubmenu(); submenu.getItems().setAll(menu.getItems()); submenu.show(selectedBackground, Side.RIGHT, 0, 0); } private void hideSubmenu() { if (submenu == null) return; submenu.hide(); openSubmenu = null; // Fix for RT-37022 - we dispose content so that we do not process CSS // on hidden submenus disposeContextMenu(submenu); submenu = null; } private void hideAllMenus(MenuItem item) { if (contextMenu != null) contextMenu.hide(); Menu parentMenu; while ((parentMenu = item.getParentMenu()) != null) { parentMenu.hide(); item = parentMenu; } if (item.getParentPopup() != null) { item.getParentPopup().hide(); } } private Menu openSubmenu; private ContextMenu submenu; // FIXME: HACKY. We use this so that a submenu knows where to open from // but this will only work for mouse hovers currently - and won't work // programmatically. // package protected for testing only! Region selectedBackground; void scroll(double delta) { double newTy = ty + delta; if (ty == newTy) return; // translation should never be positive (this would mean the top of the // menu content is detaching from the top of the menu!) if (newTy > 0.0) { newTy = 0.0; } // translation should never be greater than the preferred height of the // menu content (otherwise the menu content will be detaching from the // bottom of the menu). // RT-37185: We check the direction of the scroll, to prevent it locking // up when scrolling upwards from the very bottom (using the on-screen // up arrow). if (delta < 0 && (getHeight() - newTy) > itemsContainer.getHeight() - downArrow.getHeight()) { newTy = getHeight() - itemsContainer.getHeight() - downArrow.getHeight(); } ty = newTy; itemsContainer.requestLayout(); } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ @Override public Styleable getStyleableParent() { return contextMenu; } /** @treatAsPrivate */ private static class StyleableProperties { private static final List> STYLEABLES; static { final List> styleables = new ArrayList>(Region.getClassCssMetaData()); // // SkinBase only has Region's unique StlyleableProperty's, none of Nodes // So, we need to add effect back in. The effect property is in a // private inner class, so get the property from Node the hard way. final List> nodeStyleables = Node.getClassCssMetaData(); for(int n=0, max=nodeStyleables.size(); n styleable = nodeStyleables.get(n); if ("effect".equals(styleable.getProperty())) { styleables.add(styleable); break; } } STYLEABLES = Collections.unmodifiableList(styleables); } } /** * @return The CssMetaData associated with this class, which may include the * CssMetaData of its super classes. */ public static List> getClassCssMetaData() { return StyleableProperties.STYLEABLES; } /** * {@inheritDoc} */ @Override public List> getCssMetaData() { return getClassCssMetaData(); } public Label getLabelAt(int index) { return ((MenuItemContainer)itemsContainer.getChildren().get(index)).getLabel(); } /** * Custom VBox to enable scrolling of items. Scrolling effect is achieved by * controlling the translate Y coordinate of the menu item "ty" which is set by a * timeline when mouse is over up/down arrow. */ class MenuBox extends VBox { MenuBox() { setAccessibleRole(AccessibleRole.CONTEXT_MENU); } @Override protected void layoutChildren() { double yOffset = ty; for (Node n : getChildren()) { if (n.isVisible()) { final double prefHeight = snapSize(n.prefHeight(-1)); n.resize(snapSize(getWidth()), prefHeight); n.relocate(snappedLeftInset(), yOffset); yOffset += prefHeight; } } } @Override public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { switch (attribute) { case VISIBLE: return contextMenu.isShowing(); case PARENT_MENU: return contextMenu.getOwnerNode(); default: return super.queryAccessibleAttribute(attribute, parameters); } } } class ArrowMenuItem extends StackPane { private StackPane upDownArrow; private ContextMenuContent popupMenuContent; private boolean up = false; public final boolean isUp() { return up; } public void setUp(boolean value) { up = value; upDownArrow.getStyleClass().setAll(isUp() ? "menu-up-arrow" : "menu-down-arrow"); } // used to automatically scroll through menu items when the user performs // certain interactions, e.g. pressing and holding the arrow buttons private Timeline scrollTimeline; public ArrowMenuItem(ContextMenuContent pmc) { getStyleClass().setAll("scroll-arrow"); upDownArrow = new StackPane(); this.popupMenuContent = pmc; upDownArrow.setMouseTransparent(true); upDownArrow.getStyleClass().setAll(isUp() ? "menu-up-arrow" : "menu-down-arrow"); // setMaxWidth(Math.max(upDownArrow.prefWidth(-1), getWidth())); addEventHandler(MouseEvent.MOUSE_ENTERED, me -> { if (scrollTimeline != null && (scrollTimeline.getStatus() != Status.STOPPED)) { return; } startTimeline(); }); addEventHandler(MouseEvent.MOUSE_EXITED, me -> { stopTimeline(); }); setVisible(false); setManaged(false); getChildren().add(upDownArrow); } @Override protected double computePrefWidth(double height) { // return snapSize(getInsets().getLeft()) + snapSize(getInsets().getRight()); return itemsContainer.getWidth(); } @Override protected double computePrefHeight(double width) { return snappedTopInset() + upDownArrow.prefHeight(-1) + snappedBottomInset(); } @Override protected void layoutChildren() { double w = snapSize(upDownArrow.prefWidth(-1)); double h = snapSize(upDownArrow.prefHeight(-1)); upDownArrow.resize(w, h); positionInArea(upDownArrow, 0, 0, getWidth(), getHeight(), /*baseline ignored*/0, HPos.CENTER, VPos.CENTER); } private void adjust() { if(up) popupMenuContent.scroll(12); else popupMenuContent.scroll(-12); } private void startTimeline() { scrollTimeline = new Timeline(); scrollTimeline.setCycleCount(Timeline.INDEFINITE); KeyFrame kf = new KeyFrame( Duration.millis(60), event -> { adjust(); } ); scrollTimeline.getKeyFrames().clear(); scrollTimeline.getKeyFrames().add(kf); scrollTimeline.play(); } private void stopTimeline() { scrollTimeline.stop(); scrollTimeline = null; } } /* * Container responsible for laying out a single row in the menu - in other * words, this contains and lays out a single MenuItem, regardless of it's * specific subtype. */ public class MenuItemContainer extends Region { private final MenuItem item; private Node left; private Node graphic; private Node label; private Node right; private final LambdaMultiplePropertyChangeListenerHandler listener = new LambdaMultiplePropertyChangeListenerHandler(); private EventHandler mouseEnteredEventHandler; private EventHandler mouseReleasedEventHandler; private EventHandler actionEventHandler; protected Label getLabel(){ return (Label) label; } public MenuItem getItem() { return item; } public MenuItemContainer(MenuItem item){ if (item == null) { throw new NullPointerException("MenuItem can not be null"); } getStyleClass().addAll(item.getStyleClass()); setId(item.getId()); setFocusTraversable(!(item instanceof CustomMenuItem)); this.item = item; createChildren(); // listen to changes in the state of certain MenuItem types ReadOnlyBooleanProperty pseudoProperty; if (item instanceof Menu) { pseudoProperty = ((Menu)item).showingProperty(); listener.registerChangeListener(pseudoProperty, e -> pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, ((Menu) item).isShowing())); pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, pseudoProperty.get()); setAccessibleRole(AccessibleRole.MENU); } else if (item instanceof RadioMenuItem) { pseudoProperty = ((RadioMenuItem)item).selectedProperty(); listener.registerChangeListener(pseudoProperty, e -> pseudoClassStateChanged(CHECKED_PSEUDOCLASS_STATE, ((RadioMenuItem) item).isSelected())); pseudoClassStateChanged(CHECKED_PSEUDOCLASS_STATE, pseudoProperty.get()); setAccessibleRole(AccessibleRole.RADIO_MENU_ITEM); } else if (item instanceof CheckMenuItem) { pseudoProperty = ((CheckMenuItem)item).selectedProperty(); listener.registerChangeListener(pseudoProperty, e -> pseudoClassStateChanged(CHECKED_PSEUDOCLASS_STATE, ((CheckMenuItem) item).isSelected())); pseudoClassStateChanged(CHECKED_PSEUDOCLASS_STATE, pseudoProperty.get()); setAccessibleRole(AccessibleRole.CHECK_MENU_ITEM); } else { setAccessibleRole(AccessibleRole.MENU_ITEM); } pseudoClassStateChanged(DISABLED_PSEUDOCLASS_STATE, item.disableProperty().get()); listener.registerChangeListener(item.disableProperty(), e -> pseudoClassStateChanged(DISABLED_PSEUDOCLASS_STATE, item.isDisable())); // Add the menu item to properties map of this node. Used by QA for testing // This allows associating this container with corresponding MenuItem. getProperties().put(MenuItem.class, item); listener.registerChangeListener(item.graphicProperty(), e -> { createChildren(); computeVisualMetrics(); }); actionEventHandler = e -> { if (item instanceof Menu) { final Menu menu = (Menu) item; if (openSubmenu == menu && submenu.isShowing()) return; if (openSubmenu != null) { hideSubmenu(); } selectedBackground = MenuItemContainer.this; showMenu(menu); } else { doSelect(); } }; addEventHandler(ActionEvent.ACTION, actionEventHandler); } public void dispose() { if (item instanceof CustomMenuItem) { Node node = ((CustomMenuItem)item).getContent(); if (node != null) { node.removeEventHandler(MouseEvent.MOUSE_CLICKED, customMenuItemMouseClickedHandler); } } listener.dispose(); removeEventHandler(ActionEvent.ACTION, actionEventHandler); if (label != null) { ((Label)label).textProperty().unbind(); } left = null; graphic = null; label = null; right = null; } private void createChildren() { getChildren().clear(); // draw background region for hover effects. All content (other // than Nodes from NodeMenuItems) are set to be mouseTransparent, so // this background also acts as the receiver of user input if (item instanceof CustomMenuItem) { createNodeMenuItemChildren((CustomMenuItem)item); if (mouseEnteredEventHandler == null) { mouseEnteredEventHandler = event -> { requestFocus(); // request Focus on hover }; } else { removeEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); } addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); } else { // --- add check / radio to left column Node leftNode = getLeftGraphic(item); if (leftNode != null) { StackPane leftPane = new StackPane(); leftPane.getStyleClass().add("left-container"); leftPane.getChildren().add(leftNode); left = leftPane; getChildren().add(left); left.setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); } // -- add graphic to graphic pane if (item.getGraphic() != null) { Node graphicNode = item.getGraphic(); StackPane graphicPane = new StackPane(); graphicPane.getStyleClass().add("graphic-container"); graphicPane.getChildren().add(graphicNode); graphic = graphicPane; getChildren().add(graphic); } // --- add text to center column label = new MenuLabel(item, this); // make this a menulabel to handle mnemonics fire() label.setStyle(item.getStyle()); // bind to text property in menu item ((Label)label).textProperty().bind(item.textProperty()); label.setMouseTransparent(true); getChildren().add(label); listener.unregisterChangeListener(focusedProperty()); // RT-19546 update currentFocusedIndex when MenuItemContainer gets focused. // e.g this happens when you press the Right key to open a submenu; the first // menuitem is focused. listener.registerChangeListener(focusedProperty(), e -> { if (isFocused()) { currentFocusedIndex = itemsContainer.getChildren().indexOf(MenuItemContainer.this); } }); // --- draw in right column - this depends on whether we are // a Menu or not. A Menu gets an arrow, whereas other MenuItems // get the ability to draw an accelerator if (item instanceof Menu) { // --- add arrow / accelerator / mnemonic to right column Region rightNode = new Region(); rightNode.setMouseTransparent(true); rightNode.getStyleClass().add("arrow"); StackPane rightPane = new StackPane(); rightPane.setMaxWidth(Math.max(rightNode.prefWidth(-1), 10)); rightPane.setMouseTransparent(true); rightPane.getStyleClass().add("right-container"); rightPane.getChildren().add(rightNode); right = rightPane; getChildren().add(rightPane); if (mouseEnteredEventHandler == null) { mouseEnteredEventHandler = event -> { if (openSubmenu != null && item != openSubmenu) { // if a submenu of a different menu is already // open then close it (RT-15049) hideSubmenu(); } final Menu menu = (Menu) item; if (menu.isDisable()) return; selectedBackground = MenuItemContainer.this; menu.show(); requestFocus(); // request Focus on hover }; } else { removeEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); } if (mouseReleasedEventHandler == null) { mouseReleasedEventHandler = event -> { item.fire(); }; } else { removeEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); } // show submenu when the menu is hovered over addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); addEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); } else { // normal MenuItem // remove old listeners listener.unregisterChangeListener(item.acceleratorProperty()); // accelerator support updateAccelerator(); if (mouseEnteredEventHandler == null) { mouseEnteredEventHandler = event -> { if (openSubmenu != null) { openSubmenu.hide(); } requestFocus(); // request Focus on hover }; } else { removeEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); } if (mouseReleasedEventHandler == null) { mouseReleasedEventHandler = event -> { doSelect(); }; } else { removeEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); } addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); addEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); listener.registerChangeListener(item.acceleratorProperty(), e -> updateAccelerator()); } } } private void updateAccelerator() { if (item.getAccelerator() != null) { if (right != null) { getChildren().remove(right); } String text = item.getAccelerator().getDisplayText(); right = new Label(text); right.setStyle(item.getStyle()); right.getStyleClass().add("accelerator-text"); getChildren().add(right); } else { getChildren().remove(right); } } void doSelect() { // don't do anything on disabled menu items if (item.isDisable()) return; // toggle state of check or radio items if (item instanceof CheckMenuItem) { CheckMenuItem checkItem = (CheckMenuItem)item; checkItem.setSelected(!checkItem.isSelected()); } else if (item instanceof RadioMenuItem) { // this is a radio button. If there is a toggleGroup specified, we // simply set selected to true. If no toggleGroup is specified, we // toggle the selected state, as there is no assumption of mutual // exclusivity when no toggleGroup is set. final RadioMenuItem radioItem = (RadioMenuItem) item; radioItem.setSelected(radioItem.getToggleGroup() != null ? true : !radioItem.isSelected()); } // fire the action before hiding the menu item.fire(); if (item instanceof CustomMenuItem) { CustomMenuItem customMenuItem = (CustomMenuItem) item; if (customMenuItem.isHideOnClick()) { hideAllMenus(item); } } else { hideAllMenus(item); } } private EventHandler customMenuItemMouseClickedHandler; private void createNodeMenuItemChildren(final CustomMenuItem item) { Node node = item.getContent(); getChildren().add(node); // handle hideOnClick customMenuItemMouseClickedHandler = event -> { if (item == null || item.isDisable()) return; item.fire(); if (item.isHideOnClick()) { hideAllMenus(item); } }; node.addEventHandler(MouseEvent.MOUSE_CLICKED, customMenuItemMouseClickedHandler); } @Override protected void layoutChildren() { double xOffset; final double prefHeight = prefHeight(-1); if (left != null) { xOffset = snappedLeftInset(); left.resize(left.prefWidth(-1), left.prefHeight(-1)); positionInArea(left, xOffset, 0, maxLeftWidth, prefHeight, 0, HPos.LEFT, VPos.CENTER); } if (graphic != null) { xOffset = snappedLeftInset() + maxLeftWidth; graphic.resize(graphic.prefWidth(-1), graphic.prefHeight(-1)); positionInArea(graphic, xOffset, 0, maxGraphicWidth, prefHeight, 0, HPos.LEFT, VPos.CENTER); } if (label != null) { xOffset = snappedLeftInset() + maxLeftWidth + maxGraphicWidth; label.resize(label.prefWidth(-1), label.prefHeight(-1)); positionInArea(label, xOffset, 0, maxLabelWidth, prefHeight, 0, HPos.LEFT, VPos.CENTER); } if (right != null) { xOffset = snappedLeftInset() + maxLeftWidth + maxGraphicWidth + maxLabelWidth; right.resize(right.prefWidth(-1), right.prefHeight(-1)); positionInArea(right, xOffset, 0, maxRightWidth, prefHeight, 0, HPos.RIGHT, VPos.CENTER); } if ( item instanceof CustomMenuItem) { Node n = ((CustomMenuItem) item).getContent(); if (item instanceof SeparatorMenuItem) { double width = prefWidth(-1) - (snappedLeftInset() + maxGraphicWidth + snappedRightInset()); n.resize(width, n.prefHeight(-1)); positionInArea(n, snappedLeftInset() + maxGraphicWidth, 0, prefWidth(-1), prefHeight, 0, HPos.LEFT, VPos.CENTER); } else { n.resize(n.prefWidth(-1), n.prefHeight(-1)); //the node should be left aligned positionInArea(n, snappedLeftInset(), 0, getWidth(), prefHeight, 0, HPos.LEFT, VPos.CENTER); } } } @Override protected double computePrefHeight(double width) { double prefHeight = 0; if (item instanceof CustomMenuItem || item instanceof SeparatorMenuItem) { prefHeight = (getChildren().isEmpty()) ? 0 : getChildren().get(0).prefHeight(-1); } else { prefHeight = Math.max(prefHeight, (left != null) ? left.prefHeight(-1) : 0); prefHeight = Math.max(prefHeight, (graphic != null) ? graphic.prefHeight(-1) : 0); prefHeight = Math.max(prefHeight, (label != null) ? label.prefHeight(-1) : 0); prefHeight = Math.max(prefHeight, (right != null) ? right.prefHeight(-1) : 0); } return snappedTopInset() + prefHeight + snappedBottomInset(); } @Override protected double computePrefWidth(double height) { double nodeMenuItemWidth = 0; if (item instanceof CustomMenuItem && !(item instanceof SeparatorMenuItem)) { nodeMenuItemWidth = snappedLeftInset() + ((CustomMenuItem) item).getContent().prefWidth(-1) + snappedRightInset(); } return Math.max(nodeMenuItemWidth, snappedLeftInset() + maxLeftWidth + maxGraphicWidth + maxLabelWidth + maxRightWidth + snappedRightInset()); } // Responsible for returning a graphic (if necessary) to position in the // left column of the menu. This may be a Node from the MenuItem.graphic // property, or it may be a check/radio item if necessary. private Node getLeftGraphic(MenuItem item) { if (item instanceof RadioMenuItem) { final Region _graphic = new Region(); _graphic.getStyleClass().add("radio"); return _graphic; } else if (item instanceof CheckMenuItem) { final StackPane _graphic = new StackPane(); _graphic.getStyleClass().add("check"); return _graphic; } return null; } @Override public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { switch (attribute) { case SELECTED: if (item instanceof CheckMenuItem) { return ((CheckMenuItem)item).isSelected(); } if (item instanceof RadioMenuItem) { return ((RadioMenuItem) item).isSelected(); } return false; case ACCELERATOR: return item.getAccelerator(); case TEXT: { String title = ""; if (graphic != null) { String t = (String)graphic.queryAccessibleAttribute(AccessibleAttribute.TEXT); if (t != null) title += t; } final Label label = getLabel(); if (label != null) { String t = (String)label.queryAccessibleAttribute(AccessibleAttribute.TEXT); if (t != null) title += t; } if (item instanceof CustomMenuItem) { Node content = ((CustomMenuItem) item).getContent(); if (content != null) { String t = (String)content.queryAccessibleAttribute(AccessibleAttribute.TEXT); if (t != null) title += t; } } return title; } case MNEMONIC: { final Label label = getLabel(); if (label != null) { String mnemonic = (String)label.queryAccessibleAttribute(AccessibleAttribute.MNEMONIC); if (mnemonic != null) return mnemonic; } return null; } case DISABLED: return item.isDisable(); case SUBMENU: createSubmenu(); // Accessibility might need to see the menu node before the window // is visible (i.e. before the skin is applied). if (submenu.getSkin() == null) { submenu.impl_styleableGetNode().impl_processCSS(true); } ContextMenuContent cmContent = (ContextMenuContent)submenu.getSkin().getNode(); return cmContent.itemsContainer; default: return super.queryAccessibleAttribute(attribute, parameters); } } @Override public void executeAccessibleAction(AccessibleAction action, Object... parameters) { switch (action) { case SHOW_MENU:{ if (item instanceof Menu) { final Menu menuItem = (Menu) item; if (menuItem.isShowing()) { menuItem.hide(); } else { menuItem.show(); } } break; } case FIRE: doSelect(); break; default: super.executeAccessibleAction(action); } } } private static final PseudoClass SELECTED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("selected"); private static final PseudoClass DISABLED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("disabled"); private static final PseudoClass CHECKED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("checked"); private class MenuLabel extends Label { public MenuLabel(MenuItem item, MenuItemContainer mic) { super(item.getText()); setMnemonicParsing(item.isMnemonicParsing()); setLabelFor(mic); } } }