79d1d9a05a63dcb0fb79c3eb7a04ada79f80a74f
[idea/community.git] / platform / platform-impl / src / com / intellij / ide / actionMacro / ActionMacroManager.java
1 // Copyright 2000-2019 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.ide.actionMacro;
3
4 import com.intellij.icons.AllIcons;
5 import com.intellij.ide.IdeBundle;
6 import com.intellij.ide.IdeEventQueue;
7 import com.intellij.openapi.Disposable;
8 import com.intellij.openapi.actionSystem.*;
9 import com.intellij.openapi.actionSystem.ex.ActionManagerEx;
10 import com.intellij.openapi.actionSystem.ex.AnActionListener;
11 import com.intellij.openapi.application.ApplicationManager;
12 import com.intellij.openapi.components.PersistentStateComponent;
13 import com.intellij.openapi.components.State;
14 import com.intellij.openapi.components.Storage;
15 import com.intellij.openapi.diagnostic.Logger;
16 import com.intellij.openapi.keymap.KeymapUtil;
17 import com.intellij.openapi.project.Project;
18 import com.intellij.openapi.ui.Messages;
19 import com.intellij.openapi.ui.playback.PlaybackContext;
20 import com.intellij.openapi.ui.playback.PlaybackRunner;
21 import com.intellij.openapi.ui.popup.Balloon;
22 import com.intellij.openapi.ui.popup.JBPopupAdapter;
23 import com.intellij.openapi.ui.popup.JBPopupFactory;
24 import com.intellij.openapi.ui.popup.LightweightWindowEvent;
25 import com.intellij.openapi.util.Disposer;
26 import com.intellij.openapi.util.registry.Registry;
27 import com.intellij.openapi.wm.*;
28 import com.intellij.ui.AnimatedIcon.Recording;
29 import com.intellij.ui.awt.RelativePoint;
30 import com.intellij.ui.components.panels.NonOpaquePanel;
31 import com.intellij.util.Consumer;
32 import com.intellij.util.messages.MessageBus;
33 import com.intellij.util.ui.AnimatedIcon;
34 import com.intellij.util.ui.BaseButtonBehavior;
35 import com.intellij.util.ui.PositionTracker;
36 import com.intellij.util.ui.UIUtil;
37 import org.jdom.Element;
38 import org.jetbrains.annotations.NonNls;
39 import org.jetbrains.annotations.NotNull;
40 import org.jetbrains.annotations.Nullable;
41
42 import javax.swing.*;
43 import java.awt.*;
44 import java.awt.event.InputEvent;
45 import java.awt.event.KeyEvent;
46 import java.awt.event.MouseEvent;
47 import java.util.ArrayList;
48 import java.util.HashSet;
49 import java.util.Set;
50
51 @State(name = "ActionMacroManager", storages = @Storage("macros.xml"))
52 public class ActionMacroManager implements PersistentStateComponent<Element>, Disposable {
53   private static final Logger LOG = Logger.getInstance(ActionMacroManager.class);
54
55   private static final String TYPING_SAMPLE = "WWWWWWWWWWWWWWWWWWWW";
56   private static final String RECORDED = "Recorded: ";
57
58   private boolean myIsRecording;
59   private ActionMacro myLastMacro;
60   private ActionMacro myRecordingMacro;
61   private ArrayList<ActionMacro> myMacros = new ArrayList<>();
62   private String myLastMacroName = null;
63   private boolean myIsPlaying = false;
64   @NonNls
65   private static final String ELEMENT_MACRO = "macro";
66   private final IdeEventQueue.EventDispatcher myKeyProcessor;
67
68   private final Set<InputEvent> myLastActionInputEvent = new HashSet<>();
69   private ActionMacroManager.Widget myWidget;
70
71   private String myLastTyping = "";
72
73   public ActionMacroManager(@NotNull MessageBus messageBus) {
74     messageBus.connect(this).subscribe(AnActionListener.TOPIC, new AnActionListener() {
75       @Override
76       public void beforeActionPerformed(@NotNull AnAction action, @NotNull DataContext dataContext, @NotNull final AnActionEvent event) {
77         String id = ActionManager.getInstance().getId(action);
78         if (id == null) return;
79         //noinspection HardCodedStringLiteral
80         if ("StartStopMacroRecording".equals(id)) {
81           myLastActionInputEvent.add(event.getInputEvent());
82         }
83         else if (myIsRecording) {
84           myRecordingMacro.appendAction(id);
85           String shortcut = null;
86           if (event.getInputEvent() instanceof KeyEvent) {
87             shortcut = KeymapUtil.getKeystrokeText(KeyStroke.getKeyStrokeForEvent((KeyEvent)event.getInputEvent()));
88           }
89           notifyUser(id + (shortcut != null ? " (" + shortcut + ")" : ""), false);
90           myLastActionInputEvent.add(event.getInputEvent());
91         }
92       }
93     });
94
95     myKeyProcessor = new MyKeyPostpocessor();
96     IdeEventQueue.getInstance().addPostprocessor(myKeyProcessor, null);
97   }
98
99   @Override
100   public void loadState(@NotNull Element state) {
101     myMacros = new ArrayList<>();
102     for (Element macroElement : state.getChildren(ELEMENT_MACRO)) {
103       ActionMacro macro = new ActionMacro();
104       macro.readExternal(macroElement);
105       myMacros.add(macro);
106     }
107
108     registerActions();
109   }
110
111   @Nullable
112    @Override
113    public Element getState() {
114     Element element = new Element("state");
115     for (ActionMacro macro : myMacros) {
116       Element macroElement = new Element(ELEMENT_MACRO);
117       macro.writeExternal(macroElement);
118       element.addContent(macroElement);
119     }
120     return element;
121   }
122
123   public static ActionMacroManager getInstance() {
124     return ApplicationManager.getApplication().getComponent(ActionMacroManager.class);
125   }
126
127   public void startRecording(String macroName) {
128     LOG.assertTrue(!myIsRecording);
129     myIsRecording = true;
130     myRecordingMacro = new ActionMacro(macroName);
131
132     final StatusBar statusBar = WindowManager.getInstance().getIdeFrame(null).getStatusBar();
133     myWidget = new Widget(statusBar);
134     statusBar.addWidget(myWidget);
135   }
136
137
138   private class Widget implements CustomStatusBarWidget, Consumer<MouseEvent> {
139
140     private final AnimatedIcon myIcon = new AnimatedIcon("Macro recording",
141                                                          Recording.ICONS.toArray(new Icon[0]),
142                                                          AllIcons.Ide.Macro.Recording_1,
143                                                          Recording.DELAY * Recording.ICONS.size());
144     private final StatusBar myStatusBar;
145     private final WidgetPresentation myPresentation;
146
147     private final JPanel myBalloonComponent;
148     private Balloon myBalloon;
149     private final JLabel myText;
150
151     private Widget(StatusBar statusBar) {
152       myStatusBar = statusBar;
153       myIcon.setBorder(StatusBarWidget.WidgetBorder.ICON);
154       myPresentation = new WidgetPresentation() {
155         @Override
156         public String getTooltipText() {
157           return "Macro is being recorded now";
158         }
159
160         @Override
161         public Consumer<MouseEvent> getClickConsumer() {
162           return Widget.this;
163         }
164       };
165
166
167       new BaseButtonBehavior(myIcon) {
168         @Override
169         protected void execute(MouseEvent e) {
170           showBalloon();
171         }
172       };
173
174       myBalloonComponent = new NonOpaquePanel(new BorderLayout());
175
176       final AnAction stopAction = ActionManager.getInstance().getAction("StartStopMacroRecording");
177       final DefaultActionGroup group = new DefaultActionGroup();
178       group.add(stopAction);
179       final ActionToolbar tb = ActionManager.getInstance().createActionToolbar(ActionPlaces.STATUS_BAR_PLACE, group, true);
180       tb.setMiniMode(true);
181
182       final NonOpaquePanel top = new NonOpaquePanel(new BorderLayout());
183       top.add(tb.getComponent(), BorderLayout.WEST);
184       myText = new JLabel(RECORDED + "..." + TYPING_SAMPLE, SwingConstants.LEFT);
185       final Dimension preferredSize = myText.getPreferredSize();
186       myText.setPreferredSize(preferredSize);
187       myText.setText("Macro recording started...");
188       myLastTyping = "";
189       top.add(myText, BorderLayout.CENTER);
190       myBalloonComponent.add(top, BorderLayout.CENTER);
191     }
192
193     private void showBalloon() {
194       if (myBalloon != null) {
195         Disposer.dispose(myBalloon);
196         return;
197       }
198
199       myBalloon = JBPopupFactory.getInstance().createBalloonBuilder(myBalloonComponent)
200         .setAnimationCycle(200)
201         .setCloseButtonEnabled(true)
202         .setHideOnAction(false)
203         .setHideOnClickOutside(false)
204         .setHideOnFrameResize(false)
205         .setHideOnKeyOutside(false)
206         .setSmallVariant(true)
207         .setShadow(true)
208         .createBalloon();
209
210       Disposer.register(myBalloon, new Disposable() {
211         @Override
212         public void dispose() {
213           myBalloon = null;
214         }
215       });
216
217       myBalloon.addListener(new JBPopupAdapter() {
218         @Override
219         public void onClosed(@NotNull LightweightWindowEvent event) {
220           if (myBalloon != null) {
221             Disposer.dispose(myBalloon);
222           }
223         }
224       });
225
226       myBalloon.show(new PositionTracker<Balloon>(myIcon) {
227         @Override
228         public RelativePoint recalculateLocation(Balloon object) {
229           return new RelativePoint(myIcon, new Point(myIcon.getSize().width / 2, 4));
230         }
231       }, Balloon.Position.above);
232     }
233
234     @Override
235     public JComponent getComponent() {
236       return myIcon;
237     }
238
239     @NotNull
240     @Override
241     public String ID() {
242       return "MacroRecording";
243     }
244
245     @Override
246     public void consume(MouseEvent mouseEvent) {
247     }
248
249     @Override
250     public WidgetPresentation getPresentation() {
251       return myPresentation;
252     }
253
254     @Override
255     public void install(@NotNull StatusBar statusBar) {
256       showBalloon();
257     }
258
259     @Override
260     public void dispose() {
261       Disposer.dispose(myIcon);
262       if (myBalloon != null) {
263         Disposer.dispose(myBalloon);
264       }
265     }
266
267     public void delete() {
268       if (myBalloon != null) {
269         Disposer.dispose(myBalloon);
270       }
271       myStatusBar.removeWidget(ID());
272     }
273
274     public void notifyUser(String text) {
275       myText.setText(text);
276       myText.revalidate();
277       myText.repaint();
278     }
279   }
280
281   public void stopRecording(@Nullable Project project) {
282     LOG.assertTrue(myIsRecording);
283
284     if (myWidget != null) {
285       myWidget.delete();
286       myWidget = null;
287     }
288
289     myIsRecording = false;
290     myLastActionInputEvent.clear();
291     String macroName;
292     do {
293       macroName = Messages.showInputDialog(project,
294                                            IdeBundle.message("prompt.enter.macro.name"),
295                                            IdeBundle.message("title.enter.macro.name"),
296                                            Messages.getQuestionIcon());
297       if (macroName == null) {
298         myRecordingMacro = null;
299         return;
300       }
301
302       if (macroName.isEmpty()) macroName = null;
303     }
304     while (macroName != null && !checkCanCreateMacro(macroName));
305
306     myLastMacro = myRecordingMacro;
307     addRecordedMacroWithName(macroName);
308     registerActions();
309   }
310
311   private void addRecordedMacroWithName(@Nullable String macroName) {
312     if (macroName != null) {
313       myRecordingMacro.setName(macroName);
314       myMacros.add(myRecordingMacro);
315       myRecordingMacro = null;
316     }
317     else {
318       for (int i = 0; i < myMacros.size(); i++) {
319         ActionMacro macro = myMacros.get(i);
320         if (IdeBundle.message("macro.noname").equals(macro.getName())) {
321           myMacros.set(i, myRecordingMacro);
322           myRecordingMacro = null;
323           break;
324         }
325       }
326       if (myRecordingMacro != null) {
327         myMacros.add(myRecordingMacro);
328         myRecordingMacro = null;
329       }
330     }
331   }
332
333   public void playbackLastMacro() {
334     if (myLastMacro != null) {
335       playbackMacro(myLastMacro);
336     }
337   }
338
339   private void playbackMacro(ActionMacro macro) {
340     final IdeFrame frame = WindowManager.getInstance().getIdeFrame(null);
341     assert frame != null;
342
343     StringBuffer script = new StringBuffer();
344     ActionMacro.ActionDescriptor[] actions = macro.getActions();
345     for (ActionMacro.ActionDescriptor each : actions) {
346       each.generateTo(script);
347     }
348
349     final PlaybackRunner runner = new PlaybackRunner(script.toString(), new PlaybackRunner.StatusCallback.Edt() {
350
351       @Override
352       public void messageEdt(PlaybackContext context, String text, Type type) {
353         if (type == Type.message || type == Type.error) {
354           StatusBar statusBar = frame.getStatusBar();
355           if (statusBar != null) {
356             if (context != null) {
357               text = "Line " + context.getCurrentLine() + ": " + text;
358             }
359             statusBar.setInfo(text);
360           }
361         }
362       }
363     }, Registry.is("actionSystem.playback.useDirectActionCall"), true, Registry.is("actionSystem.playback.useTypingTargets"));
364
365     myIsPlaying = true;
366
367     runner.run()
368       .doWhenDone(() -> {
369         StatusBar statusBar = frame.getStatusBar();
370         statusBar.setInfo("Script execution finished");
371       })
372       .doWhenProcessed(() -> myIsPlaying = false);
373   }
374
375   public boolean isRecording() {
376     return myIsRecording;
377   }
378
379   @Override
380   public void dispose() {
381     IdeEventQueue.getInstance().removePostprocessor(myKeyProcessor);
382   }
383
384   public ActionMacro[] getAllMacros() {
385     return myMacros.toArray(new ActionMacro[0]);
386   }
387
388   public void removeAllMacros() {
389     if (myLastMacro != null) {
390       myLastMacroName = myLastMacro.getName();
391       myLastMacro = null;
392     }
393     myMacros = new ArrayList<>();
394   }
395
396   public void addMacro(ActionMacro macro) {
397     myMacros.add(macro);
398     if (myLastMacroName != null && myLastMacroName.equals(macro.getName())) {
399       myLastMacro = macro;
400       myLastMacroName = null;
401     }
402   }
403
404   public void playMacro(ActionMacro macro) {
405     playbackMacro(macro);
406     myLastMacro = macro;
407   }
408
409   public boolean hasRecentMacro() {
410     return myLastMacro != null;
411   }
412
413   public void registerActions() {
414     unregisterActions();
415     HashSet<String> registeredIds = new HashSet<>(); // to prevent exception if 2 or more targets have the same name
416
417     ActionMacro[] macros = getAllMacros();
418     for (final ActionMacro macro : macros) {
419       String actionId = macro.getActionId();
420
421       if (!registeredIds.contains(actionId)) {
422         registeredIds.add(actionId);
423         ActionManager.getInstance().registerAction(actionId, new InvokeMacroAction(macro));
424       }
425     }
426   }
427
428   public void unregisterActions() {
429
430     // unregister Tool actions
431     String[] oldIds = ActionManager.getInstance().getActionIds(ActionMacro.MACRO_ACTION_PREFIX);
432     for (final String oldId : oldIds) {
433       ActionManager.getInstance().unregisterAction(oldId);
434     }
435   }
436
437   public boolean checkCanCreateMacro(String name) {
438     final ActionManagerEx actionManager = (ActionManagerEx)ActionManager.getInstance();
439     final String actionId = ActionMacro.MACRO_ACTION_PREFIX + name;
440     if (actionManager.getAction(actionId) != null) {
441       if (Messages.showYesNoDialog(IdeBundle.message("message.macro.exists", name),
442                                    IdeBundle.message("title.macro.name.already.used"),
443                                    Messages.getWarningIcon()) != Messages.YES) {
444         return false;
445       }
446       actionManager.unregisterAction(actionId);
447       removeMacro(name);
448     }
449
450     return true;
451   }
452
453   private void removeMacro(String name) {
454     for (int i = 0; i < myMacros.size(); i++) {
455       ActionMacro macro = myMacros.get(i);
456       if (name.equals(macro.getName())) {
457         myMacros.remove(i);
458         break;
459       }
460     }
461   }
462
463   public boolean isPlaying() {
464     return myIsPlaying;
465   }
466
467   private static class InvokeMacroAction extends AnAction {
468     private final ActionMacro myMacro;
469
470     InvokeMacroAction(ActionMacro macro) {
471       myMacro = macro;
472       getTemplatePresentation().setText(macro.getName(), false);
473     }
474
475     @Override
476     public void actionPerformed(@NotNull AnActionEvent e) {
477       IdeEventQueue.getInstance().doWhenReady(() -> getInstance().playMacro(myMacro));
478     }
479
480     @Override
481     public void update(@NotNull AnActionEvent e) {
482       e.getPresentation().setEnabled(!getInstance().isPlaying());
483     }
484
485     @Nullable
486     @Override
487     public String getTemplateText() {
488       return "Invoke Macro";
489     }
490   }
491
492   private class MyKeyPostpocessor implements IdeEventQueue.EventDispatcher {
493
494     @Override
495     public boolean dispatch(@NotNull AWTEvent e) {
496       if (isRecording() && e instanceof KeyEvent) {
497         postProcessKeyEvent((KeyEvent)e);
498       }
499       return false;
500     }
501
502     public void postProcessKeyEvent(KeyEvent e) {
503       if (e.getID() != KeyEvent.KEY_PRESSED) return;
504       if (myLastActionInputEvent.contains(e)) {
505         myLastActionInputEvent.remove(e);
506         return;
507       }
508       final boolean modifierKeyIsPressed = e.getKeyCode() == KeyEvent.VK_CONTROL ||
509                                            e.getKeyCode() == KeyEvent.VK_ALT ||
510                                            e.getKeyCode() == KeyEvent.VK_META ||
511                                            e.getKeyCode() == KeyEvent.VK_SHIFT;
512       if (modifierKeyIsPressed) return;
513
514       final boolean ready = IdeEventQueue.getInstance().getKeyEventDispatcher().isReady();
515       final boolean isChar = e.getKeyChar() != KeyEvent.CHAR_UNDEFINED && UIUtil.isReallyTypedEvent(e);
516       final boolean hasActionModifiers = e.isAltDown() | e.isControlDown() | e.isMetaDown();
517       final boolean plainType = isChar && !hasActionModifiers;
518       final boolean isEnter = e.getKeyCode() == KeyEvent.VK_ENTER;
519
520       if (plainType && ready && !isEnter) {
521         myRecordingMacro.appendKeytyped(e.getKeyChar(), e.getKeyCode(), e.getModifiers());
522         notifyUser(Character.valueOf(e.getKeyChar()).toString(), true);
523       }
524       else if ((!plainType && ready) || isEnter) {
525         final String stroke = KeyStroke.getKeyStrokeForEvent(e).toString();
526
527         final int pressed = stroke.indexOf("pressed");
528         String key = stroke.substring(pressed + "pressed".length());
529         String modifiers = stroke.substring(0, pressed);
530
531         String shortcut = (modifiers.replaceAll("ctrl", "control").trim() + " " + key.trim()).trim();
532
533         myRecordingMacro.appendShortcut(shortcut);
534         notifyUser(KeymapUtil.getKeystrokeText(KeyStroke.getKeyStrokeForEvent(e)), false);
535       }
536     }
537   }
538
539   private void notifyUser(String text, boolean typing) {
540     String actualText = text;
541     if (typing) {
542       int maxLength = TYPING_SAMPLE.length();
543       myLastTyping += text;
544       if (myLastTyping.length() > maxLength) {
545         myLastTyping = "..." + myLastTyping.substring(myLastTyping.length() - maxLength);
546       }
547       actualText = myLastTyping;
548     } else {
549       myLastTyping = "";
550     }
551
552     if (myWidget != null) {
553       myWidget.notifyUser(RECORDED + actualText);
554     }
555   }
556 }