/* ============================================================================== This file is part of the JUCE library. Copyright (c) 2013 - Raw Material Software Ltd. Permission is granted to use this software under the terms of either: a) the GPL v2 (or any later version) b) the Affero GPL v3 Details of these licenses can be found at: www.gnu.org/licenses JUCE 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 for more details. ------------------------------------------------------------------------------ To release a closed-source product which uses JUCE, commercial licenses are available: visit www.juce.com for more information. ============================================================================== */ //============================================================================== namespace PopupMenuSettings { const int scrollZone = 24; const int borderSize = 2; const int timerInterval = 50; const int dismissCommandId = 0x6287345f; const int sectionHeaderID = 0x4734a34f; static bool menuWasHiddenBecauseOfAppChange = false; } class PopupMenu::Item { public: Item() : itemID (0), isActive (true), isSeparator (true), isTicked (false), usesColour (false), commandManager (nullptr) {} Item (const int itemId, const String& name, const bool active, const bool ticked, const Image& im, const Colour colour, const bool useColour, CustomComponent* const custom, const PopupMenu* const sub, ApplicationCommandManager* const manager) : itemID (itemId), text (name), textColour (colour), isActive (active), isSeparator (false), isTicked (ticked), usesColour (useColour), image (im), customComp (custom), subMenu (createCopyIfNotNull (sub)), commandManager (manager) { if (commandManager != nullptr && itemID != 0) { String shortcutKey; const Array keyPresses (commandManager->getKeyMappings() ->getKeyPressesAssignedToCommand (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; } shortcutKey = shortcutKey.trim(); if (shortcutKey.isNotEmpty()) text << "" << shortcutKey; } } Item (const Item& other) : itemID (other.itemID), text (other.text), textColour (other.textColour), isActive (other.isActive), isSeparator (other.isSeparator), isTicked (other.isTicked), usesColour (other.usesColour), image (other.image), customComp (other.customComp), subMenu (createCopyIfNotNull (other.subMenu.get())), commandManager (other.commandManager) {} bool canBeTriggered() const noexcept { return isActive && itemID != 0 && itemID != PopupMenuSettings::sectionHeaderID; } bool hasActiveSubMenu() const noexcept { return isActive && subMenu != nullptr && subMenu->items.size() > 0; } //============================================================================== const int itemID; String text; const Colour textColour; const bool isActive, isSeparator, isTicked, usesColour; Image image; ReferenceCountedObjectPtr customComp; ScopedPointer subMenu; ApplicationCommandManager* const commandManager; private: Item& operator= (const Item&); JUCE_LEAK_DETECTOR (Item) }; //============================================================================== class PopupMenu::ItemComponent : public Component { public: ItemComponent (const PopupMenu::Item& info, int standardItemHeight, Component* const parent) : itemInfo (info), isHighlighted (false) { addAndMakeVisible (itemInfo.customComp); parent->addAndMakeVisible (this); int itemW = 80; int itemH = 16; getIdealSize (itemW, itemH, standardItemHeight); setSize (itemW, jlimit (2, 600, itemH)); addMouseListener (parent, false); } ~ItemComponent() { removeChildComponent (itemInfo.customComp); } void getIdealSize (int& idealWidth, int& idealHeight, const int standardItemHeight) { if (itemInfo.customComp != nullptr) itemInfo.customComp->getIdealSize (idealWidth, idealHeight); else getLookAndFeel().getIdealPopupMenuItemSize (itemInfo.text, itemInfo.isSeparator, standardItemHeight, idealWidth, idealHeight); } void paint (Graphics& g) override { if (itemInfo.customComp == nullptr) { String mainText (itemInfo.text); String endText; const int endIndex = mainText.indexOf (""); if (endIndex >= 0) { endText = mainText.substring (endIndex + 5).trim(); mainText = mainText.substring (0, endIndex); } getLookAndFeel() .drawPopupMenuItem (g, getWidth(), getHeight(), itemInfo.isSeparator, itemInfo.isActive, isHighlighted, itemInfo.isTicked, itemInfo.subMenu != nullptr && (itemInfo.itemID == 0 || itemInfo.subMenu->getNumItems() > 0), mainText, endText, itemInfo.image.isValid() ? &itemInfo.image : nullptr, itemInfo.usesColour ? &(itemInfo.textColour) : nullptr); } } void resized() override { if (Component* const child = getChildComponent (0)) child->setBounds (getLocalBounds().reduced (2, 0)); } void setHighlighted (bool shouldBeHighlighted) { shouldBeHighlighted = shouldBeHighlighted && itemInfo.isActive; if (isHighlighted != shouldBeHighlighted) { isHighlighted = shouldBeHighlighted; if (itemInfo.customComp != nullptr) itemInfo.customComp->setHighlighted (shouldBeHighlighted); repaint(); } } PopupMenu::Item itemInfo; private: bool isHighlighted; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ItemComponent) }; //============================================================================== class PopupMenu::Window : public Component, private Timer { public: Window (const PopupMenu& menu, Window* const parentWindow, const Options& opts, const bool alignToRectangle, const bool shouldDismissOnMouseUp, ApplicationCommandManager** const manager) : Component ("menu"), owner (parentWindow), options (opts), activeSubMenu (nullptr), managerOfChosenCommand (manager), componentAttachedTo (options.targetComponent), isOver (false), hasBeenOver (false), isDown (false), needsToScroll (false), dismissOnMouseUp (shouldDismissOnMouseUp), hideOnExit (false), disableMouseMoves (false), hasAnyJuceCompHadFocus (false), numColumns (0), contentHeight (0), childYOffset (0), menuCreationTime (Time::getMillisecondCounter()), lastMouseMoveTime (0), timeEnteredCurrentChildComp (0), scrollAcceleration (1.0) { lastFocusedTime = lastScrollTime = menuCreationTime; setWantsKeyboardFocus (false); setMouseClickGrabsKeyboardFocus (false); setAlwaysOnTop (true); setLookAndFeel (owner != nullptr ? &(owner->getLookAndFeel()) : menu.lookAndFeel); setOpaque (getLookAndFeel().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 PopupMenu::ItemComponent (*item, options.standardHeight, this)); } calculateWindowPos (options.targetArea, alignToRectangle); setTopLeftPosition (windowPos.getPosition()); updateYPositions(); if (options.visibleItemID != 0) { const int y = options.targetArea.getY() - windowPos.getY(); ensureItemIsVisible (options.visibleItemID, isPositiveAndBelow (y, windowPos.getHeight()) ? y : -1); } resizeToBestWindowPos(); addToDesktop (ComponentPeer::windowIsTemporary | ComponentPeer::windowIgnoresKeyPresses | getLookAndFeel().getMenuWindowFlags()); getActiveWindows().add (this); Desktop::getInstance().addGlobalMouseListener (this); } ~Window() { 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 { if (canScroll()) { LookAndFeel& lf = getLookAndFeel(); 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 (item != nullptr ? item->itemID : 0); if (makeInvisible && (deletionChecker != nullptr)) setVisible (false); } } void dismissMenu (const PopupMenu::Item* const item) { if (owner != nullptr) { owner->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); } } } //============================================================================== void mouseMove (const MouseEvent&) override { timerCallback(); } void mouseDown (const MouseEvent&) override { timerCallback(); } void mouseDrag (const MouseEvent&) override { timerCallback(); } void mouseUp (const MouseEvent&) override { timerCallback(); } void mouseWheelMove (const MouseEvent&, const MouseWheelDetails& wheel) override { alterChildYPos (roundToInt (-10.0f * wheel.deltaY * PopupMenuSettings::scrollZone)); lastMousePos = Point (-1, -1); } 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 (owner != nullptr) { Component::SafePointer parentWindow (owner); PopupMenu::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); timerCallback(); if (deletionChecker != nullptr && ! 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 orginally 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 timerCallback() override { if (! isVisible()) return; if (componentAttachedTo != options.targetComponent) { dismissMenu (nullptr); return; } if (Window* currentlyModalWindow = dynamic_cast (Component::getCurrentlyModalComponent())) if (! treeContains (currentlyModalWindow)) return; startTimer (PopupMenuSettings::timerInterval); // do this in case it was called from a mouse // move rather than a real timer callback const Point globalMousePos (Desktop::getMousePosition()); const Point localMousePos (getLocalPoint (nullptr, globalMousePos)); const uint32 timeNow = Time::getMillisecondCounter(); if (timeNow > timeEnteredCurrentChildComp + 100 && reallyContains (localMousePos, true) && currentChild != nullptr && ! (disableMouseMoves || isSubMenuVisible())) { showSubMenuFor (currentChild); } highlightItemUnderMouse (globalMousePos, localMousePos, timeNow); const bool overScrollArea = scrollIfNecessary (localMousePos, timeNow); const bool wasDown = isDown; bool isOverAny = isOverAnyMenu(); if (activeSubMenu != nullptr && hideOnExit && hasBeenOver && ! isOverAny) { activeSubMenu->updateMouseOverStatus (globalMousePos); isOverAny = isOverAnyMenu(); } if (hideOnExit && hasBeenOver && ! isOverAny) hide (nullptr, true); else checkButtonState (localMousePos, timeNow, wasDown, overScrollArea, isOverAny); } static Array& getActiveWindows() { static Array activeMenuWindows; return activeMenuWindows; } //============================================================================== private: Window* owner; const Options options; OwnedArray items; Component::SafePointer currentChild; ScopedPointer activeSubMenu; ApplicationCommandManager** managerOfChosenCommand; WeakReference componentAttachedTo; Rectangle windowPos; Point lastMousePos; bool isOver, hasBeenOver, isDown, needsToScroll; bool dismissOnMouseUp, hideOnExit, disableMouseMoves, hasAnyJuceCompHadFocus; int numColumns, contentHeight, childYOffset; Array columnWidths; uint32 menuCreationTime, lastFocusedTime, lastScrollTime, lastMouseMoveTime, timeEnteredCurrentChildComp; double scrollAcceleration; //============================================================================== bool overlaps (const Rectangle& r) const { return r.intersects (getBounds()) || (owner != nullptr && owner->overlaps (r)); } bool isOverAnyMenu() const { return owner != nullptr ? owner->isOverAnyMenu() : isOverChildren(); } bool isOverChildren() const { return isVisible() && (isOver || (activeSubMenu != nullptr && activeSubMenu->isOverChildren())); } void updateMouseOverStatus (Point globalMousePos) { isOver = reallyContains (getLocalPoint (nullptr, globalMousePos), true); if (activeSubMenu != nullptr) activeSubMenu->updateMouseOverStatus (globalMousePos); } bool treeContains (const Window* const window) const noexcept { const Window* mw = this; while (mw->owner != nullptr) mw = mw->owner; 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; } //============================================================================== void calculateWindowPos (const Rectangle& target, const bool alignToRectangle) { const Rectangle mon (Desktop::getInstance().getDisplays() .getDisplayContaining (target.getCentre()) #if JUCE_MAC .userArea); #else .totalArea); // on windows, don't stop the menu overlapping the taskbar #endif const int maxMenuHeight = mon.getHeight() - 24; int x, y, widthToUse, heightToUse; layoutMenuItems (mon.getWidth() - 24, maxMenuHeight, widthToUse, heightToUse); if (alignToRectangle) { x = target.getX(); const int spaceUnder = mon.getHeight() - (target.getBottom() - mon.getY()); const int spaceOver = target.getY() - mon.getY(); if (heightToUse < spaceUnder - 30 || spaceUnder >= spaceOver) y = target.getBottom(); else y = target.getY() - heightToUse; } else { bool tendTowardsRight = target.getCentreX() < mon.getCentreX(); if (owner != nullptr) { if (owner->owner != nullptr) { const bool ownerGoingRight = (owner->getX() + owner->getWidth() / 2 > owner->owner->getX() + owner->owner->getWidth() / 2); if (ownerGoingRight && target.getRight() + widthToUse < mon.getRight() - 4) tendTowardsRight = true; else if ((! ownerGoingRight) && target.getX() > widthToUse + 4) tendTowardsRight = false; } else if (target.getRight() + widthToUse < mon.getRight() - 32) { tendTowardsRight = true; } } const int biggestSpace = jmax (mon.getRight() - target.getRight(), target.getX() - mon.getX()) - 32; if (biggestSpace < widthToUse) { layoutMenuItems (biggestSpace + target.getWidth() / 3, maxMenuHeight, widthToUse, heightToUse); if (numColumns > 1) layoutMenuItems (biggestSpace - 4, maxMenuHeight, widthToUse, heightToUse); tendTowardsRight = (mon.getRight() - target.getRight()) >= (target.getX() - mon.getX()); } if (tendTowardsRight) x = jmin (mon.getRight() - widthToUse - 4, target.getRight()); else x = jmax (mon.getX() + 4, target.getX() - widthToUse); y = target.getY(); if (target.getCentreY() > mon.getCentreY()) y = jmax (mon.getY(), target.getBottom() - heightToUse); } x = jmax (mon.getX() + 1, jmin (mon.getRight() - (widthToUse + 6), x)); y = jmax (mon.getY() + 1, jmin (mon.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 = owner != nullptr && owner->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; } if (totalW < options.minWidth) { totalW = options.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;) { PopupMenu::ItemComponent* const m = items.getUnchecked(i); if (m != nullptr && m->itemInfo.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 mon (Desktop::getInstance().getDisplays() .getDisplayContaining (windowPos.getPosition()).userArea); int deltaY = wantedY - currentY; windowPos.setSize (jmin (windowPos.getWidth(), mon.getWidth()), jmin (windowPos.getHeight(), mon.getHeight())); const int newY = jlimit (mon.getY(), mon.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 (PopupMenu::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 (PopupMenu::ItemComponent* const childComp) { activeSubMenu = nullptr; if (childComp != nullptr && childComp->itemInfo.hasActiveSubMenu()) { activeSubMenu = new Window (*(childComp->itemInfo.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 highlightItemUnderMouse (Point globalMousePos, Point localMousePos, const uint32 timeNow) { if (globalMousePos != lastMousePos || timeNow > lastMouseMoveTime + 350) { isOver = reallyContains (localMousePos, true); if (isOver) hasBeenOver = true; if (lastMousePos.getDistanceFrom (globalMousePos) > 2) { lastMouseMoveTime = timeNow; if (disableMouseMoves && isOver) disableMouseMoves = false; } if (disableMouseMoves || (activeSubMenu != nullptr && activeSubMenu->isOverChildren())) return; bool isMovingTowardsMenu = false; if (isOver && (activeSubMenu != nullptr) && globalMousePos != lastMousePos) { // 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.. float subX = (float) activeSubMenu->getScreenX(); if (activeSubMenu->getX() > getX()) { lastMousePos -= Point (2, 0); // to enlarge the triangle a bit, in case the mouse only moves a couple of pixels } else { lastMousePos += Point (2, 0); subX += activeSubMenu->getWidth(); } Path areaTowardsSubMenu; areaTowardsSubMenu.addTriangle ((float) lastMousePos.x, (float) lastMousePos.y, subX, (float) activeSubMenu->getScreenY(), subX, (float) (activeSubMenu->getScreenY() + activeSubMenu->getHeight())); isMovingTowardsMenu = areaTowardsSubMenu.contains (globalMousePos.toFloat()); } lastMousePos = globalMousePos; if (! isMovingTowardsMenu) { Component* c = getComponentAt (localMousePos); if (c == this) c = nullptr; PopupMenu::ItemComponent* itemUnderMouse = dynamic_cast (c); if (itemUnderMouse == nullptr && c != nullptr) itemUnderMouse = c->findParentComponentOfClass(); if (itemUnderMouse != currentChild && (isOver || (activeSubMenu == nullptr) || ! activeSubMenu->isVisible())) { if (isOver && (c != nullptr) && (activeSubMenu != nullptr)) activeSubMenu->hide (nullptr, true); if (! isOver) itemUnderMouse = nullptr; setCurrentlyHighlightedChild (itemUnderMouse); } } } } void checkButtonState (Point localMousePos, const uint32 timeNow, const bool wasDown, const bool overScrollArea, const bool isOverAny) { isDown = hasBeenOver && (ModifierKeys::getCurrentModifiers().isAnyMouseButtonDown() || ModifierKeys::getCurrentModifiersRealtime().isAnyMouseButtonDown()); if (! doesAnyJuceCompHaveFocus()) { if (timeNow > lastFocusedTime + 10) { PopupMenuSettings::menuWasHiddenBecauseOfAppChange = true; dismissMenu (nullptr); // Note: this object may have been deleted by the previous call.. } } else if (wasDown && timeNow > menuCreationTime + 250 && ! (isDown || overScrollArea)) { isOver = reallyContains (localMousePos, true); if (isOver) triggerCurrentlyHighlightedItem(); else if ((hasBeenOver || ! dismissOnMouseUp) && ! isOverAny) dismissMenu (nullptr); // Note: this object may have been deleted by the previous call.. } else { lastFocusedTime = timeNow; } } void triggerCurrentlyHighlightedItem() { if (currentChild != nullptr && currentChild->itemInfo.canBeTriggered() && (currentChild->itemInfo.customComp == nullptr || currentChild->itemInfo.customComp->isTriggeredAutomatically())) { dismissMenu (¤tChild->itemInfo); } } void selectNextItem (const int delta) { disableTimerUntilMouseMoves(); int start = jmax (0, items.indexOf (currentChild)); for (int i = items.size(); --i >= 0;) { start += delta; if (PopupMenu::ItemComponent* mic = items.getUnchecked ((start + items.size()) % items.size())) { if (mic->itemInfo.canBeTriggered() || mic->itemInfo.hasActiveSubMenu()) { setCurrentlyHighlightedChild (mic); break; } } } } void disableTimerUntilMouseMoves() { disableMouseMoves = true; if (owner != nullptr) owner->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(); } bool scrollIfNecessary (Point localMousePos, const uint32 timeNow) { if (canScroll() && (isOver || (isDown && isPositiveAndBelow (localMousePos.x, getWidth())))) { if (isTopScrollZoneActive() && localMousePos.y < PopupMenuSettings::scrollZone) return scroll (timeNow, -1); if (isBottomScrollZoneActive() && localMousePos.y > 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 < items.size() && amount == 0; ++i) amount = ((int) scrollAcceleration) * items.getUnchecked(i)->getHeight(); alterChildYPos (amount * direction); lastScrollTime = timeNow; } lastMousePos = Point (-1, -1); // to trigger a mouse-move return true; } JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Window) }; //============================================================================== PopupMenu::PopupMenu() : lookAndFeel (nullptr) { } 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; } #if JUCE_COMPILER_SUPPORTS_MOVE_SEMANTICS PopupMenu::PopupMenu (PopupMenu&& other) noexcept : lookAndFeel (other.lookAndFeel) { items.swapWithArray (other.items); } PopupMenu& PopupMenu::operator= (PopupMenu&& other) noexcept { jassert (this != &other); // hopefully the compiler should make this situation impossible! items.swapWithArray (other.items); lookAndFeel = other.lookAndFeel; return *this; } #endif PopupMenu::~PopupMenu() { } void PopupMenu::clear() { items.clear(); } void PopupMenu::addItem (const int itemResultID, const String& itemText, const bool isActive, const bool isTicked, const Image& iconToUse) { jassert (itemResultID != 0); // 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.. items.add (new Item (itemResultID, itemText, isActive, isTicked, iconToUse, Colours::black, false, nullptr, nullptr, nullptr)); } void PopupMenu::addCommandItem (ApplicationCommandManager* commandManager, const int commandID, const String& displayName) { jassert (commandManager != nullptr && commandID != 0); if (const ApplicationCommandInfo* const registeredInfo = commandManager->getCommandForID (commandID)) { ApplicationCommandInfo info (*registeredInfo); ApplicationCommandTarget* const target = commandManager->getTargetForCommand (commandID, info); items.add (new Item (commandID, displayName.isNotEmpty() ? displayName : info.shortName, target != nullptr && (info.flags & ApplicationCommandInfo::isDisabled) == 0, (info.flags & ApplicationCommandInfo::isTicked) != 0, Image::null, Colours::black, false, nullptr, nullptr, commandManager)); } } void PopupMenu::addColouredItem (const int itemResultID, const String& itemText, Colour itemTextColour, const bool isActive, const bool isTicked, const Image& iconToUse) { jassert (itemResultID != 0); // 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.. items.add (new Item (itemResultID, itemText, isActive, isTicked, iconToUse, itemTextColour, true, nullptr, nullptr, nullptr)); } //============================================================================== class PopupMenu::NormalComponentWrapper : public PopupMenu::CustomComponent { public: 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) { idealWidth = width; idealHeight = height; } void resized() { if (Component* const child = getChildComponent(0)) child->setBounds (getLocalBounds()); } private: const int width, height; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NormalComponentWrapper) }; void PopupMenu::addCustomItem (const int itemID, CustomComponent* const cc, const PopupMenu* subMenu) { jassert (itemID != 0); // 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.. items.add (new Item (itemID, String::empty, true, false, Image::null, Colours::black, false, cc, subMenu, nullptr)); } void PopupMenu::addCustomItem (const int itemResultID, Component* customComponent, int idealWidth, int idealHeight, const bool triggerMenuItemAutomaticallyWhenClicked, const PopupMenu* subMenu) { items.add (new Item (itemResultID, String::empty, true, false, Image::null, Colours::black, false, new NormalComponentWrapper (customComponent, idealWidth, idealHeight, triggerMenuItemAutomaticallyWhenClicked), subMenu, nullptr)); } //============================================================================== void PopupMenu::addSubMenu (const String& subMenuName, const PopupMenu& subMenu, const bool isActive, const Image& iconToUse, const bool isTicked, const int itemResultID) { items.add (new Item (itemResultID, subMenuName, isActive && (itemResultID != 0 || subMenu.getNumItems() > 0), isTicked, iconToUse, Colours::black, false, nullptr, &subMenu, nullptr)); } void PopupMenu::addSeparator() { if (items.size() > 0 && ! items.getLast()->isSeparator) items.add (new Item()); } //============================================================================== class PopupMenu::HeaderItemComponent : public PopupMenu::CustomComponent { public: HeaderItemComponent (const String& name) : PopupMenu::CustomComponent (false) { setName (name); } void paint (Graphics& g) override { g.setFont (getLookAndFeel().getPopupMenuFont().boldened()); g.setColour (findColour (PopupMenu::headerTextColourId)); g.drawFittedText (getName(), 12, 0, getWidth() - 16, proportionOfHeight (0.8f), Justification::bottomLeft, 1); } void getIdealSize (int& idealWidth, int& idealHeight) { getLookAndFeel().getIdealPopupMenuItemSize (getName(), false, -1, idealWidth, idealHeight); idealHeight += idealHeight / 2; idealWidth += idealWidth / 4; } private: JUCE_LEAK_DETECTOR (HeaderItemComponent) }; void PopupMenu::addSectionHeader (const String& title) { addCustomItem (PopupMenuSettings::sectionHeaderID, new HeaderItemComponent (title)); } //============================================================================== PopupMenu::Options::Options() : targetComponent (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; } Component* PopupMenu::createWindow (const Options& options, ApplicationCommandManager** managerOfChosenCommand) const { if (items.size() > 0) return new Window (*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 class PopupMenuCompletionCallback : public ModalComponentManager::Callback { public: PopupMenuCompletionCallback() : managerOfChosenCommand (nullptr), prevFocused (Component::getCurrentlyFocusedComponent()), prevTopLevel (prevFocused != nullptr ? prevFocused->getTopLevelComponent() : nullptr) { PopupMenuSettings::menuWasHiddenBecauseOfAppChange = false; } void modalStateFinished (int result) { 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; private: 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()); Component* window = createWindow (options, &(callback->managerOfChosenCommand)); if (window == nullptr) return 0; 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 return (userCallback == nullptr && canBeModal) ? window->runModalLoop() : 0; #else jassert (! (userCallback == nullptr && canBeModal)); return 0; #endif } //============================================================================== #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 = Window::getActiveWindows(); const int numWindows = windows.size(); for (int i = numWindows; --i >= 0;) if (Window* 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.isActive) { 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 (PopupMenu::ItemComponent* const mic = dynamic_cast (getParentComponent())) { if (PopupMenu::Window* const pmw = dynamic_cast (mic->getParentComponent())) { pmw->dismissMenu (&mic->itemInfo); } 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::MenuItemIterator::MenuItemIterator (const PopupMenu& m) : subMenu (nullptr), itemId (0), isSeparator (false), isTicked (false), isEnabled (false), isCustomComponent (false), isSectionHeader (false), customColour (nullptr), menu (m), index (0) { } PopupMenu::MenuItemIterator::~MenuItemIterator() { } bool PopupMenu::MenuItemIterator::next() { if (index >= menu.items.size()) return false; const Item* const item = menu.items.getUnchecked (index); ++index; if (item->isSeparator && index >= menu.items.size()) // (avoid showing a separator at the end) return false; itemName = item->customComp != nullptr ? item->customComp->getName() : item->text; subMenu = item->subMenu; itemId = item->itemID; isSeparator = item->isSeparator; isTicked = item->isTicked; isEnabled = item->isActive; isSectionHeader = dynamic_cast (static_cast (item->customComp)) != nullptr; isCustomComponent = (! isSectionHeader) && item->customComp != nullptr; customColour = item->usesColour ? &(item->textColour) : nullptr; customImage = item->image; commandManager = item->commandManager; return true; } void PopupMenu::MenuItemIterator::addItemTo (PopupMenu& targetMenu) { targetMenu.items.add (new Item (itemId, itemName, isEnabled, isTicked, customImage, customColour != nullptr ? *customColour : Colours::black, customColour != nullptr, nullptr, subMenu, commandManager)); }