001    /*
002     * $Id: AutoCompleteDocument.java 3365 2009-06-17 01:52:57Z kschaefe $
003     *
004     * Copyright 2004 Sun Microsystems, Inc., 4150 Network Circle,
005     * Santa Clara, California 95054, U.S.A. All rights reserved.
006     *
007     * This library is free software; you can redistribute it and/or
008     * modify it under the terms of the GNU Lesser General Public
009     * License as published by the Free Software Foundation; either
010     * version 2.1 of the License, or (at your option) any later version.
011     * 
012     * This library is distributed in the hope that it will be useful,
013     * but WITHOUT ANY WARRANTY; without even the implied warranty of
014     * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
015     * Lesser General Public License for more details.
016     * 
017     * You should have received a copy of the GNU Lesser General Public
018     * License along with this library; if not, write to the Free Software
019     * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
020     */
021    package org.jdesktop.swingx.autocomplete;
022    
023    import static org.jdesktop.swingx.autocomplete.ObjectToStringConverter.DEFAULT_IMPLEMENTATION;
024    
025    import javax.swing.UIManager;
026    import javax.swing.event.DocumentEvent;
027    import javax.swing.event.DocumentListener;
028    import javax.swing.event.EventListenerList;
029    import javax.swing.event.UndoableEditEvent;
030    import javax.swing.event.UndoableEditListener;
031    import javax.swing.text.AttributeSet;
032    import javax.swing.text.BadLocationException;
033    import javax.swing.text.Document;
034    import javax.swing.text.Element;
035    import javax.swing.text.PlainDocument;
036    import javax.swing.text.Position;
037    import javax.swing.text.Segment;
038    
039    import org.jdesktop.swingx.util.Contract;
040    
041    /**
042     * A document that can be plugged into any JTextComponent to enable automatic completion.
043     * It finds and selects matching items using any implementation of the AbstractAutoCompleteAdaptor.
044     */
045    public class AutoCompleteDocument implements Document {
046        private class Handler implements DocumentListener, UndoableEditListener {
047            private final EventListenerList listenerList = new EventListenerList();
048    
049            public void addDocumentListener(DocumentListener listener) {
050                listenerList.add(DocumentListener.class, listener);
051            }
052    
053            public void addUndoableEditListener(UndoableEditListener listener) {
054                listenerList.add(UndoableEditListener.class, listener);
055            }
056    
057            /**
058             * {@inheritDoc}
059             */
060            public void removeDocumentListener(DocumentListener listener) {
061                listenerList.remove(DocumentListener.class, listener);
062            }
063    
064            /**
065             * {@inheritDoc}
066             */
067            public void removeUndoableEditListener(UndoableEditListener listener) {
068                listenerList.remove(UndoableEditListener.class, listener);
069            }
070            
071            /**
072             * {@inheritDoc}
073             */
074            public void changedUpdate(DocumentEvent e) {
075                e = new DelegatingDocumentEvent(AutoCompleteDocument.this, e);
076                
077                // Guaranteed to return a non-null array
078                Object[] listeners = listenerList.getListenerList();
079                // Process the listeners last to first, notifying
080                // those that are interested in this event
081                for (int i = listeners.length-2; i>=0; i-=2) {
082                    if (listeners[i]==DocumentListener.class) {
083                        // Lazily create the event:
084                        // if (e == null)
085                        // e = new ListSelectionEvent(this, firstIndex, lastIndex);
086                        ((DocumentListener)listeners[i+1]).changedUpdate(e);
087                    }          
088                }
089            }
090    
091            /**
092             * {@inheritDoc}
093             */
094            public void insertUpdate(DocumentEvent e) {
095                e = new DelegatingDocumentEvent(AutoCompleteDocument.this, e);
096                
097                // Guaranteed to return a non-null array
098                Object[] listeners = listenerList.getListenerList();
099                // Process the listeners last to first, notifying
100                // those that are interested in this event
101                for (int i = listeners.length-2; i>=0; i-=2) {
102                    if (listeners[i]==DocumentListener.class) {
103                        // Lazily create the event:
104                        // if (e == null)
105                        // e = new ListSelectionEvent(this, firstIndex, lastIndex);
106                        ((DocumentListener)listeners[i+1]).insertUpdate(e);
107                    }          
108                }
109            }
110    
111            /**
112             * {@inheritDoc}
113             */
114            public void removeUpdate(DocumentEvent e) {
115                e = new DelegatingDocumentEvent(AutoCompleteDocument.this, e);
116                
117                // Guaranteed to return a non-null array
118                Object[] listeners = listenerList.getListenerList();
119                // Process the listeners last to first, notifying
120                // those that are interested in this event
121                for (int i = listeners.length-2; i>=0; i-=2) {
122                    if (listeners[i]==DocumentListener.class) {
123                        // Lazily create the event:
124                        // if (e == null)
125                        // e = new ListSelectionEvent(this, firstIndex, lastIndex);
126                        ((DocumentListener)listeners[i+1]).removeUpdate(e);
127                    }          
128                }
129            }
130    
131            /**
132             * {@inheritDoc}
133             */
134            public void undoableEditHappened(UndoableEditEvent e) {
135                e = new UndoableEditEvent(AutoCompleteDocument.this, e.getEdit());
136                
137                // Guaranteed to return a non-null array
138                Object[] listeners = listenerList.getListenerList();
139                // Process the listeners last to first, notifying
140                // those that are interested in this event
141                for (int i = listeners.length-2; i>=0; i-=2) {
142                    if (listeners[i]==UndoableEditListener.class) {
143                    // Lazily create the event:
144                    // if (e == null)
145                    // e = new ListSelectionEvent(this, firstIndex, lastIndex);
146                    ((UndoableEditListener)listeners[i+1]).undoableEditHappened(e);
147                    }          
148                }
149            }
150        }
151        
152        /** Flag to indicate if adaptor.setSelectedItem has been called.
153         * Subsequent calls to remove/insertString should be ignored
154         * as they are likely have been caused by the adapted Component that
155         * is trying to set the text for the selected component.*/
156        boolean selecting=false;
157        
158        /**
159         * true, if only items from the adaptors's list can be entered
160         * false, otherwise (selected item might not be in the adaptors's list)
161         */
162        protected boolean strictMatching;
163        
164        /**
165         * The adaptor that is used to find and select items.
166         */
167        AbstractAutoCompleteAdaptor adaptor;
168        
169        ObjectToStringConverter stringConverter;
170        
171        private final Handler handler;
172        
173        protected final Document delegate;
174        
175        /**
176         * Creates a new AutoCompleteDocument for the given AbstractAutoCompleteAdaptor.
177         * @param adaptor The adaptor that will be used to find and select matching
178         * items.
179         * @param strictMatching true, if only items from the adaptor's list should
180         * be allowed to be entered
181         * @param stringConverter the converter used to transform items to strings
182         * @param delegate the {@code Document} delegate backing this document
183         */
184        public AutoCompleteDocument(AbstractAutoCompleteAdaptor adaptor, boolean strictMatching,
185                ObjectToStringConverter stringConverter, Document delegate) {
186            this.adaptor = Contract.asNotNull(adaptor, "adaptor cannot be null");
187            this.strictMatching = strictMatching;
188            this.stringConverter = stringConverter == null ? DEFAULT_IMPLEMENTATION : stringConverter;
189            this.delegate = delegate == null ? createDefaultDocument() : delegate;
190            
191            handler = new Handler();
192            this.delegate.addDocumentListener(handler);
193            
194            // Handle initially selected object
195            Object selected = adaptor.getSelectedItem();
196            if (selected!=null) setText(this.stringConverter.getPreferredStringForItem(selected));
197            this.adaptor.markEntireText();
198        }
199        
200        
201        /**
202         * Creates a new AutoCompleteDocument for the given AbstractAutoCompleteAdaptor.
203         * @param adaptor The adaptor that will be used to find and select matching
204         * items.
205         * @param strictMatching true, if only items from the adaptor's list should
206         * be allowed to be entered
207         * @param stringConverter the converter used to transform items to strings
208         */
209        public AutoCompleteDocument(AbstractAutoCompleteAdaptor adaptor, boolean strictMatching, ObjectToStringConverter stringConverter) {
210            this(adaptor, strictMatching, stringConverter, null);
211        }
212        
213        /**
214         * Creates a new AutoCompleteDocument for the given AbstractAutoCompleteAdaptor.
215         * @param strictMatching true, if only items from the adaptor's list should
216         * be allowed to be entered
217         * @param adaptor The adaptor that will be used to find and select matching
218         * items.
219         */
220        public AutoCompleteDocument(AbstractAutoCompleteAdaptor adaptor, boolean strictMatching) {
221            this(adaptor, strictMatching, null);
222        }
223    
224        /**
225         * Creates the default backing document when no delegate is passed to this
226         * document.
227         * 
228         * @return the default backing document
229         */
230        protected Document createDefaultDocument() {
231            return new PlainDocument();
232        }
233        
234        public void remove(int offs, int len) throws BadLocationException {
235            // return immediately when selecting an item
236            if (selecting) return;
237            delegate.remove(offs, len);
238            if (!strictMatching) {
239                setSelectedItem(getText(0, getLength()), getText(0, getLength()));
240                adaptor.getTextComponent().setCaretPosition(offs);
241            }
242        }
243        
244        public void insertString(int offs, String str, AttributeSet a) throws BadLocationException {
245            // return immediately when selecting an item
246            if (selecting) return;
247            // insert the string into the document
248            delegate.insertString(offs, str, a);
249            // lookup and select a matching item
250            LookupResult lookupResult;
251            String pattern = getText(0, getLength());
252            
253            if(pattern == null || pattern.length() == 0) {
254                lookupResult = new LookupResult(null, "");
255                setSelectedItem(lookupResult.matchingItem, lookupResult.matchingString);
256            } else {
257                lookupResult = lookupItem(pattern);
258                if (lookupResult.matchingItem != null) {
259                    setSelectedItem(lookupResult.matchingItem, lookupResult.matchingString);
260                } else {
261                    if (strictMatching) {
262                        // keep old item selected if there is no match
263                        lookupResult.matchingItem = adaptor.getSelectedItem();
264                        lookupResult.matchingString = adaptor.getSelectedItemAsString();
265                        // imitate no insert (later on offs will be incremented by
266                        // str.length(): selection won't move forward)
267                        offs = offs-str.length();
268                        // provide feedback to the user that his input has been received but can not be accepted
269                        UIManager.getLookAndFeel().provideErrorFeedback(adaptor.getTextComponent());
270                    } else {
271                        // no item matches => use the current input as selected item
272                        lookupResult.matchingItem=getText(0, getLength());
273                        lookupResult.matchingString=getText(0, getLength());
274                        setSelectedItem(lookupResult.matchingItem, lookupResult.matchingString);
275                    }
276                }
277            }
278            setText(lookupResult.matchingString);
279            // select the completed part
280            adaptor.markText(offs+str.length());
281        }
282        
283        /**
284         * Sets the text of this AutoCompleteDocument to the given text.
285         * 
286         * @param text the text that will be set for this document
287         */
288        private void setText(String text) {
289            try {
290                // remove all text and insert the completed string
291                delegate.remove(0, getLength());
292                delegate.insertString(0, text, null);
293            } catch (BadLocationException e) {
294                throw new RuntimeException(e.toString());
295            }
296        }
297        
298        /**
299         * Selects the given item using the AbstractAutoCompleteAdaptor.
300         * @param itemAsString string representation of the item to be selected
301         * @param item the item that is to be selected
302         */
303        private void setSelectedItem(Object item, String itemAsString) {
304            selecting = true;
305            adaptor.setSelectedItem(item);
306            adaptor.setSelectedItemAsString(itemAsString);
307            selecting = false;
308        }
309        
310        /**
311         * Searches for an item that matches the given pattern. The AbstractAutoCompleteAdaptor
312         * is used to access the candidate items. The match is not case-sensitive
313         * and will only match at the beginning of each item's string representation.
314         * 
315         * @param pattern the pattern that should be matched
316         * @return the first item that matches the pattern or <code>null</code> if no item matches
317         */
318        private LookupResult lookupItem(String pattern) {
319            Object selectedItem = adaptor.getSelectedItem();
320            
321            String[] possibleStrings;
322    
323            // iterate over all items to find an exact match
324            for (int i=0, n=adaptor.getItemCount(); i < n; i++) {
325                Object currentItem = adaptor.getItem(i);
326                possibleStrings = stringConverter.getPossibleStringsForItem(currentItem);
327                if (possibleStrings!=null) {
328                    // current item exactly matches the pattern?
329                    for (int j=0; j<possibleStrings.length; j++) {
330                        if (possibleStrings[j].equalsIgnoreCase(pattern)) {
331                            return new LookupResult(currentItem, possibleStrings[j]);
332                        }
333                    }
334                }
335            }
336            // check if the currently selected item matches
337            possibleStrings = stringConverter.getPossibleStringsForItem(selectedItem);
338            if (possibleStrings!=null) {
339                for (int i=0; i<possibleStrings.length; i++) {
340                    if (startsWithIgnoreCase(possibleStrings[i], pattern)) {
341                        return new LookupResult(selectedItem, possibleStrings[i]);
342                    }
343                }
344            }
345            // search for any matching item, if the currently selected does not match
346            for (int i=0, n=adaptor.getItemCount(); i < n; i++) {
347                Object currentItem = adaptor.getItem(i);
348                possibleStrings = stringConverter.getPossibleStringsForItem(currentItem);
349                if (possibleStrings!=null) {
350                    for (int j=0; j<possibleStrings.length; j++) {
351                        if (startsWithIgnoreCase(possibleStrings[j], pattern)) {
352                            return new LookupResult(currentItem, possibleStrings[j]);
353                        }
354                    }
355                }
356            }
357            // no item starts with the pattern => return null
358            return new LookupResult(null, "");
359        }
360        
361        private static class LookupResult {
362            Object matchingItem;
363            String matchingString;
364            public LookupResult(Object matchingItem, String matchingString) {
365                this.matchingItem = matchingItem;
366                this.matchingString = matchingString;
367            }
368        }
369        
370        /**
371         * Returns true if <code>base</code> starts with <code>prefix</code> (ignoring case).
372         * @param base the string to be checked
373         * @param prefix the prefix to check for
374         * @return true if <code>base</code> starts with <code>prefix</code>; false otherwise
375         */
376        //TODO entry point for handling 739
377        private boolean startsWithIgnoreCase(String base, String prefix) {
378            if (base.length() < prefix.length()) return false;
379            return base.regionMatches(true, 0, prefix, 0, prefix.length());
380        }
381    
382        /**
383         * {@inheritDoc}
384         */
385        public void addDocumentListener(DocumentListener listener) {
386            handler.addDocumentListener(listener);
387        }
388    
389        /**
390         * {@inheritDoc}
391         */
392        public void addUndoableEditListener(UndoableEditListener listener) {
393            handler.addUndoableEditListener(listener);
394        }
395    
396        /**
397         * {@inheritDoc}
398         */
399        public Position createPosition(int offs) throws BadLocationException {
400            return delegate.createPosition(offs);
401        }
402    
403        /**
404         * {@inheritDoc}
405         */
406        public Element getDefaultRootElement() {
407            return delegate.getDefaultRootElement();
408        }
409    
410        /**
411         * {@inheritDoc}
412         */
413        public Position getEndPosition() {
414            return delegate.getEndPosition();
415        }
416    
417        /**
418         * {@inheritDoc}
419         */
420        public int getLength() {
421            return delegate.getLength();
422        }
423    
424        /**
425         * {@inheritDoc}
426         */
427        public Object getProperty(Object key) {
428            return delegate.getProperty(key);
429        }
430    
431        /**
432         * {@inheritDoc}
433         */
434        public Element[] getRootElements() {
435            return delegate.getRootElements();
436        }
437    
438        /**
439         * {@inheritDoc}
440         */
441        public Position getStartPosition() {
442            return delegate.getStartPosition();
443        }
444    
445        /**
446         * {@inheritDoc}
447         */
448        public String getText(int offset, int length) throws BadLocationException {
449            return delegate.getText(offset, length);
450        }
451    
452        /**
453         * {@inheritDoc}
454         */
455        public void getText(int offset, int length, Segment txt) throws BadLocationException {
456            delegate.getText(offset, length, txt);
457        }
458    
459        /**
460         * {@inheritDoc}
461         */
462        public void putProperty(Object key, Object value) {
463            delegate.putProperty(key, value);
464        }
465    
466        /**
467         * {@inheritDoc}
468         */
469        public void removeDocumentListener(DocumentListener listener) {
470            handler.removeDocumentListener(listener);
471        }
472    
473        /**
474         * {@inheritDoc}
475         */
476        public void removeUndoableEditListener(UndoableEditListener listener) {
477            handler.removeUndoableEditListener(listener);
478        }
479    
480        /**
481         * {@inheritDoc}
482         */
483        public void render(Runnable r) {
484            delegate.render(r);
485        }
486    
487        /**
488         * Returns if only items from the adaptor's list should be allowed to be entered.
489         * @return if only items from the adaptor's list should be allowed to be entered
490         */
491        public boolean isStrictMatching() {
492            return strictMatching;
493        }
494    }