IDEA-245047 ui: fix duplicated entries in git menu
[idea/community.git] / platform / platform-impl / src / com / intellij / openapi / actionSystem / impl / ActionMenu.java
1 // Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2 package com.intellij.openapi.actionSystem.impl;
3
4 import com.intellij.ide.DataManager;
5 import com.intellij.ide.IdeEventQueue;
6 import com.intellij.ide.ui.UISettings;
7 import com.intellij.openapi.Disposable;
8 import com.intellij.openapi.actionSystem.*;
9 import com.intellij.openapi.actionSystem.impl.actionholder.ActionRef;
10 import com.intellij.openapi.application.ApplicationManager;
11 import com.intellij.openapi.application.ModalityState;
12 import com.intellij.openapi.application.impl.LaterInvocator;
13 import com.intellij.openapi.ui.JBPopupMenu;
14 import com.intellij.openapi.util.Disposer;
15 import com.intellij.openapi.util.IconLoader;
16 import com.intellij.openapi.util.SystemInfo;
17 import com.intellij.openapi.util.registry.Registry;
18 import com.intellij.openapi.wm.IdeFocusManager;
19 import com.intellij.openapi.wm.IdeFrame;
20 import com.intellij.openapi.wm.StatusBar;
21 import com.intellij.ui.ComponentUtil;
22 import com.intellij.ui.components.JBMenu;
23 import com.intellij.ui.mac.foundation.NSDefaults;
24 import com.intellij.ui.plaf.beg.IdeaMenuUI;
25 import com.intellij.util.ReflectionUtil;
26 import com.intellij.util.SingleAlarm;
27 import com.intellij.util.ui.JBSwingUtilities;
28 import com.intellij.util.ui.UIUtil;
29 import org.jetbrains.annotations.NotNull;
30
31 import javax.swing.*;
32 import javax.swing.event.ChangeEvent;
33 import javax.swing.event.ChangeListener;
34 import javax.swing.event.MenuEvent;
35 import javax.swing.event.MenuListener;
36 import java.awt.*;
37 import java.awt.event.AWTEventListener;
38 import java.awt.event.ComponentEvent;
39 import java.awt.event.KeyEvent;
40 import java.awt.event.MouseEvent;
41 import java.beans.PropertyChangeEvent;
42 import java.beans.PropertyChangeListener;
43
44 public final class ActionMenu extends JBMenu {
45   private static final boolean KEEP_MENU_HIERARCHY = SystemInfo.isMacSystemMenu && Registry.is("keep.menu.hierarchy", false);
46   private final String myPlace;
47   private DataContext myContext;
48   private final ActionRef<ActionGroup> myGroup;
49   private final PresentationFactory myPresentationFactory;
50   private final Presentation myPresentation;
51   private boolean myMnemonicEnabled;
52   private MenuItemSynchronizer myMenuItemSynchronizer;
53   private StubItem myStubItem;  // A PATCH!!! Do not remove this code, otherwise you will lose all keyboard navigation in JMenuBar.
54   private final boolean myUseDarkIcons;
55   private Disposable myDisposable;
56
57   public ActionMenu(final DataContext context,
58                     @NotNull final String place,
59                     final ActionGroup group,
60                     final PresentationFactory presentationFactory,
61                     final boolean enableMnemonics,
62                     final boolean useDarkIcons
63   ) {
64     myContext = context;
65     myPlace = place;
66     myGroup = ActionRef.fromAction(group);
67     myPresentationFactory = presentationFactory;
68     myPresentation = myPresentationFactory.getPresentation(group);
69     myMnemonicEnabled = enableMnemonics;
70     myUseDarkIcons = useDarkIcons;
71
72     updateUI();
73
74     init();
75
76     // addNotify won't be called for menus in MacOS system menu
77     if (SystemInfo.isMacSystemMenu) {
78       installSynchronizer();
79     }
80
81     // Triggering initialization of private field "popupMenu" from JMenu with our own JBPopupMenu
82     getPopupMenu();
83   }
84
85   @Override
86   protected Graphics getComponentGraphics(Graphics graphics) {
87     if (!(getParent() instanceof JMenuBar)) return super.getComponentGraphics(graphics);
88     return JBSwingUtilities.runGlobalCGTransform(this, super.getComponentGraphics(graphics));
89   }
90
91   public void updateContext(DataContext context) {
92     myContext = context;
93   }
94
95   public AnAction getAnAction() { return myGroup.getAction(); }
96
97   @Override
98   public void addNotify() {
99     super.addNotify();
100     installSynchronizer();
101   }
102
103   private void installSynchronizer() {
104     if (myMenuItemSynchronizer == null) {
105       myMenuItemSynchronizer = new MenuItemSynchronizer();
106       myGroup.getAction().addPropertyChangeListener(myMenuItemSynchronizer);
107       myPresentation.addPropertyChangeListener(myMenuItemSynchronizer);
108     }
109   }
110
111   @Override
112   public void removeNotify() {
113     uninstallSynchronizer();
114     super.removeNotify();
115     if (myDisposable != null) {
116       Disposer.dispose(myDisposable);
117       myDisposable = null;
118     }
119   }
120
121   private void uninstallSynchronizer() {
122     if (myMenuItemSynchronizer != null) {
123       myGroup.getAction().removePropertyChangeListener(myMenuItemSynchronizer);
124       myPresentation.removePropertyChangeListener(myMenuItemSynchronizer);
125       myMenuItemSynchronizer = null;
126     }
127   }
128
129   private JPopupMenu mySpecialMenu;
130   @Override
131   public JPopupMenu getPopupMenu() {
132     if (mySpecialMenu == null) {
133       mySpecialMenu = new JBPopupMenu();
134       mySpecialMenu.setInvoker(this);
135       popupListener = createWinListener(mySpecialMenu);
136       ReflectionUtil.setField(JMenu.class, this, JPopupMenu.class, "popupMenu", mySpecialMenu);
137     }
138     return super.getPopupMenu();
139   }
140
141   @Override
142   public void updateUI() {
143     setUI(IdeaMenuUI.createUI(this));
144     setFont(UIUtil.getMenuFont());
145
146     JPopupMenu popupMenu = getPopupMenu();
147     if (popupMenu != null) {
148       popupMenu.updateUI();
149     }
150   }
151
152   private void init() {
153     boolean macSystemMenu = SystemInfo.isMacSystemMenu && myPlace.equals(ActionPlaces.MAIN_MENU);
154
155     myStubItem = macSystemMenu ? null : new StubItem();
156     addStubItem();
157     setBorderPainted(false);
158
159     MenuListenerImpl menuListener = new MenuListenerImpl();
160     addMenuListener(menuListener);
161     getModel().addChangeListener(menuListener);
162
163     setVisible(myPresentation.isVisible());
164     setEnabled(myPresentation.isEnabled());
165     setText(myPresentation.getText());
166     updateIcon();
167
168     setMnemonicEnabled(myMnemonicEnabled);
169   }
170
171   private void addStubItem() {
172     if (myStubItem != null) {
173       add(myStubItem);
174     }
175   }
176
177   public void setMnemonicEnabled(boolean enable) {
178     myMnemonicEnabled = enable;
179     setMnemonic(myPresentation.getMnemonic());
180     setDisplayedMnemonicIndex(myPresentation.getDisplayedMnemonicIndex());
181   }
182
183   @Override
184   public void setDisplayedMnemonicIndex(final int index) throws IllegalArgumentException {
185     super.setDisplayedMnemonicIndex(myMnemonicEnabled ? index : -1);
186   }
187
188   @Override
189   public void setMnemonic(int mnemonic) {
190     super.setMnemonic(myMnemonicEnabled ? mnemonic : 0);
191   }
192
193   private void updateIcon() {
194     UISettings settings = UISettings.getInstanceOrNull();
195     if (settings != null && settings.getShowIconsInMenus()) {
196       final Presentation presentation = myPresentation;
197       Icon icon = presentation.getIcon();
198       if (SystemInfo.isMacSystemMenu && ActionPlaces.MAIN_MENU.equals(myPlace) && icon != null) {
199         // JDK can't paint correctly our HiDPI icons at the system menu bar
200         icon = IconLoader.getMenuBarIcon(icon, myUseDarkIcons);
201       }
202       if (isShowIcons()) {
203         setIcon(null);
204         setDisabledIcon(null);
205       } else {
206         setIcon(icon);
207         if (presentation.getDisabledIcon() != null) {
208           setDisabledIcon(presentation.getDisabledIcon());
209         }
210         else {
211           setDisabledIcon(icon == null ? null : IconLoader.getDisabledIcon(icon));
212         }
213       }
214     }
215   }
216
217   static boolean isShowIcons() {
218     return SystemInfo.isMac && Registry.get("ide.macos.main.menu.alignment.options").isOptionEnabled("No icons");
219   }
220
221   static boolean isAligned() {
222     return SystemInfo.isMac && Registry.get("ide.macos.main.menu.alignment.options").isOptionEnabled("Aligned");
223   }
224
225   static boolean isAlignedInGroup() {
226     return SystemInfo.isMac && Registry.get("ide.macos.main.menu.alignment.options").isOptionEnabled("Aligned in group");
227   }
228
229   @Override
230   public void menuSelectionChanged(boolean isIncluded) {
231     super.menuSelectionChanged(isIncluded);
232     showDescriptionInStatusBar(isIncluded, this, myPresentation.getDescription());
233   }
234
235   public static void showDescriptionInStatusBar(boolean isIncluded, Component component, String description) {
236     IdeFrame frame = (IdeFrame)(component instanceof IdeFrame ? component : SwingUtilities.getAncestorOfClass(IdeFrame.class, component));
237     StatusBar statusBar;
238     if (frame != null && (statusBar = frame.getStatusBar()) != null) {
239       statusBar.setInfo(isIncluded ? description : null);
240     }
241   }
242
243   private class MenuListenerImpl implements ChangeListener, MenuListener {
244     boolean isSelected = false;
245
246     boolean myIsHidden = false;
247
248     @Override
249     public void stateChanged(ChangeEvent e) {
250       // Re-implement javax.swing.JMenu.MenuChangeListener to avoid recursive event notifications
251       // if 'menuSelected' fires unrelated 'stateChanged' event, without changing 'model.isSelected()' value.
252       ButtonModel model = (ButtonModel)e.getSource();
253       boolean modelSelected = model.isSelected();
254
255       if (modelSelected != isSelected) {
256         isSelected = modelSelected;
257
258         if (modelSelected) {
259           menuSelected();
260         }
261         else {
262           menuDeselected();
263         }
264       }
265     }
266
267     @Override
268     public void menuCanceled(MenuEvent e) {
269       onMenuHidden();
270     }
271
272     @Override
273     public void menuDeselected(MenuEvent e) {
274       // Use ChangeListener instead to guard against recursive calls
275     }
276
277     @Override
278     public void menuSelected(MenuEvent e) {
279       // Use ChangeListener instead to guard against recursive calls
280     }
281
282     private void menuDeselected() {
283       if (myDisposable != null) {
284         Disposer.dispose(myDisposable);
285         myDisposable = null;
286       }
287       onMenuHidden();
288     }
289
290     private void onMenuHidden() {
291       if (KEEP_MENU_HIERARCHY) {
292         return;
293       }
294
295       Runnable clearSelf = () -> {
296         clearItems();
297         addStubItem();
298       };
299
300       if (SystemInfo.isMacSystemMenu && myPlace.equals(ActionPlaces.MAIN_MENU)) {
301         // Menu items may contain mnemonic and they can affect key-event dispatching (when Alt pressed)
302         // To avoid influence of mnemonic it's necessary to clear items when menu was hidden.
303         // When user selects item of system menu (under MacOs) AppKit generates such sequence: CloseParentMenu -> PerformItemAction
304         // So we can destroy menu-item before item's action performed, and because of that action will not be executed.
305         // Defer clearing to avoid this problem.
306         Disposable listenerHolder = Disposer.newDisposable();
307         Disposer.register(ApplicationManager.getApplication(), listenerHolder);
308         IdeEventQueue.getInstance().addDispatcher(e -> {
309           if (e instanceof KeyEvent) {
310             if (myIsHidden) {
311               clearSelf.run();
312             }
313             ApplicationManager.getApplication().invokeLater(() -> Disposer.dispose(listenerHolder));
314           }
315           return false;
316         }, listenerHolder);
317
318         myIsHidden = true;
319       }
320       else {
321         clearSelf.run();
322       }
323     }
324
325     private void menuSelected() {
326       UsabilityHelper helper = new UsabilityHelper(ActionMenu.this);
327       if (myDisposable == null) {
328         myDisposable = Disposer.newDisposable();
329       }
330       Disposer.register(myDisposable, helper);
331       if (KEEP_MENU_HIERARCHY || myIsHidden) {
332         clearItems();
333       }
334       myIsHidden = false;
335       fillMenu();
336     }
337   }
338
339   public void clearItems() {
340     if (SystemInfo.isMacSystemMenu && myPlace.equals(ActionPlaces.MAIN_MENU)) {
341       for (Component menuComponent : getMenuComponents()) {
342         if (menuComponent instanceof ActionMenu) {
343           ((ActionMenu)menuComponent).clearItems();
344           // hideNotify is not called on Macs
345           ((ActionMenu)menuComponent).uninstallSynchronizer();
346         }
347         else if (menuComponent instanceof ActionMenuItem) {
348           // Looks like an old-fashioned ugly workaround
349           // JDK 1.7 on Mac works wrong with such functional keys
350           if (!SystemInfo.isMac) {
351             ((ActionMenuItem)menuComponent).setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F24, 0));
352           }
353         }
354       }
355     }
356
357     removeAll();
358     validate();
359   }
360
361   public void fillMenu() {
362     DataContext context;
363
364     if (myContext != null) {
365       context = myContext;
366     }
367     else {
368       DataManager dataManager = DataManager.getInstance();
369       @SuppressWarnings("deprecation") DataContext contextFromFocus = dataManager.getDataContext();
370       context = contextFromFocus;
371       if (PlatformDataKeys.CONTEXT_COMPONENT.getData(context) == null) {
372         IdeFrame frame = ComponentUtil.getParentOfType((Class<? extends IdeFrame>)IdeFrame.class, (Component)this);
373         context = dataManager.getDataContext(IdeFocusManager.getGlobalInstance().getLastFocusedFor((Window)frame));
374       }
375     }
376
377     final boolean isDarkMenu = SystemInfo.isMacSystemMenu && NSDefaults.isDarkMenuBar();
378     Utils.fillMenu(myGroup.getAction(), this, myMnemonicEnabled, myPresentationFactory, context, myPlace, true, LaterInvocator.isInModalContext(), isDarkMenu);
379   }
380
381   private class MenuItemSynchronizer implements PropertyChangeListener {
382     @Override
383     public void propertyChange(PropertyChangeEvent e) {
384       String name = e.getPropertyName();
385       if (Presentation.PROP_VISIBLE.equals(name)) {
386         setVisible(myPresentation.isVisible());
387         if (SystemInfo.isMacSystemMenu && myPlace.equals(ActionPlaces.MAIN_MENU)) {
388           validate();
389         }
390       }
391       else if (Presentation.PROP_ENABLED.equals(name)) {
392         setEnabled(myPresentation.isEnabled());
393       }
394       else if (Presentation.PROP_MNEMONIC_KEY.equals(name)) {
395         setMnemonic(myPresentation.getMnemonic());
396       }
397       else if (Presentation.PROP_MNEMONIC_INDEX.equals(name)) {
398         setDisplayedMnemonicIndex(myPresentation.getDisplayedMnemonicIndex());
399       }
400       else if (Presentation.PROP_TEXT.equals(name)) {
401         setText(myPresentation.getText());
402       }
403       else if (Presentation.PROP_ICON.equals(name) || Presentation.PROP_DISABLED_ICON.equals(name)) {
404         updateIcon();
405       }
406     }
407   }
408   private static final class UsabilityHelper implements IdeEventQueue.EventDispatcher, AWTEventListener, Disposable {
409
410     private Component myComponent;
411     private Point myLastMousePoint;
412     private Point myUpperTargetPoint;
413     private Point myLowerTargetPoint;
414     private SingleAlarm myCallbackAlarm;
415     private MouseEvent myEventToRedispatch;
416
417     private long myLastEventTime = 0L;
418     private boolean myInBounds = false;
419     private SingleAlarm myCheckAlarm;
420
421     private UsabilityHelper(Component component) {
422       myCallbackAlarm = new SingleAlarm(() -> {
423         Disposer.dispose(myCallbackAlarm);
424         myCallbackAlarm = null;
425         if (myEventToRedispatch != null) {
426           IdeEventQueue.getInstance().dispatchEvent(myEventToRedispatch);
427         }
428       }, 50, ModalityState.any(), this);
429       myCheckAlarm = new SingleAlarm(() -> {
430         if (myLastEventTime > 0 && System.currentTimeMillis() - myLastEventTime > 1500) {
431           if (!myInBounds && myCallbackAlarm != null && !myCallbackAlarm.isDisposed()) {
432             myCallbackAlarm.request();
433           }
434         }
435         myCheckAlarm.request();
436       }, 100, ModalityState.any(), this);
437       myComponent = component;
438       PointerInfo info = MouseInfo.getPointerInfo();
439       myLastMousePoint = info != null ? info.getLocation() : null;
440       if (myLastMousePoint != null) {
441         Toolkit.getDefaultToolkit().addAWTEventListener(this, AWTEvent.COMPONENT_EVENT_MASK);
442         IdeEventQueue.getInstance().addDispatcher(this, this);
443       }
444     }
445
446     @Override
447     public void eventDispatched(AWTEvent event) {
448       if (event instanceof ComponentEvent) {
449         ComponentEvent componentEvent = (ComponentEvent)event;
450         Component component = componentEvent.getComponent();
451         JPopupMenu popup = ComponentUtil.getParentOfType((Class<? extends JPopupMenu>)JPopupMenu.class, component);
452         if (popup != null && popup.getInvoker() == myComponent && popup.isShowing()) {
453           Rectangle bounds = popup.getBounds();
454           if (bounds.isEmpty()) return;
455           bounds.setLocation(popup.getLocationOnScreen());
456           if (myLastMousePoint.x < bounds.x) {
457             myUpperTargetPoint = new Point(bounds.x, bounds.y);
458             myLowerTargetPoint = new Point(bounds.x, bounds.y + bounds.height);
459           }
460           if (myLastMousePoint.x > bounds.x + bounds.width) {
461             myUpperTargetPoint = new Point(bounds.x + bounds.width, bounds.y);
462             myLowerTargetPoint = new Point(bounds.x + bounds.width, bounds.y + bounds.height);
463           }
464         }
465       }
466     }
467
468     @Override
469     public boolean dispatch(@NotNull AWTEvent e) {
470       if (e instanceof MouseEvent && myUpperTargetPoint != null && myLowerTargetPoint != null && myCallbackAlarm != null) {
471         if (e.getID() == MouseEvent.MOUSE_PRESSED || e.getID() == MouseEvent.MOUSE_RELEASED || e.getID() == MouseEvent.MOUSE_CLICKED) {
472           return false;
473         }
474         Point point = ((MouseEvent)e).getLocationOnScreen();
475         Rectangle bounds = myComponent.getBounds();
476         bounds.setLocation(myComponent.getLocationOnScreen());
477         myInBounds = bounds.contains(point);
478         boolean isMouseMovingTowardsSubmenu = myInBounds || new Polygon(
479           new int[]{myLastMousePoint.x, myUpperTargetPoint.x, myLowerTargetPoint.x},
480           new int[]{myLastMousePoint.y, myUpperTargetPoint.y, myLowerTargetPoint.y},
481           3).contains(point);
482
483         myEventToRedispatch = (MouseEvent)e;
484         myLastEventTime = System.currentTimeMillis();
485
486         if (!isMouseMovingTowardsSubmenu) {
487           myCallbackAlarm.request();
488         } else {
489           myCallbackAlarm.cancel();
490         }
491         myLastMousePoint = point;
492         return true;
493       }
494       return false;
495     }
496
497     @Override
498     public void dispose() {
499       myComponent = null;
500       myEventToRedispatch = null;
501       myLastMousePoint = myUpperTargetPoint = myLowerTargetPoint = null;
502       Toolkit.getDefaultToolkit().removeAWTEventListener(this);
503     }
504   }
505 }