/* * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. * * Copyright 2012 Oracle and/or its affiliates. All rights reserved. * * Oracle and Java are registered trademarks of Oracle and/or its affiliates. * Other names may be trademarks of their respective owners. * * The contents of this file are subject to the terms of either the GNU * General Public License Version 2 only ("GPL") or the Common * Development and Distribution License("CDDL") (collectively, the * "License"). You may not use this file except in compliance with the * License. You can obtain a copy of the License at * http://www.netbeans.org/cddl-gplv2.html * or nbbuild/licenses/CDDL-GPL-2-CP. See the License for the * specific language governing permissions and limitations under the * License. When distributing the software, include this License Header * Notice in each file and include the License file at * nbbuild/licenses/CDDL-GPL-2-CP. Oracle designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the GPL Version 2 section of the License file that * accompanied this code. If applicable, add the following below the * License Header, with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" * * If you wish your version of this file to be governed by only the CDDL * or only the GPL Version 2, indicate your decision by adding * "[Contributor] elects to include this software in this distribution * under the [CDDL or GPL Version 2] license." If you do not indicate a * single choice of license, a recipient has the option to distribute * your version of this file under either the CDDL, the GPL Version 2 or * to extend the choice of license to its licensees as provided above. * However, if you add GPL Version 2 code and therefore, elected the GPL * Version 2 license, then the option applies only if the new code is * made subject to such option by the copyright holder. * * Contributor(s): * * Portions Copyrighted 2012 Sun Microsystems, Inc. */ package org.openide.awt; import java.awt.*; import java.awt.event.*; import java.lang.ref.WeakReference; import java.util.LinkedList; import java.util.List; import javax.activation.DataContentHandler; import javax.activation.DataContentHandlerFactory; import javax.swing.*; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import org.netbeans.api.annotations.common.StaticResource; import org.openide.util.ImageUtilities; import org.openide.util.RequestProcessor; /** * Quick search infrastructure for an arbitrary component. * When quick search is attached to a component, it listens on key events going * to the component and displays a quick search field. * * @author Martin Entlicher * @since 7.43 */ public class QuickSearch { @StaticResource private static final String ICON_FIND = "org/openide/awt/resources/quicksearch/find.png"; // NOI18N @StaticResource private static final String ICON_FIND_WITH_MENU = "org/openide/awt/resources/quicksearch/findMenu.png"; // NOI18N private static final Object CLIENT_PROPERTY_KEY = new Object(); private final JComponent component; private final Object constraints; private final Callback callback; private final JMenu popupMenu; private final boolean asynchronous; private boolean enabled = true; private SearchTextField searchTextField; private KeyAdapter quickSearchKeyAdapter; private SearchFieldListener searchFieldListener; private JPanel searchPanel; private final RequestProcessor rp; private static enum QS_FIRE { UPDATE, NEXT, MAX } private AnimationTimer animationTimer; private QuickSearch(JComponent component, Object constraints, Callback callback, boolean asynchronous, JMenu popupMenu) { this.component = component; this.constraints = constraints; this.callback = callback; this.asynchronous = asynchronous; this.popupMenu = popupMenu; if (asynchronous) { rp = new RequestProcessor(QuickSearch.class); } else { rp = null; } setUpSearch(); } /** * Attach quick search to a component with given constraints. * It listens on key events going to the component and displays a quick search * field. * * @param component The component to attach to * @param constraints The constraints that are used to add the search field * to the component. It's passed to {@link JComponent#add(java.awt.Component, java.lang.Object)} * when adding the quick search UI to the component. * @param callback The call back implementation, which is notified from the * quick search field submissions. * @return An instance of QuickSearch class. */ public static QuickSearch attach(JComponent component, Object constraints, Callback callback) { return attach(component, constraints, callback, false, null); } /** * Attach quick search to a component with given constraints. * It listens on key events going to the component and displays a quick search * field. * * @param component The component to attach to * @param constraints The constraints that are used to add the search field * to the component. It's passed to {@link JComponent#add(java.awt.Component, java.lang.Object)} * when adding the quick search UI to the component. * @param callback The call back implementation, which is notified from the * quick search field submissions. * @return An instance of QuickSearch class. */ public static QuickSearch attach(JComponent component, Object constraints, Callback callback, boolean asynchronous) { return attach(component, constraints, callback, asynchronous, null); } /** * Attach quick search to a component with given constraints. * It listens on key events going to the component and displays a quick search * field. * * @param component The component to attach to * @param constraints The constraints that are used to add the search field * to the component. It's passed to {@link JComponent#add(java.awt.Component, java.lang.Object)} * when adding the quick search UI to the component. * @param callback The call back implementation, which is notified from the * quick search field submissions. * @param popupMenu A pop-up menu, that is displayed on the find icon, next to the search * field. This allows customization of the search criteria. The pop-up menu * is taken from {@link JMenu#getPopupMenu()}. * @return An instance of QuickSearch class. */ public static QuickSearch attach(JComponent component, Object constraints, Callback callback, JMenu popupMenu) { return attach(component, constraints, callback, false, popupMenu); } /** * Attach quick search to a component with given constraints. * It listens on key events going to the component and displays a quick search * field. * * @param component The component to attach to * @param constraints The constraints that are used to add the search field * to the component. It's passed to {@link JComponent#add(java.awt.Component, java.lang.Object)} * when adding the quick search UI to the component. * @param callback The call back implementation, which is notified from the * quick search field submissions. * @param asynchronous Set whether the quick search notifies the call back * asynchronously, or not. * By default, Callback is notified synchronously on EQ thread. * If true, three notification methods are called asynchronously * on a background thread. These are * {@link Callback#quickSearchUpdate(java.lang.String)}, * {@link Callback#showNextSelection(javax.swing.text.Position.Bias)}, * {@link Callback#findMaxPrefix(java.lang.String)}. * If false all methods are called synchronously on EQ thread. * @param popupMenu A pop-up menu, that is displayed on the find icon, next to the search * field. This allows customization of the search criteria. The pop-up menu * is taken from {@link JMenu#getPopupMenu()}. * @return An instance of QuickSearch class. */ public static QuickSearch attach(JComponent component, Object constraints, Callback callback, boolean asynchronous, JMenu popupMenu) { Object qso = component.getClientProperty(CLIENT_PROPERTY_KEY); if (qso instanceof QuickSearch) { throw new IllegalStateException("A quick search is attached to this component already, detach it first."); // NOI18N } else { QuickSearch qs = new QuickSearch(component, constraints, callback, asynchronous, popupMenu); component.putClientProperty(CLIENT_PROPERTY_KEY, qs); return qs; } } /** * Detach the quick search from the component it was attached to. */ public void detach() { setEnabled(false); component.putClientProperty(CLIENT_PROPERTY_KEY, null); } /** * Test whether the quick search is enabled. This is true * by default. * @return true when the quick search is enabled, * false otherwise. */ public boolean isEnabled() { return enabled; } /** * Set the enabled state of the quick search. * This allows to activate/deactivate the quick search functionality. * @param enabled true to enable the quick search, * false otherwise. */ public void setEnabled(boolean enabled) { if (this.enabled == enabled) { return ; } this.enabled = enabled; if (enabled) { component.addKeyListener(quickSearchKeyAdapter); } else { removeSearchField(); component.removeKeyListener(quickSearchKeyAdapter); } } /** * Process this key event in addition to the key events obtained from the * component we're attached to. * @param ke a key event to process. */ public void processKeyEvent(KeyEvent ke) { if (searchPanel != null) { searchTextField.setCaretPosition(searchTextField.getText().length()); searchTextField.processKeyEvent(ke); } else { switch(ke.getID()) { case KeyEvent.KEY_PRESSED: quickSearchKeyAdapter.keyPressed(ke); break; case KeyEvent.KEY_RELEASED: quickSearchKeyAdapter.keyReleased(ke); break; case KeyEvent.KEY_TYPED: quickSearchKeyAdapter.keyTyped(ke); break; } } } private void fireQuickSearchUpdate(String searchText) { if (asynchronous) { rp.post(new LazyFire(QS_FIRE.UPDATE, searchText)); } else { callback.quickSearchUpdate(searchText); } } private void fireShowNextSelection(boolean forward) { if (asynchronous) { rp.post(new LazyFire(QS_FIRE.NEXT, forward)); } else { callback.showNextSelection(forward); } } private void findMaxPrefix(String prefix, DataContentHandlerFactory newPrefixSetter) { if (asynchronous) { rp.post(new LazyFire(QS_FIRE.MAX, prefix, newPrefixSetter)); } else { prefix = callback.findMaxPrefix(prefix); newPrefixSetter.createDataContentHandler(prefix); } } private void setUpSearch() { searchTextField = new SearchTextField(); // create new key listeners quickSearchKeyAdapter = ( new KeyAdapter() { @Override public void keyTyped(KeyEvent e) { int modifiers = e.getModifiers(); int keyCode = e.getKeyCode(); char c = e.getKeyChar(); //#43617 - don't eat + and - //#98634 - and all its duplicates dont't react to space if ((c == '+') || (c == '-') || (c==' ')) return; // NOI18N if (((modifiers > 0) && (modifiers != KeyEvent.SHIFT_MASK)) || e.isActionKey()) { return; } if (Character.isISOControl(c) || (keyCode == KeyEvent.VK_SHIFT) || (keyCode == KeyEvent.VK_ESCAPE)) return; displaySearchField(); final KeyStroke stroke = KeyStroke.getKeyStrokeForEvent(e); searchTextField.setText(String.valueOf(stroke.getKeyChar())); e.consume(); } } ); if (isEnabled()) { component.addKeyListener(quickSearchKeyAdapter); } // Create a the "multi-event" listener for the text field. Instead of // adding separate instances of each needed listener, we're using a // class which implements them all. This approach is used in order // to avoid the creation of 4 instances which takes some time searchFieldListener = new SearchFieldListener(); searchTextField.addKeyListener(searchFieldListener); searchTextField.addFocusListener(searchFieldListener); searchTextField.getDocument().addDocumentListener(searchFieldListener); } private void displaySearchField() { if (searchPanel != null || !isEnabled()) { return; } searchTextField.setOriginalFocusOwner(); searchTextField.setFont(component.getFont()); searchPanel = new SearchPanel(); final JLabel lbl; if (popupMenu != null) { lbl = new JLabel(org.openide.util.ImageUtilities.loadImageIcon(ICON_FIND_WITH_MENU, false)); lbl.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { if (e != null && !SwingUtilities.isLeftMouseButton(e)) { return; } JPopupMenu pm = popupMenu.getPopupMenu(); pm.show(lbl, 0, lbl.getHeight() - 1); } }); } else { lbl = new JLabel(org.openide.util.ImageUtilities.loadImageIcon(ICON_FIND, false)); } if (asynchronous) { animationTimer = new AnimationTimer(lbl, lbl.getIcon()); } else { animationTimer = null; } searchPanel.setLayout(new BoxLayout(searchPanel, BoxLayout.X_AXIS)); searchPanel.add(lbl); searchPanel.add(searchTextField); lbl.setLabelFor(searchTextField); searchTextField.setColumns(10); searchTextField.setMaximumSize(searchTextField.getPreferredSize()); searchTextField.putClientProperty("JTextField.variant", "search"); //NOI18N lbl.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 5)); if (constraints == null) { component.add(searchPanel); } else { component.add(searchPanel, constraints); } component.invalidate(); component.revalidate(); component.repaint(); searchTextField.requestFocus(); } private void removeSearchField() { if (searchPanel == null) { return; } if (animationTimer != null) { animationTimer.stopProgressAnimation(); } Component sp = searchPanel; searchPanel = null; component.remove(sp); component.invalidate(); component.revalidate(); component.repaint(); } /** Accessed from test. */ JTextField getSearchField() { return searchTextField; } /** * Utility method, that finds a greatest common prefix of two supplied * strings. * * @param str1 The first string * @param str2 The second string * @param ignoreCase Whether to ignore case in the comparisons * @return The greatest common prefix of the two strings. */ public static String findMaxPrefix(String str1, String str2, boolean ignoreCase) { int n1 = str1.length(); int n2 = str2.length(); int i = 0; if (ignoreCase) { for ( ; i < n1 && i < n2; i++) { char c1 = Character.toUpperCase(str1.charAt(i)); char c2 = Character.toUpperCase(str2.charAt(i)); if (c1 != c2) { break; } } } else { for ( ; i < n1 && i < n2; i++) { char c1 = str1.charAt(i); char c2 = str2.charAt(i); if (c1 != c2) { break; } } } return str1.substring(0, i); } private final static class AnimationTimer { private final JLabel jLabel; private final Icon findIcon; private final Timer animationTimer; public AnimationTimer(final JLabel jLabel, Icon findIcon) { this.jLabel = jLabel; this.findIcon = findIcon; animationTimer = new Timer(100, new ActionListener() { ImageIcon icons[]; int index = 0; @Override public void actionPerformed(ActionEvent e) { if (icons == null) { icons = new ImageIcon[8]; for (int i = 0; i < 8; i++) { icons[i] = ImageUtilities.loadImageIcon("org/openide/awt/resources/quicksearch/progress_" + i + ".png", false); //NOI18N } } jLabel.setBorder(javax.swing.BorderFactory.createEmptyBorder(1, 1, 1, 6)); jLabel.setIcon(icons[index]); //mac os x jLabel.repaint(); index = (index + 1) % 8; } }); } public void startProgressAnimation() { if (animationTimer != null && !animationTimer.isRunning()) { animationTimer.start(); } } public void stopProgressAnimation() { if (animationTimer != null && animationTimer.isRunning()) { animationTimer.stop(); jLabel.setIcon(findIcon); jLabel.setBorder(javax.swing.BorderFactory.createEmptyBorder(1, 1, 1, 1)); } } } private class LazyFire implements Runnable { private final QS_FIRE fire; //private final QuickSearchListener[] qsls; private final String searchText; private final boolean forward; private final DataContentHandlerFactory newPrefixSetter; LazyFire(QS_FIRE fire, String searchText) { this(fire, searchText, true, null); } LazyFire(QS_FIRE fire, boolean forward) { this(fire, null, forward); } LazyFire(QS_FIRE fire, String searchText, boolean forward) { this(fire, searchText, forward, null); } LazyFire(QS_FIRE fire, String searchText, DataContentHandlerFactory newPrefixSetter) { this(fire, searchText, true, newPrefixSetter); } LazyFire(QS_FIRE fire, String searchText, boolean forward, DataContentHandlerFactory newPrefixSetter) { this.fire = fire; //this.qsls = qsls; this.searchText = searchText; this.forward = forward; this.newPrefixSetter = newPrefixSetter; animationTimer.startProgressAnimation(); } @Override public void run() { try { switch (fire) { case UPDATE: callback.quickSearchUpdate(searchText);//fireQuickSearchUpdate(qsls, searchText); break; case NEXT: callback.showNextSelection(forward);//fireShowNextSelection(qsls, forward); break; case MAX: String mp = callback.findMaxPrefix(searchText);//String mp = findMaxPrefix(qsls, searchText); newPrefixSetter.createDataContentHandler(mp); break; } } finally { animationTimer.stopProgressAnimation(); } } } private static class SearchPanel extends JPanel { public static final boolean isAquaLaF = "Aqua".equals(UIManager.getLookAndFeel().getID()); //NOI18N public SearchPanel() { if (isAquaLaF) { setBorder(BorderFactory.createEmptyBorder(9,6,8,2)); } else { setBorder(BorderFactory.createEmptyBorder(2,6,2,2)); } setOpaque(true); } @Override protected void paintComponent(Graphics g) { if (isAquaLaF && g instanceof Graphics2D) { Graphics2D g2d = (Graphics2D) g; g2d.setPaint(new GradientPaint(0, 0, UIManager.getColor("NbExplorerView.quicksearch.background.top"), //NOI18N 0, getHeight(), UIManager.getColor("NbExplorerView.quicksearch.background.bottom")));//NOI18N g2d.fillRect(0, 0, getWidth(), getHeight()); g2d.setColor(UIManager.getColor("NbExplorerView.quicksearch.border")); //NOI18N g2d.drawLine(0, 0, getWidth(), 0); } else { super.paintComponent(g); } } } /** searchTextField manages focus because it handles VK_ESCAPE key */ private class SearchTextField extends JTextField { private WeakReference originalFocusOwner = new WeakReference(null); public SearchTextField() { } void setOriginalFocusOwner() { Component focusOwner = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); if (focusOwner != null && component.isAncestorOf(focusOwner)) { originalFocusOwner = new WeakReference(focusOwner); } else { originalFocusOwner = new WeakReference(component); } } void requestOriginalFocusOwner() { SwingUtilities.invokeLater( new Runnable() { //additional bugfix - do focus change later or removing //the component while it's focused will cause focus to //get transferred to the next component in the //parent focusTraversalPolicy *after* our request //focus completes, so focus goes into a black hole - Tim @Override public void run() { Component fo = originalFocusOwner.get(); if (fo != null) { fo.requestFocusInWindow(); } } } ); } @Override public boolean isManagingFocus() { return true; } @Override public void processKeyEvent(KeyEvent ke) { //override the default handling so that //the parent will never receive the escape key and //close a modal dialog if (ke.getKeyCode() == KeyEvent.VK_ESCAPE) { removeSearchField(); ke.consume(); // bugfix #32909, reqest focus when search field is removed requestOriginalFocusOwner(); //fireQuickSearchCanceled(); callback.quickSearchCanceled(); } else { super.processKeyEvent(ke); } } }; private class SearchFieldListener extends KeyAdapter implements DocumentListener, FocusListener { private boolean ignoreEvents; SearchFieldListener() { } @Override public void changedUpdate(DocumentEvent e) { if (ignoreEvents) return; searchForNode(); } @Override public void insertUpdate(DocumentEvent e) { if (ignoreEvents) return; searchForNode(); } @Override public void removeUpdate(DocumentEvent e) { if (ignoreEvents) return; searchForNode(); } @Override public void keyPressed(KeyEvent e) { int keyCode = e.getKeyCode(); if (keyCode == KeyEvent.VK_ESCAPE) { removeSearchField(); searchTextField.requestOriginalFocusOwner(); //fireQuickSearchCanceled(); callback.quickSearchCanceled(); e.consume(); } else if (keyCode == KeyEvent.VK_UP || (keyCode == KeyEvent.VK_F3 && e.isShiftDown())) { fireShowNextSelection(false); // Stop processing the event here. Otherwise it's dispatched // to the tree too (which scrolls) e.consume(); } else if (keyCode == KeyEvent.VK_DOWN || keyCode == KeyEvent.VK_F3) { fireShowNextSelection(true); // Stop processing the event here. Otherwise it's dispatched // to the tree too (which scrolls) e.consume(); } else if (keyCode == KeyEvent.VK_TAB) { findMaxPrefix(searchTextField.getText(), new DataContentHandlerFactory() { @Override public DataContentHandler createDataContentHandler(final String maxPrefix) { if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { createDataContentHandler(maxPrefix); } }); return null; } ignoreEvents = true; try { searchTextField.setText(maxPrefix); } finally { ignoreEvents = false; } return null; } }); e.consume(); } else if (keyCode == KeyEvent.VK_ENTER) { removeSearchField(); //fireQuickSearchConfirmed(); callback.quickSearchConfirmed(); component.requestFocusInWindow(); e.consume(); } } /** Searches for a node in the tree. */ private void searchForNode() { String text = searchTextField.getText(); fireQuickSearchUpdate(text); } @Override public void focusGained(FocusEvent e) { if (e.getSource() == searchTextField) { // make sure nothing is selected int n = searchTextField.getText().length(); searchTextField.select(n, n); } } @Override public void focusLost(FocusEvent e) { if (e.isTemporary()) return ; Component oppositeComponent = e.getOppositeComponent(); if (e.getSource() != searchTextField) { ((Component) e.getSource()).removeFocusListener(this); } if (oppositeComponent instanceof JMenuItem || oppositeComponent instanceof JPopupMenu) { oppositeComponent.addFocusListener(this); return ; } if (oppositeComponent == searchTextField) { return ; } if (searchPanel != null) { removeSearchField(); //fireQuickSearchConfirmed(); callback.quickSearchConfirmed(); } } } /** * Call back interface, that is notified with the submissions to the quick search field. * * @author Martin Entlicher * @since 7.43 */ public static interface Callback { /** * Called with an updated search text. * When {@link #isAsynchronous()} is false * it's called in EQ thread, otherwise, it's called in a background thread. * The client should update the visual representation of the search results * and then return.

* This method is called to initiate and update the search process. * @param searchText The new text to search for. */ void quickSearchUpdate(String searchText); /** * Called to select a next occurrence of the search result. * When {@link #isAsynchronous()} is false * it's called in EQ thread, otherwise, it's called in a background thread. * The client should update the visual representation of the search results * and then return.

* @param forward The direction of the next search result. * true for forward direction, * false for backward direction. */ void showNextSelection(boolean forward); /** * Find the maximum prefix among the search results, that starts with the provided string. * This method is called when user press TAB in the search field, to auto-complete * the maximum prefix. * When {@link #isAsynchronous()} is false * it's called in EQ thread, otherwise, it's called in a background thread. * Utility method {@link QuickSearch#findMaxPrefix(java.lang.String, java.lang.String, boolean)} * can be used by the implementation. * @param prefix The prefix to start with * @return The maximum prefix. */ String findMaxPrefix(String prefix); /** * Called when the quick search is confirmed by the user. * This method is called in EQ thread always. */ void quickSearchConfirmed(); /** * Called when the quick search is canceled by the user. * This method is called in EQ thread always. */ void quickSearchCanceled(); } }