001package org.jdesktop.swingx.search;
002
003import java.awt.event.ActionEvent;
004import java.awt.event.ActionListener;
005import java.security.AccessControlException;
006import java.util.ArrayList;
007import java.util.Arrays;
008import java.util.List;
009import java.util.logging.Logger;
010import java.util.prefs.BackingStoreException;
011import java.util.prefs.Preferences;
012
013import javax.swing.JMenuItem;
014import javax.swing.JPopupMenu;
015import javax.swing.JTextField;
016import javax.swing.event.ChangeEvent;
017import javax.swing.event.ChangeListener;
018
019import org.jdesktop.swingx.JXSearchField;
020import org.jdesktop.swingx.plaf.UIManagerExt;
021
022/**
023 * Maintains a list of recent searches and persists this list automatically
024 * using {@link Preferences}. A recent searches popup menu can be installed on
025 * a {@link JXSearchField} using {@link #install(JXSearchField)}.
026 * 
027 * @author Peter Weishapl <petw@gmx.net>
028 * 
029 */
030public class RecentSearches implements ActionListener {
031        private Preferences prefsNode;
032
033        private int maxRecents = 5;
034
035        private List<String> recentSearches = new ArrayList<String>();
036
037        private List<ChangeListener> listeners = new ArrayList<ChangeListener>();
038
039        /**
040         * Creates a list of recent searches and uses <code>saveName</code> to
041         * persist this list under the {@link Preferences} user root node. Existing
042         * entries will be loaded automatically.
043         * 
044         * @param saveName
045         *            a unique name for saving this list of recent searches
046         */
047        public RecentSearches(String saveName) {
048                this(null, saveName);
049        }
050
051        /**
052         * Creates a list of recent searches and uses <code>saveName</code> to
053         * persist this list under the <code>prefs</code> node. Existing entries
054         * will be loaded automatically.
055         * 
056         * @param prefsNode
057         *            the preferences node under which this list will be persisted.
058         *            If prefsNode is <code>null</code> the preferences node will
059         *            be set to the user root node
060         * @param saveName
061         *            a unique name for saving this list of recent searches. If
062         *            saveName is <code>null</code>, the list will not be
063         *            persisted
064         */
065        public RecentSearches(Preferences prefs, String saveName) {
066                if (prefs == null) {
067                        try {
068                                prefs = Preferences.userRoot();
069                        } catch (AccessControlException ace) {
070                                // disable persistency, if we aren't allowed to access
071                                // preferences.
072                                Logger.getLogger(getClass().getName()).warning("cannot acces preferences. persistency disabled.");
073                        }
074                }
075
076                if (prefs != null && saveName != null) {
077                        this.prefsNode = prefs.node(saveName);
078                        load();
079                }
080        }
081
082        private void load() {
083                // load persisted entries
084                try {
085                        String[] recent = new String[prefsNode.keys().length];
086                        for (String key : prefsNode.keys()) {
087                                recent[prefsNode.getInt(key, -1)] = key;
088                        }
089                        recentSearches.addAll(Arrays.asList(recent));
090                } catch (Exception ex) {
091                        // ignore
092                }
093        }
094
095        private void save() {
096                if (prefsNode == null) {
097                        return;
098                }
099
100                try {
101                        prefsNode.clear();
102                } catch (BackingStoreException e) {
103                        // ignore
104                }
105
106                int i = 0;
107                for (String search : recentSearches) {
108                        prefsNode.putInt(search, i++);
109                }
110        }
111
112        /**
113         * Add a search string as the first element. If the search string is
114         * <code>null</code> or empty nothing will be added. If the search string
115         * already exists, the old element will be removed. The modified list will
116         * automatically be persisted.
117         * 
118         * If the number of elements exceeds the maximum number of entries, the last
119         * entry will be removed.
120         * 
121         * @see #getMaxRecents()
122         * @param searchString
123         *            the search string to add
124         */
125        public void put(String searchString) {
126                if (searchString == null || searchString.trim().length() == 0) {
127                        return;
128                }
129
130                int lastIndex = recentSearches.indexOf(searchString);
131                if (lastIndex != -1) {
132                        recentSearches.remove(lastIndex);
133                }
134                recentSearches.add(0, searchString);
135                if (getLength() > getMaxRecents()) {
136                        recentSearches.remove(recentSearches.size() - 1);
137                }
138                save();
139                fireChangeEvent();
140        }
141
142        /**
143         * Returns all recent searches in this list.
144         * 
145         * @return the recent searches
146         */
147        public String[] getRecentSearches() {
148                return recentSearches.toArray(new String[] {});
149        }
150
151        /**
152         * The number of recent searches.
153         * 
154         * @return number of recent searches
155         */
156        public int getLength() {
157                return recentSearches.size();
158        }
159
160        /**
161         * Remove all recent searches.
162         */
163        public void removeAll() {
164                recentSearches.clear();
165                save();
166                fireChangeEvent();
167        }
168
169        /**
170         * Returns the maximum number of recent searches.
171         * 
172         * @see #put(String)
173         * @return the maximum number of recent searches
174         */
175        public int getMaxRecents() {
176                return maxRecents;
177        }
178
179        /**
180         * Set the maximum number of recent searches.
181         * 
182         * @see #put(String)
183         * @param maxRecents
184         *            maximum number of recent searches
185         */
186        public void setMaxRecents(int maxRecents) {
187                this.maxRecents = maxRecents;
188        }
189
190        /**
191         * Add a change listener. A {@link ChangeEvent} will be fired whenever a
192         * search is added or removed.
193         * 
194         * @param l
195         *            the {@link ChangeListener}
196         */
197        public void addChangeListener(ChangeListener l) {
198                listeners.add(l);
199        }
200
201        /**
202         * Remove a change listener.
203         * 
204         * @param l
205         *            a registered {@link ChangeListener}
206         */
207        public void removeChangeListener(ChangeListener l) {
208                listeners.remove(l);
209        }
210
211        /**
212         * Returns all registered {@link ChangeListener}s.
213         * 
214         * @return all registered {@link ChangeListener}s
215         */
216        public ChangeListener[] getChangeListeners() {
217                return listeners.toArray(new ChangeListener[] {});
218        }
219
220        private void fireChangeEvent() {
221                ChangeEvent e = new ChangeEvent(this);
222
223                for (ChangeListener l : listeners) {
224                        l.stateChanged(e);
225                }
226        }
227
228        /**
229         * Creates the recent searches popup menu which will be used by
230         * {@link #install(JXSearchField)} to set a search popup menu on
231         * <code>searchField</code>.
232         * 
233         * Override to return a custom popup menu.
234         * 
235         * @param searchField
236         *            the search field the returned popup menu will be installed on
237         * @return the recent searches popup menu
238         */
239        protected JPopupMenu createPopupMenu(JTextField searchField) {
240                return new RecentSearchesPopup(this, searchField);
241        }
242
243        /**
244         * Install a recent the searches popup menu returned by
245         * {@link #createPopupMenu(JXSearchField)} on <code>searchField</code>.
246         * Also registers an {@link ActionListener} on <code>searchField</code>
247         * and adds the search string to the list of recent searches whenever a
248         * {@link ActionEvent} is received.
249         * 
250         * Uses {@link NativeSearchFieldSupport} to achieve compatibility with the native
251         * search field support provided by the Mac Look And Feel since Mac OS 10.5.
252         * 
253         * @param searchField
254         *            the search field to install a recent searches popup menu on
255         */
256        public void install(JTextField searchField) {
257                searchField.addActionListener(this);
258                NativeSearchFieldSupport.setFindPopupMenu(searchField, createPopupMenu(searchField));
259        }
260
261        /**
262         * Remove the recent searches popup from <code>searchField</code> when
263         * installed and stop listening for {@link ActionEvent}s fired by the
264         * search field.
265         * 
266         * @param searchField
267         *            uninstall recent searches popup menu
268         */
269        public void uninstall(JXSearchField searchField) {
270                searchField.removeActionListener(this);
271                if (searchField.getFindPopupMenu() instanceof RecentSearchesPopup) {
272                        removeChangeListener((ChangeListener) searchField.getFindPopupMenu());
273                        searchField.setFindPopupMenu(null);
274                }
275        }
276
277        /**
278         * Calls {@link #put(String)} with the {@link ActionEvent}s action command
279         * as the search string.
280         */
281        @Override
282    public void actionPerformed(ActionEvent e) {
283                put(e.getActionCommand());
284        }
285
286        /**
287         * The popup menu returned by
288         * {@link RecentSearches#createPopupMenu(JXSearchField)}.
289         */
290        public static class RecentSearchesPopup extends JPopupMenu implements ActionListener, ChangeListener {
291                private RecentSearches recentSearches;
292
293                private JTextField searchField;
294
295                private JMenuItem clear;
296
297                /**
298                 * Creates a new popup menu based on the given {@link RecentSearches}
299                 * and {@link JXSearchField}.
300                 * 
301                 * @param recentSearches
302                 * @param searchField
303                 */
304                public RecentSearchesPopup(RecentSearches recentSearches, JTextField searchField) {
305                        this.searchField = searchField;
306                        this.recentSearches = recentSearches;
307
308                        recentSearches.addChangeListener(this);
309                        buildMenu();
310                }
311
312                /**
313                 * Rebuilds the menu according to the recent searches.
314                 */
315                private void buildMenu() {
316                        setVisible(false);
317                        removeAll();
318
319                        if (recentSearches.getLength() == 0) {
320                                JMenuItem noRecent = new JMenuItem(UIManagerExt.getString("SearchField.noRecentsText"));
321                                noRecent.setEnabled(false);
322                                add(noRecent);
323                        } else {
324                                JMenuItem recent = new JMenuItem(UIManagerExt.getString("SearchField.recentsMenuTitle"));
325                                recent.setEnabled(false);
326                                add(recent);
327
328                                for (String searchString : recentSearches.getRecentSearches()) {
329                                        JMenuItem mi = new JMenuItem(searchString);
330                                        mi.addActionListener(this);
331                                        add(mi);
332                                }
333
334                                addSeparator();
335                                clear = new JMenuItem(UIManagerExt.getString("SearchField.clearRecentsText"));
336                                clear.addActionListener(this);
337                                add(clear);
338                        }
339                }
340
341                /**
342                 * Sets {@link #searchField}s text to the {@link ActionEvent}s action
343                 * command and call {@link JXSearchField#postActionEvent()} to fire an
344                 * {@link ActionEvent}, if <code>e</code>s source is not the clear
345                 * menu item. If the source is the clear menu item, all recent searches
346                 * will be removed.
347                 */
348                @Override
349        public void actionPerformed(ActionEvent e) {
350                        if (e.getSource() == clear) {
351                                recentSearches.removeAll();
352                        } else {
353                                searchField.setText(e.getActionCommand());
354                                searchField.postActionEvent();
355                        }
356                }
357
358                /**
359                 * Every time the recent searches fires a {@link ChangeEvent} call
360                 * {@link #buildMenu()} to rebuild the whole menu.
361                 */
362                @Override
363        public void stateChanged(ChangeEvent e) {
364                        buildMenu();
365                }
366        }
367}