/* ============================================================================== This file is part of the JUCE library. Copyright (c) 2017 - ROLI Ltd. JUCE is an open source library subject to commercial or open-source licensing. By using JUCE, you agree to the terms of both the JUCE 5 End-User License Agreement and JUCE 5 Privacy Policy (both updated and effective as of the 27th April 2017). End User License Agreement: www.juce.com/juce-5-licence Privacy Policy: www.juce.com/juce-5-privacy-policy Or: You may also use this code under the terms of the GPL v3 (see www.gnu.org/licenses). JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE DISCLAIMED. ============================================================================== */ namespace PopupMenuSettings { const int scrollZone = 24; const int borderSize = 2; const int dismissCommandId = 0x6287345f; static bool menuWasHiddenBecauseOfAppChange = false; } //============================================================================== struct PopupMenu::HelperClasses { class MouseSourceState; class MenuWindow; static bool canBeTriggered (const PopupMenu::Item& item) noexcept { return item.isEnabled && item.itemID != 0 && ! item.isSectionHeader; } static bool hasActiveSubMenu (const PopupMenu::Item& item) noexcept { return item.isEnabled && item.subMenu != nullptr && item.subMenu->items.size() > 0; } static const Colour* getColour (const PopupMenu::Item& item) noexcept { return item.colour != Colour (0x00000000) ? &item.colour : nullptr; } static bool hasSubMenu (const PopupMenu::Item& item) noexcept { return item.subMenu != nullptr && (item.itemID == 0 || item.subMenu->getNumItems() > 0); } //============================================================================== struct HeaderItemComponent : public PopupMenu::CustomComponent { HeaderItemComponent (const String& name) : PopupMenu::CustomComponent (false) { setName (name); } void paint (Graphics& g) override { getLookAndFeel().drawPopupMenuSectionHeader (g, getLocalBounds(), getName()); } void getIdealSize (int& idealWidth, int& idealHeight) override { getLookAndFeel().getIdealPopupMenuItemSize (getName(), false, -1, idealWidth, idealHeight); idealHeight += idealHeight / 2; idealWidth += idealWidth / 4; } JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HeaderItemComponent) }; //============================================================================== struct ItemComponent : public Component { ItemComponent (const PopupMenu::Item& i, int standardItemHeight, MenuWindow& parent) : item (i), customComp (i.customComponent), isHighlighted (false) { if (item.isSectionHeader) customComp = new HeaderItemComponent (item.text); addAndMakeVisible (customComp); parent.addAndMakeVisible (this); updateShortcutKeyDescription(); int itemW = 80; int itemH = 16; getIdealSize (itemW, itemH, standardItemHeight); setSize (itemW, jlimit (2, 600, itemH)); addMouseListener (&parent, false); } ~ItemComponent() { removeChildComponent (customComp); } void getIdealSize (int& idealWidth, int& idealHeight, const int standardItemHeight) { if (customComp != nullptr) customComp->getIdealSize (idealWidth, idealHeight); else getLookAndFeel().getIdealPopupMenuItemSize (getTextForMeasurement(), item.isSeparator, standardItemHeight, idealWidth, idealHeight); } void paint (Graphics& g) override { if (customComp == nullptr) getLookAndFeel().drawPopupMenuItem (g, getLocalBounds(), item.isSeparator, item.isEnabled, isHighlighted, item.isTicked, hasSubMenu (item), item.text, item.shortcutKeyDescription, item.image, getColour (item)); } void resized() override { if (Component* const child = getChildComponent (0)) child->setBounds (getLocalBounds().reduced (2, 0)); } void setHighlighted (bool shouldBeHighlighted) { shouldBeHighlighted = shouldBeHighlighted && item.isEnabled; if (isHighlighted != shouldBeHighlighted) { isHighlighted = shouldBeHighlighted; if (customComp != nullptr) customComp->setHighlighted (shouldBeHighlighted); repaint(); } } PopupMenu::Item item; private: // NB: we use a copy of the one from the item info in case we're using our own section comp ReferenceCountedObjectPtr customComp; bool isHighlighted; void updateShortcutKeyDescription() { if (item.commandManager != nullptr && item.itemID != 0 && item.shortcutKeyDescription.isEmpty()) { String shortcutKey; const Array keyPresses (item.commandManager->getKeyMappings() ->getKeyPressesAssignedToCommand (item.itemID)); for (int i = 0; i < keyPresses.size(); ++i) { const String key (keyPresses.getReference (i).getTextDescriptionWithIcons()); if (shortcutKey.isNotEmpty()) shortcutKey << ", "; if (key.length() == 1 && key[0] < 128) shortcutKey << "shortcut: '" << key << '\''; else shortcutKey << key; } item.shortcutKeyDescription = shortcutKey.trim(); } } String getTextForMeasurement() const { return item.shortcutKeyDescription.isNotEmpty() ? item.text + " " + item.shortcutKeyDescription : item.text; } JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ItemComponent) }; //============================================================================== class MenuWindow : public Component { public: MenuWindow (const PopupMenu& menu, MenuWindow* const parentWindow, const Options& opts, const bool alignToRectangle, const bool shouldDismissOnMouseUp, ApplicationCommandManager** const manager) : Component ("menu"), parent (parentWindow), options (opts), managerOfChosenCommand (manager), componentAttachedTo (options.targetComponent), parentComponent (nullptr), hasBeenOver (false), needsToScroll (false), dismissOnMouseUp (shouldDismissOnMouseUp), hideOnExit (false), disableMouseMoves (false), hasAnyJuceCompHadFocus (false), numColumns (0), contentHeight (0), childYOffset (0), windowCreationTime (Time::getMillisecondCounter()), lastFocusedTime (windowCreationTime), timeEnteredCurrentChildComp (windowCreationTime) { setWantsKeyboardFocus (false); setMouseClickGrabsKeyboardFocus (false); setAlwaysOnTop (true); setLookAndFeel (parent != nullptr ? &(parent->getLookAndFeel()) : menu.lookAndFeel.get()); LookAndFeel& lf = getLookAndFeel(); parentComponent = lf.getParentComponentForMenuOptions (options); setOpaque (lf.findColour (PopupMenu::backgroundColourId).isOpaque() || ! Desktop::canUseSemiTransparentWindows()); for (int i = 0; i < menu.items.size(); ++i) { PopupMenu::Item* const item = menu.items.getUnchecked (i); if (i < menu.items.size() - 1 || ! item->isSeparator) items.add (new ItemComponent (*item, options.standardHeight, *this)); } calculateWindowPos (options.targetArea, alignToRectangle); setTopLeftPosition (windowPos.getPosition()); updateYPositions(); if (options.visibleItemID != 0) { const Point targetPosition = (parentComponent != nullptr ? parentComponent->getLocalPoint (nullptr, options.targetArea.getTopLeft()) : options.targetArea.getTopLeft()); const int y = targetPosition.getY() - windowPos.getY(); ensureItemIsVisible (options.visibleItemID, isPositiveAndBelow (y, windowPos.getHeight()) ? y : -1); } resizeToBestWindowPos(); if (parentComponent != nullptr) { parentComponent->addChildComponent (this); } else { addToDesktop (ComponentPeer::windowIsTemporary | ComponentPeer::windowIgnoresKeyPresses | lf.getMenuWindowFlags()); getActiveWindows().add (this); Desktop::getInstance().addGlobalMouseListener (this); } lf.preparePopupMenuWindow (*this); } ~MenuWindow() { getActiveWindows().removeFirstMatchingValue (this); Desktop::getInstance().removeGlobalMouseListener (this); activeSubMenu = nullptr; items.clear(); } //============================================================================== void paint (Graphics& g) override { if (isOpaque()) g.fillAll (Colours::white); getLookAndFeel().drawPopupMenuBackground (g, getWidth(), getHeight()); } void paintOverChildren (Graphics& g) override { LookAndFeel& lf = getLookAndFeel(); if (parentComponent != nullptr) lf.drawResizableFrame (g, getWidth(), getHeight(), BorderSize (PopupMenuSettings::borderSize)); if (canScroll()) { if (isTopScrollZoneActive()) lf.drawPopupMenuUpDownArrow (g, getWidth(), PopupMenuSettings::scrollZone, true); if (isBottomScrollZoneActive()) { g.setOrigin (0, getHeight() - PopupMenuSettings::scrollZone); lf.drawPopupMenuUpDownArrow (g, getWidth(), PopupMenuSettings::scrollZone, false); } } } //============================================================================== // hide this and all sub-comps void hide (const PopupMenu::Item* const item, const bool makeInvisible) { if (isVisible()) { WeakReference deletionChecker (this); activeSubMenu = nullptr; currentChild = nullptr; if (item != nullptr && item->commandManager != nullptr && item->itemID != 0) { *managerOfChosenCommand = item->commandManager; } exitModalState (getResultItemID (item)); if (makeInvisible && (deletionChecker != nullptr)) setVisible (false); } } static int getResultItemID (const PopupMenu::Item* item) { if (item == nullptr) return 0; if (CustomCallback* cc = item->customCallback) if (! cc->menuItemTriggered()) return 0; return item->itemID; } void dismissMenu (const PopupMenu::Item* const item) { if (parent != nullptr) { parent->dismissMenu (item); } else { if (item != nullptr) { // need a copy of this on the stack as the one passed in will get deleted during this call const PopupMenu::Item mi (*item); hide (&mi, false); } else { hide (nullptr, false); } } } //============================================================================== bool keyPressed (const KeyPress& key) override { if (key.isKeyCode (KeyPress::downKey)) { selectNextItem (1); } else if (key.isKeyCode (KeyPress::upKey)) { selectNextItem (-1); } else if (key.isKeyCode (KeyPress::leftKey)) { if (parent != nullptr) { Component::SafePointer parentWindow (parent); ItemComponent* currentChildOfParent = parentWindow->currentChild; hide (nullptr, true); if (parentWindow != nullptr) parentWindow->setCurrentlyHighlightedChild (currentChildOfParent); disableTimerUntilMouseMoves(); } else if (componentAttachedTo != nullptr) { componentAttachedTo->keyPressed (key); } } else if (key.isKeyCode (KeyPress::rightKey)) { disableTimerUntilMouseMoves(); if (showSubMenuFor (currentChild)) { if (isSubMenuVisible()) activeSubMenu->selectNextItem (1); } else if (componentAttachedTo != nullptr) { componentAttachedTo->keyPressed (key); } } else if (key.isKeyCode (KeyPress::returnKey)) { triggerCurrentlyHighlightedItem(); } else if (key.isKeyCode (KeyPress::escapeKey)) { dismissMenu (nullptr); } else { return false; } return true; } void inputAttemptWhenModal() override { WeakReference deletionChecker (this); for (int i = mouseSourceStates.size(); --i >= 0;) { mouseSourceStates.getUnchecked (i)->timerCallback(); if (deletionChecker == nullptr) return; } if (! isOverAnyMenu()) { if (componentAttachedTo != nullptr) { // we want to dismiss the menu, but if we do it synchronously, then // the mouse-click will be allowed to pass through. That's good, except // when the user clicks on the button that originally popped the menu up, // as they'll expect the menu to go away, and in fact it'll just // come back. So only dismiss synchronously if they're not on the original // comp that we're attached to. const Point mousePos (componentAttachedTo->getMouseXYRelative()); if (componentAttachedTo->reallyContains (mousePos, true)) { postCommandMessage (PopupMenuSettings::dismissCommandId); // dismiss asynchrounously return; } } dismissMenu (nullptr); } } void handleCommandMessage (int commandId) override { Component::handleCommandMessage (commandId); if (commandId == PopupMenuSettings::dismissCommandId) dismissMenu (nullptr); } //============================================================================== void mouseMove (const MouseEvent& e) override { handleMouseEvent (e); } void mouseDown (const MouseEvent& e) override { handleMouseEvent (e); } void mouseDrag (const MouseEvent& e) override { handleMouseEvent (e); } void mouseUp (const MouseEvent& e) override { handleMouseEvent (e); } void mouseWheelMove (const MouseEvent&, const MouseWheelDetails& wheel) override { alterChildYPos (roundToInt (-10.0f * wheel.deltaY * PopupMenuSettings::scrollZone)); } void handleMouseEvent (const MouseEvent& e) { getMouseState (e.source).handleMouseEvent (e); } bool windowIsStillValid() { if (! isVisible()) return false; if (componentAttachedTo != options.targetComponent) { dismissMenu (nullptr); return false; } if (MenuWindow* currentlyModalWindow = dynamic_cast (Component::getCurrentlyModalComponent())) if (! treeContains (currentlyModalWindow)) return false; return true; } static Array& getActiveWindows() { static Array activeMenuWindows; return activeMenuWindows; } MouseSourceState& getMouseState (MouseInputSource source) { for (int i = mouseSourceStates.size(); --i >= 0;) { MouseSourceState& ms = *mouseSourceStates.getUnchecked (i); if (ms.source == source) return ms; } MouseSourceState* ms = new MouseSourceState (*this, source); mouseSourceStates.add (ms); return *ms; } //============================================================================== bool isOverAnyMenu() const { return parent != nullptr ? parent->isOverAnyMenu() : isOverChildren(); } bool isOverChildren() const { return isVisible() && (isAnyMouseOver() || (activeSubMenu != nullptr && activeSubMenu->isOverChildren())); } bool isAnyMouseOver() const { for (int i = 0; i < mouseSourceStates.size(); ++i) if (mouseSourceStates.getUnchecked (i)->isOver()) return true; return false; } bool treeContains (const MenuWindow* const window) const noexcept { const MenuWindow* mw = this; while (mw->parent != nullptr) mw = mw->parent; while (mw != nullptr) { if (mw == window) return true; mw = mw->activeSubMenu; } return false; } bool doesAnyJuceCompHaveFocus() { bool anyFocused = Process::isForegroundProcess(); if (anyFocused && Component::getCurrentlyFocusedComponent() == nullptr) { // because no component at all may have focus, our test here will // only be triggered when something has focus and then loses it. anyFocused = ! hasAnyJuceCompHadFocus; for (int i = ComponentPeer::getNumPeers(); --i >= 0;) { if (ComponentPeer::getPeer (i)->isFocused()) { anyFocused = true; hasAnyJuceCompHadFocus = true; break; } } } return anyFocused; } //============================================================================== Rectangle getParentArea (Point targetPoint) { Rectangle parentArea (Desktop::getInstance().getDisplays() .getDisplayContaining (targetPoint) #if JUCE_MAC .userArea); #else .totalArea); // on windows, don't stop the menu overlapping the taskbar #endif if (parentComponent == nullptr) return parentArea; return parentComponent->getLocalArea (nullptr, parentComponent->getScreenBounds() .reduced (PopupMenuSettings::borderSize) .getIntersection (parentArea)); } void calculateWindowPos (Rectangle target, const bool alignToRectangle) { const Rectangle parentArea = getParentArea (target.getCentre()); if (parentComponent != nullptr) target = parentComponent->getLocalArea (nullptr, target).getIntersection (parentArea); const int maxMenuHeight = parentArea.getHeight() - 24; int x, y, widthToUse, heightToUse; layoutMenuItems (parentArea.getWidth() - 24, maxMenuHeight, widthToUse, heightToUse); if (alignToRectangle) { x = target.getX(); const int spaceUnder = parentArea.getHeight() - (target.getBottom() - parentArea.getY()); const int spaceOver = target.getY() - parentArea.getY(); if (heightToUse < spaceUnder - 30 || spaceUnder >= spaceOver) y = target.getBottom(); else y = target.getY() - heightToUse; } else { bool tendTowardsRight = target.getCentreX() < parentArea.getCentreX(); if (parent != nullptr) { if (parent->parent != nullptr) { const bool parentGoingRight = (parent->getX() + parent->getWidth() / 2 > parent->parent->getX() + parent->parent->getWidth() / 2); if (parentGoingRight && target.getRight() + widthToUse < parentArea.getRight() - 4) tendTowardsRight = true; else if ((! parentGoingRight) && target.getX() > widthToUse + 4) tendTowardsRight = false; } else if (target.getRight() + widthToUse < parentArea.getRight() - 32) { tendTowardsRight = true; } } const int biggestSpace = jmax (parentArea.getRight() - target.getRight(), target.getX() - parentArea.getX()) - 32; if (biggestSpace < widthToUse) { layoutMenuItems (biggestSpace + target.getWidth() / 3, maxMenuHeight, widthToUse, heightToUse); if (numColumns > 1) layoutMenuItems (biggestSpace - 4, maxMenuHeight, widthToUse, heightToUse); tendTowardsRight = (parentArea.getRight() - target.getRight()) >= (target.getX() - parentArea.getX()); } if (tendTowardsRight) x = jmin (parentArea.getRight() - widthToUse - 4, target.getRight()); else x = jmax (parentArea.getX() + 4, target.getX() - widthToUse); y = target.getY(); if (target.getCentreY() > parentArea.getCentreY()) y = jmax (parentArea.getY(), target.getBottom() - heightToUse); } x = jmax (parentArea.getX() + 1, jmin (parentArea.getRight() - (widthToUse + 6), x)); y = jmax (parentArea.getY() + 1, jmin (parentArea.getBottom() - (heightToUse + 6), y)); windowPos.setBounds (x, y, widthToUse, heightToUse); // sets this flag if it's big enough to obscure any of its parent menus hideOnExit = parent != nullptr && parent->windowPos.intersects (windowPos.expanded (-4, -4)); } void layoutMenuItems (const int maxMenuW, const int maxMenuH, int& width, int& height) { numColumns = 0; contentHeight = 0; int totalW; const int maximumNumColumns = options.maxColumns > 0 ? options.maxColumns : 7; do { ++numColumns; totalW = workOutBestSize (maxMenuW); if (totalW > maxMenuW) { numColumns = jmax (1, numColumns - 1); workOutBestSize (maxMenuW); // to update col widths break; } else if (totalW > maxMenuW / 2 || contentHeight < maxMenuH) { break; } } while (numColumns < maximumNumColumns); const int actualH = jmin (contentHeight, maxMenuH); needsToScroll = contentHeight > actualH; width = updateYPositions(); height = actualH + PopupMenuSettings::borderSize * 2; } int workOutBestSize (const int maxMenuW) { int totalW = 0; contentHeight = 0; int childNum = 0; for (int col = 0; col < numColumns; ++col) { int colW = options.standardHeight, colH = 0; const int numChildren = jmin (items.size() - childNum, (items.size() + numColumns - 1) / numColumns); for (int i = numChildren; --i >= 0;) { colW = jmax (colW, items.getUnchecked (childNum + i)->getWidth()); colH += items.getUnchecked (childNum + i)->getHeight(); } colW = jmin (maxMenuW / jmax (1, numColumns - 2), colW + PopupMenuSettings::borderSize * 2); columnWidths.set (col, colW); totalW += colW; contentHeight = jmax (contentHeight, colH); childNum += numChildren; } // width must never be larger than the screen const int minWidth = jmin (maxMenuW, options.minWidth); if (totalW < minWidth) { totalW = minWidth; for (int col = 0; col < numColumns; ++col) columnWidths.set (0, totalW / numColumns); } return totalW; } void ensureItemIsVisible (const int itemID, int wantedY) { jassert (itemID != 0); for (int i = items.size(); --i >= 0;) { if (ItemComponent* const m = items.getUnchecked (i)) { if (m->item.itemID == itemID && windowPos.getHeight() > PopupMenuSettings::scrollZone * 4) { const int currentY = m->getY(); if (wantedY > 0 || currentY < 0 || m->getBottom() > windowPos.getHeight()) { if (wantedY < 0) wantedY = jlimit (PopupMenuSettings::scrollZone, jmax (PopupMenuSettings::scrollZone, windowPos.getHeight() - (PopupMenuSettings::scrollZone + m->getHeight())), currentY); const Rectangle parantArea = getParentArea (windowPos.getPosition()); int deltaY = wantedY - currentY; windowPos.setSize (jmin (windowPos.getWidth(), parantArea.getWidth()), jmin (windowPos.getHeight(), parantArea.getHeight())); const int newY = jlimit (parantArea.getY(), parantArea.getBottom() - windowPos.getHeight(), windowPos.getY() + deltaY); deltaY -= newY - windowPos.getY(); childYOffset -= deltaY; windowPos.setPosition (windowPos.getX(), newY); updateYPositions(); } break; } } } } void resizeToBestWindowPos() { Rectangle r (windowPos); if (childYOffset < 0) { r = r.withTop (r.getY() - childYOffset); } else if (childYOffset > 0) { const int spaceAtBottom = r.getHeight() - (contentHeight - childYOffset); if (spaceAtBottom > 0) r.setSize (r.getWidth(), r.getHeight() - spaceAtBottom); } setBounds (r); updateYPositions(); } void alterChildYPos (const int delta) { if (canScroll()) { childYOffset += delta; if (delta < 0) childYOffset = jmax (childYOffset, 0); else if (delta > 0) childYOffset = jmin (childYOffset, contentHeight - windowPos.getHeight() + PopupMenuSettings::borderSize); updateYPositions(); } else { childYOffset = 0; } resizeToBestWindowPos(); repaint(); } int updateYPositions() { int x = 0; int childNum = 0; for (int col = 0; col < numColumns; ++col) { const int numChildren = jmin (items.size() - childNum, (items.size() + numColumns - 1) / numColumns); const int colW = columnWidths [col]; int y = PopupMenuSettings::borderSize - (childYOffset + (getY() - windowPos.getY())); for (int i = 0; i < numChildren; ++i) { Component* const c = items.getUnchecked (childNum + i); c->setBounds (x, y, colW, c->getHeight()); y += c->getHeight(); } x += colW; childNum += numChildren; } return x; } void setCurrentlyHighlightedChild (ItemComponent* const child) { if (currentChild != nullptr) currentChild->setHighlighted (false); currentChild = child; if (currentChild != nullptr) { currentChild->setHighlighted (true); timeEnteredCurrentChildComp = Time::getApproximateMillisecondCounter(); } } bool isSubMenuVisible() const noexcept { return activeSubMenu != nullptr && activeSubMenu->isVisible(); } bool showSubMenuFor (ItemComponent* const childComp) { activeSubMenu = nullptr; if (childComp != nullptr && hasActiveSubMenu (childComp->item)) { activeSubMenu = new HelperClasses::MenuWindow (*(childComp->item.subMenu), this, options.withTargetScreenArea (childComp->getScreenBounds()) .withMinimumWidth (0) .withTargetComponent (nullptr), false, dismissOnMouseUp, managerOfChosenCommand); activeSubMenu->setVisible (true); // (must be called before enterModalState on Windows to avoid DropShadower confusion) activeSubMenu->enterModalState (false); activeSubMenu->toFront (false); return true; } return false; } void triggerCurrentlyHighlightedItem() { if (currentChild != nullptr && canBeTriggered (currentChild->item) && (currentChild->item.customComponent == nullptr || currentChild->item.customComponent->isTriggeredAutomatically())) { dismissMenu (¤tChild->item); } } void selectNextItem (const int delta) { disableTimerUntilMouseMoves(); int start = jmax (0, items.indexOf (currentChild)); for (int i = items.size(); --i >= 0;) { start += delta; if (ItemComponent* mic = items.getUnchecked ((start + items.size()) % items.size())) { if (canBeTriggered (mic->item) || hasActiveSubMenu (mic->item)) { setCurrentlyHighlightedChild (mic); break; } } } } void disableTimerUntilMouseMoves() { disableMouseMoves = true; if (parent != nullptr) parent->disableTimerUntilMouseMoves(); } bool canScroll() const noexcept { return childYOffset != 0 || needsToScroll; } bool isTopScrollZoneActive() const noexcept { return canScroll() && childYOffset > 0; } bool isBottomScrollZoneActive() const noexcept { return canScroll() && childYOffset < contentHeight - windowPos.getHeight(); } //============================================================================== MenuWindow* parent; const Options options; OwnedArray items; ApplicationCommandManager** managerOfChosenCommand; WeakReference componentAttachedTo; Component* parentComponent; Rectangle windowPos; bool hasBeenOver, needsToScroll; bool dismissOnMouseUp, hideOnExit, disableMouseMoves, hasAnyJuceCompHadFocus; int numColumns, contentHeight, childYOffset; Component::SafePointer currentChild; ScopedPointer activeSubMenu; Array columnWidths; uint32 windowCreationTime, lastFocusedTime, timeEnteredCurrentChildComp; OwnedArray mouseSourceStates; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MenuWindow) }; //============================================================================== class MouseSourceState : private Timer { public: MouseSourceState (MenuWindow& w, MouseInputSource s) : window (w), source (s), scrollAcceleration (1.0), lastScrollTime (Time::getMillisecondCounter()), lastMouseMoveTime (0), isDown (false) { } void handleMouseEvent (const MouseEvent& e) { if (! window.windowIsStillValid()) return; startTimerHz (20); handleMousePosition (e.getScreenPosition()); } void timerCallback() override { if (window.windowIsStillValid()) handleMousePosition (source.getScreenPosition().roundToInt()); } bool isOver() const { return window.reallyContains (window.getLocalPoint (nullptr, source.getScreenPosition()).roundToInt(), true); } MenuWindow& window; MouseInputSource source; private: Point lastMousePos; double scrollAcceleration; uint32 lastScrollTime, lastMouseMoveTime; bool isDown; void handleMousePosition (Point globalMousePos) { const Point localMousePos (window.getLocalPoint (nullptr, globalMousePos)); const uint32 timeNow = Time::getMillisecondCounter(); if (timeNow > window.timeEnteredCurrentChildComp + 100 && window.reallyContains (localMousePos, true) && window.currentChild != nullptr && ! (window.disableMouseMoves || window.isSubMenuVisible())) { window.showSubMenuFor (window.currentChild); } highlightItemUnderMouse (globalMousePos, localMousePos, timeNow); const bool overScrollArea = scrollIfNecessary (localMousePos, timeNow); const bool isOverAny = window.isOverAnyMenu(); if (window.hideOnExit && window.hasBeenOver && ! isOverAny) window.hide (nullptr, true); else checkButtonState (localMousePos, timeNow, isDown, overScrollArea, isOverAny); } void checkButtonState (Point localMousePos, const uint32 timeNow, const bool wasDown, const bool overScrollArea, const bool isOverAny) { isDown = window.hasBeenOver && (ModifierKeys::getCurrentModifiers().isAnyMouseButtonDown() || ModifierKeys::getCurrentModifiersRealtime().isAnyMouseButtonDown()); if (! window.doesAnyJuceCompHaveFocus()) { if (timeNow > window.lastFocusedTime + 10) { PopupMenuSettings::menuWasHiddenBecauseOfAppChange = true; window.dismissMenu (nullptr); // Note: this object may have been deleted by the previous call.. } } else if (wasDown && timeNow > window.windowCreationTime + 250 && ! (isDown || overScrollArea)) { if (window.reallyContains (localMousePos, true)) window.triggerCurrentlyHighlightedItem(); else if ((window.hasBeenOver || ! window.dismissOnMouseUp) && ! isOverAny) window.dismissMenu (nullptr); // Note: this object may have been deleted by the previous call.. } else { window.lastFocusedTime = timeNow; } } void highlightItemUnderMouse (Point globalMousePos, Point localMousePos, const uint32 timeNow) { if (globalMousePos != lastMousePos || timeNow > lastMouseMoveTime + 350) { const bool isMouseOver = window.reallyContains (localMousePos, true); if (isMouseOver) window.hasBeenOver = true; if (lastMousePos.getDistanceFrom (globalMousePos) > 2) { lastMouseMoveTime = timeNow; if (window.disableMouseMoves && isMouseOver) window.disableMouseMoves = false; } if (window.disableMouseMoves || (window.activeSubMenu != nullptr && window.activeSubMenu->isOverChildren())) return; const bool isMovingTowardsMenu = isMouseOver && globalMousePos != lastMousePos && isMovingTowardsSubmenu (globalMousePos); lastMousePos = globalMousePos; if (! isMovingTowardsMenu) { Component* c = window.getComponentAt (localMousePos); if (c == &window) c = nullptr; ItemComponent* itemUnderMouse = dynamic_cast (c); if (itemUnderMouse == nullptr && c != nullptr) itemUnderMouse = c->findParentComponentOfClass(); if (itemUnderMouse != window.currentChild && (isMouseOver || (window.activeSubMenu == nullptr) || ! window.activeSubMenu->isVisible())) { if (isMouseOver && (c != nullptr) && (window.activeSubMenu != nullptr)) window.activeSubMenu->hide (nullptr, true); if (! isMouseOver) itemUnderMouse = nullptr; window.setCurrentlyHighlightedChild (itemUnderMouse); } } } } bool isMovingTowardsSubmenu (Point newGlobalPos) const { if (window.activeSubMenu == nullptr) return false; // try to intelligently guess whether the user is moving the mouse towards a currently-open // submenu. To do this, look at whether the mouse stays inside a triangular region that // extends from the last mouse pos to the submenu's rectangle.. const Rectangle itemScreenBounds (window.activeSubMenu->getScreenBounds()); float subX = (float) itemScreenBounds.getX(); Point oldGlobalPos (lastMousePos); if (itemScreenBounds.getX() > window.getX()) { oldGlobalPos -= Point (2, 0); // to enlarge the triangle a bit, in case the mouse only moves a couple of pixels } else { oldGlobalPos += Point (2, 0); subX += itemScreenBounds.getWidth(); } Path areaTowardsSubMenu; areaTowardsSubMenu.addTriangle ((float) oldGlobalPos.x, (float) oldGlobalPos.y, subX, (float) itemScreenBounds.getY(), subX, (float) itemScreenBounds.getBottom()); return areaTowardsSubMenu.contains (newGlobalPos.toFloat()); } bool scrollIfNecessary (Point localMousePos, const uint32 timeNow) { if (window.canScroll() && isPositiveAndBelow (localMousePos.x, window.getWidth()) && (isPositiveAndBelow (localMousePos.y, window.getHeight()) || source.isDragging())) { if (window.isTopScrollZoneActive() && localMousePos.y < PopupMenuSettings::scrollZone) return scroll (timeNow, -1); if (window.isBottomScrollZoneActive() && localMousePos.y > window.getHeight() - PopupMenuSettings::scrollZone) return scroll (timeNow, 1); } scrollAcceleration = 1.0; return false; } bool scroll (const uint32 timeNow, const int direction) { if (timeNow > lastScrollTime + 20) { scrollAcceleration = jmin (4.0, scrollAcceleration * 1.04); int amount = 0; for (int i = 0; i < window.items.size() && amount == 0; ++i) amount = ((int) scrollAcceleration) * window.items.getUnchecked (i)->getHeight(); window.alterChildYPos (amount * direction); lastScrollTime = timeNow; } return true; } JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MouseSourceState) }; //============================================================================== struct NormalComponentWrapper : public PopupMenu::CustomComponent { NormalComponentWrapper (Component* const comp, const int w, const int h, const bool triggerMenuItemAutomaticallyWhenClicked) : PopupMenu::CustomComponent (triggerMenuItemAutomaticallyWhenClicked), width (w), height (h) { addAndMakeVisible (comp); } void getIdealSize (int& idealWidth, int& idealHeight) override { idealWidth = width; idealHeight = height; } void resized() override { if (Component* const child = getChildComponent (0)) child->setBounds (getLocalBounds()); } const int width, height; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NormalComponentWrapper) }; }; //============================================================================== PopupMenu::PopupMenu() { } PopupMenu::PopupMenu (const PopupMenu& other) : lookAndFeel (other.lookAndFeel) { items.addCopiesOf (other.items); } PopupMenu& PopupMenu::operator= (const PopupMenu& other) { if (this != &other) { lookAndFeel = other.lookAndFeel; clear(); items.addCopiesOf (other.items); } return *this; } PopupMenu::PopupMenu (PopupMenu&& other) noexcept : lookAndFeel (other.lookAndFeel) { items.swapWith (other.items); } PopupMenu& PopupMenu::operator= (PopupMenu&& other) noexcept { jassert (this != &other); // hopefully the compiler should make this situation impossible! items.swapWith (other.items); lookAndFeel = other.lookAndFeel; return *this; } PopupMenu::~PopupMenu() { } void PopupMenu::clear() { items.clear(); } //============================================================================== PopupMenu::Item::Item() noexcept : itemID (0), commandManager (nullptr), colour (0x00000000), isEnabled (true), isTicked (false), isSeparator (false), isSectionHeader (false) { } PopupMenu::Item::Item (const Item& other) : text (other.text), itemID (other.itemID), subMenu (createCopyIfNotNull (other.subMenu.get())), image (other.image != nullptr ? other.image->createCopy() : nullptr), customComponent (other.customComponent), customCallback (other.customCallback), commandManager (other.commandManager), shortcutKeyDescription (other.shortcutKeyDescription), colour (other.colour), isEnabled (other.isEnabled), isTicked (other.isTicked), isSeparator (other.isSeparator), isSectionHeader (other.isSectionHeader) { } PopupMenu::Item& PopupMenu::Item::operator= (const Item& other) { text = other.text; itemID = other.itemID; subMenu = createCopyIfNotNull (other.subMenu.get()); image = (other.image != nullptr ? other.image->createCopy() : nullptr); customComponent = other.customComponent; customCallback = other.customCallback; commandManager = other.commandManager; shortcutKeyDescription = other.shortcutKeyDescription; colour = other.colour; isEnabled = other.isEnabled; isTicked = other.isTicked; isSeparator = other.isSeparator; isSectionHeader = other.isSectionHeader; return *this; } void PopupMenu::addItem (const Item& newItem) { // An ID of 0 is used as a return value to indicate that the user // didn't pick anything, so you shouldn't use it as the ID for an item.. jassert (newItem.itemID != 0 || newItem.isSeparator || newItem.isSectionHeader || newItem.subMenu != nullptr); items.add (new Item (newItem)); } void PopupMenu::addItem (int itemResultID, const String& itemText, bool isActive, bool isTicked) { Item i; i.text = itemText; i.itemID = itemResultID; i.isEnabled = isActive; i.isTicked = isTicked; addItem (i); } static Drawable* createDrawableFromImage (const Image& im) { if (im.isValid()) { DrawableImage* d = new DrawableImage(); d->setImage (im); return d; } return nullptr; } void PopupMenu::addItem (int itemResultID, const String& itemText, bool isActive, bool isTicked, const Image& iconToUse) { addItem (itemResultID, itemText, isActive, isTicked, createDrawableFromImage (iconToUse)); } void PopupMenu::addItem (int itemResultID, const String& itemText, bool isActive, bool isTicked, Drawable* iconToUse) { Item i; i.text = itemText; i.itemID = itemResultID; i.isEnabled = isActive; i.isTicked = isTicked; i.image = iconToUse; addItem (i); } void PopupMenu::addCommandItem (ApplicationCommandManager* commandManager, const CommandID commandID, const String& displayName, Drawable* iconToUse) { jassert (commandManager != nullptr && commandID != 0); if (const ApplicationCommandInfo* const registeredInfo = commandManager->getCommandForID (commandID)) { ApplicationCommandInfo info (*registeredInfo); ApplicationCommandTarget* const target = commandManager->getTargetForCommand (commandID, info); Item i; i.text = displayName.isNotEmpty() ? displayName : info.shortName; i.itemID = (int) commandID; i.commandManager = commandManager; i.isEnabled = target != nullptr && (info.flags & ApplicationCommandInfo::isDisabled) == 0; i.isTicked = (info.flags & ApplicationCommandInfo::isTicked) != 0; i.image = iconToUse; addItem (i); } } void PopupMenu::addColouredItem (int itemResultID, const String& itemText, Colour itemTextColour, bool isActive, bool isTicked, Drawable* iconToUse) { Item i; i.text = itemText; i.itemID = itemResultID; i.colour = itemTextColour; i.isEnabled = isActive; i.isTicked = isTicked; i.image = iconToUse; addItem (i); } void PopupMenu::addColouredItem (int itemResultID, const String& itemText, Colour itemTextColour, bool isActive, bool isTicked, const Image& iconToUse) { Item i; i.text = itemText; i.itemID = itemResultID; i.colour = itemTextColour; i.isEnabled = isActive; i.isTicked = isTicked; i.image = createDrawableFromImage (iconToUse); addItem (i); } void PopupMenu::addCustomItem (int itemResultID, CustomComponent* cc, const PopupMenu* subMenu) { Item i; i.itemID = itemResultID; i.customComponent = cc; i.subMenu = createCopyIfNotNull (subMenu); addItem (i); } void PopupMenu::addCustomItem (int itemResultID, Component* customComponent, int idealWidth, int idealHeight, bool triggerMenuItemAutomaticallyWhenClicked, const PopupMenu* subMenu) { addCustomItem (itemResultID, new HelperClasses::NormalComponentWrapper (customComponent, idealWidth, idealHeight, triggerMenuItemAutomaticallyWhenClicked), subMenu); } void PopupMenu::addSubMenu (const String& subMenuName, const PopupMenu& subMenu, bool isActive) { addSubMenu (subMenuName, subMenu, isActive, nullptr, false, 0); } void PopupMenu::addSubMenu (const String& subMenuName, const PopupMenu& subMenu, bool isActive, const Image& iconToUse, bool isTicked, int itemResultID) { addSubMenu (subMenuName, subMenu, isActive, createDrawableFromImage (iconToUse), isTicked, itemResultID); } void PopupMenu::addSubMenu (const String& subMenuName, const PopupMenu& subMenu, bool isActive, Drawable* iconToUse, bool isTicked, int itemResultID) { Item i; i.text = subMenuName; i.itemID = itemResultID; i.subMenu = new PopupMenu (subMenu); i.isEnabled = isActive && (itemResultID != 0 || subMenu.getNumItems() > 0); i.isTicked = isTicked; i.image = iconToUse; addItem (i); } void PopupMenu::addSeparator() { if (items.size() > 0 && ! items.getLast()->isSeparator) { Item i; i.isSeparator = true; addItem (i); } } void PopupMenu::addSectionHeader (const String& title) { Item i; i.text = title; i.isSectionHeader = true; addItem (i); } //============================================================================== PopupMenu::Options::Options() : targetComponent (nullptr), parentComponent (nullptr), visibleItemID (0), minWidth (0), maxColumns (0), standardHeight (0) { targetArea.setPosition (Desktop::getMousePosition()); } PopupMenu::Options PopupMenu::Options::withTargetComponent (Component* comp) const noexcept { Options o (*this); o.targetComponent = comp; if (comp != nullptr) o.targetArea = comp->getScreenBounds(); return o; } PopupMenu::Options PopupMenu::Options::withTargetScreenArea (const Rectangle& area) const noexcept { Options o (*this); o.targetArea = area; return o; } PopupMenu::Options PopupMenu::Options::withMinimumWidth (int w) const noexcept { Options o (*this); o.minWidth = w; return o; } PopupMenu::Options PopupMenu::Options::withMaximumNumColumns (int cols) const noexcept { Options o (*this); o.maxColumns = cols; return o; } PopupMenu::Options PopupMenu::Options::withStandardItemHeight (int height) const noexcept { Options o (*this); o.standardHeight = height; return o; } PopupMenu::Options PopupMenu::Options::withItemThatMustBeVisible (int idOfItemToBeVisible) const noexcept { Options o (*this); o.visibleItemID = idOfItemToBeVisible; return o; } PopupMenu::Options PopupMenu::Options::withParentComponent (Component* parent) const noexcept { Options o (*this); o.parentComponent = parent; return o; } Component* PopupMenu::createWindow (const Options& options, ApplicationCommandManager** managerOfChosenCommand) const { if (items.size() > 0) return new HelperClasses::MenuWindow (*this, nullptr, options, ! options.targetArea.isEmpty(), ModifierKeys::getCurrentModifiers().isAnyMouseButtonDown(), managerOfChosenCommand); return nullptr; } //============================================================================== // This invokes any command manager commands and deletes the menu window when it is dismissed struct PopupMenuCompletionCallback : public ModalComponentManager::Callback { PopupMenuCompletionCallback() : managerOfChosenCommand (nullptr), prevFocused (Component::getCurrentlyFocusedComponent()), prevTopLevel (prevFocused != nullptr ? prevFocused->getTopLevelComponent() : nullptr) { PopupMenuSettings::menuWasHiddenBecauseOfAppChange = false; } void modalStateFinished (int result) override { if (managerOfChosenCommand != nullptr && result != 0) { ApplicationCommandTarget::InvocationInfo info (result); info.invocationMethod = ApplicationCommandTarget::InvocationInfo::fromMenu; managerOfChosenCommand->invoke (info, true); } // (this would be the place to fade out the component, if that's what's required) component = nullptr; if (! PopupMenuSettings::menuWasHiddenBecauseOfAppChange) { if (prevTopLevel != nullptr) prevTopLevel->toFront (true); if (prevFocused != nullptr) prevFocused->grabKeyboardFocus(); } } ApplicationCommandManager* managerOfChosenCommand; ScopedPointer component; WeakReference prevFocused, prevTopLevel; JUCE_DECLARE_NON_COPYABLE (PopupMenuCompletionCallback) }; int PopupMenu::showWithOptionalCallback (const Options& options, ModalComponentManager::Callback* const userCallback, const bool canBeModal) { ScopedPointer userCallbackDeleter (userCallback); ScopedPointer callback (new PopupMenuCompletionCallback()); if (Component* window = createWindow (options, &(callback->managerOfChosenCommand))) { callback->component = window; window->setVisible (true); // (must be called before enterModalState on Windows to avoid DropShadower confusion) window->enterModalState (false, userCallbackDeleter.release()); ModalComponentManager::getInstance()->attachCallback (window, callback.release()); window->toFront (false); // need to do this after making it modal, or it could // be stuck behind other comps that are already modal.. #if JUCE_MODAL_LOOPS_PERMITTED if (userCallback == nullptr && canBeModal) return window->runModalLoop(); #else ignoreUnused (canBeModal); jassert (! (userCallback == nullptr && canBeModal)); #endif } return 0; } //============================================================================== #if JUCE_MODAL_LOOPS_PERMITTED int PopupMenu::showMenu (const Options& options) { return showWithOptionalCallback (options, nullptr, true); } #endif void PopupMenu::showMenuAsync (const Options& options, ModalComponentManager::Callback* userCallback) { #if ! JUCE_MODAL_LOOPS_PERMITTED jassert (userCallback != nullptr); #endif showWithOptionalCallback (options, userCallback, false); } //============================================================================== #if JUCE_MODAL_LOOPS_PERMITTED int PopupMenu::show (const int itemIDThatMustBeVisible, const int minimumWidth, const int maximumNumColumns, const int standardItemHeight, ModalComponentManager::Callback* callback) { return showWithOptionalCallback (Options().withItemThatMustBeVisible (itemIDThatMustBeVisible) .withMinimumWidth (minimumWidth) .withMaximumNumColumns (maximumNumColumns) .withStandardItemHeight (standardItemHeight), callback, true); } int PopupMenu::showAt (const Rectangle& screenAreaToAttachTo, const int itemIDThatMustBeVisible, const int minimumWidth, const int maximumNumColumns, const int standardItemHeight, ModalComponentManager::Callback* callback) { return showWithOptionalCallback (Options().withTargetScreenArea (screenAreaToAttachTo) .withItemThatMustBeVisible (itemIDThatMustBeVisible) .withMinimumWidth (minimumWidth) .withMaximumNumColumns (maximumNumColumns) .withStandardItemHeight (standardItemHeight), callback, true); } int PopupMenu::showAt (Component* componentToAttachTo, const int itemIDThatMustBeVisible, const int minimumWidth, const int maximumNumColumns, const int standardItemHeight, ModalComponentManager::Callback* callback) { Options options (Options().withItemThatMustBeVisible (itemIDThatMustBeVisible) .withMinimumWidth (minimumWidth) .withMaximumNumColumns (maximumNumColumns) .withStandardItemHeight (standardItemHeight)); if (componentToAttachTo != nullptr) options = options.withTargetComponent (componentToAttachTo); return showWithOptionalCallback (options, callback, true); } #endif bool JUCE_CALLTYPE PopupMenu::dismissAllActiveMenus() { const Array& windows = HelperClasses::MenuWindow::getActiveWindows(); const int numWindows = windows.size(); for (int i = numWindows; --i >= 0;) if (HelperClasses::MenuWindow* const pmw = windows[i]) pmw->dismissMenu (nullptr); return numWindows > 0; } //============================================================================== int PopupMenu::getNumItems() const noexcept { int num = 0; for (int i = items.size(); --i >= 0;) if (! items.getUnchecked (i)->isSeparator) ++num; return num; } bool PopupMenu::containsCommandItem (const int commandID) const { for (int i = items.size(); --i >= 0;) { const Item& mi = *items.getUnchecked (i); if ((mi.itemID == commandID && mi.commandManager != nullptr) || (mi.subMenu != nullptr && mi.subMenu->containsCommandItem (commandID))) return true; } return false; } bool PopupMenu::containsAnyActiveItems() const noexcept { for (int i = items.size(); --i >= 0;) { const Item& mi = *items.getUnchecked (i); if (mi.subMenu != nullptr) { if (mi.subMenu->containsAnyActiveItems()) return true; } else if (mi.isEnabled) { return true; } } return false; } void PopupMenu::setLookAndFeel (LookAndFeel* const newLookAndFeel) { lookAndFeel = newLookAndFeel; } //============================================================================== PopupMenu::CustomComponent::CustomComponent (bool autoTrigger) : isHighlighted (false), triggeredAutomatically (autoTrigger) { } PopupMenu::CustomComponent::~CustomComponent() { } void PopupMenu::CustomComponent::setHighlighted (bool shouldBeHighlighted) { isHighlighted = shouldBeHighlighted; repaint(); } void PopupMenu::CustomComponent::triggerMenuItem() { if (HelperClasses::ItemComponent* const mic = findParentComponentOfClass()) { if (HelperClasses::MenuWindow* const pmw = mic->findParentComponentOfClass()) { pmw->dismissMenu (&mic->item); } else { // something must have gone wrong with the component hierarchy if this happens.. jassertfalse; } } else { // why isn't this component inside a menu? Not much point triggering the item if // there's no menu. jassertfalse; } } //============================================================================== PopupMenu::CustomCallback::CustomCallback() {} PopupMenu::CustomCallback::~CustomCallback() {} //============================================================================== PopupMenu::MenuItemIterator::MenuItemIterator (const PopupMenu& m, bool searchR) : searchRecursively (searchR) { currentItem = nullptr; index.add (0); menus.add (&m); } PopupMenu::MenuItemIterator::~MenuItemIterator() {} bool PopupMenu::MenuItemIterator::next() { if (index.size() == 0 || menus.getLast()->items.size() == 0) return false; currentItem = menus.getLast()->items.getUnchecked (index.getLast()); if (searchRecursively && currentItem->subMenu != nullptr) { index.add (0); menus.add (currentItem->subMenu); } else index.setUnchecked (index.size() - 1, index.getLast() + 1); while (index.size() > 0 && index.getLast() >= menus.getLast()->items.size()) { index.removeLast(); menus.removeLast(); if (index.size() > 0) index.setUnchecked (index.size() - 1, index.getLast() + 1); } return true; } PopupMenu::Item& PopupMenu::MenuItemIterator::getItem() const noexcept { jassert (currentItem != nullptr); return *(currentItem); }