file structure dialog speed search match highlighting
[idea/community.git] / platform / platform-impl / src / com / intellij / ui / SpeedSearchBase.java
1 /*
2  * Copyright 2000-2009 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.intellij.ui;
17
18 import com.intellij.featureStatistics.FeatureUsageTracker;
19 import com.intellij.ide.DataManager;
20 import com.intellij.openapi.actionSystem.PlatformDataKeys;
21 import com.intellij.openapi.application.ApplicationManager;
22 import com.intellij.openapi.diagnostic.Logger;
23 import com.intellij.openapi.project.Project;
24 import com.intellij.openapi.util.Key;
25 import com.intellij.openapi.wm.ToolWindowManager;
26 import com.intellij.openapi.wm.ex.ToolWindowManagerAdapter;
27 import com.intellij.openapi.wm.ex.ToolWindowManagerEx;
28 import com.intellij.openapi.wm.ex.ToolWindowManagerListener;
29 import com.intellij.util.StringBuilderSpinAllocator;
30 import com.intellij.util.ui.UIUtil;
31 import org.jetbrains.annotations.NonNls;
32 import org.jetbrains.annotations.NotNull;
33 import org.jetbrains.annotations.Nullable;
34
35 import javax.swing.*;
36 import javax.swing.text.AttributeSet;
37 import javax.swing.text.BadLocationException;
38 import javax.swing.text.PlainDocument;
39 import java.awt.*;
40 import java.awt.event.FocusAdapter;
41 import java.awt.event.FocusEvent;
42 import java.awt.event.KeyAdapter;
43 import java.awt.event.KeyEvent;
44 import java.beans.PropertyChangeListener;
45 import java.beans.PropertyChangeSupport;
46 import java.util.ListIterator;
47 import java.util.NoSuchElementException;
48 import java.util.regex.Matcher;
49 import java.util.regex.Pattern;
50 import java.util.regex.PatternSyntaxException;
51
52 public abstract class SpeedSearchBase<Comp extends JComponent> {
53   private static final Logger LOG = Logger.getInstance("#com.intellij.ui.SpeedSearchBase");
54   private SearchPopup mySearchPopup;
55   private JLayeredPane myPopupLayeredPane;
56   protected final Comp myComponent;
57   private final ToolWindowManagerListener myWindowManagerListener = new MyToolWindowManagerListener();
58   private final PropertyChangeSupport myChangeSupport = new PropertyChangeSupport(this);
59   private String myRecentEnteredPrefix;
60   private SpeedSearchComparator myComparator = new SpeedSearchComparator();
61
62   private static final Key SPEED_SEARCH_COMPONENT_MARKER = new Key("SPEED_SEARCH_COMPONENT_MARKER");
63   @NonNls protected static final String ENTERED_PREFIX_PROPERTY_NAME = "enteredPrefix";
64
65   public SpeedSearchBase(Comp component) {
66     myComponent = component;
67
68     myComponent.addFocusListener(new FocusAdapter() {
69       public void focusLost(FocusEvent e) {
70         manageSearchPopup(null);
71       }
72     });
73     myComponent.addKeyListener(new KeyAdapter() {
74       public void keyTyped(KeyEvent e) {
75         processKeyEvent(e);
76       }
77
78       public void keyPressed(KeyEvent e) {
79         processKeyEvent(e);
80       }
81     });
82
83     component.putClientProperty(SPEED_SEARCH_COMPONENT_MARKER, this);
84   }
85
86   public static boolean hasActiveSpeedSearch(JComponent component) {
87     SpeedSearchBase speedSearch = (SpeedSearchBase)component.getClientProperty(SPEED_SEARCH_COMPONENT_MARKER);
88     return speedSearch != null && speedSearch.mySearchPopup != null && speedSearch.mySearchPopup.isVisible();
89   }
90
91   /**
92    * Returns visual (view) selection index.
93    */
94   protected abstract int getSelectedIndex();
95
96   protected abstract Object[] getAllElements();
97
98   protected abstract String getElementText(Object element);
99
100   /**
101    * Should convert given view index to model index
102    */
103   protected int convertIndexToModel(final int viewIndex) {
104     return viewIndex;
105   }
106
107   /**
108    * @param element Element to select. Don't forget to convert model index to view index if needed (i.e. table.convertRowIndexToView(modelIndex), etc).
109    * @param selectedText search text
110    */
111   protected abstract void selectElement(Object element, String selectedText);
112
113   protected ListIterator<Object> getElementIterator(int startingIndex) {
114     final Object[] allElements = getAllElements();
115     return new ViewIterator(this, startingIndex < 0 ? allElements.length : startingIndex);
116   }
117
118   public void addChangeListener(PropertyChangeListener listener) {
119     myChangeSupport.addPropertyChangeListener(listener);
120   }
121
122   public void removeChangeListener(PropertyChangeListener listener) {
123     myChangeSupport.removePropertyChangeListener(listener);
124   }
125
126   private void fireStateChanged() {
127     String enteredPrefix = getEnteredPrefix();
128     myChangeSupport.firePropertyChange(ENTERED_PREFIX_PROPERTY_NAME, myRecentEnteredPrefix, enteredPrefix);
129     myRecentEnteredPrefix = enteredPrefix;
130   }
131
132   protected boolean isMatchingElement(Object element, String pattern) {
133     String str = getElementText(element);
134     return str != null && compare(str, pattern);
135   }
136
137   protected boolean compare(String text, String pattern) {
138     return myComparator.doCompare(pattern, text);
139   }
140
141   public SpeedSearchComparator getComparator() {
142     return myComparator;
143   }
144
145   public void setComparator(final SpeedSearchComparator comparator) {
146     myComparator = comparator;
147   }
148
149   public static class SpeedSearchComparator {
150     private Matcher myRecentSearchMatcher;
151     private String myRecentSearchText;
152     private boolean myShouldMatchFromTheBeginning;
153
154     public SpeedSearchComparator() {
155       this(true);
156     }
157
158     public SpeedSearchComparator(boolean shouldMatchFromTheBeginning) {
159       myShouldMatchFromTheBeginning = shouldMatchFromTheBeginning;
160     }
161
162     public boolean doCompare(String pattern, String text) {
163       if (myRecentSearchText != null &&
164           myRecentSearchText.equals(pattern)
165         ) {
166         myRecentSearchMatcher.reset(text);
167         return myRecentSearchMatcher.find();
168       }
169       else {
170         myRecentSearchText = pattern;
171         @NonNls final StringBuilder buf = StringBuilderSpinAllocator.alloc();
172
173         try {
174           translatePattern(buf, pattern);
175
176           try {
177             boolean allLowercase = pattern.equals(pattern.toLowerCase());
178             final Pattern recentSearchPattern = Pattern.compile(buf.toString(), allLowercase ? Pattern.CASE_INSENSITIVE : 0);
179             return (myRecentSearchMatcher = recentSearchPattern.matcher(text)).find();
180           }
181           catch (PatternSyntaxException ex) {
182             myRecentSearchText = null;
183           }
184         }
185         finally {
186           StringBuilderSpinAllocator.dispose(buf);
187         }
188
189         return false;
190       }
191     }
192
193     public void translatePattern(final StringBuilder buf, final String pattern) {
194       if (myShouldMatchFromTheBeginning) buf.append('^'); // match from the line start
195       final int len = pattern.length();
196       for (int i = 0; i < len; ++i) {
197         translateCharacter(buf, pattern.charAt(i));
198       }
199
200       if (buf.length() > 0 && "*^".indexOf(buf.charAt(buf.length() - 1)) == -1) buf.append(')');
201     }
202
203     public String getRecentSearchText() {
204       return myRecentSearchText;
205     }
206
207     public Matcher getRecentSearchMatcher() {
208       return myRecentSearchMatcher;
209     }
210
211     public void translateCharacter(final StringBuilder buf, final char ch) {
212       if (ch == '*' ) {
213         buf.append("(\\w|:)"); // ':' for xml tags
214       }
215       else if ("{}[].+^$()?".indexOf(ch) != -1) {
216         // do not bother with other metachars
217         buf.append('\\');
218       }
219
220       if (Character.isUpperCase(ch)) {
221         if (buf.length() > 0 && "*^".indexOf(buf.charAt(buf.length() - 1)) == -1) buf.append(')');
222         // for camel humps
223         buf.append("[A-Za-z_]*");
224         buf.append('(');
225       } else {
226         if (buf.length() > 0 && "*^".indexOf(buf.charAt(buf.length() - 1)) != -1) buf.append('(');
227       }
228
229       if (buf.length() == 0 || buf.length() > 0 && "^".indexOf(buf.charAt(buf.length() - 1)) != -1) buf.append('(');
230       buf.append(ch);
231     }
232   }
233
234   @Nullable
235   private Object findNextElement(String s) {
236     final String _s = s.trim();
237     final int selectedIndex = getSelectedIndex();
238     final ListIterator<?> it = getElementIterator(selectedIndex + 1);
239     final Object current;
240     if (it.hasPrevious()) {
241       current = it.previous();
242       it.next();
243     }
244     else current = null;
245     while (it.hasNext()) {
246       final Object element = it.next();
247       if (isMatchingElement(element, _s)) return element;
248     }
249     return isMatchingElement(current, _s) ? current : null;
250   }
251
252   @Nullable
253   private Object findPreviousElement(String s) {
254     final String _s = s.trim();
255     final int selectedIndex = getSelectedIndex();
256     if (selectedIndex < 0) return null;
257     final ListIterator<?> it = getElementIterator(selectedIndex);
258     final Object current;
259     if (it.hasNext()) {
260       current = it.next();
261       it.previous();
262     }
263     else current = null;
264     while (it.hasPrevious()) {
265       final Object element = it.previous();
266       if (isMatchingElement(element, _s)) return element;
267     }
268     return selectedIndex != -1 && isMatchingElement(current, _s) ? current : null;
269   }
270
271   @Nullable
272   private Object findElement(String s) {
273     final String _s = s.trim();
274     int selectedIndex = getSelectedIndex();
275     if (selectedIndex < 0) {
276       selectedIndex = 0;
277     }
278     final ListIterator<Object> it = getElementIterator(selectedIndex);
279     while (it.hasNext()) {
280       final Object element = it.next();
281       if (isMatchingElement(element, _s)) return element;
282     }
283     if (selectedIndex > 0) {
284       while (it.hasPrevious()) it.previous();
285       while (it.hasNext() && it.nextIndex() != selectedIndex) {
286         final Object element = it.next();
287         if (isMatchingElement(element, _s)) return element;
288       }
289     }
290     return null;
291   }
292
293   @Nullable
294   private Object findFirstElement(String s) {
295     final String _s = s.trim();
296     for (ListIterator<?> it = getElementIterator(0); it.hasNext();) {
297       final Object element = it.next();
298       if (isMatchingElement(element, _s)) return element;
299     }
300     return null;
301   }
302
303   @Nullable
304   private Object findLastElement(String s) {
305     final String _s = s.trim();
306     for (ListIterator<?> it = getElementIterator(-1); it.hasPrevious();) {
307       final Object element = it.previous();
308       if (isMatchingElement(element, _s)) return element;
309     }
310     return null;
311   }
312
313   private void processKeyEvent(KeyEvent e) {
314     if (e.isAltDown()) return;
315     if (mySearchPopup != null) {
316       mySearchPopup.processKeyEvent(e);
317       return;
318     }
319     if (!isSpeedSearchEnabled()) return;
320     if (e.getID() == KeyEvent.KEY_TYPED) {
321       if (!UIUtil.isReallyTypedEvent(e)) return;
322
323       char c = e.getKeyChar();
324       if (Character.isLetterOrDigit(c) || c == '_' || c == '*' || c == '/' || c == ':') {
325         manageSearchPopup(new SearchPopup(String.valueOf(c)));
326         e.consume();
327       }
328     }
329   }
330
331
332   public Comp getComponent() {
333     return myComponent;
334   }
335
336   protected boolean isSpeedSearchEnabled() {
337     return true;
338   }
339
340   @Nullable
341   public String getEnteredPrefix() {
342     return mySearchPopup != null ? mySearchPopup.mySearchField.getText() : null;
343   }
344
345   public void refreshSelection() {
346     if ( mySearchPopup != null ) mySearchPopup.refreshSelection();
347   }
348
349   private class SearchPopup extends JPanel {
350     private final SearchField mySearchField;
351
352     public SearchPopup(String initialString) {
353       final Color foregroundColor = UIUtil.getToolTipForeground();
354       Color color1 = UIUtil.getToolTipBackground();
355       mySearchField = new SearchField();
356       final JLabel searchLabel = new JLabel(" " + UIBundle.message("search.popup.search.for.label") + " ");
357       searchLabel.setFont(searchLabel.getFont().deriveFont(Font.BOLD));
358       searchLabel.setForeground(foregroundColor);
359       mySearchField.setBorder(null);
360       mySearchField.setBackground(color1.brighter());
361       mySearchField.setForeground(foregroundColor);
362
363       mySearchField.setDocument(new PlainDocument() {
364         public void insertString(int offs, String str, AttributeSet a) throws BadLocationException {
365           String oldText;
366           try {
367             oldText = getText(0, getLength());
368           }
369           catch (BadLocationException e1) {
370             oldText = "";
371           }
372
373           String newText = oldText.substring(0, offs) + str + oldText.substring(offs);
374           super.insertString(offs, str, a);
375           if (findElement(newText) == null) {
376             mySearchField.setForeground(Color.RED);
377           }
378           else {
379             mySearchField.setForeground(foregroundColor);
380           }
381         }
382       });
383       mySearchField.setText(initialString);
384
385       setBorder(BorderFactory.createLineBorder(Color.gray, 1));
386       setBackground(color1.brighter());
387       setLayout(new BorderLayout());
388       add(searchLabel, BorderLayout.WEST);
389       add(mySearchField, BorderLayout.EAST);
390       Object element = findElement(mySearchField.getText());
391       updateSelection(element);
392     }
393
394     public void processKeyEvent(KeyEvent e) {
395       mySearchField.processKeyEvent(e);
396       if (e.isConsumed()) {
397         int keyCode = e.getKeyCode();
398         String s = mySearchField.getText();
399         Object element;
400         if (keyCode == KeyEvent.VK_UP) {
401           element = findPreviousElement(s);
402         }
403         else if (keyCode == KeyEvent.VK_DOWN) {
404           element = findNextElement(s);
405         }
406         else if (keyCode == KeyEvent.VK_HOME) {
407           element = findFirstElement(s);
408         }
409         else if (keyCode == KeyEvent.VK_END) {
410           element = findLastElement(s);
411         }
412         else {
413           element = findElement(s);
414         }
415         updateSelection(element);
416       }
417     }
418
419     public void refreshSelection () {
420       updateSelection(findElement(mySearchField.getText()));
421     }
422
423     private void updateSelection(Object element) {
424       if (element != null) {
425         selectElement(element, mySearchField.getText());
426         mySearchField.setForeground(Color.black);
427       }
428       else {
429         mySearchField.setForeground(Color.red);
430       }
431       if (mySearchPopup != null) {
432         mySearchPopup.setSize(mySearchPopup.getPreferredSize());
433         mySearchPopup.validate();
434       }
435
436       fireStateChanged();
437     }
438   }
439
440   private class SearchField extends JTextField {
441     SearchField() {
442       setFocusable(false);
443     }
444
445     public Dimension getPreferredSize() {
446       Dimension dim = super.getPreferredSize();
447       dim.width = getFontMetrics(getFont()).stringWidth(getText()) + 10;
448       return dim;
449     }
450
451     /**
452      * I made this method public in order to be able to call it from the outside.
453      * This is needed for delegating calls.
454      */
455     public void processKeyEvent(KeyEvent e) {
456       int i = e.getKeyCode();
457       if (i == KeyEvent.VK_BACK_SPACE && getDocument().getLength() == 0) {
458         e.consume();
459         return;
460       }
461       if (
462         i == KeyEvent.VK_ENTER ||
463         i == KeyEvent.VK_ESCAPE ||
464         i == KeyEvent.VK_PAGE_UP ||
465         i == KeyEvent.VK_PAGE_DOWN ||
466         i == KeyEvent.VK_LEFT ||
467         i == KeyEvent.VK_RIGHT
468         ) {
469         manageSearchPopup(null);
470         if (i == KeyEvent.VK_ESCAPE) {
471           e.consume();
472         }
473         return;
474       }
475       
476       if (
477         i == KeyEvent.VK_HOME ||
478         i == KeyEvent.VK_END ||
479         i == KeyEvent.VK_UP ||
480         i == KeyEvent.VK_DOWN
481         ) {
482         e.consume();
483         return;
484       }
485
486       super.processKeyEvent(e);
487       if (i == KeyEvent.VK_BACK_SPACE) {
488         e.consume();
489       }
490     }
491   }
492
493   private void manageSearchPopup(SearchPopup searchPopup) {
494     final Project project;
495     if (ApplicationManager.getApplication() != null && !ApplicationManager.getApplication().isDisposed()) {
496       project = PlatformDataKeys.PROJECT.getData(DataManager.getInstance().getDataContext(myComponent));
497     }
498     else {
499       project = null;
500     }
501
502     if (mySearchPopup != null) {
503       myPopupLayeredPane.remove(mySearchPopup);
504       myPopupLayeredPane.validate();
505       myPopupLayeredPane.repaint();
506       myPopupLayeredPane = null;
507
508       if (project != null) {
509         ((ToolWindowManagerEx)ToolWindowManager.getInstance(project)).removeToolWindowManagerListener(myWindowManagerListener);
510       }
511     }
512     else if (searchPopup != null) {
513       FeatureUsageTracker.getInstance().triggerFeatureUsed("ui.tree.speedsearch");
514     }
515
516     if (!myComponent.isShowing()) {
517       mySearchPopup = null;
518     }
519     else {
520       mySearchPopup = searchPopup;
521     }
522
523     fireStateChanged();
524
525     if (mySearchPopup == null || !myComponent.isDisplayable()) return;
526
527     if (project != null) {
528       ((ToolWindowManagerEx)ToolWindowManager.getInstance(project)).addToolWindowManagerListener(myWindowManagerListener);
529     }
530     JRootPane rootPane = myComponent.getRootPane();
531     if (rootPane != null) {
532       myPopupLayeredPane = rootPane.getLayeredPane();
533     }
534     else {
535       myPopupLayeredPane = null;
536     }
537     if (myPopupLayeredPane == null) {
538       LOG.error(toString() + " in " + String.valueOf(myComponent));
539       return;
540     }
541     myPopupLayeredPane.add(mySearchPopup, JLayeredPane.POPUP_LAYER);
542     if (myPopupLayeredPane == null) return; // See # 27482. Somewho it does happen...
543     Point lPaneP = myPopupLayeredPane.getLocationOnScreen();
544     Point componentP = myComponent.getLocationOnScreen();
545     Rectangle r = myComponent.getVisibleRect();
546     Dimension prefSize = mySearchPopup.getPreferredSize();
547     Window window = (Window)SwingUtilities.getAncestorOfClass(Window.class, myComponent);
548     Point windowP;
549     if (window instanceof JDialog) {
550       windowP = ((JDialog)window).getContentPane().getLocationOnScreen();
551     }
552     else if (window instanceof JFrame) {
553       windowP = ((JFrame)window).getContentPane().getLocationOnScreen();
554     }
555     else {
556       windowP = window.getLocationOnScreen();
557     }
558     int y = r.y + componentP.y - lPaneP.y - prefSize.height;
559     y = Math.max(y, windowP.y - lPaneP.y);
560     mySearchPopup.setLocation(componentP.x - lPaneP.x + r.x, y);
561     mySearchPopup.setSize(prefSize);
562     mySearchPopup.setVisible(true);
563     mySearchPopup.validate();
564   }
565
566   private class MyToolWindowManagerListener extends ToolWindowManagerAdapter {
567     public void stateChanged() {
568       manageSearchPopup(null);
569     }
570   }
571
572   protected class ViewIterator implements ListIterator {
573     private SpeedSearchBase mySpeedSearch;
574     private int myCurrentIndex;
575     private Object[] myElements;
576
577     public ViewIterator(@NotNull final SpeedSearchBase speedSearch, final int startIndex) {
578       mySpeedSearch = speedSearch;
579       myCurrentIndex = startIndex;
580       myElements = speedSearch.getAllElements();
581
582       if (startIndex < 0 || startIndex > myElements.length) {
583         throw new IndexOutOfBoundsException("Index: " + startIndex);
584       }
585     }
586
587     @Override
588     public boolean hasPrevious() {
589       return myCurrentIndex != 0;
590     }
591
592     @Override
593     public Object previous() {
594       final int i = myCurrentIndex - 1;
595       if (i < 0) throw new NoSuchElementException();
596       final Object previous = myElements[mySpeedSearch.convertIndexToModel(i)];
597       myCurrentIndex = i;
598       return previous;
599     }
600
601     @Override
602     public int nextIndex() {
603       return myCurrentIndex;
604     }
605
606     @Override
607     public int previousIndex() {
608       return myCurrentIndex - 1;
609     }
610
611     @Override
612     public boolean hasNext() {
613       return myCurrentIndex != myElements.length;
614     }
615
616     @Override
617     public Object next() {
618       if (myCurrentIndex + 1 > myElements.length) throw new NoSuchElementException();
619       return myElements[mySpeedSearch.convertIndexToModel(myCurrentIndex++)];
620     }
621
622     @Override
623     public void remove() {
624       throw new UnsupportedOperationException("Not implemented in: " + getClass().getCanonicalName());
625     }
626
627     @Override
628     public void set(Object o) {
629       throw new UnsupportedOperationException("Not implemented in: " + getClass().getCanonicalName());
630     }
631
632     @Override
633     public void add(Object o) {
634       throw new UnsupportedOperationException("Not implemented in: " + getClass().getCanonicalName());
635     }
636   }
637 }