org.yccheok.jstock.gui.AjaxAutoCompleteJComboBox.java Source code

Java tutorial

Introduction

Here is the source code for org.yccheok.jstock.gui.AjaxAutoCompleteJComboBox.java

Source

/*
 * JStock - Free Stock Market Software
 * Copyright (C) 2015 Yan Cheng Cheok <yccheok@yahoo.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

package org.yccheok.jstock.gui;

import java.awt.Component;
import java.awt.Dimension;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JPopupMenu;
import javax.swing.JScrollBar;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import javax.swing.plaf.basic.BasicComboBoxEditor;
import javax.swing.plaf.metal.MetalComboBoxEditor;
import javax.swing.text.BadLocationException;
import javax.swing.text.JTextComponent;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.yccheok.jstock.engine.AjaxGoogleSearchEngineMonitor;
import org.yccheok.jstock.engine.AjaxStockInfoSearchEngine;
import org.yccheok.jstock.engine.ResultSetType;
import org.yccheok.jstock.engine.ResultType;
import org.yccheok.jstock.engine.AjaxYahooSearchEngineMonitor;
import org.yccheok.jstock.engine.DispType;
import org.yccheok.jstock.engine.MatchSetType;
import org.yccheok.jstock.engine.MatchType;
import org.yccheok.jstock.engine.Observer;
import org.yccheok.jstock.engine.StockInfo;
import org.yccheok.jstock.engine.Subject;

/**
 * This is a combo box, which will provides a list of suggested stocks, by
 * getting asynchronous Ajax search result from server.
 *
 * @author yccheok
 */
public class AjaxAutoCompleteJComboBox extends JComboBox implements JComboBoxPopupAdjustable {

    // Use SubjectEx, in order to make notify method public.
    private static class SubjectEx<S, A> extends Subject<S, A> {
        @Override
        public void notify(S subject, A arg) {
            super.notify(subject, arg);
        }
    }

    private final SubjectEx<AjaxAutoCompleteJComboBox, DispType> dispSubject = new SubjectEx<AjaxAutoCompleteJComboBox, DispType>();
    private final SubjectEx<AjaxAutoCompleteJComboBox, Boolean> busySubject = new SubjectEx<AjaxAutoCompleteJComboBox, Boolean>();

    /**
     * Attach an observer to listen to ResultType available event.
     *
     * @param observer An observer to listen to ResultType available event
     */
    public void attachDispObserver(Observer<AjaxAutoCompleteJComboBox, DispType> observer) {
        dispSubject.attach(observer);
    }

    /**
     * Attach an observer to listen to busy state event.
     *
     * @param observer An observer to listen to busy state event
     */
    public void attachBusyObserver(Observer<AjaxAutoCompleteJComboBox, Boolean> observer) {
        busySubject.attach(observer);
    }

    /**
     * Dettach all observers.
     */
    public void dettachAll() {
        dispSubject.dettachAll();
        busySubject.dettachAll();
    }

    /**
     * Create an instance of AjaxAutoCompleteJComboBox.
     */
    public AjaxAutoCompleteJComboBox() {
        super();

        this.setModel(new SortedComboBoxModel());

        this.setEditable(true);

        this.keyAdapter = this.getEditorComponentKeyAdapter();

        // Use our own editor, in order to implement auto-complete feature.
        this.jComboBoxEditor = new MyJComboBoxEditor();
        this.setEditor(this.jComboBoxEditor);

        // Use to handle ENTER key pressed.
        this.getEditor().getEditorComponent().addKeyListener(this.keyAdapter);

        // Do not use keyAdapter to handle auto-complete feature, as it doesn't
        // handle IME input well. For example, you type "wm" and press "3",
        // keyAdapter will have no idea you are trying to choose the 3rd choice
        // provided by your IME. Instead, use documentListener, which will be
        // much more reliable.
        final Component component = this.getEditor().getEditorComponent();
        if (component instanceof JTextComponent) {
            ((JTextComponent) component).getDocument().addDocumentListener(this.getDocumentListener());
        } else {
            log.error("Unable to attach DocumentListener to AjaxAutoCompleteJComboBox.");
        }

        this.setRenderer(new DispTypeCellRenderer());

        ajaxYahooSearchEngineMonitor.attach(getYahooMonitorObserver());
        ajaxGoogleSearchEngineMonitor.attach(getGoogleMonitorObserver());

        this.addActionListener(getActionListener());

        // Have a wide enough drop down list.
        this.addPopupMenuListener(this.getPopupMenuListener());

        // Create horizontal scroll bar if needed.
        // (I am not sure I still need this one as I already have adjustPopupWidth)
        this.adjustScrollBar();
    }

    private ActionListener getActionListener() {
        return new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                /* Handle mouse clicked. */
                if ((e.getModifiers()
                        & java.awt.event.InputEvent.BUTTON1_MASK) == java.awt.event.InputEvent.BUTTON1_MASK) {
                    // Not sure why during debug mode, we cannot enter this block during mouse click?

                    final Object object = AjaxAutoCompleteJComboBox.this.getEditor().getItem();

                    // The object can be either String or AjaxYahooSearchEngine.ResultType.
                    // If user keys in the item, editor's item will be String.
                    // If user clicks on the drop down list, editor's item will be
                    // AjaxYahooSearchEngine.ResultType.
                    if (object instanceof DispType) {
                        DispType lastEnteredResult = (DispType) object;
                        AjaxAutoCompleteJComboBox.this.dispSubject.notify(AjaxAutoCompleteJComboBox.this,
                                lastEnteredResult);
                    }

                    SwingUtilities.invokeLater(new Runnable() {
                        @Override
                        public void run() {
                            // We schedule the below actions in GUI event queue,
                            // so that DocumentListener will not be triggered.
                            // But I am not sure why.
                            AjaxAutoCompleteJComboBox.this.getEditor().setItem(null);
                            AjaxAutoCompleteJComboBox.this.hidePopup();
                            AjaxAutoCompleteJComboBox.this.removeAllItems();
                        }
                    });

                    return;
                }
            }
        };
    }

    private PopupMenuListener getPopupMenuListener() {
        return new PopupMenuListener() {
            @Override
            public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
                // We will have a much wider drop down list.
                Utils.adjustPopupWidth(AjaxAutoCompleteJComboBox.this);
            }

            @Override
            public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
                // Reset popup width.
                AjaxAutoCompleteJComboBox.this.setPopupWidth(-1);
            }

            @Override
            public void popupMenuCanceled(PopupMenuEvent e) {
                // Reset popup width.
                AjaxAutoCompleteJComboBox.this.setPopupWidth(-1);
            }
        };
    }

    private DocumentListener getDocumentListener() {
        return new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) {
                try {
                    final String string = e.getDocument().getText(0, e.getDocument().getLength()).trim();
                    _handle(string);
                } catch (BadLocationException ex) {
                    log.error(null, ex);
                }
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
                try {
                    final String string = e.getDocument().getText(0, e.getDocument().getLength()).trim();
                    _handle(string);
                } catch (BadLocationException ex) {
                    log.error(null, ex);
                }
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
                try {
                    final String string = e.getDocument().getText(0, e.getDocument().getLength()).trim();
                    _handle(string);
                } catch (BadLocationException ex) {
                    log.error(null, ex);
                }
            }

            private void _handle(final String string) {
                // We are no longer busy.
                busySubject.notify(AjaxAutoCompleteJComboBox.this, false);

                if (AjaxAutoCompleteJComboBox.this.getSelectedItem() != null) {
                    if (AjaxAutoCompleteJComboBox.this.getSelectedItem().toString().equals(string)) {
                        // We need to differentiate, whether "string" is from user
                        // typing, or drop down list selection. This is because when
                        // user perform selection, document change event will be triggered
                        // too. When string is from drop down list selection, user
                        // are not expecting any auto complete suggestion. Return early.
                        return;
                    }
                }

                if (string.isEmpty()) {
                    // Empty string. Return early. Do not perform hidePopup and
                    // removeAllItems right here. As when user performs list
                    // selection, previous text field item will be removed, and
                    // cause us fall into this scope. We do not want to hidePopup
                    // and removeAllItems when user is selecting his item.
                    //
                    // hidePopup and removeAllItems when user clears off all items
                    // in text field, will be performed through keyReleased.
                    return;
                }

                // We are busy contacting server right now.
                busySubject.notify(AjaxAutoCompleteJComboBox.this, true);

                canRemoveAllItems = true;
                ajaxYahooSearchEngineMonitor.clearAndPut(string);
                ajaxGoogleSearchEngineMonitor.clearAndPut(string);
            }
        };
    }

    // We should make this powerful combo box shared amoing different classes.
    private KeyAdapter getEditorComponentKeyAdapter() {

        return new KeyAdapter() {
            @Override
            public void keyReleased(KeyEvent e) {
                if (KeyEvent.VK_ENTER == e.getKeyCode()) {
                    // We are no longer busy.
                    busySubject.notify(AjaxAutoCompleteJComboBox.this, false);

                    DispType lastEnteredDispType = null;

                    if (AjaxAutoCompleteJComboBox.this.getItemCount() > 0) {
                        int index = AjaxAutoCompleteJComboBox.this.getSelectedIndex();

                        if (index == -1) {
                            Object object = AjaxAutoCompleteJComboBox.this.getItemAt(0);
                            if (object instanceof DispType) {
                                lastEnteredDispType = (DispType) object;
                            }
                        } else {
                            Object object = AjaxAutoCompleteJComboBox.this.getItemAt(index);
                            if (object instanceof DispType) {
                                lastEnteredDispType = (DispType) object;
                            }
                        }
                    } else {
                        final Object object = AjaxAutoCompleteJComboBox.this.getEditor().getItem();

                        if (object instanceof String) {
                            // All upper-case, if the result is not coming from server.
                            final String string = ((String) object).trim().toUpperCase();
                            if (string.length() > 0) {
                                lastEnteredDispType = new ResultType(string, string);
                            }
                        }
                    }

                    AjaxAutoCompleteJComboBox.this.removeAllItems();
                    if (lastEnteredDispType != null) {
                        AjaxAutoCompleteJComboBox.this.dispSubject.notify(AjaxAutoCompleteJComboBox.this,
                                lastEnteredDispType);
                    }
                    return;
                } /* if(KeyEvent.VK_ENTER == e.getKeyCode()) */

                // If user removes item from text field, we will hidePopup and
                // removeAllItems. Please refer DocumentListener.handle, on why
                // don't we handle hidePopup and removeAllItems there.
                final Object object = AjaxAutoCompleteJComboBox.this.getEditor().getItem();
                if (object == null || object.toString().length() <= 0) {
                    AjaxAutoCompleteJComboBox.this.hidePopup();
                    AjaxAutoCompleteJComboBox.this.removeAllItems();
                }
            } /* public void keyReleased(KeyEvent e) */
        };
    }

    private Observer<AjaxGoogleSearchEngineMonitor, MatchSetType> getGoogleMonitorObserver() {
        return new Observer<AjaxGoogleSearchEngineMonitor, MatchSetType>() {

            @Override
            public void update(final AjaxGoogleSearchEngineMonitor subject, MatchSetType arg) {
                // Can we further enhance our search result?
                if (arg.Match.isEmpty()) {
                    StockInfo stockInfo = ajaxStockInfoSearchEngine.search(arg.Query);
                    if (stockInfo != null) {
                        MatchType matchType = new MatchType(stockInfo.code.toString().toUpperCase(),
                                stockInfo.symbol.toString(), null, null);
                        List<MatchType> matchTypes = new ArrayList<>();
                        matchTypes.add(matchType);
                        MatchSetType matchSetType = MatchSetType.newInstance(arg.Query, matchTypes);
                        // Overwrite!
                        arg = matchSetType;
                    }
                }

                final MatchSetType _arg = arg;

                if (SwingUtilities.isEventDispatchThread()) {
                    _update(subject, _arg);
                } else {
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override
                        public void run() {
                            _update(subject, _arg);
                        }
                    });
                }
            }

            public void _update(AjaxGoogleSearchEngineMonitor subject, MatchSetType arg) {
                final String string = AjaxAutoCompleteJComboBox.this.getEditor().getItem().toString().trim();
                if (string.isEmpty() || false == string.equalsIgnoreCase(arg.Query)) {
                    return;
                }

                // We are no longer busy.
                busySubject.notify(AjaxAutoCompleteJComboBox.this, false);

                // During _update operation, there will be a lot of ListDataListeners
                // trying to modify the content of our text field. We will not allow
                // them to do so.
                //
                // Without setReadOnly(true), when we type the first character "w", IME
                // will suggest ... However, when we call removeAllItems and addItem,
                // JComboBox will "commit" this suggestion to JComboBox's text field.
                // Hence, if we continue to type second character "m", the string displayed
                // at JComboBox's text field will be ...
                //
                AjaxAutoCompleteJComboBox.this.jComboBoxEditor.setReadOnly(true);

                // Must hide popup. If not, the pop up windows will not be
                // resized. But this causes flickering. :(
                boolean isPopupHide = false;
                if (canRemoveAllItems) {
                    canRemoveAllItems = false;

                    isPopupHide = true;
                    AjaxAutoCompleteJComboBox.this.hidePopup();
                    AjaxAutoCompleteJComboBox.this.removeAllItems();

                    codes.clear();
                }

                for (MatchType match : arg.Match) {
                    if (codes.contains(match.getCode().toString())) {
                        continue;
                    }

                    if (!isPopupHide) {
                        isPopupHide = true;
                        AjaxAutoCompleteJComboBox.this.hidePopup();
                    }

                    codes.add(match.getCode().toString());
                    AjaxAutoCompleteJComboBox.this.addItem(match);
                }
                if (isPopupHide && AjaxAutoCompleteJComboBox.this.getItemCount() > 0) {
                    AjaxAutoCompleteJComboBox.this.showPopup();
                }

                // Restore.
                AjaxAutoCompleteJComboBox.this.jComboBoxEditor.setReadOnly(false);
            }
        };
    }

    private Observer<AjaxYahooSearchEngineMonitor, ResultSetType> getYahooMonitorObserver() {
        return new Observer<AjaxYahooSearchEngineMonitor, ResultSetType>() {
            @Override
            public void update(final AjaxYahooSearchEngineMonitor subject, ResultSetType arg) {
                // Can we further enhance our search result?
                if (arg.Result.isEmpty()) {
                    StockInfo stockInfo = ajaxStockInfoSearchEngine.search(arg.Query);
                    if (stockInfo != null) {
                        ResultType resultType = new ResultType(stockInfo.code.toString().toUpperCase(),
                                stockInfo.symbol.toString());
                        List<ResultType> resultTypes = new ArrayList<>();
                        resultTypes.add(resultType);
                        // Overwrite!
                        arg = ResultSetType.newInstance(arg.Query, resultTypes);
                    }
                }

                final ResultSetType _arg = arg;

                if (SwingUtilities.isEventDispatchThread()) {
                    _update(subject, _arg);
                } else {
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override
                        public void run() {
                            _update(subject, _arg);
                        }
                    });
                }
            }

            public void _update(AjaxYahooSearchEngineMonitor subject, ResultSetType arg) {
                final String string = AjaxAutoCompleteJComboBox.this.getEditor().getItem().toString().trim();
                if (string.isEmpty() || false == string.equalsIgnoreCase(arg.Query)) {
                    return;
                }

                // We are no longer busy.
                busySubject.notify(AjaxAutoCompleteJComboBox.this, false);

                // During _update operation, there will be a lot of ListDataListeners
                // trying to modify the content of our text field. We will not allow
                // them to do so.
                //
                // Without setReadOnly(true), when we type the first character "w", IME
                // will suggest ... However, when we call removeAllItems and addItem,
                // JComboBox will "commit" this suggestion to JComboBox's text field.
                // Hence, if we continue to type second character "m", the string displayed
                // at JComboBox's text field will be ...
                //
                AjaxAutoCompleteJComboBox.this.jComboBoxEditor.setReadOnly(true);

                // Must hide popup. If not, the pop up windows will not be
                // resized. But this causes flickering. :(
                boolean isPopupHide = false;

                if (canRemoveAllItems) {
                    canRemoveAllItems = false;

                    isPopupHide = true;
                    AjaxAutoCompleteJComboBox.this.hidePopup();
                    AjaxAutoCompleteJComboBox.this.removeAllItems();

                    codes.clear();
                }

                boolean shouldShowPopup = false;
                for (ResultType result : arg.Result) {
                    if (codes.contains(result.symbol)) {
                        continue;
                    }

                    if (!isPopupHide) {
                        isPopupHide = true;
                        AjaxAutoCompleteJComboBox.this.hidePopup();
                    }

                    codes.add(result.symbol);
                    AjaxAutoCompleteJComboBox.this.addItem(result);
                }
                if (isPopupHide && AjaxAutoCompleteJComboBox.this.getItemCount() > 0) {
                    AjaxAutoCompleteJComboBox.this.showPopup();
                }

                // Restore.
                AjaxAutoCompleteJComboBox.this.jComboBoxEditor.setReadOnly(false);
            }
        };
    }

    private void adjustScrollBar() {
        final int max_search = 8;
        // i < max_search is just a safe guard when getAccessibleChildrenCount
        // returns an arbitary large number. 8 is magic number
        JPopupMenu popup = null;
        for (int i = 0, count = this.getUI().getAccessibleChildrenCount(this); i < count && i < max_search; i++) {
            Object o = this.getUI().getAccessibleChild(this, i);
            if (o instanceof JPopupMenu) {
                popup = (JPopupMenu) o;
                break;
            }
        }
        if (popup == null) {
            return;
        }
        JScrollPane scrollPane = null;
        for (int i = 0, count = popup.getComponentCount(); i < count && i < max_search; i++) {
            Component c = popup.getComponent(i);
            if (c instanceof JScrollPane) {
                scrollPane = (JScrollPane) c;
                break;
            }
        }
        if (scrollPane == null) {
            return;
        }
        scrollPane.setHorizontalScrollBar(new JScrollBar(JScrollBar.HORIZONTAL));
        scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
    }

    // WARNING : If Java is having a major refactor on BasicComboBoxEditor class,
    // the following workaround will break. However, this is the best we can do
    // at this moment.
    private class MyJComboBoxEditor extends BasicComboBoxEditor {
        @Override
        protected JTextField createEditorComponent() {
            final MyTextField _editor = new MyTextField("");
            // Is there a better way to configure the correct UI for
            // JTextField?
            if (UIManager.getLookAndFeel().getClass().getName().equals("javax.swing.plaf.metal.MetalLookAndFeel")) {
                final Component component = new MetalComboBoxEditor().getEditorComponent();
                if (component instanceof JComponent) {
                    final JComponent jComponent = (JComponent) component;
                    _editor.setBorder(jComponent.getBorder());
                }
            } else {
                _editor.setBorder(null);
            }
            return _editor;
        }

        public void setReadOnly(boolean readonly) {
            this.readonly = readonly;
        }

        private class MyTextField extends JTextField {
            public MyTextField(String s) {
                super(s);
            }

            // workaround for 4530952
            @Override
            public void setText(String s) {
                if (readonly || getText().equals(s)) {
                    return;
                }
                super.setText(s);
            }
        }

        private boolean readonly = false;
    }

    /**
     * Stop Ajax threading activity in this combo box. Once stop, this combo box
     * can no longer be reused.
     */
    public void stop() {
        ajaxYahooSearchEngineMonitor.stop();
        ajaxGoogleSearchEngineMonitor.stop();
    }

    // Online database. 
    private final AjaxYahooSearchEngineMonitor ajaxYahooSearchEngineMonitor = new AjaxYahooSearchEngineMonitor();
    private final AjaxGoogleSearchEngineMonitor ajaxGoogleSearchEngineMonitor = new AjaxGoogleSearchEngineMonitor();
    private final AjaxStockInfoSearchEngine ajaxStockInfoSearchEngine = new AjaxStockInfoSearchEngine();

    private final MyJComboBoxEditor jComboBoxEditor;
    private final KeyAdapter keyAdapter;
    private static final Log log = LogFactory.getLog(AjaxAutoCompleteJComboBox.class);

    /*
     * =========================================================================
     * START >>
     * 
     * Hacking code picked from
     * http://javabyexample.wisdomplug.com/java-concepts/34-core-java/59-tips-and-tricks-for-jtree-jlist-and-jcombobox-part-i.html
     *
     */

    /**
     * Set the combo box popup width.
     * 
     * @param popupWidth the combo box popup width
     */
    @Override
    public void setPopupWidth(int popupWidth) {
        this.popupWidth = popupWidth;
    }

    /**
    * Override to handle the popup Size.
    */
    @Override
    public void doLayout() {
        try {
            layingOut = true;
            super.doLayout();
        } finally {
            layingOut = false;
        }
    }

    /**
    * Overriden to handle the popup Size
    */
    @Override
    public Dimension getSize() {
        Dimension dim = super.getSize();
        if (!layingOut && popupWidth != 0) {
            // Ensure the popup size must be a least equal or larger than combo
            // box size.
            if (dim.width < popupWidth) {
                dim.width = popupWidth;
            }
        }
        return dim;
    }

    /**
     * Set the popup Width.
     */
    private int popupWidth = 0;

    /**
     * Keep track of whether layout is happening.
     */
    private boolean layingOut = false;

    private volatile boolean canRemoveAllItems = false;
    private final Set<String> codes = new HashSet<>();

    /*
     * 
     * Hacking code picked from
     * http://javabyexample.wisdomplug.com/java-concepts/34-core-java/59-tips-and-tricks-for-jtree-jlist-and-jcombobox-part-i.html
     * 
     * << END
     * =========================================================================
     */
}