/*
* Copyright 2000-2017 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.intellij.openapi.keymap.impl;
import com.intellij.ide.DataManager;
import com.intellij.ide.IdeEventQueue;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.actionSystem.*;
import com.intellij.openapi.actionSystem.ex.ActionManagerEx;
import com.intellij.openapi.actionSystem.ex.AnActionListener;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.components.ApplicationComponent;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.util.Clock;
import com.intellij.openapi.util.Couple;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.SystemInfo;
import com.intellij.openapi.wm.IdeFocusManager;
import com.intellij.util.containers.ContainerUtil;
import gnu.trove.TIntIntHashMap;
import gnu.trove.TIntIntProcedure;
import org.jetbrains.annotations.NotNull;
import java.awt.*;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import static com.intellij.openapi.keymap.KeymapUtil.getActiveKeymapShortcuts;
/**
* Support for keyboard shortcuts like Control-double-click or Control-double-click+A
*
* Timings that are used in the implementation to detect double click were tuned for SearchEverywhere
* functionality (invoked on double Shift), so if you need to change them, please make sure
* SearchEverywhere behaviour remains intact.
*/
public class ModifierKeyDoubleClickHandler implements Disposable, ApplicationComponent {
private static final Logger LOG = Logger.getInstance(ModifierKeyDoubleClickHandler.class);
private static final TIntIntHashMap KEY_CODE_TO_MODIFIER_MAP = new TIntIntHashMap();
static {
KEY_CODE_TO_MODIFIER_MAP.put(KeyEvent.VK_ALT, InputEvent.ALT_MASK);
KEY_CODE_TO_MODIFIER_MAP.put(KeyEvent.VK_CONTROL, InputEvent.CTRL_MASK);
KEY_CODE_TO_MODIFIER_MAP.put(KeyEvent.VK_META, InputEvent.META_MASK);
KEY_CODE_TO_MODIFIER_MAP.put(KeyEvent.VK_SHIFT, InputEvent.SHIFT_MASK);
}
private final ActionManagerEx myActionManagerEx;
private final ConcurrentMap<String, MyDispatcher> myDispatchers = ContainerUtil.newConcurrentMap();
private boolean myIsRunningAction;
private ModifierKeyDoubleClickHandler(ActionManagerEx actionManagerEx) {
myActionManagerEx = actionManagerEx;
}
@Override
public void initComponent() {
int modifierKeyCode = getMultiCaretActionModifier();
registerAction(IdeActions.ACTION_EDITOR_CLONE_CARET_ABOVE, modifierKeyCode, KeyEvent.VK_UP);
registerAction(IdeActions.ACTION_EDITOR_CLONE_CARET_BELOW, modifierKeyCode, KeyEvent.VK_DOWN);
registerAction(IdeActions.ACTION_EDITOR_MOVE_CARET_LEFT_WITH_SELECTION, modifierKeyCode, KeyEvent.VK_LEFT);
registerAction(IdeActions.ACTION_EDITOR_MOVE_CARET_RIGHT_WITH_SELECTION, modifierKeyCode, KeyEvent.VK_RIGHT);
registerAction(IdeActions.ACTION_EDITOR_MOVE_LINE_START_WITH_SELECTION, modifierKeyCode, KeyEvent.VK_HOME);
registerAction(IdeActions.ACTION_EDITOR_MOVE_LINE_END_WITH_SELECTION, modifierKeyCode, KeyEvent.VK_END);
}
@Override
public void dispose() {
for (MyDispatcher dispatcher : myDispatchers.values()) {
Disposer.dispose(dispatcher);
}
myDispatchers.clear();
}
public static ModifierKeyDoubleClickHandler getInstance() {
return ApplicationManager.getApplication().getComponent(ModifierKeyDoubleClickHandler.class);
}
public static int getMultiCaretActionModifier() {
return SystemInfo.isMac ? KeyEvent.VK_ALT : KeyEvent.VK_CONTROL;
}
/**
* @param actionId Id of action to be triggered on modifier+modifier[+actionKey]
* @param modifierKeyCode keyCode for modifier, e.g. KeyEvent.VK_SHIFT
* @param actionKeyCode keyCode for actionKey, or -1 if action should be triggered on bare modifier double click
* @param skipIfActionHasShortcut do not invoke action if a shortcut is already bound to it in keymap
*/
public void registerAction(@NotNull String actionId,
int modifierKeyCode,
int actionKeyCode,
boolean skipIfActionHasShortcut) {
final MyDispatcher dispatcher = new MyDispatcher(actionId, modifierKeyCode, actionKeyCode, skipIfActionHasShortcut);
MyDispatcher oldDispatcher = myDispatchers.put(actionId, dispatcher);
IdeEventQueue.getInstance().addDispatcher(dispatcher, dispatcher);
myActionManagerEx.addAnActionListener(dispatcher, dispatcher);
if (oldDispatcher != null) {
Disposer.dispose(oldDispatcher);
}
}
/**
* @param actionId Id of action to be triggered on modifier+modifier[+actionKey]
* @param modifierKeyCode keyCode for modifier, e.g. KeyEvent.VK_SHIFT
* @param actionKeyCode keyCode for actionKey, or -1 if action should be triggered on bare modifier double click
*/
public void registerAction(@NotNull String actionId,
int modifierKeyCode,
int actionKeyCode) {
registerAction(actionId, modifierKeyCode, actionKeyCode, true);
}
public void unregisterAction(@NotNull String actionId) {
MyDispatcher oldDispatcher = myDispatchers.remove(actionId);
if (oldDispatcher != null) {
Disposer.dispose(oldDispatcher);
}
}
public boolean isRunningAction() {
return myIsRunningAction;
}
private class MyDispatcher extends AnActionListener.Adapter implements IdeEventQueue.EventDispatcher, Disposable {
private final String myActionId;
private final int myModifierKeyCode;
private final int myActionKeyCode;
private final boolean mySkipIfActionHasShortcut;
private final Couple<AtomicBoolean> ourPressed = Couple.of(new AtomicBoolean(false), new AtomicBoolean(false));
private final Couple<AtomicBoolean> ourReleased = Couple.of(new AtomicBoolean(false), new AtomicBoolean(false));
private final AtomicBoolean ourOtherKeyWasPressed = new AtomicBoolean(false);
private final AtomicLong ourLastTimePressed = new AtomicLong(0);
public MyDispatcher(@NotNull String actionId, int modifierKeyCode, int actionKeyCode, boolean skipIfActionHasShortcut) {
myActionId = actionId;
myModifierKeyCode = modifierKeyCode;
myActionKeyCode = actionKeyCode;
mySkipIfActionHasShortcut = skipIfActionHasShortcut;
}
@Override
public boolean dispatch(@NotNull AWTEvent event) {
if (event instanceof KeyEvent) {
final KeyEvent keyEvent = (KeyEvent)event;
final int keyCode = keyEvent.getKeyCode();
LOG.debug("", this, event);
if (keyCode == myModifierKeyCode) {
if (hasOtherModifiers(keyEvent)) {
resetState();
return false;
}
if (myActionKeyCode == -1 && ourOtherKeyWasPressed.get() && Clock.getTime() - ourLastTimePressed.get() < 500) {
resetState();
return false;
}
ourOtherKeyWasPressed.set(false);
if (ourPressed.first.get() && Clock.getTime() - ourLastTimePressed.get() > 500) {
resetState();
}
handleModifier((KeyEvent)event);
return false;
} else if (ourPressed.first.get() && ourReleased.first.get() && ourPressed.second.get() && myActionKeyCode != -1) {
if (keyCode == myActionKeyCode && !hasOtherModifiers(keyEvent)) {
if (event.getID() == KeyEvent.KEY_PRESSED) {
return run(keyEvent);
}
return true;
}
return false;
} else {
ourLastTimePressed.set(Clock.getTime());
ourOtherKeyWasPressed.set(true);
if (keyCode == KeyEvent.VK_ESCAPE || keyCode == KeyEvent.VK_TAB) {
ourLastTimePressed.set(0);
}
}
resetState();
}
return false;
}
private boolean hasOtherModifiers(KeyEvent keyEvent) {
final int modifiers = keyEvent.getModifiers();
return !KEY_CODE_TO_MODIFIER_MAP.forEachEntry(new TIntIntProcedure() {
@Override
public boolean execute(int keyCode, int modifierMask) {
return keyCode == myModifierKeyCode || (modifiers & modifierMask) == 0;
}
});
}
private void handleModifier(KeyEvent event) {
if (ourPressed.first.get() && Clock.getTime() - ourLastTimePressed.get() > 300) {
resetState();
return;
}
if (event.getID() == KeyEvent.KEY_PRESSED) {
if (!ourPressed.first.get()) {
resetState();
ourPressed.first.set(true);
ourLastTimePressed.set(Clock.getTime());
return;
} else {
if (ourPressed.first.get() && ourReleased.first.get()) {
ourPressed.second.set(true);
ourLastTimePressed.set(Clock.getTime());
return;
}
}
} else if (event.getID() == KeyEvent.KEY_RELEASED) {
if (ourPressed.first.get() && !ourReleased.first.get()) {
ourReleased.first.set(true);
ourLastTimePressed.set(Clock.getTime());
return;
} else if (ourPressed.first.get() && ourReleased.first.get() && ourPressed.second.get()) {
resetState();
if (myActionKeyCode == -1 && !shouldSkipIfActionHasShortcut()) {
run(event);
}
return;
}
}
resetState();
}
private void resetState() {
ourPressed.first.set(false);
ourPressed.second.set(false);
ourReleased.first.set(false);
ourReleased.second.set(false);
}
private boolean run(KeyEvent event) {
myIsRunningAction = true;
try {
AnAction action = myActionManagerEx.getAction(myActionId);
if (action == null) return false;
DataContext context = DataManager.getInstance().getDataContext(IdeFocusManager.findInstance().getFocusOwner());
AnActionEvent anActionEvent = AnActionEvent.createFromAnAction(action, event, ActionPlaces.MAIN_MENU, context);
action.update(anActionEvent);
if (!anActionEvent.getPresentation().isEnabled()) return false;
myActionManagerEx.fireBeforeActionPerformed(action, anActionEvent.getDataContext(), anActionEvent);
action.actionPerformed(anActionEvent);
myActionManagerEx.fireAfterActionPerformed(action, anActionEvent.getDataContext(), anActionEvent);
return true;
}
finally {
myIsRunningAction = false;
}
}
private boolean shouldSkipIfActionHasShortcut() {
return mySkipIfActionHasShortcut && getActiveKeymapShortcuts(myActionId).getShortcuts().length > 0;
}
@Override
public void beforeActionPerformed(AnAction action, DataContext dataContext, AnActionEvent event) {
if (!myIsRunningAction) resetState();
}
@Override
public void dispose() {
}
@Override
public String toString() {
return "modifier double-click dispatcher [modifierKeyCode=" + myModifierKeyCode +
",actionKeyCode=" + myActionKeyCode + ",actionId=" + myActionId + "]";
}
}
}